From a26d7151a17f0bc3d569862f9e984d233921454a Mon Sep 17 00:00:00 2001 From: Justin Icenhour Date: Sat, 10 Oct 2026 21:14:50 -0500 Subject: [PATCH 1/7] feat(log): name the visitor Cloudflare saw, beside Caddy's peer Every clearnet request line in 21 hours of live log carried `"ip": "172.18.0.1"` (5,548 of 5,555): Caddy trusts no proxy, so the x-forwarded-for it sets is its own peer, and behind Docker's port proxy that is the bridge gateway. Only LAN visits, which skip Cloudflare, ever showed a real address. The request log now also writes `cfIp`, from `cf-connecting-ip`, which Caddy passes through. It is logged under its own name because nothing verifies the request really came through Cloudflare; nothing in the site decides anything from it. A request over the onion gateway logs neither address. - shared/log/client.ts: clientAddress() - server/plugins/request-log.ts: uses it on both the request and error line - test/client-address.test.ts: 4 cases - README.md: the log fields Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 6 +++-- server/plugins/request-log.ts | 16 +++++++++---- shared/log/client.ts | 43 +++++++++++++++++++++++++++++++++++ test/client-address.test.ts | 26 +++++++++++++++++++++ 4 files changed, 84 insertions(+), 7 deletions(-) create mode 100644 shared/log/client.ts create mode 100644 test/client-address.test.ts diff --git a/README.md b/README.md index 1e44710..3a74aec 100644 --- a/README.md +++ b/README.md @@ -145,9 +145,11 @@ service was last reached over Tor, from the gateway's own ten-minute self-fetch. Thirty minutes without one turns `/healthz`'s `status` to `degraded` (still a 200: the clearnet site is serving). The server also logs one JSON object per request on stdout, failed ones included, each -with the client's `ip`, `ua` and `ref` — `docker logs basicautomation-site | jq +with the client's `ua` and `ref`, the `ip` Caddy forwarded (behind Docker's port +proxy that is the bridge gateway) and `cfIp`, the visitor as Cloudflare names +them — a header, not a verified hop. `docker logs basicautomation-site | jq 'select(.status >= 400)'`, or `select(.via == "onion")` for the visits that -came over Tor — and warns once an hour for +came over Tor, which carry no address at all — and warns once an hour for as long as it has been answering from the snapshot, once when the GitHub quota runs out (`event: "upstream.rate_limited"`), and once an hour while the onion service is unreachable (`onion.unreachable`, then `onion.recovered`). Every other line the app diff --git a/server/plugins/request-log.ts b/server/plugins/request-log.ts index cbe989b..7d3a9a7 100644 --- a/server/plugins/request-log.ts +++ b/server/plugins/request-log.ts @@ -11,6 +11,7 @@ * a log that is 90% noise does not get read. */ +import { clientAddress } from '~~/shared/log/client' import { VIA_ONION, cameOverOnion } from '~~/shared/onion/via' const SILENT = [/^\/healthz$/, /^\/_nuxt\//, /^\/fonts\//, /\.(?:ico|png|webp|svg|woff2?)$/] @@ -59,11 +60,16 @@ function via(event: InstanceType): 'onion' | 'web' { * were the one kind of line with no address and no user agent. */ function requester(event: InstanceType) { + const way = via(event) return { - // Caddy terminates TLS and proxies in, so the real client is in the - // forwarded header; the socket address is Caddy's every time. - ip: getRequestHeader(event, 'x-forwarded-for')?.split(',')[0]?.trim() ?? null, - via: via(event), + // The socket address is Caddy's every time, and Caddy's own peer is + // docker's bridge gateway; `shared/log/client.ts` says which header is + // whose. + ...clientAddress({ + forwardedFor: getRequestHeader(event, 'x-forwarded-for'), + cfConnectingIp: getRequestHeader(event, 'cf-connecting-ip'), + }, way), + via: way, ua: getRequestHeader(event, 'user-agent') ?? null, ref: getRequestHeader(event, 'referer') ?? null, } @@ -109,7 +115,7 @@ export default defineNitroPlugin((nitro) => { path: event?.path ?? null, status, ms: started === undefined ? null : Math.round(performance.now() - started), - ...(event ? requester(event) : { ip: null, via: null, ua: null, ref: null }), + ...(event ? requester(event) : { ip: null, cfIp: null, via: null, ua: null, ref: null }), message: error.message, })) }) diff --git a/shared/log/client.ts b/shared/log/client.ts new file mode 100644 index 0000000..c820fa4 --- /dev/null +++ b/shared/log/client.ts @@ -0,0 +1,43 @@ +/** + * Where a request came from, as far as the request log can tell. + * + * The clearnet path is visitor → Cloudflare → docker's port proxy → Caddy → + * the site. Caddy trusts no proxy, so the `x-forwarded-for` it sets is its own + * peer — and behind docker's port proxy that peer is the bridge gateway. In + * 21 hours of live log (2026-10-10) 5,548 of 5,555 lines carried `172.18.0.1`: + * the field was there and said nothing. Only the LAN, which resolves the name + * to the host and skips Cloudflare, ever showed a real address. + * + * Cloudflare names the visitor in `cf-connecting-ip`, and Caddy passes request + * headers through untouched. It is a claim, not a verified hop: nothing on the + * way in checks that the request really came from Cloudflare, so anyone who + * reaches the origin directly can write it. It is logged under its own name, + * `cfIp`, beside `ip`, so a reader knows which is which — and nothing in the + * site decides anything from either. + * + * Over the onion gateway there is no Cloudflare: the gateway strips + * `x-forwarded-for`, and a Tor visitor's own `cf-connecting-ip` is just a + * header they typed, so neither is logged for `via: onion`. + */ +export interface ClientAddress { + /** Caddy's peer: the first `x-forwarded-for` hop. */ + ip: string | null + /** Cloudflare's word for the visitor, when the request came through it. */ + cfIp: string | null +} + +type Header = string | string[] | null | undefined + +function first(value: Header): string | null { + const raw = Array.isArray(value) ? value[0] : value + const hop = raw?.split(',')[0]?.trim() + return hop ? hop : null +} + +export function clientAddress( + headers: { forwardedFor: Header, cfConnectingIp: Header }, + via: 'onion' | 'web', +): ClientAddress { + if (via === 'onion') return { ip: null, cfIp: null } + return { ip: first(headers.forwardedFor), cfIp: first(headers.cfConnectingIp) } +} diff --git a/test/client-address.test.ts b/test/client-address.test.ts new file mode 100644 index 0000000..f3a2c45 --- /dev/null +++ b/test/client-address.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { clientAddress } from '~~/shared/log/client' + +describe('clientAddress', () => { + it('logs Caddy\'s peer and Cloudflare\'s visitor side by side', () => { + expect(clientAddress({ forwardedFor: '172.18.0.1', cfConnectingIp: '203.0.113.7' }, 'web')) + .toEqual({ ip: '172.18.0.1', cfIp: '203.0.113.7' }) + }) + + it('takes the first hop of a list, trimmed', () => { + expect(clientAddress({ forwardedFor: ' 198.51.100.2 , 172.18.0.1', cfConnectingIp: ['2001:db8::1'] }, 'web')) + .toEqual({ ip: '198.51.100.2', cfIp: '2001:db8::1' }) + }) + + it('is null, not empty, where a header is missing or blank', () => { + expect(clientAddress({ forwardedFor: '192.168.3.97', cfConnectingIp: undefined }, 'web')) + .toEqual({ ip: '192.168.3.97', cfIp: null }) + expect(clientAddress({ forwardedFor: '', cfConnectingIp: ' ' }, 'web')) + .toEqual({ ip: null, cfIp: null }) + }) + + it('logs no address for a request over Tor, whatever it sent', () => { + expect(clientAddress({ forwardedFor: undefined, cfConnectingIp: '203.0.113.7' }, 'onion')) + .toEqual({ ip: null, cfIp: null }) + }) +}) From fa732a72d3142f8fb365dda1ba2f59118a509e8b Mon Sep 17 00:00:00 2001 From: Justin Icenhour Date: Sat, 10 Oct 2026 21:41:58 -0500 Subject: [PATCH 2/7] fix(checks): the browser accessibility pass measures a page at rest The site scrolls smoothly (`scroll-behavior: smooth`), so the reading pass's bare `scrollTo` calls only started animations. Measured at 390 px: the pass stalled a screen or so down (Chromium, onyums: 1,014 of 3,894 px by its last step), and the jump back to the top was still ~1,700 px out when axe ran. axe then judged a page in motion, with links passing under the fixed masthead: `target-size` failed /status's /sitemap.xml link as "partially obscured" on one Firefox run of four tonight, and passed the other three. Every jump is now `behavior: 'instant'`, and the pass throws if the page is not at the top when axe starts. Probed in both engines: the pass now reaches the bottom (3,894 / 3,968 px) and is at 0 when axe runs. a11y:browser and a11y:firefox clean, twice each. - scripts/check-a11y-browser.mjs Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/check-a11y-browser.mjs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/scripts/check-a11y-browser.mjs b/scripts/check-a11y-browser.mjs index 292a7e1..b3215e0 100644 --- a/scripts/check-a11y-browser.mjs +++ b/scripts/check-a11y-browser.mjs @@ -98,13 +98,23 @@ try { // Then read to the bottom, a screen at a time, as a visitor would. The // onion frame and the README's images are lazy: unscrolled, they never // load, and whatever they would log is never seen. + // + // Every jump is `instant`. The site scrolls smoothly, so a bare + // `scrollTo` only starts an animation: the pass stalled a screen or + // so down (Chromium, onyums at 390 px: 1,014 of 3,894 px by the last + // step), and the return to the top was still ~1,700 px out when axe + // ran. axe then measured a page in motion, with links passing under + // the fixed masthead, and `target-size` failed /status's links as + // "partially obscured" on one Firefox run in four (2026-10-10). await page.evaluate(`(async () => { for (let y = 0; y < document.documentElement.scrollHeight; y += innerHeight) { - scrollTo(0, y) + scrollTo({ top: y, behavior: 'instant' }) await new Promise((r) => setTimeout(r, 150)) } await new Promise((r) => setTimeout(r, 1000)) - scrollTo(0, 0) + scrollTo({ top: 0, behavior: 'instant' }) + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))) + if (scrollY !== 0) throw new Error('still ' + scrollY + ' px down after scrolling to the top') })()`) const expected = path === NOT_FOUND_PATH ? 'error page' : 'page' From a7d2d88a083c5fcbf903af98e581213a645aadc3 Mon Sep 17 00:00:00 2001 From: Justin Icenhour Date: Sat, 10 Oct 2026 21:42:20 -0500 Subject: [PATCH 3/7] chore(deps): Nuxt 4.6.1, vue-tsc 3.3.13, @types/node 26.6.5 Nuxt 4.6.1 after 27 hours with two issues filed against it, neither touching this site (#36529 reproduces on 3.x; #36530 is the cloudflare_module preset). Mostly dev-server and typing fixes plus a lazy portable-event view in Nitro. The lockfile also moved cssnano 9.3 -> 9.6; the built CSS is byte-identical (same hash). Checks on the upgraded tree: typecheck clean, 273 tests, build green, all 42 verify routes identical to 4.6.0's with every page body the same size, check (22 pages, 79 URLs), a11y (23 pages), a11y:browser, a11y:firefox, check:navigation 144/144 in Chromium and in Firefox, audit:runtime, font:check, og:check, sizes:check, contrast, bases. - package.json, package-lock.json - ROADMAP.md: the 4.6.1 item Co-Authored-By: Claude Opus 5.5 (1M context) --- ROADMAP.md | 22 +- package-lock.json | 582 ++++++++++++++++++++++------------------------ package.json | 6 +- 3 files changed, 298 insertions(+), 312 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 87aac97..726bc5d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -462,15 +462,19 @@ record. There is no run log. output. Nuxt's own `scrollBehavior` governs all of it, so the new defaults change nothing a visitor sees here. -- [ ] Nuxt 4.6.1 (published 2026-10-09 22:59 UTC), once it has soaked like - 4.6.0 did. A patch release: the fixes are mostly dev-server and typing - (`Serialize` types aligned with JSON, `$fetch` typed as `TypedFetch`, - `useId` hydration keeping `onServerPrefetch`), plus a Nitro performance - change that builds the portable event's web view lazily. The first issue - filed against it, nuxt/nuxt#36529 (an aborted navigation to an unmatched - route renders a 404), reproduces on 3.x too, so it is not a 4.6.1 - regression. Land it with the full gates plus `check:navigation` in both - engines. +- [x] Nuxt 4.6.1 (published 2026-10-09 22:59 UTC), landed 2026-10-10 after + 27 hours with two issues filed against it, neither touching this site: + nuxt/nuxt#36529 (an aborted navigation to an unmatched route renders a + 404) reproduces on 3.x too, and #36530 is the `cloudflare_module` + preset. A patch release: mostly dev-server and typing fixes, plus a + Nitro change that builds the portable event's web view lazily. Taken + with vue-tsc 3.3.13 (setup macros without static imports) and + `@types/node` 26.6.5; the lockfile also moved cssnano 9.3 → 9.6, and the + built CSS came out byte-identical (same hash). Typecheck, 273 tests, + build, `check`, `a11y`, `a11y:browser`, `a11y:firefox`, + `check:navigation` in both engines (144 each), `audit:runtime`; all 42 + routes answer as on 4.6.0, every page body the same size. + - [x] Silenced Nitro's own `[request error]` stack-trace block on a 404, without replacing the error handler. Nitro logs it when the error is `fatal`, and diff --git a/package-lock.json b/package-lock.json index d149554..05ba90b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,14 +12,14 @@ "@nuxt/ui": "^4.11.2", "bcryptjs": "^3.0.3", "marked": "^18.1.0", - "nuxt": "^4.6.0", + "nuxt": "^4.6.1", "shiki": "^4.4.3", "vue": "^3.5.43", "vue-router": "^5.4.0" }, "devDependencies": { "@tailwindcss/vite": "^4.3.3", - "@types/node": "^26.6.4", + "@types/node": "^26.6.5", "axe-core": "^4.14.0", "fontverter": "^2.0.0", "harfbuzzjs": "^1.6.2", @@ -28,7 +28,7 @@ "tailwindcss": "^4.3.3", "typescript": "npm:@typescript/typescript6@^6.0.2", "vitest": "^5.0.2", - "vue-tsc": "^3.3.12" + "vue-tsc": "^3.3.13" } }, "node_modules/@alloc/quick-lru": { @@ -1518,9 +1518,9 @@ } }, "node_modules/@nuxt/cli": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@nuxt/cli/-/cli-4.0.0.tgz", - "integrity": "sha512-XTBvl84e8UTbk8CkWu48J919JpWifLjN08bBUcg+JjKcB9ufXIe7S6A4l1dz0pEJPBKal68+uMy7oevy357dqw==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@nuxt/cli/-/cli-4.0.1.tgz", + "integrity": "sha512-vtB77CmkrYUMoIKwk+HUdNQX1vuMPpnu7ExNYB91WuIxzzGrGQLSxGUpXa360xsyUkZ4h9s+qElAQQW9ZQP4WA==", "license": "MIT", "dependencies": { "@clack/prompts": "^1.8.1", @@ -1534,7 +1534,7 @@ "get-port-please": "^3.2.0", "my-bad": "^0.2.9", "obug": "^3.0.0", - "package-manager-detector": "^1.8.0", + "package-manager-detector": "^1.9.0", "pathe": "^2.0.3", "perfect-debounce": "^2.1.0", "pkg-types": "^2.3.3", @@ -1585,12 +1585,6 @@ } } }, - "node_modules/@nuxt/cli/node_modules/confbox": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.3.1.tgz", - "integrity": "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==", - "license": "MIT" - }, "node_modules/@nuxt/cli/node_modules/obug": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", @@ -1741,9 +1735,9 @@ } }, "node_modules/@nuxt/kit": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.6.0.tgz", - "integrity": "sha512-UXTajJ+MFJgwV9UL1CTNNd2PSw5PC3Pjb2rM7FsSYcr92ySQKXEslBnUKbvnXT+gG3NbZ/EF70+WenoNIHX2tw==", + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.6.1.tgz", + "integrity": "sha512-ld5+/o4EMqyw6Qy+SdEYkezfprS87+p+xOBhnQgXwyrg1EdtILfLeudQ+hX6UyCnsoF6o0SOw3pjN1P4Tg9O0w==", "license": "MIT", "dependencies": { "consola": "^3.4.2", @@ -1755,7 +1749,7 @@ "klona": "^2.0.6", "microdiff": "^1.6.0", "nostics": "^1.3.0", - "package-manager-detector": "^1.8.0", + "package-manager-detector": "^1.9.0", "pathe": "^2.0.3", "rc9": "^3.1.0", "scule": "^1.3.0", @@ -1806,12 +1800,12 @@ } }, "node_modules/@nuxt/nitro-server": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@nuxt/nitro-server/-/nitro-server-4.6.0.tgz", - "integrity": "sha512-QuWqouECcxutCrC3Z09w3x12GZtWjqbjoKrhjkMf0eSVm586yGfkSEfGIjRuVhy/wcYMEGnMOKA2bGTbndnreQ==", + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/@nuxt/nitro-server/-/nitro-server-4.6.1.tgz", + "integrity": "sha512-ecMNTxJ44uany4cjA58EW5AMbqcG0XgqP0d0HJmZ7z3CupPCc+qmd9InvzsX53uU7ZVzAkWmQ8K+0lrzl9Xp3A==", "license": "MIT", "dependencies": { - "@nuxt/kit": "4.6.0", + "@nuxt/kit": "4.6.1", "@unhead/vue": "^3.4.2", "consola": "^3.4.2", "defu": "^6.1.7", @@ -1825,7 +1819,7 @@ "impound": "^1.2.0", "klona": "^2.0.6", "mocked-exports": "^0.1.1", - "my-bad": "^0.2.10", + "my-bad": "^0.2.11", "nitropack": "^2.13.4", "nostics": "^1.3.0", "ohash": "^2.0.12", @@ -1847,7 +1841,7 @@ "@babel/plugin-proposal-decorators": "^7.25.0 || ^8.0.0", "@babel/plugin-syntax-typescript": "^7.25.0 || ^8.0.0", "@rollup/plugin-babel": "^6.0.0 || ^7.0.0", - "nuxt": "^4.6.0" + "nuxt": "^4.6.1" }, "peerDependenciesMeta": { "@babel/plugin-proposal-decorators": { @@ -1862,9 +1856,9 @@ } }, "node_modules/@nuxt/schema": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@nuxt/schema/-/schema-4.6.0.tgz", - "integrity": "sha512-lwAA2MDJ5WkNKqIpNnvH3gK1aHfh3DK8evQNbun+nzMKPP7MqpQ6rs/UClqTnhhhTwqkw0b6Sm24VRCVw7f4Tw==", + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/@nuxt/schema/-/schema-4.6.1.tgz", + "integrity": "sha512-GecoPGRBk5Okpj51On87wZzBMseOqrp0bm5oa9CFIOEYR3rN9QNVsIwPw7P9OA4GOaK/smIVbo0tsmUScJFQiw==", "license": "MIT", "dependencies": { "@vue/shared": "^3.5.43", @@ -2043,18 +2037,18 @@ } }, "node_modules/@nuxt/vite-builder": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@nuxt/vite-builder/-/vite-builder-4.6.0.tgz", - "integrity": "sha512-XWIi+OBn2Zql8lrFWvAX/sCmd09XMLuorm/uuF2cSm5g9TbDSFDOSgWPA5oY1OC9rx7ahMKNKlWnkceJust+OA==", + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/@nuxt/vite-builder/-/vite-builder-4.6.1.tgz", + "integrity": "sha512-i4cGwvYVAOzEgnZ8kZh02gWX+44rg+PI71GIxLqc2F9fdHTfLP0ZG0rsMmGWZbed9DgzpO4hK9uhPC1DwCrW6w==", "license": "MIT", "dependencies": { - "@nuxt/kit": "4.6.0", + "@nuxt/kit": "4.6.1", "@vitejs/plugin-vue": "^6.0.9", "@vitejs/plugin-vue-jsx": "^5.1.6", "autoprefixer": "^10.6.1", "clickable-path": "^0.1.1", "consola": "^3.4.2", - "cssnano": "^9.2.2", + "cssnano": "^9.4.0", "defu": "^6.1.7", "escape-string-regexp": "^5.0.0", "exsolve": "^1.1.3", @@ -2063,10 +2057,10 @@ "js-tokens": "^10.0.0", "knitwork": "^1.3.0", "mocked-exports": "^0.1.1", - "my-bad": "^0.2.10", + "my-bad": "^0.2.11", "pathe": "^2.0.3", "pkg-types": "^2.3.3", - "postcss": "^8.5.28", + "postcss": "^8.5.29", "rolldown-string": "^0.3.1", "seroval": "^1.6.8", "std-env": "^4.3.0", @@ -2083,7 +2077,7 @@ "peerDependencies": { "@babel/plugin-proposal-decorators": "^7.25.0 || ^8.0.0", "@babel/plugin-syntax-jsx": "^7.25.0 || ^8.0.0", - "nuxt": "4.6.0", + "nuxt": "4.6.1", "rolldown": "^1.0.0", "rollup-plugin-visualizer": "^6.0.0 || ^7.0.1", "vue": "^3.3.4" @@ -2104,17 +2098,17 @@ } }, "node_modules/@nuxt/vite-server": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@nuxt/vite-server/-/vite-server-4.6.0.tgz", - "integrity": "sha512-QewPpajptByrJ5yzRVCWNZCLtWMOaP4Jm8JUCO8qzW8Kq06xzF2DLvfS8k5GYoGdASpapFHlc9UVucOY6bBc6w==", + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/@nuxt/vite-server/-/vite-server-4.6.1.tgz", + "integrity": "sha512-4/CE30fFc3loj2k9m37Q9kskLwFCjKa+2AePafb5Q6AMtHE9RYGH+lShbH4xJVk/uFXSiibUU8VB3H+84O9ZoQ==", "license": "MIT", "dependencies": { - "@nuxt/kit": "4.6.0", + "@nuxt/kit": "4.6.1", "clickable-path": "^0.1.1", "defu": "^6.1.7", "exsolve": "^1.1.3", "mocked-exports": "^0.1.1", - "my-bad": "^0.2.10", + "my-bad": "^0.2.11", "pathe": "^2.0.3", "rou3": "^0.9.2", "srvx": "1.0.5", @@ -2125,7 +2119,7 @@ "node": "^22.22.3 || ^24.15.0 || >=26.0.0" }, "peerDependencies": { - "nuxt": "^4.6.0", + "nuxt": "^4.6.1", "vite": "^8.3.2" } }, @@ -4454,9 +4448,9 @@ } }, "node_modules/@types/node": { - "version": "26.6.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz", - "integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==", + "version": "26.6.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", "devOptional": true, "license": "MIT", "dependencies": { @@ -4607,9 +4601,9 @@ } }, "node_modules/@vitejs/plugin-vue": { - "version": "6.0.9", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.9.tgz", - "integrity": "sha512-rD/MORlhaZMlXWW0rEn4FB4wMinWC0z/D7Ye160S5+Rs1mC8ZDAdcDp4SjKfUhG5t8q1ktcPVw4xuTkPRlzrSA==", + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.10.tgz", + "integrity": "sha512-b80aUQh3n+qEzzZm5oQp3rT2QImeCx58K6eFl/RkJ3d65KW0kTuv5lNsLRVAjbW9YRUpo/wzmvzMgV34bTAItA==", "license": "MIT", "dependencies": { "@rolldown/pluginutils": "^1.0.1" @@ -4619,7 +4613,7 @@ }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", - "vue": "^3.2.25" + "vue": "^3.2.25 || ^3.6.0-rc.10" } }, "node_modules/@vitejs/plugin-vue-jsx": { @@ -4910,6 +4904,22 @@ "integrity": "sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g==", "license": "MIT" }, + "node_modules/@vue/language-core": { + "version": "3.3.13", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.3.13.tgz", + "integrity": "sha512-axA0Xemn5LuUKuKWob0/kVq7xQ2mJJM6VqA9Exvt35nM9yRC31goWVve/G/Zb2ZCLsXC8FHYjjfXlnshknSCew==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.2.1", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.4" + } + }, "node_modules/@vue/reactivity": { "version": "3.5.43", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.43.tgz", @@ -5535,9 +5545,9 @@ } }, "node_modules/bare-fs": { - "version": "4.8.3", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.3.tgz", - "integrity": "sha512-KQi9ShyPS/nOCtKpma7At8wbq4ORSNUmD67hsZrPcaWJcEmL8PHyS2xVWT308y8/LvHFOUTytTTDsD8dInx6bA==", + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.4.tgz", + "integrity": "sha512-MPC2eyrpbk3A873sK4y6rk+Iv4o2enxTjHZ8zH3HO0mjrpm5XjnguY2XmfidIsDV6vnjpbMBKUAJpV0ISD2SsQ==", "license": "Apache-2.0", "dependencies": { "bare-events": "^2.5.4", @@ -5621,9 +5631,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.11.27", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz", - "integrity": "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==", + "version": "2.11.28", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.28.tgz", + "integrity": "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -5814,6 +5824,12 @@ } } }, + "node_modules/c12/node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "license": "MIT" + }, "node_modules/cac": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/cac/-/cac-7.0.1.tgz", @@ -6062,9 +6078,9 @@ } }, "node_modules/confbox": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", - "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.3.1.tgz", + "integrity": "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==", "license": "MIT" }, "node_modules/consola": { @@ -6223,25 +6239,13 @@ "url": "https://github.com/sponsors/fb55" } }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/cssnano": { - "version": "9.3.2", - "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-9.3.2.tgz", - "integrity": "sha512-KvuGq1GUUfC8nxaPDSqFhMAVMJQ/3BuVPFKTQIalOu9icM3pqCUoyNGnVkL/bO3sMPnDaeKFHNaP03y21yvGiA==", + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-9.6.0.tgz", + "integrity": "sha512-HVcCfGemKShupFMTKcZblan2zOA/W0uGMB9PKzWtliaY3L1DmyzAAqriMi5TzCo0WOYP4nwTnLi9AIxWn3Ma/w==", "license": "MIT", "dependencies": { - "cssnano-preset-default": "^9.3.2" + "cssnano-preset-default": "^9.6.0" }, "engines": { "node": "^22.22.3 || ^24.15.0 || >=26.0" @@ -6251,56 +6255,56 @@ "url": "https://opencollective.com/cssnano" }, "peerDependencies": { - "postcss": "^8.5.28" + "postcss": "^8.5.29" } }, "node_modules/cssnano-preset-default": { - "version": "9.3.2", - "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-9.3.2.tgz", - "integrity": "sha512-HpBRavK5tdHJLW9eL2vc3zByyjpmDLV2R/3t8pAtykjUp4lPOtF5vR3g7IWJsv7QgdS2PRuBbVKPAcOVMCwrwQ==", + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-9.6.0.tgz", + "integrity": "sha512-UmIa9PVDvU6UDjNGb1g2w3uLuKCHO1CHv0prMUmhwXhuGXtRuOznbZBBTZNISrk8IW0GEgoNRcHucVtVENYN5g==", "license": "MIT", "dependencies": { "browserslist": "^4.29.3", - "cssnano-utils": "^8.2.0", + "cssnano-utils": "^9.0.0", "postcss-calc": "^11.2.2", - "postcss-colormin": "^9.0.6", - "postcss-convert-values": "^9.0.7", - "postcss-discard-comments": "^9.0.6", - "postcss-discard-duplicates": "^9.0.5", - "postcss-discard-empty": "^9.0.6", - "postcss-discard-overridden": "^9.0.5", - "postcss-merge-longhand": "^9.2.1", - "postcss-merge-rules": "^9.1.2", - "postcss-minify-font-values": "^9.0.7", - "postcss-minify-gradients": "^9.0.6", - "postcss-minify-params": "^9.0.6", - "postcss-minify-selectors": "^9.0.6", - "postcss-normalize-charset": "^9.0.5", - "postcss-normalize-display-values": "^9.0.5", - "postcss-normalize-positions": "^9.0.6", - "postcss-normalize-repeat-style": "^9.0.5", - "postcss-normalize-string": "^9.0.5", - "postcss-normalize-timing-functions": "^9.0.5", - "postcss-normalize-unicode": "^9.0.5", - "postcss-normalize-url": "^9.0.5", - "postcss-normalize-whitespace": "^9.0.5", - "postcss-ordered-values": "^9.0.7", - "postcss-reduce-initial": "^9.0.6", - "postcss-reduce-transforms": "^9.0.4", - "postcss-svgo": "^9.0.4", - "postcss-unique-selectors": "^9.0.5" + "postcss-colormin": "^9.0.8", + "postcss-convert-values": "^9.0.9", + "postcss-discard-comments": "^9.0.8", + "postcss-discard-duplicates": "^9.0.7", + "postcss-discard-empty": "^9.0.8", + "postcss-discard-overridden": "^9.0.6", + "postcss-merge-longhand": "^9.4.0", + "postcss-merge-rules": "^9.1.4", + "postcss-minify-font-values": "^9.0.9", + "postcss-minify-gradients": "^9.0.8", + "postcss-minify-params": "^9.0.8", + "postcss-minify-selectors": "^9.0.8", + "postcss-normalize-charset": "^9.0.6", + "postcss-normalize-display-values": "^9.0.7", + "postcss-normalize-positions": "^9.0.8", + "postcss-normalize-repeat-style": "^9.0.7", + "postcss-normalize-string": "^9.0.7", + "postcss-normalize-timing-functions": "^9.0.7", + "postcss-normalize-unicode": "^9.1.1", + "postcss-normalize-url": "^9.0.7", + "postcss-normalize-whitespace": "^9.0.8", + "postcss-ordered-values": "^9.0.9", + "postcss-reduce-initial": "^9.0.7", + "postcss-reduce-transforms": "^9.0.6", + "postcss-svgo": "^9.0.6", + "postcss-unique-selectors": "^9.0.6" }, "engines": { "node": "^22.22.3 || ^24.15.0 || >=26.0" }, "peerDependencies": { - "postcss": "^8.5.28" + "postcss": "^8.5.29" } }, "node_modules/cssnano-utils": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-8.2.0.tgz", - "integrity": "sha512-S8lXvsU3u0kMJ8JXQ5UekIZQbBZkOuncYaRTXGTRzztrg5Kis/nPrYnJQxPwL+LcePs9aqa/UD6mB78BoHldvw==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-9.0.0.tgz", + "integrity": "sha512-HIdFG2S06bygEl6+bZYPvA0/UyoYseXR/RpcvvwyE2NxWBbkZ70y9uRnvFwlQ4ztSlIeBqQBcwOMQG6DVrRHuA==", "license": "MIT", "dependencies": { "@csstools/css-tokenizer": "^4.0.2" @@ -6309,7 +6313,7 @@ "node": "^22.22.3 || ^24.15.0 || >=26.0" }, "peerDependencies": { - "postcss": "^8.5.28" + "postcss": "^8.5.29" } }, "node_modules/csso": { @@ -6702,9 +6706,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.451", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.451.tgz", - "integrity": "sha512-vUNKPrUzw8ByEMOxloq0a71iD/mPuPxVkbDm3G5YhnxTzOw/I6qAg2MUS0cYj/c267lWmw26yW4Vv7foxTxPLg==", + "version": "1.5.452", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.452.tgz", + "integrity": "sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg==", "license": "ISC" }, "node_modules/embla-carousel": { @@ -9622,6 +9626,12 @@ } } }, + "node_modules/nitropack/node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "license": "MIT" + }, "node_modules/nitropack/node_modules/cookie-es": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-2.0.1.tgz", @@ -9810,22 +9820,22 @@ } }, "node_modules/nuxt": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/nuxt/-/nuxt-4.6.0.tgz", - "integrity": "sha512-e7wephwGjleaIJ6ENm5KT4kYxQ9ejZLhMLqo2hNECcrK7+6rwBCGuhmHZD2bG9zOCXb3mLS8fhhkAwDCCZhmYw==", + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/nuxt/-/nuxt-4.6.1.tgz", + "integrity": "sha512-L6Emg0OezAgqVlFrQ64Mjd8NpSu3FfR0GchDmmSjYrjhGyDFgVzY0pw201UCApXJaeK/06jW7ZjxE2pLZkELTw==", "license": "MIT", "dependencies": { "@dxup/nuxt": "^0.5.10", "@jridgewell/trace-mapping": "^0.3.31", - "@nuxt/cli": "^4.0.0", + "@nuxt/cli": "^4.0.1", "@nuxt/devalue": "^2.0.2", "@nuxt/devtools": "^3.4.2", - "@nuxt/kit": "4.6.0", - "@nuxt/nitro-server": "4.6.0", - "@nuxt/schema": "4.6.0", + "@nuxt/kit": "4.6.1", + "@nuxt/nitro-server": "4.6.1", + "@nuxt/schema": "4.6.1", "@nuxt/telemetry": "^2.9.1", - "@nuxt/vite-builder": "4.6.0", - "@nuxt/vite-server": "4.6.0", + "@nuxt/vite-builder": "4.6.1", + "@nuxt/vite-server": "4.6.1", "@unhead/vue": "^3.4.2", "@vue/shared": "^3.5.43", "chokidar": "^5.0.0", @@ -9848,8 +9858,8 @@ "jiti": "^2.7.0", "klona": "^2.0.6", "knitwork": "^1.3.0", - "magic-string": "^1.4.2", - "my-bad": "^0.2.10", + "magic-string": "^1.4.3", + "my-bad": "^0.2.11", "nanotar": "^0.3.0", "nostics": "^1.3.0", "object-identity": "^0.2.3", @@ -9880,7 +9890,7 @@ "vue": "^3.5.43", "vue-bundle-renderer": "^2.4.0", "vue-component-type-helpers": "^3.3.12", - "vue-router": "^5.3.1" + "vue-router": "^5.4.0" }, "bin": { "nuxi": "bin/nuxt.mjs", @@ -10144,9 +10154,9 @@ "license": "BlueOak-1.0.0" }, "node_modules/package-manager-detector": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz", - "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.9.0.tgz", + "integrity": "sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==", "license": "MIT" }, "node_modules/pako": { @@ -10279,12 +10289,6 @@ "pathe": "^2.0.3" } }, - "node_modules/pkg-types/node_modules/confbox": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.3.1.tgz", - "integrity": "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==", - "license": "MIT" - }, "node_modules/postcss": { "version": "8.5.29", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", @@ -10314,9 +10318,9 @@ } }, "node_modules/postcss-calc": { - "version": "11.2.2", - "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-11.2.2.tgz", - "integrity": "sha512-wSquceGIY20fnsXcdIYtzW0r34aoXlT6iDYpYKpgR5Ax/KCZMDteE54Z+4Ee24ryYkjnRU5F3cxvhRfNp6t5VA==", + "version": "11.2.3", + "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-11.2.3.tgz", + "integrity": "sha512-pFW3MR2a4W7uHcSGUiyQGGXZWHon83ljbNwnUJ1mRKublWQbv+3o9NiqhlV4TJfNby+U+zLY2OgiywzhA5eFqw==", "license": "MIT", "dependencies": { "@csstools/css-tokenizer": "^4.0.2" @@ -10325,363 +10329,363 @@ "node": "^22.22.3 || ^24.15 || >=26.0" }, "peerDependencies": { - "postcss": "^8.5.28" + "postcss": "^8.5.29" } }, "node_modules/postcss-colormin": { - "version": "9.0.6", - "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-9.0.6.tgz", - "integrity": "sha512-uKsm564OtyCc0WgUhhs1+vuk6jWK+0T128NvYxiINThP5Jgn7GjsYpkc6nwiFeVpqPMXEBYsWdb3xhb4tfnT6w==", + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-9.0.8.tgz", + "integrity": "sha512-rQBcLCyI54KbpedRpCYMGoqY1IRGx/erQwh8+QjyeUCRM32sQYLAuZTJFh17VVEAV/4QuIl1RPzF3boCugHAiQ==", "license": "MIT", "dependencies": { - "@colordx/core": "^7.2.0", + "@colordx/core": "^7.3.0", "@csstools/css-tokenizer": "^4.0.2", "browserslist": "^4.29.3", "caniuse-api": "^4.0.0", - "cssnano-utils": "^8.0.2" + "cssnano-utils": "^9.0.0" }, "engines": { "node": "^22.22.3 || ^24.15.0 || >=26.0" }, "peerDependencies": { - "postcss": "^8.5.28" + "postcss": "^8.5.29" } }, "node_modules/postcss-convert-values": { - "version": "9.0.7", - "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-9.0.7.tgz", - "integrity": "sha512-H2wfTbGzeU/71cPn6z3MWco+x46XPvgAISUVT3lo1JU6wAJWWEQ71JevslwjWr3GaY0unDixcmRIT0vIn5Imqg==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-9.0.9.tgz", + "integrity": "sha512-rxfc/loH4qU3Q/PQeAJ1e+9VtKDUxBtMVa0fUjsldNj/eKnkmKbxOJSJjYt82uSKZZNCi/0L2upBOJMj2BcuqA==", "license": "MIT", "dependencies": { "browserslist": "^4.29.3", - "cssnano-utils": "^8.0.2" + "cssnano-utils": "^9.0.0" }, "engines": { "node": "^22.22.3 || ^24.15.0 || >=26.0" }, "peerDependencies": { - "postcss": "^8.5.28" + "postcss": "^8.5.29" } }, "node_modules/postcss-discard-comments": { - "version": "9.0.6", - "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-9.0.6.tgz", - "integrity": "sha512-jK5bfKASmV7fSf6rL8XEU4uJaQUe5/5OFkA8pzHooUugV73uIg2JFEMnNwdHBOXJ+bAcGPZ7b7DxkXZdtqG7Bg==", + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-9.0.8.tgz", + "integrity": "sha512-YaHPbph+EYK2TXx1UieOtLKpBecoQE+wQmrlz6ob+OWaRwabniUJ1ugkTvHxX43inPsd1iEeJ2qxLaHJFte84Q==", "license": "MIT", "dependencies": { "@csstools/css-tokenizer": "^4.0.2", - "cssnano-utils": "^8.0.2" + "cssnano-utils": "^9.0.0" }, "engines": { "node": "^22.22.3 || ^24.15.0 || >=26.0" }, "peerDependencies": { - "postcss": "^8.5.28" + "postcss": "^8.5.29" } }, "node_modules/postcss-discard-duplicates": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-9.0.5.tgz", - "integrity": "sha512-Il321Y6C/In5Uyub+wmf+9TwiYtTGg1Fk4F5DtjXXbQezeNdO3wLNeTRLXzmuihu3vHRmks2bG9di1ImyaGsQw==", + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-9.0.7.tgz", + "integrity": "sha512-rbsx1SvhBzCetQDbHVNfQrf0mG83Cyz5SPChQT2kPiXO32hWKPFtd00sy+j1NPPQJOGIVp1VHEgI55tGeqc3Kw==", "license": "MIT", "dependencies": { - "cssnano-utils": "^8.1.0" + "cssnano-utils": "^9.0.0" }, "engines": { "node": "^22.22.3 || ^24.15.0 || >=26.0" }, "peerDependencies": { - "postcss": "^8.5.28" + "postcss": "^8.5.29" } }, "node_modules/postcss-discard-empty": { - "version": "9.0.6", - "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-9.0.6.tgz", - "integrity": "sha512-Bi4fYujDBqBVKsqPOIlZ2QElVa/rDHbfz0nTrrDyML2hoKJndk/qJuc1kN2+OChHDQNIHUjfEX3jHCiGDAt3wQ==", + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-9.0.8.tgz", + "integrity": "sha512-hCGjUuCd/IxKRyVl/uaJOLJTeACXo4GkS+0kRTvO8bBs0OxrGJWhxRaRPKXCdftCI+At0aAlosIgS0yjWNubuQ==", "license": "MIT", "dependencies": { - "cssnano-utils": "^8.1.0" + "cssnano-utils": "^9.0.0" }, "engines": { "node": "^22.22.3 || ^24.15.0 || >=26.0" }, "peerDependencies": { - "postcss": "^8.5.28" + "postcss": "^8.5.29" } }, "node_modules/postcss-discard-overridden": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-9.0.5.tgz", - "integrity": "sha512-R/pSAgkbHOdPi8op7b5IyeY8Z0hJ1yemvRpNP2dh41Lm2ldjA1N1lXBdopj7vbzLzzhXLNPnQTolV+vUJ3HGVA==", + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-9.0.6.tgz", + "integrity": "sha512-9wT4dst0GdTpi3QWh/UP6ft1UiRhqNCfCKvyB4THpYzsHdpF7r2QduDBdeNUlVsgqaQhvzs+r43z3ZVZFequzQ==", "license": "MIT", "engines": { "node": "^22.22.3 || ^24.15.0 || >=26.0" }, "peerDependencies": { - "postcss": "^8.5.28" + "postcss": "^8.5.29" } }, "node_modules/postcss-merge-longhand": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-9.2.1.tgz", - "integrity": "sha512-dYwIiRP/lH2ItiUo/t9QG/Vn0ac5dPjISTTosF8yfU0PaFZcRYb8mnO82LxQtfYfUqtzWduYGrGVDyLi8N5xxQ==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-9.4.0.tgz", + "integrity": "sha512-FZqaPk9Frs6Ecxpeeb303N8DGAeYF3TpLG01mYQ1ws9FVVisQJqqHomoEmXvaEyrNyANZaHCAR5ShA1r/chM1Q==", "license": "MIT", "dependencies": { "@csstools/css-tokenizer": "^4.0.2", "browserslist": "^4.29.3", - "cssnano-utils": "^8.2.0", - "stylehacks": "^9.0.7" + "cssnano-utils": "^9.0.0", + "stylehacks": "^9.0.8" }, "engines": { "node": "^22.22.3 || ^24.15.0 || >=26.0" }, "peerDependencies": { - "postcss": "^8.5.28" + "postcss": "^8.5.29" } }, "node_modules/postcss-merge-rules": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-9.1.2.tgz", - "integrity": "sha512-hDAdZNa4MgxPK7VbKqFHz7NlmZMehhcSWP4wDi77mmmUTs6K/rTw0hekCN4t9vBhwKhM9gKsGJS/FcGQB1bZdg==", + "version": "9.1.4", + "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-9.1.4.tgz", + "integrity": "sha512-kAOfOrkLUDvBztxI8RG1ZLe5SwUs3rinFXnqdh4sPrYPz9P9FZbXNQ6XVtQKlhCvuqvR0HyrronV503u9jbxxA==", "license": "MIT", "dependencies": { "@csstools/css-tokenizer": "^4.0.2", "browserslist": "^4.29.3", "caniuse-api": "^4.0.0", - "cssnano-utils": "^8.2.0", - "postcss-minify-selectors": "^9.0.6" + "cssnano-utils": "^9.0.0", + "postcss-minify-selectors": "^9.0.8" }, "engines": { "node": "^22.22.3 || ^24.15.0 || >=26.0" }, "peerDependencies": { - "postcss": "^8.5.28" + "postcss": "^8.5.29" } }, "node_modules/postcss-minify-font-values": { - "version": "9.0.7", - "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-9.0.7.tgz", - "integrity": "sha512-cnje2Aby5P4o0IUvy02yhQ9wJ8PJNYd32iYFnM3/2G+1nvoUs/hRAkyFf2WfGlgtBJy24QPMYdWRUQ6CDm1MYw==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-9.0.9.tgz", + "integrity": "sha512-eJkOtHU/zZWJJ7c1MWgalpACGV0sERvtowpWDrgr9Ys0seX5vFBx9pWLOrN8eYO2TIOfMvatMhvgtGKtre1gsg==", "license": "MIT", "dependencies": { "@csstools/css-tokenizer": "^4.0.2", - "cssnano-utils": "^8.1.0" + "cssnano-utils": "^9.0.0" }, "engines": { "node": "^22.22.3 || ^24.15.0 || >=26.0" }, "peerDependencies": { - "postcss": "^8.5.28" + "postcss": "^8.5.29" } }, "node_modules/postcss-minify-gradients": { - "version": "9.0.6", - "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-9.0.6.tgz", - "integrity": "sha512-r0u1zcMXFn2xmafxONtZhmmeL/e5f3d9MK4vReuG1y58Vfn8iSWIzF3yiU9+QOtJjb+c2Fd+ZeaM08dkMD/ETQ==", + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-9.0.8.tgz", + "integrity": "sha512-3xGO5lvhSz5m60ElGvU8HJYt0XmYpFkJ+HDK8CYoRvwdmQIjhurClan2z4lDEVkYpeKfrc5dNWIYKie01xhpng==", "license": "MIT", "dependencies": { - "@colordx/core": "^7.2.0", + "@colordx/core": "^7.3.0", "@csstools/css-tokenizer": "^4.0.2", - "cssnano-utils": "^8.0.2" + "cssnano-utils": "^9.0.0" }, "engines": { "node": "^22.22.3 || ^24.15.0 || >=26.0" }, "peerDependencies": { - "postcss": "^8.5.28" + "postcss": "^8.5.29" } }, "node_modules/postcss-minify-params": { - "version": "9.0.6", - "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-9.0.6.tgz", - "integrity": "sha512-HbQdOob8KqH+IslULbA52A/uZZUTArDxJ+eDqSnWq7cnCvGets/H/AQU998+BlFgB4Nv5bngWeFVtWp4we26cw==", + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-9.0.8.tgz", + "integrity": "sha512-DZNhq6Lz9i4EOiuImFBqldE6XUXFvEKSA2wQnn539c24naj/Ia2Qn1QVf56bva+UiMYurM1sn217yaap+pQBYQ==", "license": "MIT", "dependencies": { "browserslist": "^4.29.3", - "cssnano-utils": "^8.0.2" + "cssnano-utils": "^9.0.0" }, "engines": { "node": "^22.22.3 || ^24.15.0 || >=26.0" }, "peerDependencies": { - "postcss": "^8.5.28" + "postcss": "^8.5.29" } }, "node_modules/postcss-minify-selectors": { - "version": "9.0.6", - "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-9.0.6.tgz", - "integrity": "sha512-JOZcNGgwy+E4eR+cy9B0KPa4DUsNHOp899xYEJW8dvpsAFuzBzJXrbnKaAEnbd3nvE4nr4Z+ECQiZTswLPsdkg==", + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-9.0.8.tgz", + "integrity": "sha512-qMeQvr3ODymnvoDSwml4uTWufma0T2FhRxce4htQ+45otzsm1QyNAg98DXGrawVpGooU2vElaBd8qI+YCwF0UQ==", "license": "MIT", "dependencies": { "browserslist": "^4.29.3", "caniuse-api": "^4.0.0", - "cssesc": "^3.0.0", - "cssnano-utils": "^8.0.2" + "cssnano-utils": "^9.0.0" }, "engines": { "node": "^22.22.3 || ^24.15.0 || >=26.0" }, "peerDependencies": { - "postcss": "^8.5.28" + "postcss": "^8.5.29" } }, "node_modules/postcss-normalize-charset": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-9.0.5.tgz", - "integrity": "sha512-mmbQmD9mlz3pTo+r4KdwcfD23cNksQo7aYNa4lfL4Bsj0XkL+rtcbNOY7Oo+U82DP1syYXCyF1PRc32gb36MqA==", + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-9.0.6.tgz", + "integrity": "sha512-H336j3ofJ5nM6TLW1nbKHj3zIMtGkycfS0KetU1cmcTtTwQ0f9CvdLVZUmBja6+IO3xuqMogjkYtB91K2slshQ==", "license": "MIT", "engines": { "node": "^22.22.3 || ^24.15.0 || >=26.0" }, "peerDependencies": { - "postcss": "^8.5.28" + "postcss": "^8.5.29" } }, "node_modules/postcss-normalize-display-values": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-9.0.5.tgz", - "integrity": "sha512-V5tOZuYQtOpYNjoSjciDckB8Ry+s2NcHqVHloYe5Xh/6OA2Z3kVymAck3zipZ1uLBdMvg7BUvxoDIcH6dJCPug==", + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-9.0.7.tgz", + "integrity": "sha512-M9eGIjAdIjuO6t4HqQJBOjbC+rI+9hF/srxppXa7xAY7EpCwdYVepq8PEAad1pb8EfEWoSCGpK9++nCcw5rl+w==", "license": "MIT", "dependencies": { "@csstools/css-tokenizer": "^4.0.2", - "cssnano-utils": "^8.0.1" + "cssnano-utils": "^9.0.0" }, "engines": { "node": "^22.22.3 || ^24.15.0 || >=26.0" }, "peerDependencies": { - "postcss": "^8.5.28" + "postcss": "^8.5.29" } }, "node_modules/postcss-normalize-positions": { - "version": "9.0.6", - "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-9.0.6.tgz", - "integrity": "sha512-n69U09h5ej9q7/mNCDdA5TJpJEgfIoLaEPZX1zMtWE+QXysEm9K62GfDxuHYnOEQle0s/HhpT5wQ/BqFGrHQ4Q==", + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-9.0.8.tgz", + "integrity": "sha512-LymeX5GrQjS2N6xyBUpKsVMX+DSufYr1OSJYmM3iWVYre0zeBkKiaWNNqLeiJZMcdHgfcRw0HsLnhh7mNhO60A==", "license": "MIT", "dependencies": { - "cssnano-utils": "^8.0.2" + "cssnano-utils": "^9.0.0" }, "engines": { "node": "^22.22.3 || ^24.15.0 || >=26.0" }, "peerDependencies": { - "postcss": "^8.5.28" + "postcss": "^8.5.29" } }, "node_modules/postcss-normalize-repeat-style": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-9.0.5.tgz", - "integrity": "sha512-1QZ9ZdyWb4TRDCXIg8iqeACo6BWKjxtfeMgMSy6ORqX21gKN4lYYQqZpxPdILpvbLaEfXGVd0ptMCiKbxCObjA==", + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-9.0.7.tgz", + "integrity": "sha512-13iZyi8hUtMEUO3vSQVOH20ByhT8zzEx/2cmONY/tcfZYnMwgPGxn+p5OvJZTbmxhUDqSFsIWpBVBIEA5fJeYQ==", "license": "MIT", "dependencies": { - "cssnano-utils": "^8.0.1" + "cssnano-utils": "^9.0.0" }, "engines": { "node": "^22.22.3 || ^24.15.0 || >=26.0" }, "peerDependencies": { - "postcss": "^8.5.28" + "postcss": "^8.5.29" } }, "node_modules/postcss-normalize-string": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-9.0.5.tgz", - "integrity": "sha512-470sWbU7N/kwSOYl+9yJUzq1BWSHAVLP86E89Ap5RGZGXmBnn7jmwF2xgH+a7uSAkaBhTGu/hf4R1U5gGHqEHw==", + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-9.0.7.tgz", + "integrity": "sha512-1XZyPwvQpoYewPpveU70LjTA/L17vxECuHnSf4Fg3lRgTzFza9Nxbk1lztTQ41+6PlNKxB2lXX/HGRX5QCbLQg==", "license": "MIT", "dependencies": { - "@csstools/css-tokenizer": "^4.0.2" + "@csstools/css-tokenizer": "^4.0.2", + "cssnano-utils": "^9.0.0" }, "engines": { "node": "^22.22.3 || ^24.15.0 || >=26.0" }, "peerDependencies": { - "postcss": "^8.5.28" + "postcss": "^8.5.29" } }, "node_modules/postcss-normalize-timing-functions": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-9.0.5.tgz", - "integrity": "sha512-uw5Y1xNsnh1Z8XB1I4Sx6/T7GKfdBTpX7X7ywDibb35IIYjJLf68qjBRDl59ETYrWkoGxZXIYU74RcO6cAjjmw==", + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-9.0.7.tgz", + "integrity": "sha512-fWFD289YZXKgIsi28EjT5FuM2NMkEp/5zj1uZZJj/OCXTXun3KFVn2/cbU9iSfgmEbNlJ+uxyFw6GW2aQybKgw==", "license": "MIT", "dependencies": { "@csstools/css-tokenizer": "^4.0.2", - "cssnano-utils": "^8.0.1" + "cssnano-utils": "^9.0.0" }, "engines": { "node": "^22.22.3 || ^24.15.0 || >=26.0" }, "peerDependencies": { - "postcss": "^8.5.28" + "postcss": "^8.5.29" } }, "node_modules/postcss-normalize-unicode": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-9.0.5.tgz", - "integrity": "sha512-mDIcHiI0JMEchOA+OdTNAkhdb6IUIJFocVuacszLC/zFJlhyc0GUeZYIkaGVg/tEj3X8jVnd8Xetm8iN/0cAvQ==", + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-9.1.1.tgz", + "integrity": "sha512-DRS7ywJHmYDvxWoMc5ran1Yc18tbnvx5g3o8rX6N1JFGrwdlsOcRZ8NyGx35/y+GxgjdpWEBXdnSGmoF7mK4mQ==", "license": "MIT", "dependencies": { "@csstools/css-tokenizer": "^4.0.2", "browserslist": "^4.29.3", - "cssnano-utils": "^8.0.2" + "cssnano-utils": "^9.0.0" }, "engines": { "node": "^22.22.3 || ^24.15.0 || >=26.0" }, "peerDependencies": { - "postcss": "^8.5.28" + "postcss": "^8.5.29" } }, "node_modules/postcss-normalize-url": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-9.0.5.tgz", - "integrity": "sha512-iGf8SEx4Abu9XqmubHU7Prl7dWT0gk+VethwzfY40Zo9y3VItrz8QSDFMCxQmWpphXiU0jGq5K4uoiSi2ySnNw==", + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-9.0.7.tgz", + "integrity": "sha512-tIHAU7kcrNCblle/wnMQUGoxnuSaTX2kzcXG52k7gfLQdZklOxqZwO59eEgbixAX9llT67oiQVUewYyyFCvf4w==", "license": "MIT", "dependencies": { "@csstools/css-tokenizer": "^4.0.2", - "cssnano-utils": "^8.0.1" + "cssnano-utils": "^9.0.0" }, "engines": { "node": "^22.22.3 || ^24.15.0 || >=26.0" }, "peerDependencies": { - "postcss": "^8.5.28" + "postcss": "^8.5.29" } }, "node_modules/postcss-normalize-whitespace": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-9.0.5.tgz", - "integrity": "sha512-vaDslAUVqguYgC2jMb90Rc+N904rPx4XtYIWhaI/8m6OYx7+EOgsQmTLOGB4WjDAIMOgdBVTQqub8dQSoSVyww==", + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-9.0.8.tgz", + "integrity": "sha512-YhqcSyZX6fVIM6sZFf/nd3ZOEEclNZmLSgork6NgetpnQK0QOCyIuT/h3nVja+up5hXCVffl6VcGVqSL8kS4OA==", "license": "MIT", "dependencies": { "@csstools/css-tokenizer": "^4.0.2", - "cssnano-utils": "^8.0.1" + "cssnano-utils": "^9.0.0" }, "engines": { "node": "^22.22.3 || ^24.15.0 || >=26.0" }, "peerDependencies": { - "postcss": "^8.5.28" + "postcss": "^8.5.29" } }, "node_modules/postcss-ordered-values": { - "version": "9.0.7", - "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-9.0.7.tgz", - "integrity": "sha512-vEXN/t5iqwZvRAURoAsp06CxIBwJ6o9ViDAbENZPd6KgLys2U/z4UIafket5S/L2ps7cR216dN++Vt6d28frqQ==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-9.0.9.tgz", + "integrity": "sha512-bEeFjrZITn6H81YjFgctaoY5XPXEQeS5NOwMroB47CocSfgnbpc9wH2QzNLXxnhcfEdsGCkTAi8kATYY/EkyTw==", "license": "MIT", "dependencies": { - "cssnano-utils": "^8.2.0" + "cssnano-utils": "^9.0.0" }, "engines": { "node": "^22.22.3 || ^24.15.0 || >=26.0" }, "peerDependencies": { - "postcss": "^8.5.28" + "postcss": "^8.5.29" } }, "node_modules/postcss-reduce-initial": { - "version": "9.0.6", - "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-9.0.6.tgz", - "integrity": "sha512-Jjj9edyokvyJ/BJ0X/8S6zHrLSKziTX0lwd4nhJwbEkaRut9v6F79sdy6tC+69KDyo8ILsCGXeR41agJSt7Kfg==", + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-9.0.7.tgz", + "integrity": "sha512-5mnNVdVCf7tI1j2uYtiopoLZ6Dm+M7y4Fz32STAtTEZa7LbOf44zYBYduwd952xXshTJkRHpT2Q2UmK+XLo5ew==", "license": "MIT", "dependencies": { "browserslist": "^4.29.3", @@ -10691,45 +10695,45 @@ "node": "^22.22.3 || ^24.15.0 || >=26.0" }, "peerDependencies": { - "postcss": "^8.5.28" + "postcss": "^8.5.29" } }, "node_modules/postcss-reduce-transforms": { - "version": "9.0.4", - "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-9.0.4.tgz", - "integrity": "sha512-hRzepc/cwIs1cCdIghQatRDFlxc7qQieXzkyCfyVl8KPTPhOZyOtdPp9Qt90fMbqWry6YdMQu/YfScxh47sTYg==", + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-9.0.6.tgz", + "integrity": "sha512-xW/3eNKeL9vVXNMiJdI7ythFkC4CGDq04ziTN0/oJwJXIKeJCenLbM9IXU5ton/iNrwtqzZphERdtad4auQ9wQ==", "license": "MIT", "dependencies": { - "cssnano-utils": "^8.0.0" + "cssnano-utils": "^9.0.0" }, "engines": { "node": "^22.22.3 || ^24.15.0 || >=26.0" }, "peerDependencies": { - "postcss": "^8.5.28" + "postcss": "^8.5.29" } }, "node_modules/postcss-svgo": { - "version": "9.0.4", - "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-9.0.4.tgz", - "integrity": "sha512-0QGF1sORc244E+DNJxEzQDK/zK24VO0CMzAMOwOW+cQ90Y65mVDR2yrSxp4/f+XcdxmAf5LPm22zlnhVUTbtAA==", + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-9.0.6.tgz", + "integrity": "sha512-XOZ3r+Zq252CoHZb1zzpRzKyO70XKJBnQgEw8QWIkqqS2k0MJAjKnXmUsoL/nsPzk1APhxyXSQuxfkwSlZpkrA==", "license": "MIT", "dependencies": { "@csstools/css-tokenizer": "^4.0.2", - "cssnano-utils": "^8.0.0", + "cssnano-utils": "^9.0.0", "svgo": "^4.1.0" }, "engines": { "node": "^22.22.3 || ^24.15.0 || >=26.0" }, "peerDependencies": { - "postcss": "^8.5.28" + "postcss": "^8.5.29" } }, "node_modules/postcss-unique-selectors": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-9.0.5.tgz", - "integrity": "sha512-1otppE5FwWqmIM1jqBpeAdA0GSP9wwzRCRsbz7EtbSNunh3bJ9PPRuYYzByvqnz7L351ZA2fDHC0CcouYoPWfA==", + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-9.0.6.tgz", + "integrity": "sha512-mhXCmLZbqDtzM2JbtjGP36555/aob2fbOXSW8cP0SiNtssVSKB8aG7EbiAhVZKV/toJLqKJm1ayWArvzc5+huA==", "license": "MIT", "dependencies": { "@csstools/css-tokenizer": "^4.0.2" @@ -10738,7 +10742,7 @@ "node": "^22.22.3 || ^24.15.0 || >=26.0" }, "peerDependencies": { - "postcss": "^8.5.28" + "postcss": "^8.5.29" } }, "node_modules/postcss-value-parser": { @@ -11513,9 +11517,9 @@ } }, "node_modules/seroval": { - "version": "1.6.8", - "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.6.8.tgz", - "integrity": "sha512-HlSgSAkTk4EqHcje1ptJjfZi1YDv5KbhVJ/d3P7T/nAXua2VmDu+AKDX5VTdFfZf48nDkWB2TKYt0DrCSa+3wg==", + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.6.9.tgz", + "integrity": "sha512-UeEzHwAK+yl0YLbsvROV1t5yMYEwZZ0CfKYxDvUgS2fO9Vxdf8djqVIB+OuVpuawRNPqw68LE1Du/GMsW2ZPqA==", "license": "MIT", "engines": { "node": ">=10" @@ -11911,9 +11915,9 @@ "license": "ISC" }, "node_modules/stylehacks": { - "version": "9.0.7", - "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-9.0.7.tgz", - "integrity": "sha512-KIpCgyoHOxm3wKEGPLvvrQT8bl0E/qCF+MkO83Pe7469JDsWE7PT0k2AlZJFivO+9j/222q+sI8EKSAalIRkNA==", + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-9.0.8.tgz", + "integrity": "sha512-PIopN/yo+/Queh2lTvJLQuXJ5yrqxWk6fQTN10daJVjnEaqkM+i+IjQdc8Jzl2fnJ8Ngq1vb57vI0hoLkKs22w==", "license": "MIT", "dependencies": { "@csstools/css-tokenizer": "^4.0.2", @@ -11923,7 +11927,7 @@ "node": "^22.22.3 || ^24.15.0 || >=26.0" }, "peerDependencies": { - "postcss": "^8.5.28" + "postcss": "^8.5.29" } }, "node_modules/subset-font": { @@ -13523,21 +13527,15 @@ } } }, - "node_modules/vue-router/node_modules/confbox": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.3.1.tgz", - "integrity": "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==", - "license": "MIT" - }, "node_modules/vue-tsc": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.12.tgz", - "integrity": "sha512-kbpaz18O9jPVkXVHD1BEjpUXM6WhXSu+62L1sGGr6Wbzd8GbvkBQWjoj3KZ/C0aq2MZllce7UdtWzwCIYsBMGA==", + "version": "3.3.13", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.13.tgz", + "integrity": "sha512-jE4/bXdOMSHVxEGgQCqNllJy+9N3Gx6tfSAsg0yhFK/MAN6eXqISihupnDEKSCt12VVnjN2gI3AqLYcqoh5x/A==", "devOptional": true, "license": "MIT", "dependencies": { "@volar/typescript": "2.4.28", - "@vue/language-core": "3.3.12" + "@vue/language-core": "3.3.13" }, "bin": { "vue-tsc": "bin/vue-tsc.js" @@ -13546,22 +13544,6 @@ "typescript": ">=5.0.0" } }, - "node_modules/vue-tsc/node_modules/@vue/language-core": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.3.12.tgz", - "integrity": "sha512-k7uIKR0n4CBidk233y7cac9osnDQ5WRidTl6nsrNvpZ3nf8cx9iJxRVsRzVsoPRHbowT/iKgGMTS9RGov3jeIA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@volar/language-core": "2.4.28", - "@vue/compiler-dom": "^3.5.0", - "@vue/shared": "^3.5.0", - "alien-signals": "^3.2.1", - "muggle-string": "^0.4.1", - "path-browserify": "^1.0.1", - "picomatch": "^4.0.4" - } - }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", diff --git a/package.json b/package.json index 8fe4da1..f152df1 100644 --- a/package.json +++ b/package.json @@ -38,14 +38,14 @@ "@nuxt/ui": "^4.11.2", "bcryptjs": "^3.0.3", "marked": "^18.1.0", - "nuxt": "^4.6.0", + "nuxt": "^4.6.1", "shiki": "^4.4.3", "vue": "^3.5.43", "vue-router": "^5.4.0" }, "devDependencies": { "@tailwindcss/vite": "^4.3.3", - "@types/node": "^26.6.4", + "@types/node": "^26.6.5", "axe-core": "^4.14.0", "fontverter": "^2.0.0", "harfbuzzjs": "^1.6.2", @@ -54,7 +54,7 @@ "tailwindcss": "^4.3.3", "typescript": "npm:@typescript/typescript6@^6.0.2", "vitest": "^5.0.2", - "vue-tsc": "^3.3.12" + "vue-tsc": "^3.3.13" }, "overrides": { "source-map-js": "^1.2.2" From 9cba5242aec0a03f667ea15c44414cd3e747023a Mon Sep 17 00:00:00 2001 From: Justin Icenhour Date: Sat, 10 Oct 2026 22:19:22 -0500 Subject: [PATCH 4/7] fix(checks): check:navigation allows half a line on a restored position main's CI went red after #20 merged on one check:navigation case: a reload of onyums' README at 390 px landed at 5,425 against the 5,418 the reader left, outside the 4 px slack. Reproduced locally at +2 px on about one reload in three. The site's own scrollTo lands exactly; the extra pixels arrive in the next frame with no script scrolling (every scrolling API hooked), page height and README offset unchanged, and with scrollRestoration manual or overflow-anchor: none alike, so it is the browser's own. The slack is now 12 px, half a 23 px line; the bug this check guards against was 3,300 px. check:navigation 144/144 in Chromium and in Firefox on this build. - scripts/check-navigation.mjs - ROADMAP.md: this and the a11y scroll fix Co-Authored-By: Claude Opus 5.5 (1M context) --- ROADMAP.md | 23 +++++++++++++++++++++-- scripts/check-navigation.mjs | 15 +++++++++++++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 726bc5d..bf0e1f0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -899,8 +899,8 @@ record. There is no run log. (`scripts/check-navigation.mjs`), in CI in both engines. None of it runs on the server, so nothing else could see it break. On every about page whose README has a heading: unfold and read down, then Back, Forward and - Back, and a reload all return to the same place (±4 px of where the - reader was once the README's lazy images settled); a visit by link + Back, and a reload all return to the same place (±12 px, half a line, + of where the reader was once the README's lazy images settled); a visit by link starts folded at the top; a cold load of `about#` lands on the heading; a client navigation to it shows it; a fold the visitor closed stays closed on Back. At 1280 and 390 px, 128 checks per engine, green @@ -915,6 +915,25 @@ record. There is no run log. number, which a fresh document load can reuse, so a cold link to a heading went to an older reading position instead; it is now read only on a reload or Back/Forward. +- [x] …and `check:navigation` stopped failing main on a few pixels. The run + on main after #20 merged went red on one case: a reload of onyums' + README at 390 px landed at 5,425 against 5,418 left, outside a ±4 px + slack. Reproduced locally at +2 px on about one reload in three. The + site's own `scrollTo` puts the page exactly where it was left; the + extra pixels arrive in the next frame with no script scrolling (every + scrolling call hooked), the page height and the README's offset + unchanged, and with `history.scrollRestoration` manual or + `overflow-anchor: none` alike — somewhere in the browser itself. The + slack is now half a line of body text (12 px). The bug the check + exists for was 3,300 px. Both engines 144/144. +- [x] The browser accessibility pass ran axe on a page still scrolling. The + site scrolls smoothly, so `check-a11y-browser.mjs`'s bare `scrollTo` + calls only started animations: its reading pass stalled about a screen + down, and its return to the top was still ~1,700 px out when axe ran, + so links passing under the fixed masthead failed `target-size` as + "partially obscured" — /status in Firefox, one run in four + (2026-10-10). Every jump is `instant` now, and the pass throws if the + page is not at the top when axe starts. - [x] …and a README opened before the app hydrates stays open. Binding the fold to `open` made hydration write the bound `false` over the element, so a reader who opened it in the first moments — on a slow line, Tor diff --git a/scripts/check-navigation.mjs b/scripts/check-navigation.mjs index 1e1075a..d690bd9 100644 --- a/scripts/check-navigation.mjs +++ b/scripts/check-navigation.mjs @@ -36,8 +36,19 @@ const BASE = (args.find((a) => !a.startsWith('--')) ?? 'http://127.0.0.1:3000').replace(/\/$/, '') const ENGINE = args.includes('--firefox') ? 'firefox' : 'chromium' -/** How far from the saved position a restored one may land, in CSS pixels. */ -const SLACK = 4 +/** + * How far from the saved position a restored one may land, in CSS pixels: + * half a line of body text (14 px × 1.65). A reload can land a couple of + * pixels past where the page was left, after the site's own `scrollTo` has + * put it exactly there: +2 px on about one reload in three here (onyums at + * 390 px, 2026-10-10), +7 on main's CI run after #20, which failed this + * check at 4. No script scrolls in between (every scrolling call was + * hooked), the page's height and the README's offset do not move, and it + * happens with `history.scrollRestoration` manual and `overflow-anchor: + * none` too — something in the browser's own first frames. A reader cannot + * see seven pixels; the bug this guards against was 3,300. + */ +const SLACK = 12 /** Where a heading scrolled to may sit: its scroll margin clears the masthead. */ const HEADING_BAND = [-2, 200] From 83dc3038279b1447a14a67c174331aca68ef07ae Mon Sep 17 00:00:00 2001 From: Justin Icenhour Date: Sat, 10 Oct 2026 22:21:07 -0500 Subject: [PATCH 5/7] docs(roadmap): the cfIp log field, and a verified client address at the edge (owner) Research from tonight: Cloudflare's header reference, Docker's port-publishing notes on the userland proxy, Caddy's trusted_proxies and client_ip_headers. Co-Authored-By: Claude Opus 5.5 (1M context) --- ROADMAP.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index bf0e1f0..b0e9af3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -207,6 +207,30 @@ record. There is no run log. now take those fields from one `requester()` (`server/plugins/request-log.ts`); still one line per request, HEAD still logged as HEAD, onion requests still `ip: null`. +- [x] …and it names a visitor again. 5,548 of 5,555 lines in 21 hours of + live log (2026-10-10) carried `ip: 172.18.0.1`: Caddy trusts no proxy, + so its `x-forwarded-for` is its own peer, and that peer is Docker's + bridge gateway. Each line now also carries `cfIp`, from Cloudflare's + `cf-connecting-ip`, which Caddy passes through + (`shared/log/client.ts`). Logged under its own name because nothing on + the way in checks that a request came through Cloudflare; nothing in + the site decides anything from it. Over Tor, neither address. + +- [ ] A verified client address at the edge — **owner's stack**. Caddy sees + every clearnet request from `172.18.0.1`, so neither its + `{client_ip}` nor the `x-forwarded-for` it hands the site is the + visitor, and Caddy cannot tell Cloudflare from anyone else who reaches + the origin. Docker's docs say a published port's IPv6 traffic to an + IPv4-only bridge network goes through the userland proxy, which + connects from the gateway; this host has a global IPv6 address and + `caddy-shared-network` is IPv4-only, which would fit, but whether + Cloudflare reaches the origin over IPv6 is in the Cloudflare + dashboard, not here. With the real peer in hand, Caddy's + `trusted_proxies` set to Cloudflare's ranges plus `client_ip_headers + CF-Connecting-IP` would make `x-forwarded-for` the verified visitor and + `cfIp` redundant. + + - [x] `/api/posts?project=` is a 404, like `/api/projects/`, not an empty list. - [x] Answer HEAD wherever GET is answered. Nitro routes by filename suffix, so From 2e3cd524af909b6e58ae166e494e8d12db55a3ab Mon Sep 17 00:00:00 2001 From: Justin Icenhour Date: Sat, 10 Oct 2026 22:21:24 -0500 Subject: [PATCH 6/7] chore(data): refresh the fallback snapshot npm run sync, 2026-10-11 03:3x UTC: all 8 projects; Nanna's v0.3.42-beta.51, WeftDB's README and pushedAt, counts. Co-Authored-By: Claude Opus 5.5 (1M context) --- data/projects.generated.json | 80 ++++++++++++++++++------------------ 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/data/projects.generated.json b/data/projects.generated.json index fcbf036..8f54726 100644 --- a/data/projects.generated.json +++ b/data/projects.generated.json @@ -1,10 +1,10 @@ { - "generatedAt": "2026-10-10T05:05:54.527Z", + "generatedAt": "2026-10-11T03:21:16.778Z", "org": "basic-automation", "repos": { "artiqwest": { "repo": "artiqwest", - "fetchedAt": "2026-10-10T05:05:43.274Z", + "fetchedAt": "2026-10-11T03:21:08.207Z", "description": "basic http request over tor using arti.", "htmlUrl": "https://github.com/basic-automation/artiqwest", "homepage": null, @@ -62,7 +62,7 @@ }, "onyums": { "repo": "onyums", - "fetchedAt": "2026-10-10T05:05:44.946Z", + "fetchedAt": "2026-10-11T03:21:09.675Z", "description": "Onion-service server using axum.", "htmlUrl": "https://github.com/basic-automation/onyums", "homepage": null, @@ -121,13 +121,13 @@ }, "download": null, "crateVersion": "0.5.0", - "crateDownloads": 19749, + "crateDownloads": 19752, "crateUrl": "https://crates.io/crates/onyums", "docsUrl": "https://docs.rs/onyums" }, "nisaba": { "repo": "nisaba", - "fetchedAt": "2026-10-10T05:05:51.893Z", + "fetchedAt": "2026-10-11T03:21:13.889Z", "description": "Desktop app for managing one product catalog and syncing it across eBay, Squarespace, XMR Bazaar and Amazon. Rust + Tauri 2 + Nuxt 3, with sandboxed vendor plugins and optional P2P sync over Tor.", "htmlUrl": "https://github.com/basic-automation/nisaba", "homepage": null, @@ -163,7 +163,7 @@ }, "Skidbladnir": { "repo": "Skidbladnir", - "fetchedAt": "2026-10-10T05:05:52.687Z", + "fetchedAt": "2026-10-11T03:21:14.639Z", "description": "Free, open-source desktop app (Windows, Linux, macOS) that converts images to WebP, AVIF, JPEG XL and HEIC, with every still-image option of the official encoders (cwebp, avifenc, cjxl, heif-enc) as labelled controls.", "htmlUrl": "https://github.com/basic-automation/Skidbladnir", "homepage": "https://basicautomation.io/projects/skidbladnir/about", @@ -341,7 +341,7 @@ }, "enlil": { "repo": "enlil", - "fetchedAt": "2026-10-10T05:05:51.184Z", + "fetchedAt": "2026-10-11T03:21:13.148Z", "description": "Bare-Metal Mesh Hypervisor", "htmlUrl": "https://github.com/basic-automation/enlil", "homepage": null, @@ -349,11 +349,11 @@ "topics": [], "stars": 0, "forks": 0, - "openIssues": 1, + "openIssues": 0, "license": "MIT", "defaultBranch": "master", "createdAt": "2026-03-26T14:25:45Z", - "pushedAt": "2026-10-10T02:41:16Z", + "pushedAt": "2026-10-11T03:19:06Z", "archived": false, "readmeHtml": "

Enlil

\n
\n

Enlil — the Sumerian god who separated heaven from earth, ruled the space between, and assigned domains to lesser gods.

\n
\n

A portable, bare-metal-capable Type-1 hypervisor written in Rust that turns a single\nx86 desktop into multiple transparent virtual PCs — with granular peripheral routing, GPU\nsharing, and a path toward pooling hardware across many physical machines.

\n
    \n
  • Language: 100% Rust (no_std for the core, std for tooling)
  • \n
  • Foundation: the RustVMM crate ecosystem
  • \n
  • Target hardware: x86-64 desktops with Intel VT-x/VT-d or AMD-V/AMD-Vi (ARM & RISC-V planned)
  • \n
  • Boot model: a UEFI application on a USB stick — non-destructive, coexists with your existing OS
  • \n
  • License: MIT
  • \n
  • Security: pre-1.0 research software — do not run untrusted guests. See SECURITY.md.
  • \n
\n
\n

What is Enlil?

\n

Most virtualization runs N guests on 1 host. Enlil's near-term goal is to make a single\nmachine feel like several independent PCs — each guest pinned to its own cores and memory,\neach seeing what looks like real, dedicated hardware — and to do it transparently enough\nthat a guest OS (including Windows, with anti-VM-detection hardening) cannot tell it is\nvirtualized.

\n

The longer-term vision is logical machines over a physical pool: Enlil mediates both\ndirections of every hardware interaction (guest→hardware traps and hardware→guest\ninterrupts) and can route them across a pool of nodes. That turns physical machines into\nstateless hardware providers and each guest into a logical machine defined only by a\nresource manifest and a routing table — hardware disaggregation underneath unmodified\noperating systems. See the Core Model\nbelow for the full picture.

\n

A defining usability constraint shapes the whole project: Enlil must be testable without\nmodifying your system. It boots from a USB stick as a standard UEFI application\n(/EFI/BOOT/BOOTX64.EFI), loads its config and per-guest virtual firmware from that stick,\nand leaves every existing drive and bootloader untouched. Unplug it and reboot to return to\nbare metal instantly.

\n
┌─────────────┐  ┌─────────────┐  ┌─────────────┐\n│  Guest OS 1 │  │  Guest OS 2 │  │  Management  │\n│ (Windows)   │  │  (Linux)    │  │   Console    │\n├─────────────┤  ├─────────────┤  ├─────────────┤\n│ Virtual HW  │  │ Virtual HW  │  │  Config API  │\n│ vCPU · vGPU │  │ vCPU · vGPU │  │  USB Route   │\n│ vUSB · vNIC │  │ vUSB · vNIC │  │  GPU Policy  │\n├─────────────┴──┴─────────────┴──┴─────────────┤\n│                  ENLIL CORE                    │\n│   CPU/Mem Sched · GPU Arbiter · USB Router ·   │\n│            Storage / Net Manager               │\n├────────────────────────────────────────────────┤\n│           UEFI Firmware (boot payload)         │\n├────────────────────────────────────────────────┤\n│                Physical Hardware               │\n└────────────────────────────────────────────────┘

Each guest gets its own virtual UEFI (OVMF) and its own bootloader inside the VM — Enlil\nsits below everything and never replaces GRUB or the Windows Boot Manager.

\n
\n

Core Model — Logical Machines over a Physical Pool

\n

The near-term product (transparent virtual PCs on one machine, described in What is Enlil?) is one point on a much larger design. Enlil mediates both directions of every hardware interaction — the guest→hardware path (VM-exits, MMIO/PIO traps, hypercalls, VirtIO kicks) and the hardware→guest path (physical IRQs, DMA completions, input events, captured on the node that owns the device and injected as virtual interrupts on the node running the guest). Because both directions pass through Enlil, the mapping from a guest to the hardware it runs on is arbitrary and may cross machine and network boundaries.

\n

That turns classic virtualization inside out. Instead of N guests on 1 host, Enlil aims for N guests on M hosts, fully composable: each physical machine becomes a stateless hardware node (a resource provider), and each guest becomes a logical machine defined only by a resource manifest plus a routing table. It is hardware disaggregation underneath unmodified operating systems — the ambition behind CXL, RDMA device pools, and LegoOS, but working transparently beneath stock Windows and Linux rather than requiring a custom OS.

\n

Three physical laws bound what such a pool can actually do, and they shape the whole design.

\n

Interconnect latency sets the granularity of sharing

\n

How tightly two nodes can share resources is governed by the latency of the link between them. Local DRAM is ~80 ns; a WAN round-trip is ~20–40 ms — roughly 100,000× slower — so a vCPU can never run against RAM that lives thousands of miles away.

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
InterconnectAdded latencyWhat can be pooled transparently
On-board PCIe / CXLsub-µs (~200–400 ns)almost anything, including memory as a NUMA tier and live devices
RDMA, same datacenter~1–5 µsstorage, NIC, GPU-compute, cold-page memory — not a hot vCPU's RAM
WAN, 1000s of miles~20–40 ms RTTcoarse/async only: remote storage, streamed I/O, whole-guest migration, replication
\n

The rule that falls out: a single kernel's tightly-coupled hot CPU+RAM working set stays on one node (or one CXL-NUMA domain). Across distance you move or replicate a guest — you don't share its hot state. Everything else (devices, storage, GPU offload, spare capacity) is poolable with latency-aware placement. To make this practical, every virtual device is built as a front-end/back-end pair behind a pluggable transport (local | CXL | RDMA | network), so remoting a device is a transport swap rather than a rewrite.

\n

ISA sets the boundary of a logical machine

\n

The latency law has a hardware-architecture twin: a single kernel's execution cannot span an ISA boundary. Page-table formats, the interrupt model, atomics, and the memory model are all ISA-fixed, and no machine description a stock OS accepts (ACPI or device tree) can express mixed-ISA SMP. So every logical machine carries a placement key — (ISA, vendor, feature baseline) — frozen at creation; its vCPUs run natively only on matching nodes, and that key defines its live-migration domain. Foreign-ISA nodes can still serve every other lane (device back-ends, memory tiers, fabric work units are ISA-blind).

\n

A guest may optionally run via binary translation, expressed as a per-guest execution mode:

\n
    \n
  • transparent — vCPUs run natively on matching nodes; remote nodes contribute device back-ends only. This is the full-fidelity product.
  • \n
  • hybrid — native vCPUs plus a translated compute device (an opt-in paravirtual accelerator); the native surface stays fully stealthed.
  • \n
  • mesh — all vCPUs translated (JIT); location- and ISA-free, but a compatibility mode only, and explicitly detectable.
  • \n
\n

Each guest manifest declares the floor it will accept (e.g. require = transparent … allow = mesh) and the pool's composition decides what is achievable — Windows-for-ARM on an all-x86 pool admits only in mesh mode, if at all. Modes are switchable at runtime without a guest reboot. Crucially, translation is a compatibility tool, never a transparency one: per-block overhead and timing artifacts defeat the anti-detection guarantees, so the translated layer is always presented to the guest as a device, never as extra CPUs.

\n

Trust domains: your own pool vs. federating with others

\n

Latency and ISA decide what can be pooled and where it runs natively. A third, orthogonal axis — the trust domain — decides how compute must be protected once a pool spans more than one owner.

\n
    \n
  • An "Enlil device" = every node under one owner. Your phone, desktop, and laptop join into a single pool that presents as one logical-machine device. Inside this boundary compute is trusted and shared in the clear — plaintext memory tiers, zero-copy fabric, native vCPU placement — exactly the Core Model above.
  • \n
  • Federating across owners = untrusted compute. Two Enlil devices can share capacity (borrow a friend's idle GPU or cores), but across that boundary a node you don't own must never see plaintext or be trusted for integrity. That is the blind-compute regime: secret-shared / MPC / ZK-verified work units, backed by attestation that the peer runs genuine Enlil.
  • \n
\n

Trust is orthogonal to latency — a node can be near-but-untrusted (a friend's LAN) or far-but-trusted (your own VPS) — so the placement key gains a trust dimension on top of (latency class, ISA, vendor, feature baseline). Each logical machine declares which trust domains may serve it: a sensitive guest pins to trust = owner-only, while fungible, decomposable work may spill to federated-blind capacity. This is what extends N guests on M hosts to M hosts owned by different people.

\n

Transparency and guest-OS feasibility

\n

Transparency is per-guest-family: Enlil presents whatever machine model each OS expects (PC/UEFI, ARM SoC, Apple platform) and defeats that family's specific VM-detection vectors, generalizing "transparent virtual PC" to a transparent virtual machine, platform-shaped per guest. How far that reaches today:

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
GuestFeasibility
Linux, *BSD (x86)✅ near-term
Windows (x86)✅ in scope, with VM-detection hardening
Android (x86)✅ near-term
Android (ARM)⏳ needs the ARM backend; Play Integrity is hardware-attested
macOS⚠️ Apple hardware only — license + SMC/board-id, a major transparency lift
iOS❌ research-only — Apple-signed boot chain + Secure Enclave, not transparently virtualizable on non-Apple hardware
\n
\n

Status

\n

Enlil is pre-alpha and under active, incremental development. Work happens on a\nKVM-backed development path on a Linux host first; the bare-metal VMX/SVM backend comes\nlater (this is the same pragmatic strategy Firecracker and Cloud Hypervisor used).

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
AreaState
Platform layer + custom std target (x86_64-unknown-enlil)✅ Implemented & tested (enlil-platform, enlil-std)
HAL portability trait (HypervisorBackend)✅ Defined (enlil-hal)
KVM-backed VMM: single & multi-guest, CPU/memory partitioning✅ Implemented on Linux (enlil-core)
Virtual device layer (VirtIO net/block, interrupts, timers, PS/2, HDA, storage)✅ Implemented (enlil-devices)
Managed vCPU run loop: stealth stack + per-entry platform-timer cadence & LAPIC TSC-deadline firing✅ Implemented on Linux (enlil-core, StealthRunLoop)
Guest-boot orchestrator: config→GuestRuntime→run, bzImage kernel load (setup-header/boot_params/initrd/E820, real·protected·long-mode entry), per-guest CPUID+vTPM+LBR transparency, guest-RAM I/O — driven by the enlil-run binary✅ Implemented on Linux (enlil-core, orchestrator), KVM-verified end to end
Suspend/resume: vCPU state snapshot/restore + automatic ACPI S3 resume — a guest that commits a suspend is resumed in place (FACS waking-vector discovery via the RSDP→XSDT→FADT→FACS walk, re-entry at the waking vector) with no teardown✅ Implemented on Linux (enlil-core, KVM-verified)
USB peripheral routing (virtual xHCI, TRB-level; config-driven [usb.routing] rules assembled into a controller-validated routing registry; Linux sysfs host enumeration + cadence-throttled hot-plug polling; console USB-tab + guest-list view-models/controllers)🚧 Substantially implemented
Windows transparency (ACPI/SMBIOS synthesis incl. S3/S4/S5 sleep states, CPUID identity+topology/timing/LBR stealth, per-guest vTPM with persistent NV state, independent endorsement keys & name-derived seeds; NIC-OUI transparency in config + safe MAC synthesis; anti-detection: IET APERF/MPERF divergence + CPUID hypervisor-tell decoders — with the hypervisor bit, empty 0x40000000 vendor leaf, and preserved Invariant-TSC all KVM-verified from inside a real guest)🚧 Building blocks implemented
Bare-metal UEFI boot (Phase 6)🚧 The bare-metal kernel now boots and brings the machine up under its own control — proven nightly under QEMU+OVMF. The UEFI payload's efi_main collects the ACPI RSDP + GOP framebuffer + final UEFI memory map into a BootHandoff, calls ExitBootServices(), prints its liveness banner to COM1 serial, and transitions into the enlil kernel, which then, with no firmware left: walks the handed-off memory map and reports usable RAM; installs its own heap (a switching global allocator: firmware pool → the kernel's linked-list heap in the largest conventional region) and proves a live allocation; loads its own IDT with exception handlers and self-tests it by taking an int3; enables the local APIC in x2APIC mode; enables AMD SVM (EFER.SVME) and programs the VM_HSAVE_PA host state-save area; runs a guest through a real #VMEXIT dispatch engine — it allocates the guest its own isolated 2 MiB RAM window and maps guest-physical 0 onto it (a non-identity NPT, so the guest sees its RAM at GPA 0 while it lives at a disjoint system-physical base — LOCKED PRINCIPLE 5), writes a real-mode program into it, and VMRUNs in a loop that routes every #VMEXIT through the HAL's arch-neutral VmExit model with a guest-GPR save/restore shell around each entry (VMRUN carries only RAX/RSP/RIP/RFLAGS; the shell swaps the other 14 registers so the guest keeps state across an intercepted-and-resumed instruction). The loop emulates the guest's intercepted instructions: CPUID answered with a stealthed host result — the guest reads CPUID.1:ECX[31] (hypervisor-present) as 0, so it cannot detect enlil in-guest (LOCKED PRINCIPLE 1); port I/O decoded + captured; RDMSR answered with a stealth value and WRMSR shadowed per-guest and read back (the write never reaches host hardware); a nested page fault on an unmapped GPA is demand-mapped (fresh frame + NPT leaf) and the access re-executes; and a native arithmetic loop runs a taken branch to completion with no #VMEXIT — near-native guest execution — before the guest HLTs. The run shell brackets VMRUN with VMSAVE/VMLOAD so the guest's FS/GS/TR/LDTR + SYSENTER state is swapped in and out (a guest using segmentation/syscalls is safe); enlil delivers interrupts into a guest and the guest handles and returns from them: an EVENTINJ-injected interrupt vectors through the guest's real-mode IVT to a handler that does work in guest RAM (which the hypervisor reads back through the NPT window) and IRETs to resume the interrupted code; the same round-trip runs in 64-bit long mode through a real guest GDT + 64-bit IDT interrupt gate + IRETQ (the way a real x86-64 OS handles interrupts); a maskable virtual interrupt posted via the VMCB INT_CONTROL (V_INTR) is correctly held off while the guest masks interrupts (IF=0) and delivered only when it STIs; and enlil forcibly preempts a guest spinning in an infinite loop that never yields by posting that virtual interrupt — pure time-slicing, the mechanism a scheduler quantum uses to reclaim a CPU. That preemption now runs off the real hardware clock: with the VMCB's physical-interrupt (INTR) intercept armed, a host LAPIC timer that fires while a guest is spinning takes an INTR #VMEXIT straight back into enlil — the host reclaims the CPU from a non-cooperative guest on a genuine time base rather than a pre-posted interrupt. enlil then delivers that tick into the guest: it services and EOIs the physical interrupt, re-posts it to the guest as a virtual interrupt, and resumes without advancing RIP, so the guest vectors through its own long-mode IDT to its own handler and returns — the full path a scheduler tick takes from a host hardware clock into a running guest OS. enlil also traps a guest's own exceptions: it arms the VMCB exception-intercept bitmap so a guest #UD (or any vector) takes a #VMEXIT, then re-delivers the fault to the guest's own IDT/IVT (carrying the error code for the vectors that push one) — the guest handles its own fault while enlil observes it. It runs a full interrupt round-trip — an injected interrupt's handler runs, IRETs, and the guest resumes past the injection point (the inject → handle → IRET → resume cycle a virtual timer tick needs). And it write-protects guest memory in the NPT: a guest store to a read-only page takes a present+write nested page fault enlil records and grants (then the store re-executes) — the dirty-page / copy-on-write signal live migration builds on, while reads and instruction fetches from the same page still succeed. enlil services a VMMCALL paravirt hypercall (result returned in the guest RAX); and a full 64-bit long-mode guest runs with paging on, its own CR3 page-table walk resolved through the NPT (the mode a real x86-64 OS boots in). All boot-verified nightly on real nested SVM. The kernel also loads its own GDT + TSS with three Interrupt Stack Table stacks (the #DF, #PF, and #GP handlers each run on their own known-good stack, every IST-switch self-tested), arms the LAPIC timer in both one-shot and TSC-deadline mode (the precise-preemption clock a scheduler quantizes on — it even arms a 2 ms deadline from a nanosecond request and confirms via its own clock that it fired on time), calibrates the TSC against the fixed-rate PIT (~4.2 GHz measured) and turns it into a monotonic nanosecond clock with a busy-sleep (verified by a 5 ms sleep), installs a per-CPU GS-base TLS block, switches to its own identity page tables (off the firmware's, span derived from the memory map + framebuffer) with the low 2 MiB rendered at 4 KiB granularity and virtual address 0 left unmapped as a null-page guard (a stray kernel null-pointer dereference faults into the #PF handler instead of silently touching page 0), and can refine any other 2 MiB region of that live map down to 4 KiB pages, keeping the bulk huge-page-granular while a chosen region is managed page by page. It uses that to run on a stack it owns with a guard page: the whole rest of bring-up executes on kernel-allocated memory instead of the firmware's boot-services stack, with the page below it left unmapped so a stack overflow takes a diagnosable #PF (on its own IST stack) rather than silently corrupting its neighbour — the guard is proven absent by walking the live page tables, not by touching it. It brings up the other CPUs: the BSP finds a firmware-conventional page below 1 MiB for the AP startup trampoline (discovered from the real memory map, not a hardcoded address), installs a 16-bit real-mode trampoline, and wakes each application processor from the MADT inventory with INIT-SIPI-SIPI at the SDM's timings, each AP reporting in on a shared counter before a bounded timeout. It self-tests a spinlock primitive, and draws both a boot indicator and an 8×8-font text banner to the GOP framebuffer (pixel-readback verified). It discovers real hardware from the firmware tables the UEFI stage handed off: ACPI RSDP→XSDT→MADT (enabled-CPU count plus each processor's APIC ID — the SMP AP inventory, booted under -smp 2), MCFG (the PCIe ECAM window), and DMAR/IVRS — for VT-d it parses the DMAR body into each DMA-remapping hardware unit (register block, PCI segment, catch-all flag) and decodes that unit's device scopes (which endpoints, bridges, I/O APICs and HPETs it governs — what can be placed in a per-guest DMA domain), verified nightly against an emulated VT-d IOMMU's real firmware table. It enumerates PCI both via the legacy 0xCF8/0xCFC mechanism (host-bridge identity, device classes, MSI-X capability, and the first memory BAR — base and size via the standard all-ones write-probe, e.g. the real VGA BAR sized at 16 MiB, plus a summary of every memory BAR on the bus — their count and total MMIO footprint, the window the hypervisor must account for in config-space routing and passthrough) and via ECAM MMIO (all buses, extended config space). Each step is asserted on serial by the headless QEMU+OVMF harness (scripts/qemu-boot-test.sh, KVM-accelerated, nested +svm). The HAL carries the decode + region seams both backends need: enlil-hal::vmx (Intel VMX capability MSRs, VMCS field/exit encodings, VMXON/VMCS region init) and enlil-hal::svm (AMD SVM CPUID/VM_CR gates, the VMCB control/save-area layout, #VMEXIT decode), plus enlil-hal::region owning the 4 KiB-aligned VMCS/VMXON/VMCB/host-save pages, enlil-hal::svm's VMCB programming layer (program_minimal_hlt_guest + segment/exit-field accessors) and enlil-hal::npt's nested-page-table identity-map builder — all built for the x86_64-unknown-enlil kernel target. The kernel now links enlil-platform directly (Phase 1.2 — the crate is no_std under platform-baremetal, so its host-agnostic modules cross-compile for the bare-metal target): it drives enlil-platform's work-stealing scheduler (four tasks submitted out of order run Critical→Low on real hardware, their closures heap-boxed through the kernel's own allocator across the crate boundary), and drives that scheduler's quantum from the LAPIC TSC-deadline timer — one dispatch per real hardware tick, each quantum armed as a nanosecond deadline, so the bare-metal scheduler advances on the same clock a preemptive OS quantizes on, builds a MemoryMap from the real firmware map and carves a disjoint DMA / heap / per-guest RAM plan (cross-checked against the kernel's own memory walk — LOCKED PRINCIPLE 5) after marking the already-installed kernel heap as in-use, so the planner can never hand a guest memory the hypervisor is allocating from — and guests now run out of that planned region rather than the kernel heap, so a guest's nested mapping cannot reach the hypervisor's own allocator. It times a busy-sleep with enlil-platform's Instant monotonic clock — all boot-verified, so the boot kernel consumes shared platform primitives instead of re-deriving them. Still landed: discover::build_device_tree assembles a HostDeviceTree from the ACPI tables (CPUs with NUMA nodes via MADT + SRAT, per-node RAM ranges and distances via SRAT/SLIT, a full MCFG-ECAM PCI walk incl. BARs/xHCI/capabilities/SR-IOV, DMAR/IVRS IOMMU kind), plus MemoryMap carve/plan from E820 & UEFI maps and x86-64 identity page-table builders. Next: bringing each started AP from real mode into long mode with its own guarded stack and per-CPU state (multi-vCPU guests follow), programming the discovered IOMMU units' root/context tables for per-guest DMA isolation, and booting a real distro / privileged service VM
GPU sharing, compute fabric, ARM/RISC-V, multi-machine mesh⏳ Planned (Phases 7–11)
\n
\n

Honest caveat: the RustVMM/KVM dependencies in enlil-core are gated to\ntarget_os = "linux", and live guest-boot/integration tests require KVM (nested\nvirtualization). On other hosts the logic-level unit tests still build and run, but a real\nguest boot does not. Wiring enlil-core and enlil-devices fully through enlil-hal is\nin progress. See ROADMAP.md for exactly what is built vs. planned.

\n
\n
\n

Architecture

\n

Enlil is a Cargo workspace of focused crates layered over a custom platform abstraction. The\nkey idea — platform-first — is that every crate above the platform layer writes ordinary\nRust (Vec, HashMap, Arc<Mutex<T>>, async/await), whether the final target is Linux\nor bare metal. The platform crate absorbs all the no_std complexity.

\n
┌─────────────────────────────────────────────────────────────────┐\n│                          Enlil Crates                           │\n│  ┌──────────┐ ┌───────────┐ ┌────────────┐ ┌───────────────┐   │\n│  │enlil-core│ │enlil-mgmt │ │enlil-config│ │ enlil-devices │   │\n│  └────┬─────┘ └─────┬─────┘ └─────┬──────┘ └──────┬────────┘   │\n│       │             │             │               │            │\n│  ┌────┴─────────────┴─────────────┴───────────────┴────────┐   │\n│  │                       enlil-hal                          │   │\n│  │        HypervisorBackend trait (VMX / SVM / EL2 / H)     │   │\n│  └──────────────────────────┬───────────────────────────────┘  │\n│                             │                                   │\n│  ┌──────────┐         ┌─────┴─────┐         ┌───────────────┐   │\n│  │enlil-boot│         │enlil-setup│         │   enlil-std   │   │\n│  │UEFI stub │         │ setup TUI │         │  std facade   │   │\n│  └──────────┘         └───────────┘         └───────────────┘   │\n├─────────────────────────────────────────────────────────────────┤\n│                     Rust std / core / alloc                     │\n│            (x86_64-unknown-enlil on bare metal, or              │\n│              x86_64-unknown-linux-gnu when hosted)              │\n├─────────────────────────────────────────────────────────────────┤\n│                         enlil-platform                          │\n│   Memory/Alloc · Threading · Sync · Async · Time · I/O          │\n│   ┌─────────────────────────┐  ┌────────────────────────────┐   │\n│   │   platform-linux        │  │   platform-baremetal       │   │\n│   │   (delegates to std)    │  │   (raw HW implementations) │   │\n│   └─────────────────────────┘  └────────────────────────────┘   │\n├─────────────────────────────────────────────────────────────────┤\n│           Hardware  ·  or  ·  Linux + KVM host                  │\n│      x86_64 (VMX/EPT)  ·  ARM (EL2)  ·  RISC-V (H-ext)          │\n└─────────────────────────────────────────────────────────────────┘

The second pillar is the HAL: enlil-hal defines a single architecture-neutral\nHypervisorBackend trait, so the rest of the system never touches VMCS fields, EPT entries,\nor KVM ioctls directly. Adding ARM EL2 or native VMX is "just" a new impl HypervisorBackend\nbehind a #[cfg(target_arch = "…")] gate — the core, device, config, and management crates\ndon't change.

\n
pub trait HypervisorBackend: Send + Sync {\n    type VCpu: Send;\n    type PageTable: Send;\n    type InterruptController: Send;\n\n    fn create_vcpu(&self, config: &VCpuConfig) -> HalResult<Self::VCpu>;\n    fn run_vcpu(&self, vcpu: &mut Self::VCpu) -> HalResult<VmExit>;\n    fn handle_exit(&self, vcpu: &mut Self::VCpu, exit: &VmExit) -> HalResult<bool>;\n    fn map_guest_memory(&self, page_table: &mut Self::PageTable, guest_addr: u64, host_addr: u64, size: u64, writable: bool) -> HalResult<()>;\n    fn inject_interrupt(&self, vcpu: &mut Self::VCpu, controller: &Self::InterruptController, irq: u32) -> HalResult<()>;\n}

Crate map

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
CrateRole
enlil-platformThe foundation. std-equivalent APIs (memory/alloc, threading + scheduler, sync, async runtime, time, I/O) for both hosted (platform-linux, delegates to std) and bare-metal (platform-baremetal, raw hardware) targets. Under platform-baremetal the crate is #![no_std] + alloc, so memory, sync, threading, and time cross-compile for x86_64-unknown-enlil / x86_64-unknown-uefi and are linked + boot-driven by enlil-boot (io/async_rt still hosted-only pending a no_std IoError / reactor).
enlil-stdA std-shaped facade (collections, future, io, sync, thread, time) over enlil-platform, with integration tests — proves the platform layer can carry ordinary Rust on the custom target.
enlil-halThe architecture-neutral HypervisorBackend trait — the only place ISA-specific virtualization details are meant to appear.
enlil-coreThe hypervisor core: VM lifecycle, vCPU management, memory partitioning, EPT, CPUID/SMBIOS/ACPI handling, serial console, timing stealth, vTPM. Carries the RustVMM stack (kvm-ioctls, vm-memory, vm-superio, linux-loader, …) gated to Linux for the KVM-backed dev path.
enlil-devicesThe virtual device library: VirtIO net/block, full ACPI table synthesis, SMBIOS, interrupt controllers (LAPIC/IOAPIC/MSI), timers (PIT/HPET/TSC/paravirt), PS/2, HDA audio, qcow2/raw storage, a virtual xHCI USB stack with routing, an inter-guest bridge (clipboard, drag-and-drop, shared FS, notifications, URL/protocol-handler routing), a display compositor (Enlil Zones, incl. per-monitor multi-monitor tiling), and anti-detection stealth modules (CPUID, timing, LBR, PMC).
enlil-configTOML guest definitions and validation (no overlapping CPU sets, memory, or device assignments).
enlil-mgmtThe management console — clap CLI + ratatui/crossterm TUI for live control (list/start/stop/attach/status).
enlil-setupFirst-run setup wizard: detect hardware, define guests, assign resources, write config.toml.
enlil-bootUEFI boot payload + early kernel for the bare-metal target (Phase 6). A real efi_main collects the boot handoff and calls ExitBootServices(), then kernel_entry brings the machine up under enlil's own control: memory-map walk, switching heap, IDT + int3 self-test, own GDT+TSS with three IST stacks (#DF/#PF/#GP self-tested), spinlock, x2APIC + LAPIC timer (one-shot and TSC-deadline, incl. ns-precise deadline arming), PIT-calibrated TSC with a monotonic ns clock + busy-sleep, per-CPU GS-base TLS, its own identity page tables (with 2 MiB→4 KiB refinement) and a kernel-owned guarded stack it runs the rest of bring-up on, SMP AP bring-up (firmware-discovered low-memory trampoline + INIT-SIPI-SIPI), ACPI/PCI hardware discovery (MADT + APIC-ID inventory / MCFG / DMAR remapping units + device scopes + legacy & ECAM PCI, with memory-BAR read + write-probe sizing), an 8×8-font GOP text console, and AMD SVM enable + VM_HSAVE_PA then runs guests through a real #VMEXIT dispatch loop — each guest in its own isolated RAM at GPA 0 (non-identity NPT), a guest-GPR save/restore shell + VMSAVE/VMLOAD around VMRUN, per-exit emulation (CPUID stealth, port I/O, RDMSR spoof + WRMSR shadow, NPF demand-paging, native compute loop, VMMCALL hypercall), EVENTINJ interrupt injection, a full 64-bit long-mode guest, guest-exception interception (#UD trapped + re-injected to the guest's IDT), a full interrupt round-trip (inject → handle → IRET → resume), timer-driven preemption (a host LAPIC timer preempting a spinning guest through the INTR intercept, then that tick delivered into the guest and handled by its own IDT), and NPT write-protection (a guest store to a read-only page trapped + granted — the dirty-tracking / copy-on-write primitive). QEMU+OVMF boot-proven nightly.
\n

The intended layering is enlil-mgmt/enlil-setup → enlil-config/enlil-core →\nenlil-devices → enlil-hal → enlil-platform, with enlil-boot as the bare-metal entry\npoint over enlil-platform + enlil-core. Some of those edges are still being wired through\nthe HAL (today enlil-core is the KVM-backed VMM and enlil-devices is a standalone library).

\n
\n

Building

\n

Enlil uses a pinned Rust nightly toolchain (see rust-toolchain.toml)\nbecause the bare-metal path needs -Z build-std and some asm/no_std features.

\n
# Hosted development build (Linux/KVM is the default backend)\ncargo build\ncargo test            # workspace unit + integration tests\ncargo clippy\ncargo fmt --check\n\n# Bare-metal target — recompiles std against the Enlil platform layer\ncargo build --target x86_64-unknown-enlil.json -Z build-std=core,alloc,std
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
TargetPlatform featureHypervisor backendUse case
x86_64-unknown-linux-gnuplatform-linuxKVM (via cfg(target_os = "linux") deps)Development (Phases 0–5)
x86_64-unknown-enlilplatform-baremetalNative VMX (bare-metal)Production (Phase 6+)
\n

The platform is selected by feature flag at compile time. The hypervisor backend\n(KVM vs. native VMX) is determined automatically by the target OS — there is no separate\nbackend-* feature flag:

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
Feature flagEffect
platform-linuxUse the std-delegating platform (default for hosted builds)
platform-baremetalUse the bare-metal platform implementations
\n
\n

The KVM-backed path requires a Linux host with /dev/kvm; live guest-boot tests\nadditionally need nested virtualization. Where that isn't available, those tests are\nskipped rather than faked.

\n
\n
\n

Configuration

\n

Guests are declared in TOML and validated by enlil-config. See\nexamples/two-guests.toml for a complete example.

\n
[[guest]]\nname = \"debian\"\ncpus = [0, 1]\nmemory_mb = 2048\nkernel = \"/boot/vmlinuz\"\ninitrd = \"/boot/initrd.img\"\ncmdline = \"console=ttyS0 root=/dev/vda1\"\n\n[guest.storage]\ntype = \"virtio-blk\"\npath = \"/dev/sda2\"\n\n[guest.network]\ntype = \"virtio-net\"\ntap = \"tap0\"\n\n[[guest]]\nname = \"windows\"\ncpus = [2, 3]\nmemory_mb = 4096\n# …

Validation enforces the invariants that make isolation real: CPU sets must not overlap\nbetween guests, memory regions must not overlap, total allocation must fit physical memory\nminus Enlil's own reservation, and device assignments must be exclusive.

\n
\n

Repository layout

\n
enlil/\n├── Cargo.toml                  # Workspace root (resolver 2, edition 2024)\n├── rust-toolchain.toml         # Pinned nightly\n├── x86_64-unknown-enlil.json   # Custom bare-metal target spec\n├── README.md                   # This file\n├── ROADMAP.md                  # Development roadmap — a [ ]/[x] task checklist of all 11 phases\n├── examples/                   # Sample guest configurations\n├── enlil-platform/             # Platform abstraction (linux + baremetal backends)\n├── enlil-std/                  # std-shaped facade over the platform layer\n├── enlil-hal/                  # HypervisorBackend trait\n├── enlil-core/                 # Hypervisor core (KVM-backed dev path)\n├── enlil-devices/              # Virtual device library\n├── enlil-config/               # TOML guest config + validation\n├── enlil-mgmt/                 # Management CLI/TUI\n├── enlil-setup/                # First-run setup wizard\n└── enlil-boot/                 # UEFI boot payload + early kernel (Phase 6)

\n

Design principles

\n
    \n
  1. Platform-first. enlil-platform exists so every other crate can write normal Rust\nregardless of target. On Linux it's a thin shim over std; on bare metal it provides a\nglobal allocator, cooperative threading, spinlock-based sync, a hardware-counter timer,\nand UART I/O.
  2. \n
  3. Architecture portability via the HAL. The HypervisorBackend trait is the only place\nISA-specific virtualization appears. The core never touches VMCS/EPT/ioctls directly.
  4. \n
  5. Dual-backend strategy. The same crate graph compiles both for a Linux/KVM development\ntarget and for the bare-metal x86_64-unknown-enlil production target.
  6. \n
  7. Custom target triple. x86_64-unknown-enlil.json disables the default std sysroot\nand recompiles std against enlil-platform via -Z build-std.
  8. \n
  9. USB live boot. Enlil is the bootloader — a UEFI application that boots from USB with\nzero modifications to existing drives, coexisting with Windows Boot Manager / GRUB.
  10. \n
  11. cfg-gated architecture code. Every ISA-specific module sits behind\n#[cfg(target_arch = "…")]; enlil-core contains zero architecture-specific imports.
  12. \n
\n
\n

Security model

\n
    \n
  • Guest isolation is non-negotiable. EPT/NPT enforces memory separation in hardware — no\nguest can read another guest's memory or Enlil's own.
  • \n
  • CPU partitioning. Each vCPU is pinned to a physical core; CPU sets must not overlap\n(validated by enlil-config).
  • \n
  • Device isolation. Passthrough devices use the IOMMU (VT-d / SMMU) to restrict DMA to\nthe owning guest's memory.
  • \n
  • Minimal attack surface. In bare-metal mode Enlil runs no OS — it is the OS. No\nsyscalls, no kernel modules, no userspace; the only ring-0/EL2 code is Enlil itself.
  • \n
  • Rust safety. unsafe is minimized and kept to small, audited blocks; it is concentrated in enlil-platform and enlil-hal, with some unsafe in enlil-core/enlil-devices for FFI and CPU instructions.
  • \n
\n

The roadmap goes further with confidential-VM support (AMD SEV-SNP / Intel TDX) and optional\nZK-proof attestation and cross-guest isolation proofs — see ROADMAP.md (Phases 8.7 / 8.9 / 9.12).

\n
\n

Documentation

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
DocumentWhat's in it
README.md (this file)Project overview — what Enlil is, its architecture, the crate layout, how to build, and where things stand today.
ROADMAP.mdThe development roadmap — a [ ]/[x] task checklist of every phase, from scaffold through to the multi-machine mesh.
Developer book (docs/)Browsable developer docs built with mdbook (pinned 0.4.52): layer stack, crate structure, KVM-to-bare-metal migration plan, dev environment, testing, and the docs/CI build. Build with mdbook build docs.
\n
\n

License

\n

Not yet finalized. The roadmap recommends MIT or Apache-2.0 for maximum ecosystem\ncompatibility with the RustVMM crates Enlil builds on.

\n", "releases": [ @@ -384,7 +384,7 @@ }, "weftdb": { "repo": "weftdb", - "fetchedAt": "2026-10-10T05:05:50.124Z", + "fetchedAt": "2026-10-11T03:21:12.055Z", "description": null, "htmlUrl": "https://github.com/basic-automation/weftdb", "homepage": null, @@ -398,7 +398,7 @@ "createdAt": "2025-06-15T01:45:58Z", "pushedAt": "2026-10-09T14:42:42Z", "archived": false, - "readmeHtml": "

WeftDB

\n

The time-series database for data that wasn't sampled on a clean grid.

\n

\"Release\"\n\"CI\"\n\"License:\n\"Rust\"\n\"Platform\"\n\"Status\"

\n

Most time-series databases assume your data arrives on a regular cadence. Real data\noften doesn't. Sensors drop out. Markets trade in bursts. Devices batch-upload after\nhours offline. When you ask those databases for a value between two samples, you get\na gap, a NULL, or a 0 — and the work of reconstructing the signal lands in your\napplication code.

\n

WeftDB puts that reconstruction in the database. Ask for any resolution you like\nand it returns a continuous series, interpolated with the spline method you choose,\nwhere every point is labelled raw, interpolated, or extrapolated — so a\nsynthetic value is never silently mistaken for an observed one.

\n
curl -s -X POST http://127.0.0.1:8080/api/v1/interpolate \\\n  -H 'content-type: application/json' \\\n  -d '{\"spline\":\"linear\",\"resolution\":\"seconds\",\n       \"points\":[{\"timestamp\":\"1970-01-01T00:00:00Z\",\"value\":0.0},\n                 {\"timestamp\":\"1970-01-01T00:01:00Z\",\"value\":60.0}]}'

Two samples a minute apart come back as 61 labelled, per-second points — the two\nobserved endpoints marked raw, the 59 between them interpolated.

\n
\n

Contents

\n

Start here · Why WeftDB · Quickstart · Is WeftDB right for you? · Project status

\n

Use it · What you get · HTTP API · Configuration · Rust library · Terminal UI

\n

Understand it · Performance · How it works · Benchmarking · Development

\n
\n

Why WeftDB

\n

Interpolation is a first-class query, not post-processing. Resampling, gap\nfilling and upsampling run next to the data, over typed columnar segments, on one CPU\nthread, the rayon pool or — once calibrated — the GPU, chosen by grid size, not in a\nclient loop pulling raw rows across the wire.

\n

Precision is declared, never silently lost. Values are logically\nBigDecimal. Each aspect declares a physical encoding\n(F64, F32, ScaledI64, ScaledI128, Decimal128, BigDecimalText) and an\nerror bound. A value the encoding cannot represent within that bound is rejected,\nnot quietly rounded. If you are storing money or calibrated instrument readings, this\nis the difference between a database you can audit and one you can't.

\n

Synthetic data is always marked as synthetic. Every reconstructed point carries\nits provenance. Downstream code can filter, weight or refuse interpolated values on\npurpose instead of by accident.

\n

Claims come with benchmarks. Every performance number in this document links to\nthe harness that produced it, and the harness ships in the repo so you can re-run it\non your hardware. Where WeftDB doesn't win, this README says so.

\n
\n

Quickstart

\n

Prerequisites: Rust 1.95 or newer on stable. A GPU with a wgpu backend\n(Vulkan / Metal / DX12) is optional — without one the engine runs on the CPU\n(one thread or the rayon pool) automatically.

\n
cargo build --release

Prefer not to build from source? Each\nGitHub release, from v0.1.0 on,\ncarries pre-built weft-server, weft-tui and weft-bench archives for Linux (x86_64\nand aarch64), macOS (x86_64 and Apple silicon) and Windows (x86_64), and a SHA256SUMS\nfile to check them with sha256sum -c SHA256SUMS. The Linux binaries are built on\nUbuntu 24.04: weft-server and weft-bench need glibc 2.34 or newer, and weft-tui\nneeds glibc 2.39 or newer. The macOS and Windows binaries are not signed, so Gatekeeper\nand SmartScreen will warn before running them. Read the release's known limitations\n(v0.1.0's are in its\nchangelog)\nbefore you run it.

\n

1. Start the server

\n
WEFT_SEGMENT_STORE_ROOT=./store cargo run --release -p weft-server\n# weft-server v0.1.0 listening on http://127.0.0.1:8080

WEFT_SEGMENT_STORE_ROOT is what enables the persistent /storage endpoints;\nwithout it the compute endpoints still work statelessly.

\n

2. Interpolate without storing anything

\n

The compute endpoints take points in the request, so you can evaluate WeftDB\nbefore committing any data to it:

\n
curl -s -X POST http://127.0.0.1:8080/api/v1/interpolate \\\n  -H 'content-type: application/json' \\\n  -d '{\"spline\":\"cubic\",\"resolution\":\"seconds\",\n       \"points\":[{\"timestamp\":\"1970-01-01T00:00:00Z\",\"value\":0.0},\n                 {\"timestamp\":\"1970-01-01T00:00:30Z\",\"value\":30.0},\n                 {\"timestamp\":\"1970-01-01T00:01:00Z\",\"value\":60.0}]}'

Already have InfluxDB Line Protocol? Point it at the ILP sibling instead:

\n
printf 'cpu,host=a load=0 1000000000\\ncpu,host=a load=60 1000000060\\n' | \\\n  curl -s -X POST \\\n    'http://127.0.0.1:8080/api/v1/interpolate/ilp?field=load&precision=s&spline=linear&resolution=seconds' \\\n    -H 'content-type: text/plain' --data-binary @-

3. Store a series and query it back

\n

Declare the aspect's schema once — this is where you choose precision:

\n
curl -s -X POST http://127.0.0.1:8080/api/v1/storage/aspects \\\n  -H 'content-type: application/json' \\\n  -d '{\"name\":\"price\",\"physical_type\":\"scaled_i64\",\"scale\":2,\n       \"value_tolerance\":\"0\",\"timestamp_unit\":\"millis\"}'

scale: 2 with value_tolerance: "0" means exactly two decimal places, no rounding\npermitted — a third decimal is rejected at ingest rather than silently truncated.

\n
# ingest\ncurl -s -X POST http://127.0.0.1:8080/api/v1/storage/price/points \\\n  -H 'content-type: application/json' \\\n  -d '{\"points\":[{\"timestamp\":1000,\"value\":\"1.25\"},\n                 {\"timestamp\":2000,\"value\":\"2.50\"},\n                 {\"timestamp\":3000,\"value\":\"3.75\"}]}'\n\n# read the range back (start/end are required, in the aspect's declared unit)\ncurl -s 'http://127.0.0.1:8080/api/v1/storage/price/points?start=0&end=9999&limit=10'\n\n# read one instant\ncurl -s 'http://127.0.0.1:8080/api/v1/storage/price/at?t=2000'\n\n# bucket it\ncurl -s 'http://127.0.0.1:8080/api/v1/storage/price/downsample?resolution=seconds&agg=avg,max,p95'

Every read surface also speaks CSV, Arrow IPC and Parquet — append .csv,\n.arrow or .parquet, or use the …/range.parquet variants, and pipe straight into\npandas, Polars or DuckDB.

\n
\n

Is WeftDB right for you?

\n

Being honest about this saves you an evaluation.

\n

A good fit

\n
    \n
  • Irregular or gappy series where you need values between the samples —\nsensor networks with dropouts, market data, batch-uploaded device telemetry.
  • \n
  • Resampling and gap filling as a hot path, run often enough that doing it in\napplication code is costing you real latency or CPU.
  • \n
  • Values where precision is not negotiable — prices, billing quantities,\ncalibrated scientific readings — and a silent f64 round-trip is unacceptable.
  • \n
  • Workloads that care which points are real, because a synthetic value would\notherwise contaminate an alert, a model, or an invoice.
  • \n
  • Point lookups and narrow windows over large columnar segments, which WeftDB\noptimises specifically (see Performance).
  • \n
\n

A poor fit — today

\n
    \n
  • You need a mature, hardened production database. WeftDB has no TLS, no\nauthentication, and no RBAC yet. Do not expose it to an untrusted network.
  • \n
  • Generic OLAP over wide tables. WeftDB is not competing with ClickHouse or\nDuckDB on broad analytical scans, and doesn't claim to.
  • \n
  • Regular, evenly-sampled data with no gaps. If interpolation isn't your\nproblem, WeftDB's core advantage doesn't apply and a conventional TSDB is the\nsimpler choice.
  • \n
  • Per-measurement tags / labels. Not implemented yet, so tag-based filtering\nand multi-dimensional grouping aren't available.
  • \n
  • High-cardinality dimensional slicing in the Prometheus sense — that's not\nthe shape of WeftDB's data model.
  • \n
  • Managed hosting. There is none; you run it yourself.
  • \n
\n
\n

Project status

\n

Pre-beta. Expect sharp edges. WeftDB is an engineering-grade codebase — over\n1,000 tests pass, the storage format is CRC-checksummed and versioned, and the\nbenchmarks are real and reproducible — but it has not been hardened for production\ndeployment.

\n

v0.1.0 is the pre-beta baseline release: pre-built binaries on the\nGitHub release, and no\ncrates on crates.io. Later releases test their upgrade, rollback and compatibility\nagainst it. Its\nknown limitations\ninclude no\nauthentication or TLS, incomplete crash consistency, and a store layout that the next\nrelease upgrades one way.

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
AreaState
Storage engine, interpolation, HTTP API, Arrow/ParquetWorking, tested, benchmarked
Backup & restore (control plane)Working, with verification and a rehearsal drill
Observability — Prometheus metrics, OpenTelemetry tracesWorking
Crash consistency — power-loss durability, crash-safe maintenanceIn progress (design)
Security — TLS, auth, RBAC, audit logsNot started
Pre-built binaries (Linux, macOS, Windows)Released: v0.1.0, on GitHub
Library crates on crates.ioNot published yet
Packaging — Docker, HelmNot started
Client SDKs (Python, TypeScript)Not started
Per-measurement tagsNot started
\n

The full work queue lives in ROADMAP.md. It is the single source of\ntruth for status and priorities, and it records negative results alongside wins.

\n
\n

What you get

\n

Query

\n
    \n
  • Interpolation on read — reconstruct any series onto any grid from nanoseconds\nto years, with Linear / Quadratic / Cubic / Polynomial splines. The engine picks\nsingle-threaded CPU, parallel (Rayon) or — once calibrated — GPU by grid size, and\ndegrades the method gracefully (Cubic → Quadratic → Linear) when data is too sparse\nto support it.
  • \n
  • Provenance on every point — raw, interpolated or extrapolated.
  • \n
  • Downsampling into epoch-aligned buckets: min/max/avg/sum/first/last,\nexact nearest-rank p50/p90/p95/p99, three time-weighted averages (LOCF\nfor change-only sensors, trapezoidal for continuous signals, and an LOCF variant\nthat carries the last sample to the bucket's end), plus bounded-error\nsketch_p* percentiles that are mergeable and cost a fixed amount of memory per\nbucket however many samples land in it.
  • \n
  • Point and range reads that skip work: a point lookup on a regular column is\nresolved in closed form without materialising timestamps at all.
  • \n
\n

Store

\n
    \n
  • Typed columnar segments (.weftseg) — written once, never rewritten: maintenance\n(reconcile, split, overlap merge, squash, compaction) writes its output to new frames,\nfsyncs them, and swaps them for its inputs in one control-plane transaction, so a\ncrash leaves either the inputs or the outputs, and a replaced frame is deleted only\nonce no read can still be using it. CRC-checksummed, page-indexed, with per-segment\nand per-page min/max statistics for data skipping and a null/quality column.
  • \n
  • Compression that adapts per column — timestamps pick among delta-of-delta,\nRLE, Gorilla, fixed and per-block bit-packing; values pick among varint,\nbit-packing, per-block adaptive packing, frame-of-reference and an opt-in delta\ncascade. The codec each column actually used is reported, and bytes/point is the\nrealized figure, not an estimate.
  • \n
  • Late and out-of-order data — detected, counted, and reconciled within a segment or\nacross segments with last-writer-wins semantics, on demand or by background sweep.
  • \n
  • Backup and restore of the control plane, with two verification modes (one safe\nunder concurrent writes) and a non-destructive restore drill so you can answer\n"is my backup actually restorable?" without risking the live store.
  • \n
\n

Integrate

\n
    \n
  • HTTP API with JSON, CSV, Arrow IPC, Parquet and InfluxDB Line Protocol in and\nout — so TSBS/Influx/QuestDB-shaped writers work unchanged, and results land\ndirectly in Arrow-native tooling.
  • \n
  • Prometheus /metrics with latency histograms, plus a live p50/p95/p99 profile\nendpoint and OpenTelemetry trace export.
  • \n
  • Rust libraries — use the engine, the storage layer or the reductions directly,\nwithout the server.
  • \n
  • Terminal UI for importing CSV, browsing and plotting aspects, and running\ncompression interactively.
  • \n
\n
\n

Performance

\n

Every number below comes from a benchmark in this repo, named so you can re-run it. The\nexception is any figure measured on the real BTC corpus: that input is a local file the\nrepository does not ship (see Ingest), so those figures cannot be reproduced from a\nclone. All were measured on one developer workstation — treat them as shape, not as a\nspecification for your hardware.

\n

Reads WeftDB optimises specifically

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
OperationWeftDBNaive full decodeGain
Point lookup, 100k-row segment56 µs2.60 ms~46×
Point lookup, paged frame33 µs279 µs~8.5×
Batch of 64 instants (one pass vs 64 single lookups)67 µs3.57 ms~54×
100-row window over 100k rows58 µs2.73 ms~47×
\n

Source: weft-physical-type/benches/pointread.rs,\nre-measured 2026-10-08 after the word-wise bit reads and the slicing-by-16 frame CRC.\nThe wins come from skipping the value column entirely until a specific row is needed,\nand from resolving regular timestamp columns in closed form.

\n

Ingest

\n

Sealing 1M rows of a real 1-minute financial series into a columnar segment:\n4.57 s — 218,963 rows/sec at 4.22 bytes/point.

\n

That corpus (database/datasets/btc_1min.csv, a BTC/USD one-minute series) is a local,\ngitignored file, not distributed with this repository and not required to build or test —\nthe suite skips the tests that use it when it is absent, and so do the benches that load it by\ndefault. Every "real BTC" figure in this README comes from it, so none of them can be reproduced\nfrom a clone. Whether those numbers are cleared for publication or retired is an open item with\ncounsel before 1.0 (1.0 plan, Appendix B).\nThe ingest figure is quoted anyway because a synthetic generator materially flatters the\nresult: on the same workload the generated corpus reports 1.71 bytes/point against the real\n4.22, roughly 2.5× too optimistic. Storage numbers measured on generated data are not reported\nas results.

\n

Reductions

\n

At 500k points, hour buckets, correctness-gated\n(weft-bench --downsample; re-measured 2026-10-08, full table under\nCompute endpoints): avg runs at 2.08M points/sec;\ntime-weighted average costs about 1.39× that. The approximate sketch_p99 is\n~2.6× faster than exact p99 (327 ms vs 852 ms) and stays within its 1% bound,\nwhile using bounded memory per bucket.

\n

Where it doesn't pay off

\n

A deliberately-kept honest result: the bit-sliced (transposed) value codec once\nmeasured ~5.7× faster than the linear bit-unpack at the kernel level, but only because\nthe linear decoder tested one bit at a time. Once that decoder moved to word-wise reads,\nthe linear layout unpacked faster (1.09 vs 1.95 ms per 1 Mi values,\nbenches/bitunpack.rs). End to end, the\nbit-sliced layout costs +0.08% bytes and reads no faster than the linear codec\n(benches/transposed_read.rs), so it\nships off by default (and is now behind the bitsliced-codec build feature, pending\npatent review). A kernel speedup measured against a weak baseline is not a result, and\nthis README would rather say so than quote the 5.7×.

\n

Exact decimals are not free yet either. Hourly avg over 1M real BTC closes (the local corpus\ndescribed under Ingest, not in the repository), all three paths proven to produce the\nsame buckets:

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
pathtimevs f64
f64 loop (lossy baseline)2.14 ms1×
weft_reduce::reduce_scaled (exact, on the stored ScaledI64 mantissas)13.52 ms~6.3×
weft_reduce::reduce (exact, per-sample BigDecimal)60.04 ms~28×
\n

QuestDB documents ~2× for its DECIMAL, so\nthe precision wedge still costs more here. Before this work the shipped path measured 100.65 ms\n(~43×). Two changes closed most of the gap: integer\naccumulation, and computing each bucket's avg by integer long division that reproduces\nbigdecimal's quotient digit for digit. Per sealed 1M-row segment (decode + six reductions), the\nstored-range downsample went from 127.6 ms to 59.7 ms. The server's GET /api/v1/storage/{aspect}/downsample now takes the integer path for ScaledI64 segments when every\nrequested reduction is streaming or a sketch_p*, and falls back otherwise\n(weft-reduce/benches/decimal_tax.rs, load average ~11).

\n
\n

HTTP API

\n

weft-server is the axum HTTP surface — Weft-Bench, TSBS-style harnesses, Grafana\nand SDKs all drive the engine through it. It carries no vendor-specific dependencies.

\n

See the Quickstart to start it.

\n

Bind address defaults to 127.0.0.1:8080 (WEFT_SERVER_ADDR overrides). Setting\nWEFT_SEGMENT_STORE_ROOT opens a Storage v2 segment store and enables the\n/storage endpoints (without it they answer 503, and GET /ready reports the\nsegment_store dependency). A store root belongs to one server at a time: the server\nholds the root's LOCK file while it runs, and a second server pointed at the same\nroot exits at startup with an error naming the first one's pid and host.

\n

The root's STORE_FORMAT file records the store's layout and the oldest layouts a\nWeftDB must know to read and to write it (plain JSON; see weftdb::StoreFormat), and\nsegment_index.db's store_meta table keeps a copy. The server reads the marker before\nit opens any database (a root without one, the copy, before it writes anything), and\nrefuses a store a newer WeftDB wrote (IncompatibleLayout) without changing anything\nbut LOCK and LOCK.holder. A store written before the marker existed is layout 1 and\nis upgraded in place on first open, through the registered migrations\n(store_migrations in segment_index.db lists those applied); its frames are not\nrewritten. A layout-1 store whose index records a frame outside segments/ (possible\nonly through the aspect-name traversal of earlier builds) is refused with\nUnsafeLegacyPath, naming the aspects, before any migration applies or a marker is\nwritten, and nothing is moved: drop or re-seal those aspects with the build that wrote\nthem first.

\n

While bound to a loopback address, the server only answers requests addressed to a\nloopback host: Host must be localhost, an address in 127.0.0.0/8 or [::1]\n(anything else is a 421), and a state-changing request whose Origin is not a\nloopback origin is a 403. That keeps web pages from non-loopback origins, open in a\nlocal browser, from driving the server; a page served by another local web server (any\nloopback origin, on any port) is still trusted until authentication lands. Clients that\nsend no Origin, like curl, are unaffected. /health and /ready follow the same\nrule, so a health probe must send a loopback Host such as localhost or 127.0.0.1,\nas every HTTP/1.1 client does; an HTTP/1.0 probe that sends no Host gets a 421, so\nconfigure it to send one or set WEFT_ALLOW_ANY_HOST=1. Behind a local reverse\nproxy that forwards a different Host, set WEFT_ALLOW_ANY_HOST=1; a proxy that keeps\nHost: 127.0.0.1 but forwards the Origin of a browser UI served from a non-loopback\norigin needs it too, or that UI's state-changing requests get a 403. A non-loopback\nbind is not guarded: there is no authentication yet, so keep such a server off\nuntrusted networks.

\n

Service endpoints

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
Method & pathPurpose
GET /healthLiveness.
GET /readyReadiness, including the segment-store dependency check. With a store, poisoned / restart_required report that a segment-index COMMIT failed in a way that may still have committed, so the store refuses writes until the server restarts (reads keep working, and ready stays true); poison_reason says which transaction and error.
GET /metricsPrometheus text exposition — request/error/output counters, ingest counters (weft_ingest_*), and latency histograms for the compute and storage-ingest paths.
GET /debug/profile/currentLive p50/p95/p99 latency snapshot per instrumented path.
\n

Compute endpoints

\n

The flagship interpolation-on-read path and its reduction counterpart. Each\naccepts JSON points or an InfluxDB-Line-Protocol body, and each returns JSON,\nCSV, Arrow IPC, or Parquet:

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
EndpointPurpose
POST /api/v1/interpolateReconstruct an irregular series onto a regular grid (spline = linear | quadratic | cubic | polynomial; resolution = nanoseconds..years). Every output point carries a kind — raw / interpolated / extrapolated. A grid of more than 10,000,000 points (MAX_INTERPOLATE_OUTPUT_POINTS, or WEFT_MAX_INTERPOLATE_POINTS) is refused with 400 before anything is computed; split larger ranges across requests. The response's spline is the method that ran: with fewer distinct timestamps than the requested method needs (cubic 4, quadratic 3, polynomial degree + 1) it is the simpler method splimes stepped down to.
POST /api/v1/interpolate/pointEvaluate the reconstructed signal at a single instant, labelled raw/interpolated/extrapolated.
POST /api/v1/downsampleReduce samples into epoch-grid-aligned buckets — min/max/avg/sum/first/last, nearest-rank percentiles p50/p90/p95/p99, the three time-weighted averages (twa, LOCF dwell-weighting, for change-only sensors; twa_linear, trapezoidal Σ½(vᵢ+vᵢ₊₁)Δtᵢ/ΣΔtᵢ, for irregularly-sampled continuous signals; twa_bucket_end, LOCF that additionally carries the bucket's last sample to its grid end — twa gives that sample no weight, so a sensor reporting 0 then 100 a minute into an hour bucket reads twa=0 but twa_bucket_end=98.33), and the approximate sketch_p50/p90/p95/p99 (see below); all reductions computed in BigDecimal by the shared weft-reduce crate. Aliases: median→p50, time_weighted_avg/twa_locf→twa, time_weighted_avg_linear→twa_linear, twa_locf_end→twa_bucket_end, sketch_median→sketch_p50. Only non-empty buckets are emitted.
POST /api/v1/{interpolate,downsample}/ilpThe same, fed an ILP text/plain body (the TSBS/InfluxDB/QuestDB wire format); field, precision (ns/us/ms/s), and the compute knobs are query parameters. interpolation= is accepted as an alias for spline= (the canonical spline wins if both are given).
POST /api/v1/{interpolate,downsample}/{csv,arrow,parquet} and …/ilp/{csv,arrow,parquet}The same computations with CSV (text/csv), Arrow IPC stream, or Parquet output — so a harness feeding line protocol pulls results in any of the four formats.
\n

What the reductions cost. Measured on the shipped harness (500k points, 5 reps, hour buckets,\ncorrectness PASS throughout — weft-bench --downsample --ds-points 500000 --ds-aggs <agg>;\nre-measured 2026-10-08 after the avg division change, at load average ~19). These\nuse the generated series, whose values are exact binary expansions of floats (~50 significant\ndigits), so they are a pessimistic bound. On real two-decimal prices (--ds-csv, 1M BTC/USD\none-minute closes from the local corpus described under Ingest, which is not distributed\nwith the repository; hourly avg,p99,twa) the same reduction runs at 4.24M points/sec against\n0.56M points/sec for a generated series of the same size (weft-bench --downsample --ds-csv database/datasets/btc_1min.csv --csv-value-col 4 --csv-skip 3000000 --ds-points 1000000 --ds-bucket h --ds-aggs avg,p99,twa --reps 5):

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
reductionp50throughputnote
avg240.8 ms2,075,477 points/secthe streaming baseline — no bucket materialized
twa334.1 ms1,515,029 points/sec1.39× the streaming cost: dwell-weighting needs the time-ordered samples
twa_bucket_end332.1 ms1,518,520 points/secwithin noise of twa — one extra weight, effectively free
twa_linear407.8 ms1,197,606 points/sec1.22× twa: an extra BigDecimal add + divide per interval for the trapezoidal mean
\n

Exact vs sketch percentiles — which to ask for. The p50/p90/p95/p99 reductions are\nexact nearest-rank: they return an actual observed BigDecimal from the bucket, but they\nmaterialize and sort the whole bucket, and two buckets' results cannot be combined. The\nsketch_p50/p90/p95/p99 reductions instead stream every value into a\nDDSketch — a 1% relative-error bound\n(weft_reduce::SKETCH_ALPHA), bounded memory regardless of bucket size (values fold in as they\narrive; SKETCH_MAX_BINS caps the store absolutely), and an exactly mergeable structure, so a\np99 can be computed over a large or streaming bucket. Measured on the shipped harness (500k points,\n5 reps, correctness PASS): sketch_p99 runs at 1,528,361 points/sec (p50 = 327.30 ms) versus\nexact p99 at 584,469 points/sec (p50 = 852.39 ms) — ~2.6× faster\n(weft-bench --downsample --ds-points 500000 --ds-aggs sketch_p99 vs --ds-aggs p99).

\n

The approximation is declared, never silent — WeftDB's precision principle. The sketch shares the\nexact percentiles' nearest-rank convention (⌈q·n⌉), so sketch_p* and p* name the same\nsample at every bucket size and the sketch is always within the 1% bound of the exact answer —\nthe two are substitutable. (DDSketch's reference rank is ⌊q·(n-1)⌋, which on a small bucket picks\na different sample; WeftDB deliberately does not inherit that.) The exact percentiles remain the\ndefault and cost nothing on a small bucket, so prefer them there; reach for a sketch when a bucket\nis too large to materialize or the result must merge.

\n
curl -s -X POST http://127.0.0.1:8080/api/v1/interpolate \\\n  -H 'content-type: application/json' \\\n  -d '{\n        \"spline\": \"linear\",\n        \"resolution\": \"seconds\",\n        \"points\": [\n          { \"timestamp\": \"1970-01-01T00:00:00Z\", \"value\": 0.0 },\n          { \"timestamp\": \"1970-01-01T00:01:00Z\", \"value\": 60.0 }\n        ]\n      }'
printf 'cpu,host=a load=0 1000000000\\ncpu,host=a load=60 1000000060\\n' | \\\n  curl -s -X POST \\\n    'http://127.0.0.1:8080/api/v1/interpolate/ilp?field=load&precision=s&spline=linear&resolution=seconds' \\\n    -H 'content-type: text/plain' --data-binary @-

Malformed payloads, unknown tokens, fewer than two usable points, or a zero-span\nseries return 400 with an {"error": "..."} body.

\n

Numeric boundary: the compute endpoints' wire values are f64 — the\ndocumented transport boundary (the logical BigDecimal is narrowed only at the\nHTTP edge, and the columnar value columns are honestly typed Float64 — no false\nprecision). The storage endpoints below are lossless.

\n

Storage endpoints (Storage v2 over HTTP)

\n

Catalog management, ingest, and stored-range reads against the .weftseg segment\nstore. The no-silent-downcast guarantee holds end to end: a value unrepresentable\nunder the declared encoding/tolerance is rejected 400.

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
EndpointPurpose
POST /api/v1/storage/aspectsDeclare an aspect's schema — physical encoding, value tolerance, timestamp unit. The name also names the aspect's files on disk, so it must be at most 160 bytes with no /, \\, control characters, bidirectional controls, line separators or invisible formatting characters (the zero-width joiner and non-joiner are allowed), leading ., trailing . or space, and must not be a Windows device name (CON, NUL, COM1, …, also with an extension or a : suffix, as in nul:x); a server running on Windows also refuses <, >, :, ", `
GET /api/v1/storage/aspects · …/{aspect}/schema · …/catalogList declared schemas; read one aspect's schema; report the store's (database, subject) scope + registered hierarchy.
POST /api/v1/storage/{aspect}/pointsIngest a JSON batch (dense or nullable; single-block or paged via rows_per_page). Set require_sorted to reject an out-of-order batch (400, naming the first backwards row) instead of sealing it. Every ingest response reports time_sorted (whether the sealed segment stored in monotonic order).
POST /api/v1/storage/{aspect}/{ilp,parquet,csv}Ingest an ILP payload, a Parquet file, or CSV rows into a declared aspect — all sealing through the same schema-enforced path, and all honouring require_sorted (query param) + reporting time_sorted.
GET /api/v1/storage/{aspect}/range · …/value-rangeStream a stored time window / value band as Arrow IPC (application/vnd.apache.arrow.stream).
GET …/range.parquet · …/value-range.parquetThe same windows as Parquet files.
GET …/range.csv · …/value-range.csvThe same windows as CSV (lossless decimal-text values; empty field = null).
GET /api/v1/storage/{aspect}/points · …/value-pointsLossless JSON reads with declarative pagination — offset/limit (+ take and 1-based page aliases), with total/count/offset in the body. For stable forward iteration the body also carries an opaque next_cursor while rows remain; a client re-issues with ?cursor=<token> (supersedes offset/page; malformed → 400) until it is absent.
GET /api/v1/storage/{aspect}/at?t=Single-instant point lookup: the present value at exactly t (lossless decimal text) or a found:false miss. An order-signal-driven read planner resolves each candidate segment with its persisted time_sorted flag — binary search on a sorted segment, linear scan only on an out-of-order one — after pruning the index to the files spanning t.
GET /api/v1/storage/{aspect}/at-multi?t=,,Batch point lookup: a comma-separated list of instants resolved in one pass (points:[{timestamp,value,found}] in query order). The index is pruned once by the batch's whole span and each segment's timestamp column decoded once for the whole batch, so N instants sharing a segment cost one decode, not N.
GET·POST /api/v1/storage/{aspect}/downsample?start=&end=&resolution=&agg=Stored-range downsample — the same reduction set as POST /api/v1/downsample (above), but over the aspect's persisted segments instead of a request-supplied series, so a client aggregates a large stored range without fetching it. Bounded memory: the index is pruned by time and each surviving segment folds into its own mergeable partial reduction, merged once — only one segment's rows are ever resident, so a range far larger than RAM reduces, and with a sketch_p* the per-bucket state is bounded too. Omitted bounds span all stored history. …/downsample.csv · …/downsample.arrow · …/downsample.parquet serve the identical reduction in the compute endpoints' columnar formats. Traced as a downsample.range stage span. The pruned segments are reduced concurrently: measured 4.69× faster than the sequential fold (16 segments over 200k rows, 39.88 ms → 8.50 ms; sketch_p99 49.49 ms → 9.42 ms, 5.25×) — see database/benches/downsample_range.rs. A single pruned segment takes an inline fast path (concurrency there costs more than it buys). Materialized partials (opt-in): with WEFT_SEGMENT_PARTIAL_BASE set, each segment carries a .weftpart partial-reduction sidecar written at seal (a sealed segment is immutable, so its partial never goes stale), and a downsample of the bounded reductions (min/max/avg/sum/first/last + sketch_p*) merges the stored partials instead of decoding the value column — measured 3.2× faster (16 segments / 200k rows, 8.39 ms → 2.60 ms, non-overlapping CIs) and re-keyed to any coarser nesting resolution (minutes→hours→days) with no decode. Rollup tiers (opt-in): WEFT_SEGMENT_PARTIAL_TIERS materializes coarser tiers beside the base (e.g. hours,days), each re-keyed from the tier below, so a coarse query folds the coarsest matching tier instead of the whole fine base — the in-storage analogue of TimescaleDB's hierarchical continuous aggregates. Measured ~1.16× on a DAY query over a MINUTES base (16 segments / 200k rows, 7.30 ms → 6.30 ms, non-overlapping CIs). The win grows with the base-bucket count per segment, and that scaling is now measured rather than asserted: holding rows and segments fixed while widening the sample stride (so only the base buckets per segment change) gives 1.15× at ~208 base buckets → 1.27× at ~3125 → 1.38× at ~12500 (bench_tiered_span). Honest read: the win is real and climbs, but a ~60× increase in base buckets buys only 1.15×→1.38×, so the elided re-key is not the dominant cost and the tiers' extra .weftpart bytes should be weighed against it — see database/benches/downsample_range.rs. A reconcile/split/squash regenerates the sidecar (tiers and all) so the acceleration survives a rewrite.
POST /api/v1/storage/{aspect}/reconcileReconciliation pass (mode in the response). Default (intra-segment): rewrite every out-of-order segment of the aspect into a time-sorted one in place. ?threshold=N gates it on unsorted_segments >= N (QuestDB-style split-count trigger). ?hot_cold=true reconciles cold segments but defers the hot tail until the backlog reaches the threshold. ?overlaps=true instead runs the cross-segment overlap merge (newer-wins) — reconciled is then the number of segments merged away, and ?split_min_bytes=N makes that merge split-not-rewrite (carve off a dominant cold prefix instead of rewriting the whole component). Returns triggered/reconciled/cold_reconciled/hot_reconciled and the post-pass unsorted_segments + overlapping_segments. Maintenance operations on one aspect take turns: while another one holds the aspect (a daemon pass, another request), the request waits up to 30 s, then answers 409 Conflict without touching the aspect; retry.
POST /api/v1/storage/{aspect}/squashSquash the aspect's segments into one (newer-wins), bounding split-path fragmentation. ?max_segments=N gates it (squash only when the count exceeds N). Returns triggered/removed/segment_count. Waits for a busy aspect and answers 409 as reconcile does.
POST /api/v1/storage/{aspect}/compact?target_rows=NSize-targeted compaction — coalesce the aspect's segments toward ~N rows per segment (leaving already-large segments untouched), holding fragmentation near the read-optimal size rather than folding to one (which squash does). Motivated by the downsample_range knee (one giant segment reads slower than several mid-sized ones). target_rows is required (absent → 400). Returns removed/segment_count. The manual counterpart of the WEFT_COMPACT_TARGET_ROWS daemon pass. Waits for a busy aspect and answers 409 as reconcile does.
POST /api/v1/storage/reconcileStore-wide reconciliation sweep across every declared aspect: threshold (default), ?hot_cold=true, or ?overlaps=true (+ ?split_min_bytes=N for split-not-rewrite). Returns mode, aspects_scanned / aspects_reconciled / segments_reconciled (+ cold/hot split), the post-sweep store-wide unsorted_segments + overlapping_segments, and failed: a [{aspect, error}] list of the aspects whose pass failed (empty on a clean sweep). A failing aspect (e.g. a torn or truncated frame) no longer aborts the sweep: every other aspect is still swept and the response is 200, so check failed to tell a partial sweep from a clean one (earlier versions returned 500 at the first failing aspect and left every aspect after it in name order unswept). Only an unreadable aspect list is still a 500. An aspect another maintenance operation holds is waited for (30 s in all, across the sweep); if some are still held then, the others are swept and the answer is 409 Conflict naming the held ones. (The background daemon never waits: it skips a busy aspect until its next tick.) The manual counterpart to the background reconcile daemon (WEFT_RECONCILE_INTERVAL_SECS / WEFT_RECONCILE_THRESHOLD / WEFT_RECONCILE_HOT_COLD / WEFT_RECONCILE_OVERLAPS / WEFT_RECONCILE_SPLIT_MIN_BYTES / WEFT_RECONCILE_MAX_SPLITS).
POST /api/v1/storage/backupOnline control-plane backup (Phase 7.4) — snapshot the store's four control-plane DBs (segment_index/metadata/aspect_catalog/catalog) to fresh files via Turso's stable VACUUM INTO. Lands in <base>/<label> where <base> is WEFT_BACKUP_DIR or <store_root>/backups and <label> is a traversal-guarded ?label= ([A-Za-z0-9][A-Za-z0-9._-]{0,99}: 1 to 100 letters, digits, ., _ or -, starting with a letter or digit and not ending with .) or, without one, a generated manual-<unix_millis>. The backup-<digits> form is reserved for the backup daemon (retention counts and prunes exactly those names), so a ?label= in that form is a 400, and no snapshot taken through this endpoint is ever pruned by retention. ?verify=source|snapshot picks the verification: source (the default) cross-checks each copy's user-table set + row counts against the live control plane — the strongest check, but it assumes a quiescent store; snapshot verifies each copy on its own terms (it reopens and fully scans every row of every table) without re-reading the source, which is what an online backup taken while ingest continues needs. An unknown token → 400. Returns per-DB {name, tables, rows, bytes} + total_rows/total_bytes + the verify mode that ran. The backup is built in a .partial-* directory beside its label and appears under the label, with a MANIFEST.json, only once complete and durable. A label outside that grammar → 400 (the grammar also keeps out .partial-*, .deleting-* and .restore-drill-*, the names of unfinished backups, prunes and drills); an existing target dir → 409 with "code": "already_exists"; no store → 503. Control plane only — the .weftseg measurement frames are not part of this backup (hard-constraint #3).
POST /api/v1/storage/restore/drill?label=<backup>Restore drill (Phase 7.4) — rehearse restoring a backup on a running server, so an operator can answer "is my backup actually restorable?" without risking anything. Restores the named backup into a throwaway directory beside the backups, verifies every restored database at its destination (it opens, and every row of every table reads), returns per-DB {name, tables, rows, bytes} + total_rows/total_bytes/restorable, then deletes the rehearsal copy (cleanup_error says why, if it could not; it is null otherwise). Non-destructive by construction — it cannot touch the live store, and restoring over a live control plane is deliberately not offered (the library primitive refuses to clobber; pointing WEFT_SEGMENT_STORE_ROOT at a restored copy is a deployment decision, not an HTTP call). Unknown backup → 404; a label outside the backup label grammar (so also traversal and the staging names .partial-*, .deleting-*, .restore-drill-*) → 400, while a daemon snapshot's backup-<digits> is accepted, since a drill only reads it; a rehearsal directory already taken by a drill started in the same millisecond → 409 with "code": "already_exists" (retry; nothing was restored or removed); a backup that will not restore → 500 carrying the failure, which is a failed drill rather than a server bug.
GET /api/v1/storage/{aspect}/stats · …/storage/statsMaterialized per-aspect and store-wide rollups, including the realized bytes/point (the north-star cost term), an unsorted_segments order-health count (segments that would force a linear scan on a point lookup), and an overlapping_segments count (time-overlapping segments — the cross-segment order-health signal, computed by an index scan). Rollup fields are served from the control plane without opening a segment.
\n

Metrics

\n

GET /metrics renders Prometheus text exposition (v0.0.4): shared\nweft_interpolate_* / weft_downsample_* counters across each endpoint family,\nweft_ingest_* counters (requests/errors/rows/segments) on the ingest paths,\nweft_reconcile_* counters (_passes_total / _segments_reconciled_total) over the\nout-of-order reconciliation passes (manual, store-wide, and the background daemon —\nthreshold-held calls excluded) plus _failed_passes_total, the per-aspect passes a\nstore-wide sweep (daemon tick or POST /api/v1/storage/reconcile) skipped after an\nerror (the daemon also logs one WARN line per failed aspect), weft_backup_*\ncounters (_snapshots_total / _bytes_written_total) over the online control-plane\nbackups, and latency histograms over the compute and seal paths.\nGET /debug/profile/current serves the same timing data as a live p50/p95/p99\nsnapshot.

\n

Backup & restore

\n

The control plane (the catalog, per-aspect schema, segment index and metadata\nrollup databases) has an online backup, two verification modes, an unattended\ndaemon, and a restore. Per the storage boundary this covers the control plane\nonly — the .weftseg measurement frames are not part of a snapshot.

\n
    \n
  • Online snapshot. POST /api/v1/storage/backup copies all four databases\nwith Turso's VACUUM INTO while the store stays fully usable, and verifies\nevery copy before returning (see the endpoint catalog above for the parameters).
  • \n
  • Two verifications, because "verified" means different things under load.\n?verify=source (default) cross-checks each copy against the live control plane\n— the strongest check, valid while the store is quiescent. ?verify=snapshot\nnever re-reads the source; it reopens each copy and fully scans every row of\nevery table, so it is correct while writers are committing. A snapshot-only\nbackup taken with six concurrent ingests in flight returns 200 with the\nconcurrent seals visible in the copy.
  • \n
  • A backup directory holds exactly its four databases and a MANIFEST.json.\nVerifying a snapshot reopens it, which leaves zero-byte -wal / -log journal\nsidecars beside the copy; these are swept after every verification, in both\nmodes. Only a sidecar that is exactly zero bytes is removed — a non-empty -wal\nholds un-checkpointed frames, which is data, and is never touched. Verification\nalso requires each database's own tables, so an empty file never passes.
  • \n
  • A backup appears only once it is complete and durable. It is built in a\n.partial-{label}-{nonce}/ directory; each database is verified and fsynced, the\nmanifest (format, creation time, each file's size, tables and rows) is written and\nfsynced, and the directory is fsynced, renamed to its label, and the parent\nfsynced. A backup that fails with an error before the rename removes its build\ndirectory again; a crash there leaves the .partial-* directory, which is never\ncounted or restored, and which the backup daemon's sweep removes (where the\ndaemon is not enabled, remove it by hand). Once renamed, only the parent's fsync\nis left: if that fails, the call reports an error (500 over HTTP) although the\ncomplete backup is already under its label, so a retry with the same label is\nrefused (409). The next backup's fsync of the same parent makes it durable.
  • \n
  • What a snapshot costs is dominated by the filesystem, not by the vacuum.\nBenchmarked in database/benches/backup_cost.rs:\non tmpfs a whole four-database backup-and-verify runs in 8.62 ms at one\naspect, 6.18 ms at 16 and 16.79 ms at 128 (snapshot verification;\nsource verification is within noise of those), so the vacuum's own work is\nmilliseconds and scales with control-plane rows rather than being a fixed\nper-database floor. On a real disk it is orders of magnitude slower, because\nTurso's VACUUM INTO fsyncs the destination and runs a TRUNCATE checkpoint\nbefore returning. On this project's btrfs volume (load average ~12) the\nbackground daemon's ticks landed 3.0–4.5 s apart at a 2 s configured interval.\nA tick fires late only when the one before it outlasts the interval (tokio's\ndefault Burst catch-up), so each snapshot and its prune took that long, and\nthey ran back to back. Set WEFT_BACKUP_INTERVAL_SECS from what your storage\ncan sustain, not from how much data you have; point WEFT_BENCH_BACKUP_DIR\nat your own volume to measure it.
  • \n
  • Unattended backups with retention. Set WEFT_BACKUP_INTERVAL_SECS to run a\nbackground daemon that snapshots into a fresh backup-<unix_millis> directory\neach tick (verifying snapshot-only, since it backs up a live store), and\nWEFT_BACKUP_KEEP to retain only the newest N. Retention is deliberately narrow:\nit only ever removes daemon-generated backup-<digits> directories, a form\nthe API refuses to create (a directory you rename into it by hand is treated as\ngenerated and can be pruned, as it always could), so a snapshot you took\nthrough the API (?label=nightly, or an unlabelled manual-<unix_millis>) is\nnever a prune candidate and never counts toward N, and\nit runs only after a successful snapshot, so a run of failures cannot prune\nyour last good backup away. It counts only complete backups (a manifest, or all\nfour databases from before manifests), and renames a pruned backup to\n.deleting-* before removing its files, so an interrupted prune never leaves a\nhalf-removed backup. A backup-<digits> directory stamped more than 24 h\npast the clock, or past the directory's own modification time, did not come from\nthe daemon: retention neither counts nor removes it, and logs a warning each time\nit sees it. The modification-time check keeps such a directory ignored after the\nclock catches up with its stamp, as long as the directory is left unmodified and the\nfilesystem reports modification times; remove it by hand when the warning appears.\nAt start and on every tick the daemon removes .partial-*, .deleting-* and\n.restore-drill-* entries untouched for an hour. Both paths record\nweft_backup_* metrics.
  • \n
  • Restore, with a drill. weftdb::restore_control_plane(backup_dir, root)\nputs a snapshot back into a store root and verifies each restored file at its\ndestination — what matters is that the file the store will open actually\nreads. It refuses an incomplete backup directory (or one whose files no longer\nmatch its manifest, or one with a staging name) and refuses to overwrite an\nexisting control plane, pre-flighting both across all four files before writing\nanything, so a rejected restore leaves nothing behind. Each file is copied to\n<name>.tmp, fsynced and verified; only once all four verify are they renamed\ninto place, one after another, and the root fsynced. A failure before the\nrenames leaves only .tmp files, which a retry replaces; one part-way through\nthem leaves some databases in place, which a retry refuses to overwrite, so clear\nthe root first. Restoring beside the\noriginal segments/ frames reconstitutes a working store: the round-trip test\nreopens the restored root and reads its measurements back.
  • \n
  • Drills on a live server. POST /api/v1/storage/restore/drill?label= rehearses\na restore of the named backup into a throwaway directory, verifies it, reports\nrestorable, and cleans up — non-destructive by construction, so the question\n"is my backup actually restorable?" can be asked of production. Restoring over\na live control plane is not offered: that is a deployment decision (point\nWEFT_SEGMENT_STORE_ROOT at a restored copy and restart), not an HTTP call.
  • \n
\n
\n

Configuration

\n

WeftDB is configured primarily through environment variables:

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
VariableUsed byPurposeDefault
WEFT_DATA_DIRweftdb, weft-tuiRoot directory for database files and the TUI log (weft-tui.log).portable per-user default (see below)
TEST_DATA_DIRweftdbHighest-priority override for the database root (used by the test suite).unset
WEFT_EXTRACTED_BATCH_RETENTION_SECSweftdbHow long (seconds) a legacy aspect remembers the batches pattern extraction consumed, so the incremental build does not queue them again. Each extraction deletes older records, keeping about one row per resolution step of this period. Set it above the longest interval between pipeline runs of an aspect plus the longest ingest call; a non-positive or unparsable value keeps the default.172800 (48 hours)
WEFT_SERVER_ADDRweft-serverHTTP bind address.127.0.0.1:8080
WEFT_ALLOW_ANY_HOSTweft-serverTruthy (1/true/yes/on) → turn off the loopback request guard. On a loopback bind the server otherwise answers only requests whose Host is localhost, 127.0.0.0/8 or [::1] (421 otherwise) and refuses state-changing requests from non-loopback web origins (403). Set it behind a local reverse proxy that forwards a different Host, or one that forwards the non-loopback Origin of a browser UI it fronts. A non-loopback bind is never guarded.unset (guarded on a loopback bind)
WEFT_GPU_CALIBRATEweft-serverStartup calibration. By default, once the listener is bound, the server runs splimes::calibrate() once in the background on a blocking thread: it starts the GPU if there is one, times the single-thread, rayon and GPU backends on grids up to 16 Mi points (several seconds, a few hundred MB), and sets where Backend::Auto switches between them. Requests are served from the start and interpolate on the CPU with splimes' default thresholds until it finishes. A CPU/software adapter (llvmpipe, lavapipe, WARP) is not calibrated, with a log line saying why; force calibrates it anyway. 0 (or false/no/off) skips calibration. It logs the adapter (or why there is none) and the thresholds, and never fails startup; skipped or failed, interpolation stays on the CPU with splimes' defaults.unset (calibrate, except a software adapter)
WEFT_MAX_INTERPOLATE_POINTSweft-serverThe most output points one /api/v1/interpolate* request may produce; a larger grid is a 400 naming its size and the limit, refused before anything is allocated. A positive integer, read once at startup; anything else (including 0) stops the server from starting with a message naming the variable.10000000
WEFT_SEGMENT_STORE_ROOTweft-serverRoot of the Storage v2 segment store; enables the /storage endpoints.unset (storage endpoints answer 503)
WEFT_ON_AMBIGUOUS_COMMITweft-serverWhat the server does once its segment store is write-poisoned (after a COMMIT that may or may not have committed): poison keeps serving, refusing every write until the server restarts while reads keep working and GET /ready reports poisoned; exit logs and exits with status 70, for a supervisor that restarts the server. The restart's open settles the transaction either way. Read once, at startup; the weftdb library never reads it and never exits the process.unset (poison)
WEFT_RECONCILE_INTERVAL_SECSweft-serverBackground reconcile daemon sweep interval in seconds; 0/unset disables it.unset (disabled)
WEFT_RECONCILE_THRESHOLDweft-serverunsorted_segments backlog an aspect must reach before the daemon reconciles it.1
WEFT_RECONCILE_HOT_COLDweft-serverTruthy → the daemon reconciles cold segments each tick and defers the hot tail until the threshold.unset (all-or-nothing)
WEFT_RECONCILE_OVERLAPSweft-serverTruthy → each daemon tick also merges cross-segment time-overlap groups.unset (disabled)
WEFT_RECONCILE_SPLIT_MIN_BYTESweft-serverSplit-not-rewrite floor (bytes) for the daemon's overlap merge; a dominant cold prefix clearing it is split off rather than rewritten.unset (default 50 MiB floor → full-rewrite)
WEFT_RECONCILE_MAX_SPLITSweft-serverSegment-count cap; each tick also squashes every aspect over it into one segment (bounds split-path fragmentation).unset (no squash)
WEFT_COMPACT_TARGET_ROWSweft-serverTarget segment size in rows for the daemon's size-aware compaction pass; each tick coalesces every aspect's segments toward ~this many rows per segment (leaving already-large segments alone), holding fragmentation near the read-optimal size instead of folding to one (WEFT_RECONCILE_MAX_SPLITS). Motivated by the downsample_range knee (one giant segment reads slower than several mid-sized ones). Needs the daemon interval set.unset (no compaction)
WEFT_BACKUP_DIRweft-serverDirectory each control-plane backup is written under — shared by the manual POST …/storage/backup endpoint and the background backup daemon, so hand-taken and automatic snapshots live together.<store_root>/backups
WEFT_BACKUP_INTERVAL_SECSweft-serverBackground control-plane backup daemon interval in seconds; 0/unset disables it. Each tick writes a verified snapshot into a fresh backup-<unix_millis> directory, using the concurrent-write-safe snapshot verification (it backs up a live store). Needs a segment store configured.unset (disabled)
WEFT_BACKUP_KEEPweft-serverHow many daemon-generated snapshots to retain; after each successful backup the oldest backup-<digits> directories beyond this many are removed. Only daemon-generated names are ever counted or pruned — a snapshot taken through POST …/storage/backup (a ?label= one, or an unlabelled manual-<unix_millis> one) is never a candidate — and a generated-looking directory stamped more than 24 h past the clock or past its own modification time is ignored (not counted, not removed, logged as a warning; remove it by hand).unset (retain everything)
BTC_TEST_MAX_ROWS / BTC_TEST_FULLweftdb (tests)Row cap for the bounded, hermetic default run of test_create_btc_1min_database (into a temp data dir), or BTC_TEST_FULL=1 for the historical whole-corpus load into the shared data dir.5000 / unset
WEFT_SEGMENT_CHECKPOINT_STRIDEweft-serverRows between entries of the sealed timestamp checkpoint index — trades a little size for much faster point lookups on sorted, irregular columns (~3.45× single-block; see the checkpointed-frames feature above). Applies only where it pays: sorted + irregular + at least WEFT_SEGMENT_CHECKPOINT_MIN_ROWS rows. ~1024 is the sweet spot (stride barely moves speed but does move size).unset (no index; frames byte-for-byte as before)
WEFT_SEGMENT_CHECKPOINT_MIN_ROWSweft-serverRow floor below which a segment is never checkpointed (a small column decodes trivially, so an index would be pure cost).8192
WEFT_SEGMENT_CHECKPOINT_MAX_CODEC_OVERHEADweft-serverCeiling on the timestamp-codec override a checkpointed seal will accept (blocked / best bytes; 1.0 = only when free). A checkpointed frame must use the range-decodable per-block codec, which is ~free where that codec already wins but ~3.5× on a Gorilla-shaped and ~14× on an RLE-shaped column — this refuses those seals rather than silently bloating them.1.25
WEFT_SEGMENT_TRANSPOSED_MAX_OVERHEADweft-serverCeiling on the size overhead the bit-sliced (transposed) value codec may pay against the size-selected codec (transposed / best bytes; 1.0 = only when free, and a value below 1.0 is meaningful — the bit-sliced layout can be a strict size win, since it pays one width header per 1024-value tile where the blocked codec pays one per 64 values). Stores the value column bit-plane-major. Measured byte-neutral end to end on a 1M-row column and no faster to read than the linear codec (see Where it doesn't pay off), so it is off by default. Needs a build with the bitsliced-codec feature; any other build ignores the variable and logs a warning.unset (no transposed codec; frames byte-for-byte as before)
WEFT_SEGMENT_PARTIAL_BASEweft-serverResolution token (seconds/minutes/hours/…) at which each sealed segment materializes a partial-reduction .weftpart sidecar — a stored mergeable partial of the bounded reductions. A stored-range downsample of those reductions then merges the sidecars instead of decoding the value column (measured 3.2×), re-keyed to any coarser nesting resolution. A sealed segment is immutable, so the sidecar never goes stale; a rewrite (reconcile/split/squash) regenerates it.unset (no sidecar; downsample_range decodes as before)
WEFT_SEGMENT_PARTIAL_MIN_ROWSweft-serverRow floor below which a segment gets no partial sidecar (a tiny segment's partial saves too little decode to be worth the extra file).4096
WEFT_SEGMENT_PARTIAL_TIERSweft-serverComma-separated fine→coarse rollup resolutions (e.g. hours,days) materialized beside the WEFT_SEGMENT_PARTIAL_BASE partial, each re-keyed from the tier below (up to four kept; a finer/non-nesting entry is skipped). A coarse stored-range downsample then folds the coarsest matching tier instead of re-keying the whole fine base (measured ~1.16× on a DAY query over a MINUTES base). No effect unless WEFT_SEGMENT_PARTIAL_BASE is set.unset (base-only sidecar)
RUST_LOGalltracing filter. On weft-server it drives a per-request root span (request{method,path,request_id}, echoed as x-request-id) that every per-stage span nests under, each with busy/idle timing: the compute paths (interpolate.parse/compute/serialize under interpolate.engine, downsample.parse/reduce); the storage read paths (storage.{range,value_range,point}.read + .serialize, with a format field over JSON/CSV/Arrow/Parquet); the ingest paths (storage.ingest.parse/normalize/seal for ILP/CSV/JSON, and storage.ingest.parquet for the Parquet decode+seal); the background reconcile daemon (reconcile.tick{kind,…,aspects,segments}); and the control-plane writes on the seal path (control_plane.index.insert{aspect,id,rows_changed}, control_plane.index.delete{…}, control_plane.metadata.put{aspect,rows_changed}), whose rows_changed is libSQL's own affected-row count (Statement::n_change()) rather than an inference — so a trace shows whether a catalog write actually changed anything.weft_tui=debug,database=debug,info
OTEL_EXPORTER_OTLP_ENDPOINTweft-serverWhen set (e.g. http://localhost:4317), export tracing spans to an OpenTelemetry collector over OTLP/gRPC in addition to the RUST_LOG fmt output. Unset → no exporter, no network dependency; a misconfigured/absent collector never blocks startup. scripts/verify-otlp.sh verifies delivery end-to-end against a local Jaeger container (starting one if needed).unset (export disabled)
SKIP_SLOW_TESTStestsSet to 1 to skip long-running tests.unset
\n

The WEFT_SEGMENT_* variables configure the store weft-server opens; the server maps\nthem to weftdb::SegmentStoreOptions. A program embedding weftdb gets them only by\nasking: SegmentStore::open uses SegmentStoreOptions::default() (every one of them\nunset), and SegmentStore::open_with_options(root, SegmentStoreOptions::from_env())\nreads them as the server does.

\n

The database root directory is resolved in this order:

\n
    \n
  1. TEST_DATA_DIR, if set (explicit override for the test suite);
  2. \n
  3. otherwise WEFT_DATA_DIR, if set;
  4. \n
  5. otherwise a portable, per-user default — ~/.weftdb/data (falling back to the\nplatform data directory + weftdb, e.g. %APPDATA%\\weftdb on Windows, if the home\ndirectory cannot be determined).
  6. \n
\n

No paths are hard-coded: data lands in a writable, machine-independent location out\nof the box, and setting WEFT_DATA_DIR relocates both the databases and the TUI log\ntogether. The resolution functions are exported as weftdb::data_dir() (resolved)\nand weftdb::default_data_dir() (the raw default).

\n

The splimes crate also exposes build features:

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
FeatureEffect
gpu (default)The wgpu backend. Without it, interpolation always runs on the CPU.
serde (default)Serialize/Deserialize for Point, PointKind, Resolution and Spline.
tokioInterpolator::run_async, which runs an interpolation on tokio's blocking pool. WeftDB enables it: every async caller (the weftdb read and compression paths, weft-server, the bench adapter) interpolates off the async workers.
\n

The GPU is started at runtime, not at build time: weft-server calibrates it in the\nbackground at startup (WEFT_GPU_CALIBRATE, above), and an embedding program calls\nsplimes::calibrate() or splimes::prewarm_gpu() itself.

\n

WeftDB's own build features are all off by default, sit outside the 1.0 semver\npromise, and are pending patent review. A default build reads every segment written\nunder the default configuration (WEFT_SEGMENT_TRANSPOSED_MAX_OVERHEAD unset). A segment\nwritten with that variable set, as builds from before v0.1.0 did whenever it was set, needs\nbitsliced-codec. The release binaries are built without either feature, except that\nweft-bench enables experimental-codecs.

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
FeatureCrate(s)Effect
bitsliced-codecweft-physical-type, forwarded by weftdb and weft-serverCompiles in the opt-in bit-sliced value codec, so WEFT_SEGMENT_TRANSPOSED_MAX_OVERHEAD takes effect. Without it, a segment written with the codec fails to read with an error naming the feature.
experimental-codecsweft-physical-type (enabled by weft-bench)Advisory codecs never written to disk: Gorilla-XOR, Chimp, Chimp128 and Elf for f64, and the Sprintz FIRE timestamp forecaster. Benchmark what-ifs only.
\n
\n

Rust library

\n

WeftDB can be used directly as a set of libraries. They are not on crates.io yet\n(v0.1.0 ships binaries only), so depend on them from this repository, pinned to a\nrelease tag. splimes, the interpolation engine, is on crates.io:

\n
[dependencies]\n# the database: capture, query, interpolate\nweftdb = { git = \"https://github.com/basic-automation/weftdb\", tag = \"v0.1.0\" }\n# the analytics pipeline\nweft-orchestration = { git = \"https://github.com/basic-automation/weftdb\", tag = \"v0.1.0\" }\n# the interpolation engine on its own\nsplimes = \"1\"
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
CrateDepend on it when you want
weftdbThe database — capture measurements, query and interpolate stored series.
splimesSpline interpolation over irregular series, standalone. No database.
weft-orchestrationThe batching → patterns → events → correlation → signals pipeline.
weft-physical-typeDeclared numeric encodings and the .weftseg columnar format.
weft-reduceDownsampling reductions over an epoch-aligned bucket grid.
weft-line-protocolTo parse InfluxDB Line Protocol without an InfluxDB client.
weft-arrowSegments as Apache Arrow RecordBatch, Arrow IPC or Parquet.
weft-arrow-storeA stored range read straight into an Arrow RecordBatch.
\n

weft-server, weft-tui and weft-bench are binaries rather than libraries, so they\nare not published to crates.io — take them from the archives on a\nGitHub release or build them\nfrom this repository.

\n

The examples below are illustrative — run cargo doc --open for the authoritative,\nversion-matched API.

\n

Capture Measurements

\n
use weftdb::{Database, DatasetId, InputMeasurement, Resolution};\nuse bigdecimal::BigDecimal;\nuse std::str::FromStr;\nuse chrono::{TimeZone, Utc};\n\n#[tokio::main]\nasync fn main() -> anyhow::Result<()> {\n    let db = Database::new(\"my_sensors\").await?;\n    let sensor = db.observe_subject(\"temperature_sensor_001\").await?;\n    let temp = db.track_aspect(&sensor.id(), \"ambient_temp\", &Resolution::Seconds, None).await?;\n\n    let measurements = vec![\n        InputMeasurement::new(Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap(), BigDecimal::from_str(\"22.5\")?),\n        InputMeasurement::new(Utc.with_ymd_and_hms(2024, 1, 1, 12, 1, 0).unwrap(), BigDecimal::from_str(\"22.8\")?),\n    ];\n    db.batch_capture_measurements(temp.id(), DatasetId::new(), measurements).await?;\n    Ok(())\n}

Query & Interpolate

\n
use weftdb::{Database, Resolution, Spline};\nuse chrono::{TimeZone, Utc};\nuse futures::StreamExt;\n\nlet db = Database::existing(\"my_sensors\").await?;\n\n// Interpolated point at a specific time:\nlet point = db.analyze_point(&aspect_id, query_time, &Resolution::Seconds, &Spline::Linear).await?;\n\n// Streamed, interpolated range:\nlet mut stream = db.analyze_range(&aspect_id, start, end, Resolution::Seconds, Spline::Linear).await?;\nwhile let Some(result) = stream.next().await {\n    let p = result?;\n    println!(\"{}: {}\", p.timestamp, p.value);\n}

Run an Analytics Pipeline

\n
use weft_orchestration::Pipeline;\nuse weftdb::Database;\nuse splimes::Spline;\n\nlet database = Database::existing(\"PlantTelemetry\").await?;\n// ... resolve `aspect_id` for the \"outlet_pressure\" aspect of pump-station-3 ...\n\nlet mut pipeline = Pipeline::builder(database.clone(), aspect_id)\n    .spline_method(Spline::Linear)\n    .batch_size(24)                              // 24-hour batches\n    .add_dictionary(\"daily\", \"Daily patterns\", constraints)\n    .with_monthly_increase_detector(0.05)        // 5% monthly increase\n    .with_peak_detector(\"Pressure Peaks\")\n    .build()\n    .await?;\n\npipeline.run().await?;                            // full pipeline (incremental)\nprintln!(\"Signals generated: {}\", pipeline.signals().len());

For finer control, the individual steps — prepare_data, extract_patterns,\ndetect_events, correlate_events, generate_signals — can be called directly.

\n
\n

Terminal UI

\n

weft-tui is the interactive way to explore WeftDB (the HTTP server is the primary\ncommercial surface; the TUI serves demos and debugging). Launch it with\ncargo run --release -p weft-tui. It is a stateful, keyboard-driven application that\nwalks you through:

\n
    \n
  • Database / Subject / Aspect creation — with name validation and resolution\nselection (+/- to step through resolutions from nanoseconds to years).
  • \n
  • CSV import — point it at a CSV file and watch live loading progress\n(rows loaded / scanned).
  • \n
  • Browsing — select an existing database, subject, and aspect.
  • \n
  • Plotting — visualize an aspect's series directly in the terminal.
  • \n
  • Compression — choose a mode (time-based, size-based, or combined), configure\ntiers/targets/aggressiveness in a form, and monitor a live compression dashboard\n(phase, tier progress, aggressiveness, compression ratio) plus last-run stats.
  • \n
\n

Logs are written to weft-tui.log (see WEFT_DATA_DIR), and the last 50 log lines\nare also shown in-app.

\n
\n

How it works

\n

The parts below are reference material — you do not need them to use WeftDB.

\n

Architecture

\n
┌──────────────────────────────┐  ┌──────────────────────────────────────┐\n│           weft-tui            │  │              weft-server               │\n│  Ratatui terminal UI (app)   │  │  axum HTTP API: interpolate ·         │\n│  create DB · import · plot   │  │  downsample · storage ingest/query ·  │\n│  · compress                  │  │  catalog · /metrics · /debug/profile  │\n└──────────────┬───────────────┘  └──────┬───────────────────┬───────────┘\n               │ uses                     │ uses              │ columnar I/O\n               ▼                          ▼                   ▼\n┌──────────────────────────────┐   ┌────────────────────────────────────┐\n│    weft-orchestration    │   │   weft-arrow / weft-arrow-store      │\n│  batch → patterns → events   │   │   Arrow IPC + Parquet interchange  │\n│  → correlations → signals    │   └──────────────────┬─────────────────┘\n└───────────────┬──────────────┘                      │\n                ▼                                     ▼\n┌─────────────────────────────────────────────────────────────────────┐\n│                              database                                │\n│   Turso/libSQL control plane (catalog · metadata · segment index)   │\n│   + typed columnar `.weftseg` segment store (measurement hot path)   │\n│   Subject/Aspect/Measurement · Patterns · Events · Compression      │\n└───────────────┬─────────────────────────────────────┬───────────────┘\n                │ physical types + segments           │ numerics\n                ▼                                     ▼\n┌──────────────────────────────┐   ┌──────────────────────────────────┐\n│      weft-physical-type       │   │             splimes              │\n│  encodings · schemas ·       │   │   Spline interpolation engine    │\n│  timestamp codecs · .weftseg  │   │  CPU · Rayon · GPU (calibrated)  │\n└──────────────────────────────┘   └──────────────────────────────────┘

weft-bench drives the whole stack from outside through the same public APIs a\ncustomer would use; weft-line-protocol is the shared ILP dialect between the\nharness and the server. Dependencies flow downward; the arrow-* tree lives only\nin the weft-arrow* leaf crates.

\n
\n

Data Model

\n

WeftDB organizes data hierarchically. Each level maps to a directory or database file\non disk, so an aspect's storage is fully self-contained.

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
ConceptDescriptionExample
DatabaseTop-level container for all data.Crypto, my_sensors
SubjectA logical entity being observed.pump-station-3, temperature_sensor_001
AspectA specific measurement type of a subject, with its own resolution, declared physical encoding, and (optional) compression config.open, ambient_temp
MeasurementA timestamped, arbitrary-precision value.(2024-01-01T12:00Z, 22.5)
\n
On-Disk Layout
\n
{data_dir}/\n└── {database_name}/\n    ├── metadata.db                  # database metadata\n    └── {subject_name}/\n        └── {aspect_name}/\n            ├── measurements.db        # raw measurements\n            ├── unprocessed_batches.db # batching queue\n            ├── processed_batches.db\n            ├── patterns.db            # extracted patterns\n            ├── events.db              # detected events\n            ├── correlations.db        # pattern↔event links\n            ├── pipeline.db            # persisted pipeline config & state\n            └── dictionaries/\n                └── {dictionary_name}.db

The Storage v2 segment store (see\nPhysical Types & Columnar Storage) adds, per\nstore root: catalog.db (database/subject/aspect hierarchy + declared schemas),\nper-aspect metadata.db rollups, segment_index.db (the prune-before-open\nsegment index), and segments/*.weftseg (the typed columnar measurement files).

\n

Higher-level analytics types build on this foundation:

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
TypePurpose
BatchA group of measurements processed together.
PatternAn extracted recurring shape, with the occurrences where it appears.
DictionaryA collection of patterns sharing similarity constraints.
EventA detected occurrence with one or more manifestations (time spans).
CorrelationA link between a pattern and an event.
SignalA probability-weighted prediction derived from a correlation.
\n
\n

Interpolation Engine

\n

splimes is the numerical heart of WeftDB. Given a set of Points and a target time\nrange + resolution, it produces an interpolated series using one of four spline\nmethods, choosing the execution backend automatically, and labels every output point\nRaw, Interpolated or Extrapolated (PointKind). It interpolates and\nextrapolates — single-instant lookups and full ranges — on the CPU (one thread or\nrayon's pool) and the GPU. WeftDB depends on splimes 1.0; its own\nmigration guide\nlists what changed from 0.1.

\n
Spline Methods
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
MethodMin. pointsNotes
Linear2Straight-line interpolation.
Quadratic3Second-degree.
Cubic4Smooth third-degree splines.
Polynomial(degree, bounds_factor)degree + 1Degree 1–8, with optional bounds damping of the extrapolation.
\n

If a method needs more distinct points than are available, splimes steps down\nautomatically (Cubic → Quadratic → Linear; Polynomial(d) → Polynomial(n − 1)),\nnever upgrading beyond what you requested, and reports the method it used.

\n
Backend Selection
\n

Every call is synchronous; Backend::Auto (the default) picks the backend by output\ngrid size, using AutoThresholds:

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
Grid sizeBackend
below parallel_min_points (default 64 Ki)CPU, the calling thread
from parallel_min_pointsParallel, rayon's pool
from gpu_min_points (default never), once the GPU has been startedGPU (wgpu compute shaders), falling back to rayon if it fails
\n

Auto never starts the GPU itself. splimes::calibrate() starts it, times every\nbackend on this machine and sets the thresholds to the measured crossovers;\nweft-server runs it once in the background at startup, skipping a CPU/software\nadapter (WEFT_GPU_CALIBRATE=0 skips it, force calibrates any adapter). Otherwise:

\n
    \n
  • splimes::prewarm_gpu() starts the GPU and returns its GpuInfo (adapter, API,\ndevice type, driver, f64 support); then set thresholds with\nsplimes::set_auto_thresholds.
  • \n
  • splimes::configure_gpu(GpuConfig) — before the GPU's first use — sizes its\nbuffer pool and per-dispatch chunk (max_pool_bytes, chunk_points, low_power;\npresets GpuConfig::minimal(), low_memory(), DEFAULT, high_performance()).\nCalling it after the GPU has started is an Error::GpuAlreadyConfigured, never a\nsilent no-op; splimes::gpu_config() reports what is in force.
  • \n
  • splimes::gpu_pool_stats() returns the buffer pool's counters once the GPU is up.
  • \n
  • The GPU computes in f64 where the adapter supports it; f32 must be asked for\n(Interpolator::gpu_precision(Precision::F32)).
  • \n
\n
use splimes::{Interpolator, Resolution, Spline};\n\n// Backend::Auto picks the backend by grid size; from async code, run it off the\n// executor (`run_async` needs the `tokio` feature):\nlet series = Interpolator::new(Spline::Cubic, Resolution::Seconds).run_async(points, start, end).await?;\nfor (timestamp, value, kind) in series.iter() {\n    // kind: PointKind::Raw | Interpolated | Extrapolated\n}

\n

Physical Types & Columnar Storage

\n

WeftDB's storage hot path is its own typed columnar format; Turso/libSQL serves as\nthe control plane (catalog, metadata, segment index, pipeline state) and never\nholds bulk measurements. BigDecimal remains the logical/API type everywhere.

\n
Physical type system (weft-physical-type)
\n
    \n
  • Six numeric encodings — F64, F32, ScaledI64, ScaledI128,\nDecimal128, BigDecimalText — each declaring storage width, hot-path/GPU\neligibility, and conversion behavior. Every encode reports an explicit\nExactness (exact / lossy-with-measured-residual); to_logical is always\navailable.
  • \n
  • No silent downcast, enforced — AspectSchema declares, per aspect, the\nphysical encoding, a permitted per-value error bound, and the timestamp unit.\nSealing a segment under a declared schema errors (SealError::Encode /\nSealError::ToleranceExceeded) rather than losing precision beyond the\ndeclared bound. Over HTTP this surfaces as a 400 — never a downcast.
  • \n
  • Advisory encoding selection — recommend_encoding picks the narrowest safe\nencoding within a tolerance, and columnar estimators report expected\nbytes/point (the north-star cost term) before anything is written.
  • \n
  • Timestamp codecs — integer-epoch timestamps (TimeUnit =\nseconds/millis/micros/nanos) with lossless delta and delta-of-delta\ntransforms and five interchangeable second-difference codecs — per-value\nzig-zag + LEB128 varint, RLE, fixed-width bit-packing, a\nGorilla-style variable-length codec, and per-block adaptive (dynamic)\nbit-packing — chosen per column by a smallest-wins selector. Each wins a different\nregime: bit-packing on regular/small-jitter series, RLE on long constant runs, Gorilla\non scattered single jitter (isolated moderate spikes among regular intervals, where\nRLE cannot form runs and bit-packing must widen every value), and per-block adaptive\nbit-packing on a mixed-magnitude stream (a contiguous wide region among narrow runs,\nwhere a single global width overpays — 184 B vs 384–961 B for the other codecs on a\n256-value mixed corpus). A regular 1000-point column packs to ~12 bytes total.
  • \n
  • Value-column codecs — a value column is stored under its schema-declared physical\nencoding, and a ScaledI64 column additionally chooses, via a self-describing\nselector byte, between a per-value zig-zag varint, fixed-width bit-packing of its\nmantissas, per-block adaptive (blocked) bit-packing, and per-block\nFrame-of-Reference (FOR) packing — bit-packing a regular scaled series (e.g. a\n2-decimal price ramp) to 37% below the varint, the blocked codec confining a wide\nburst to the blocks it spans on a mixed-magnitude column, and FOR (subtract each\nblock's minimum, pack the unsigned residual to the block's range — the standard\nFastLanes/ALP move) collapsing values clustered at a high base (a sensor reading\nnear a fixed offset: mantissas near 1e9 store 88% below the bit-packed\nalternative). Each codec is chosen only when strictly smallest, so a regular column is\nbyte-for-byte unchanged; the realized figure is reported as\nStorageEstimate.realized_value_bytes / value_codec. The timestamp column keeps an\nadvisory FOR estimate (for_estimated_bytes) — second differences are near-zero, so\nFOR rarely wins there. Every linear bit-packed codec decodes with a word-wise field read\n(one unaligned load and a shift/mask per value), so 1 Mi mantissas unpack in 1.09 ms\n(fixed width) / 1.83 ms (per-block)\n(weft-physical-type/benches/bitunpack.rs), and\n1 Mi real BTC closes stored under FOR at scale 8 decode to floats in 1.66 ms\n(weft-physical-type/benches/alp_vs_f64_codecs.rs).
  • \n
  • Two-level delta cascade (opt-in) — a trending ScaledI64 column (a counter or\nmonotone sensor whose magnitude every single-level codec pays for) is additionally\ncompressible by a cascade: delta-transform the mantissas, then pack the differences with\nthe smallest inner packer (varint / bit-pack / blocked / FOR / RLE). It is realized on disk\nas the VAL_CODEC_DELTA_CASCADE codec-chain descriptor (write_value_column_cascading,\nread back by the ordinary reader) and surfaced advisory-first in the bench\n(StorageEstimate.advisory_delta_cascade_value_bytes, schema v15). It is opt-in — the\ncascade beats even FOR broadly, so folding it into the default selector is a headline change\nheld for owner sign-off; the default codec choice is unchanged.
  • \n
  • Bit-sliced (transposed) value codec (opt-in, bitsliced-codec build feature) —\nVAL_CODEC_TRANSPOSED stores a\nScaledI64 column's mantissas bit-plane-major in 1024-value tiles, so the decoder rebuilds\neight lanes per plane byte with branch-free table spreads and a tile's width header drops the\nempty high bit-planes of a small-magnitude column. The decode kernel is checked against an\nexternal yardstick, the published fastlanes crate, at equal bit width\n(weft-physical-type/benches/fastlanes_yardstick.rs,\n1 Mi values): WeftDB's decoder takes 0.78 / 1.50 / 2.32 ms at widths 3 / 10 / 20 against\nfastlanes' 0.33 ms — still 2.4–7× behind, and that gap is an open roadmap item, not a\ndesign choice. It carries its own size function (transposed_value_bytes) and selector\nentry (best_value_codec_transposed(max_overhead)) rather than reusing the blocked figure, is\nrandom-access capable (so it does not regress the streaming point read), and is requested through\nFrameOptions / Segment::write_to_with or, from a deployment, WEFT_SEGMENT_TRANSPOSED_MAX_OVERHEAD.\nHonest result: no read-path win over the linear codec. Measured on a 1M-row zero-straddling\ncolumn (weft-physical-type/benches/transposed_read.rs,\n2026-10-08, transposed vs linear): frame bytes +0.08% (1,251,057 vs 1,250,076), full decode\n7.81 ms vs 6.87 ms; with the slicing-by-16 frame CRC, windowed 1000-row range 638 µs vs\n632 µs and single point read 820 µs vs 805 µs. So the layout is byte-neutral and no faster\n(slightly slower) on reads; it stays opt-in and the\ndefault codec choice is unchanged.\nNote the codec is chosen only when a strict size win or within the caller's overhead ceiling —\nthe ceiling may legitimately be set below 1.0, because per-tile widths with one header per 1024\nvalues can beat both a global width and the blocked codec's one-header-per-64. It is a\nbit-sliced layout, not the FastLanes layout (which keeps each value's bits together), and it\nis compiled only with the bitsliced-codec feature, pending patent review; without it the\nwriter never selects it and reading a segment that uses it fails with an error naming the\nfeature.
  • \n
  • Block-level random access — the fixed-layout value codecs support decoding a single value\n(or a sub-range) without materializing the whole column: weftseg::read_value_at(bytes, i)\nreads only the block covering row i (skipping earlier blocks by their headers) for the\nblocked/FOR codecs, reads bit i * width directly for the fixed-width bit-pack codec, and decodes\nonly the covering tile for the bit-sliced codec — the point-lookup / late-materialization lever.\nweftseg::read_value_range(bytes, start, len) is its windowed sibling: every fixed-layout codec\nlocates a value by walking its block/tile headers from the start of the stream, so resolving a\nwindow one value at a time re-walks that chain per row. The range read does the decode once,\nwhich is what keeps a windowed read on the 1024-value-tile bit-sliced layout from costing a whole tile\ndecode per row. weftseg::read_segment_point(bytes, t)\nwires this up to the framed single-block segment — it skips the value block by its framing,\ndecodes only the timestamps to find the row, and unpacks the one covering value block — so a\npoint lookup never materializes the value column on a per-block codec (equal to a full\ndecode + value_at for every frame; the non-block codecs fall back to that). read_paged_segment_point\ndoes the same for a paged frame, first pruning pages on their indexed min/max timestamp so only\nthe surviving page is touched. A regular (constant-stride) timestamp column is resolved in\nclosed form — ts[i] = first + i*step, so the row for an instant is O(1) with no timestamp\nmaterialization at all. Measured ~46× faster point lookup on a 100k-row single-block FOR\nsegment (56 µs vs 2.60 ms) and ~8.5× faster on the paged frame (33 µs vs 279 µs), with the\nclosed-form timestamp path a further ~3.4× over an irregular column (56 µs vs 188 µs), identical\nbytes on disk (weft-physical-type/benches/pointread.rs).
  • \n
  • Checkpointed frames — sublinear point lookup on an irregular sorted column (opt-in;\nwrite_segment_checkpointed / write_paged_segment_checkpointed). The closed form above only\nserves a regular column; an irregular one has no closed form, so a probe had to decode the whole\ndelta-of-delta timestamp stream. A checkpointed frame persists a sparse (row, timestamp, delta)\nindex every stride rows next to a range-decodable per-block dod stream, so a probe binary-searches\nthe index and decodes only the blocks it walks — the timestamp column is never materialized.\nMeasured ~3.45× faster point reads on a 100k-row sorted-irregular frame (352.94 ms → 102.42 ms\nfor 64 probes) for +0.41% frame bytes at stride 1024; a paged frame gains only ~1.14×\n(68.70 → 60.41 ms), because page pruning already bounds its decode to rows_per_page\n(weft-physical-type/benches/dodsearch.rs). The codec tag\nis additive — every previously written frame still reads, with no format-version bump — and a\ncheckpointed frame is byte-for-byte equivalent in behaviour to a plain one on every read.\nOpt in by setting WEFT_SEGMENT_CHECKPOINT_STRIDE (see the configuration table). The seal path\nthen writes the index only where it genuinely pays, on three counts: the column is sorted and\nirregular (a regular one already resolves O(1) closed-form, an out-of-order one cannot be\nbinary-searched), it has at least WEFT_SEGMENT_CHECKPOINT_MIN_ROWS rows, and the codec override\nis affordable — a checkpointed frame must use the range-decodable per-block codec, which costs\n~3.5× on a Gorilla-shaped column and ~14× on an RLE-shaped one, so those are refused rather than\nbloated (WEFT_SEGMENT_CHECKPOINT_MAX_CODEC_OVERHEAD). It is off by default, so an unconfigured\nstore's bytes/point are unchanged; making it the default is an open roadmap decision.
  • \n
  • Realized headline bytes/point — every headline bytes/point figure\n(Segment::bytes_per_point, StorageEstimate.bytes_per_point /\ntotal_bytes_per_point, the bench HTML val B/pt) reports the codec actually\nwritten to disk, not a fixed-width estimate (bench schema v10). The naive\nlen * width figure is retained beside it (Segment::logical_value_bytes,\nStorageEstimate.estimated_value_bytes) as the uncompressed baseline, so\nrealized / logical reads directly as the storage compression ratio.
  • \n
\n
Segment store (.weftseg + weftdb::SegmentStore)
\n
    \n
  • Sealed, checksummed segments — a Segment binds a typed value column and a\ndelta-of-delta timestamp column with min/max timestamp/value stats, row/null\ncounts, and a format version. The on-disk frame is hand-rolled, versioned, and\nCRC-32-verified before parse, so a corrupt or truncated file fails fast.
  • \n
  • Multi-codec timestamps — the timestamp block writes its second-difference\nstream under whichever of five codecs is smallest — fixed-width bit-packing,\nper-value zig-zag varint, RLE, Gorilla variable-length, or per-block\nadaptive (dynamic) bit-packing — chosen by a self-describing selector byte the reader\ndispatches on, routed through the single source of truth so the reported codec name\nalways matches the bytes on disk. A regular series packs to a handful of bytes on disk\n(a 1000-point regular block stores in <20 bytes), so the bytes/point saving is\nrealized, not just estimated. The value block carries its own codec selector too,\nso a ScaledI64 column stores its mantissas under whichever of varint, fixed-width\nbit-packing, per-block adaptive (blocked) bit-packing, or per-block\nFrame-of-Reference (FOR) packing is smallest on disk.
  • \n
  • Order semantics — every segment records whether its timestamps are monotonic\n(time_sorted). Ingest can enforce order (require_sorted rejects an\nout-of-order batch rather than sealing it), and the per-aspect/store rollups carry\nan unsorted_segments count so out-of-order data is visible before it costs a\npoint-lookup scan.
  • \n
  • Order-signal read planner — a single-instant point lookup\n(SegmentStore::read_point) prunes the index to the segments spanning the instant\nand resolves each with its persisted time_sorted flag: a sorted segment is\nbinary-searched, an out-of-order one linear-scanned (the only sound search on\nunsorted timestamps). Both frame kinds resolve through the streaming point read\n(weftseg::read_segment_point / read_paged_segment_point, below) — no value-column\nmaterialization on a per-block codec, and a paged frame prunes pages on their indexed\nmin/max timestamp without decoding a column byte before touching the one surviving page.\nSegmentStore::read_points resolves a batch of instants in one pass — the index is pruned\nonce and each segment's timestamp column decoded once for the whole batch, so N instants\nsharing a segment cost one decode, not N (~54× faster for 64 instants on a 100k-row FOR\nframe than 64 single lookups — 67 µs vs 3.57 ms,\nweft-physical-type/benches/pointread.rs).
  • \n
  • Intra-segment reconciliation — SegmentStore::reconcile_segment/reconcile_aspect\nrewrite an out-of-order segment into a sorted one in place (stable sort by\ntimestamp, re-sealed at the same id, frame kind preserved), so it drops out of the\nunsorted_segments count and its point lookups binary-search.
  • \n
  • Threshold-triggered reconciliation — the unsorted_segments backlog drives a\nQuestDB-style trigger so the rewrite is paid only once out-of-order data is worth\ncompacting, never on every late row: reconcile_aspect_if_unsorted_exceeds gates a\nsingle-aspect pass on the backlog, reconcile_all_over_threshold sweeps every\ndeclared aspect over the threshold, and a background timer daemon\n(WEFT_RECONCILE_INTERVAL_SECS / WEFT_RECONCILE_THRESHOLD) runs the sweep\nunattended. A threshold of 0 clamps to 1 (any out-of-order segment). Passes and the\nsegments they rewrite are counted in weft_reconcile_* metrics.
  • \n
  • Hot/cold reconciliation — reconcile_aspect_hot_cold/reconcile_all_hot_cold\nreconcile every cold (sealed) out-of-order segment on each pass but defer the\nhot tail (the most-recently-sealed segment) until the backlog reaches the\nthreshold, so the actively-appended segment is not rewritten on every tick —\nQuestDB's "squash non-active partitions each commit, defer the active one" policy.\nEnabled on the daemon with WEFT_RECONCILE_HOT_COLD and on the reconcile endpoints\nwith ?hot_cold=true.
  • \n
  • Cross-segment overlap signal — a sorted segment whose time window a\nlater-sealed segment re-enters (late data landing in an already-covered window) is\nnot counted by unsorted_segments; SegmentIndex::overlapping_count reports these\ntime-overlapping segments as a distinct overlapping_segments order-health\ncount in the per-aspect and store-wide rollups.
  • \n
  • Cross-segment overlap merge — SegmentStore::reconcile_overlaps/reconcile_all_overlaps\ncollapse each connected group of time-overlapping segments into one time-sorted\nsegment, folding members oldest→newest with newer-wins (last-writer-wins /\nupsert) dedup on shared timestamps — the same answer a point lookup already gives\nacross overlaps, and QuestDB's DEDUP UPSERT "last write wins on the designated\ntimestamp" semantics. Drives overlapping_segments to zero; exposed on the reconcile\nendpoints with ?overlaps=true and as a background daemon sweep\n(WEFT_RECONCILE_OVERLAPS).
  • \n
  • Split-not-rewrite merge — SegmentStore::split_segment carves a sorted segment\nat a boundary into a cold prefix (kept id) + hot suffix (new id), and\nreconcile_overlaps_with_policy consults a size-based SplitPolicy: when an overlap\ncomponent's cold prefix clears a byte floor and outweighs its hot suffix, only the\nsuffix is merged and the large cold prefix is left untouched, so later late arrivals\nre-entering the hot window never rewrite it — QuestDB's partition-split write-amp\nbound. The default reconcile_overlaps keeps QuestDB's 50 MiB floor (small components\nfull-rewrite); a floor is selectable with ?split_min_bytes= on the per-aspect and\nstore-wide reconcile endpoints and WEFT_RECONCILE_SPLIT_MIN_BYTES on the daemon.
  • \n
  • Squash — SegmentStore::squash_aspect/squash_aspect_if_exceeds/\nsquash_all_over_threshold fold an aspect's segments back into one (newer-wins),\nbounding the fragmentation repeated split carve-offs create — QuestDB's\nmax.splits squash trigger. Exposed as POST …/{aspect}/squash?max_segments= and on\nthe daemon with WEFT_RECONCILE_MAX_SPLITS (squash aspects over the cap each tick,\nafter the overlap merge that accumulates the splits).
  • \n
  • Null/quality column — a per-row presence bitmap (zero bytes for fully dense\ncolumns) makes gaps real: present values are stored densely and reconstructed\nas None on read.
  • \n
  • Paged segments — rows partition into fixed-height pages, each independently\nencoded with its own stats, behind a per-page index table: a reader prunes\npages on min/max time/value without touching a column byte and seeks\nstraight to the pages it needs.
  • \n
  • Data skipping — segment-level and page-level pruning by time, by value\nband, and by quality (segments/pages that overlap a window but hold only\nnulls there are skipped). A range read over a regular (constant-stride)\nblock-coded segment goes further — read_segment_range computes the row\nwindow in closed form and unpacks only the present values inside it, so a\nselective range decodes ~window values, not the whole segment (~47× faster\nfor a 100-row window over a 100k-row FOR frame — 58 µs vs 2.73 ms,\nweft-physical-type/benches/pointread.rs).
  • \n
  • Control plane — SegmentIndexStore persists one descriptor per sealed\nsegment in libSQL and answers range queries with SQL pruning;\nCatalogStore/AspectCatalog register the database → subject → aspect\nhierarchy with declared schemas (declare once, seal by lookup — an undeclared\naspect is refused, not guessed); per-aspect metadata.db rollups make\naspect-wide stats (including realized bytes/point) an O(1) read, always\nre-derivable from the segment index.
  • \n
\n
Arrow & Parquet interchange (weft-arrow, weft-arrow-store)
\n
    \n
  • Lossless Arrow batches — sealed segments and stored reads convert to/from\nApache Arrow RecordBatches: a lossless text form, plus a typed fast path\n(F64 → Float64, F32 → Float32, scaled integers → exact Decimal128).\nPaged segments stream one batch per page.
  • \n
  • Wire formats — Arrow IPC stream bytes and Parquet files (embedding\nthe Arrow schema so WeftDB's time-unit/encoding metadata survives), both\ndirections: export a stored range, or ingest a Parquet file into a declared\naspect under the full no-silent-downcast guarantee.
  • \n
  • Lean core preserved — the arrow-*/parquet dependency tree lives only in\nthese leaf crates; splimes/weftdb/weft-physical-type stay arrow-free.
  • \n
\n
\n

The Analytics Pipeline

\n

The weft-orchestration crate provides a fluent Pipeline API. Each aspect\nhas exactly one pipeline, persisted to its pipeline.db, so runs are incremental\nand conflict-free. A pipeline can host multiple dictionaries and multiple detectors.

\n
 Measurements ─▶ Batches ─▶ Processed Batches\n                                   │\n                                   ▼\n                          Dictionaries (1..N)        Event Detectors (1..N)\n                          extract patterns           peaks · valleys ·\n                                   │                  thresholds · monthly · …\n                                   ▼                          │\n                               Patterns ───── correlate ──────┤\n                                                              ▼\n                                  Events ─▶ Correlations ─▶ Signals\n                                                              │\n                                                              ▼\n                                          query_probability(event, time)

Built-in event detectors (in weft_orchestration::detectors):

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
DetectorDescription
detect_monthly_increaseMonths where the value rises ≥ a threshold start-to-end.
detect_peaks / detect_all_peaksGlobal / all local maxima.
detect_valleys / detect_all_valleysGlobal / all local minima.
detect_threshold_crossing_up / _downUpward / downward threshold crossings.
detect_drawdownSignificant value drops.
\n

You can also register custom detectors with the event_detector_fn! macro, and\nrun pipelines across many aspects in parallel with run_all_pipelines /\nrun_subject_pipelines.

\n
\n

Dataset Compression

\n

Long-running deployments accumulate huge volumes of raw measurements. The\ncompression module (weftdb::compression) reduces storage while preserving the\nshape of the data, configured per-aspect via CompressionConfig:

\n
    \n
  • Time-based — recent data within a "pure" window (e.g. the last 7 years) stays\nuncompressed; older data is progressively compressed across configurable tiers\nwith Linear, Exponential, or Custom aggressiveness scaling.
  • \n
  • Size-based — compress until total storage fits within a target (e.g. 10 GB),\nhitting the oldest data hardest. Takes precedence over time-based.
  • \n
\n

Compression works by interpolating to a coarser resolution and applying\nslope-change simplification to drop redundant points. Newly inserted data inside a\ncompressed range marks a dirty region for targeted re-compression. Progress is\nreported live (phase, tier, aggressiveness, time range) — surfaced in the TUI.

\n
use weftdb::{CompressionConfig, TimeBasedCompressionConfig};\n\nlet config = CompressionConfig::time_based(\n    TimeBasedCompressionConfig::default_seven_years()\n);\nlet aspect = db.track_aspect(&subject_id, \"temperature\", &Resolution::Hours, Some(config)).await?;\nlet summary = aspect.compress(&db).await?;

\n

Benchmarking

\n

Weft-Bench (weft-bench) is WeftDB's reproducible, correctness-gated benchmark\nharness — both an internal engineering suite and a public, customer-runnable\ndiagnostic. Every number it emits ships with the dataset seed, the workload\nprofile, and a correctness verdict.

\n
\n

Policy: WeftDB benchmarks guide real engineering decisions, not synthetic wins.\nA latency number is only publishable when its correctness check passes.

\n
\n

What it does today:

\n
    \n
  • Seeded workload profiles — the flagship interpolation-heavy-irregular\nprofile generates an irregularly-spaced, gap-containing series\n(ChaCha8Rng, published seed → byte-for-byte reproducible). The underlying\nanalytic signal is shape-selectable (MultiSine | Sawtooth | Step |\nDampedSine) and doubles as the accuracy ground truth.
  • \n
  • Storage, read & aggregation workloads — beside the flagship interpolation\nrun, four parallel workloads exercise WeftDB's own hot paths, each with its own\ncorrectness gate and p50/p95/p99 latency: point_lookup (--point-lookup)\nseals a .weftseg segment and times the streaming point read\n(read_segment_point/read_segment_points, single or batch, single-block or\npaged via --pl-rows-per-page, regular closed-form vs irregular), gated against\nthe full-decode value — also over a real corpus with --pl-csv (1M real BTC closes:\na 128-instant batch at p50 2.43 ms single-block vs 0.90 ms paged at 8,192 rows/page,\nweft-bench --point-lookup --pl-csv database/datasets/btc_1min.csv --csv-value-col 4 --csv-skip 3000000 --pl-rows 1000000 --pl-queries 128 [--pl-rows-per-page 8192] --reps 50);\nrange_fetch (--range-fetch, real corpus via --rf-csv: 32 × 100-row windows over 1M\nreal BTC closes at p50 60.8 ms single-block vs 17.8 ms paged) times the windowed\nrange read; compression (--compression) reports realized bytes/point, the\nvalue-column compression ratio, and decode throughput, gated on an exact\nround-trip — over a seeded shape, or a real corpus with --comp-csv <FILE>\n(--csv-value-col, --csv-skip; values read as exact decimal text; --pl-csv does the same for point_lookup). On 1M real\nBTC/USD one-minute closes (rows 3M onward of btc_1min.csv) it measures 4.22 B/point\nrealized (scaled_for, exact), full-decode 20.8M points/sec (p50 52.7 ms, 5 reps),\nand an advisory decimal-exponent FOR footprint of 1.70 B/point\n(weft-bench --compression --comp-csv database/datasets/btc_1min.csv --csv-value-col 4 --csv-skip 3000000 --comp-rows 1000000 --reps 5); and downsample (--downsample) times WeftDB's canonical\nweft-reduce reduction into grid-aligned buckets with a\n--ds-aggs selector over min/max/avg/sum/first/last/p50…p99/twa/twa_linear/twa_bucket_end/sketch_p50…sketch_p99.\n--ds-parallel <N> reduces in N chunks via mergeable partial reductions (identical\nbuckets to serial, asserted by test) — measured 14.7× at 64 chunks on a quiet 16-core box\n(441.2 ms → 30.1 ms, 1,134,659 → 16,921,104 points/sec, --ds-aggs sketch_p99, 500k points,\n5 reps, correctness PASS); a re-run on 2026-10-08 at load average ~19 measured 327.3 → 48.5 ms\n(6.7×).\nThe real-corpus figures in this bullet come from btc_1min.csv, a local file not distributed\nwith the repository (see Ingest); point --pl-csv / --rf-csv / --comp-csv /\n--ds-csv at a CSV of your own to run the same workloads on real data.\nThe underlying point/range read speedups are quantified at the codec layer in\nweft-physical-type/benches/pointread.rs.
  • \n
  • Vendor-neutral adapters — every system is driven through the\nSystemAdapter trait: the WeftDB reference adapter (splimes' Interpolator on Backend::Auto),\na precision-aware portable linear baseline (fair-protocol class C), and a\nforward-fill/LOCF baseline (class B — the in-process mirror of\nFILL(previous) / locf()).
  • \n
  • Quality beside speed — reconstructions are scored against the analytic\nground truth: RMSE / MAE / max-error / bias. Honest finding, surfaced not\nhidden: on smooth multisine (and step) WeftDB's cubic leads on RMSE, but on\nsawtooth the portable linear baseline out-accuracies the cubic, which\novershoots sharp discontinuities.
  • \n
  • Fair-protocol statistics — p50/p95/p99 + min/max/mean/stddev with\nseeded bootstrap confidence intervals, and an end-to-end timing breakdown\nproving the latency distribution times only the adapter call, never setup.
  • \n
  • Storage estimate — each result carries a StorageEstimate (recommended\nencoding + value/timestamp/total bytes/point) computed by\nweft-physical-type over the actual columns. It also surfaces advisory\npotential-saving estimates for codecs not yet realized on disk: the best-of\nf64 value codec (Gorilla / Chimp / Chimp128 XOR, advisory_best_f64_bytes /\n_codec) for a lossy F64 column, and the Sprintz FIRE forecaster's\nfootprint on the timestamp column (advisory_fire_timestamp_bytes), and the\ndecimal-exponent FOR value codec (advisory_dfor_value_bytes, schema v17,\npresent only when it beats the realized codec — e.g. a ScaledI64 column whose\nscale is forced by a few high-precision values; real BTC closes measure 13.58 vs\n33.74 bits/value in\nweft-physical-type/benches/alp_vs_f64_codecs.rs),\nplus two timestamp-column what-ifs (schema v18), each present only when it beats the\nrealized codec: advisory_common_multiple_timestamp_bytes (the GCD of the deltas\nfactored out — millisecond-precise instants stored in microseconds measure 20 → 10\nbits/value in\nweft-physical-type/benches/timestamp_vs_pco.rs)\nand advisory_delta_for_timestamp_bytes (first-order deltas under per-block FOR, for\nindependent random intervals: 20.0 → 19.4 bits/value)\n— each a what-if number the adopt-or-drop decision reads, never a realized\nheadline claim. The f64 codecs and FIRE live behind weft-physical-type's\nexperimental-codecs feature, which only weft-bench enables.
  • \n
  • Reports — a BenchReport JSON artifact (run metadata + a best-effort\nhardware probe: CPU model, cores, RAM, and the kind/file system/mount of the disk under the working directory) under reports/json/, plus a\nself-contained HTML view (--html) with the most-accurate row highlighted.
  • \n
  • The engine the server runs — an interpolation run first calls\nsplimes::calibrate() once, as weft-server does at startup (skipping a\nCPU/software adapter the same way), so Backend::Auto uses rayon and the GPU\nwhere they are faster on this machine. The calibration, GPU and thresholds are\nprinted and recorded in the report's metadata.engine (schema v16);\n--no-gpu-calibrate skips it and records splimes' defaults. There is no\ncounterpart to the server's WEFT_GPU_CALIBRATE=force: on a software adapter the\nbench always skips calibration, so there it cannot reproduce a server started with\nforce.
  • \n
  • ILP / TSBS input — a .lp / TSBS file drives the same harness, correctness\ngate, and reporting via the shared weft-line-protocol parser.
  • \n
\n
# Benchmark a line-protocol file, WeftDB vs the portable baselines, JSON + HTML report:\ncargo run -p weft-bench -- \\\n    --input data.lp --field usage --spline cubic --resolution minutes --compare --html\n\n# Seeded synthetic mode — the only mode with a known ground truth, so it also scores accuracy:\ncargo run -p weft-bench -- \\\n    --synthetic --points 300 --noise 0 --shape sawtooth \\\n    --spline cubic --resolution minutes --reps 5 --compare\n\n# Storage/read/aggregation workloads over WeftDB's own hot paths:\ncargo run -p weft-bench -- --point-lookup --pl-rows 100000 --pl-queries 128 --reps 50\ncargo run -p weft-bench -- --range-fetch --rf-window 100 --rf-windows 32 --reps 30\ncargo run -p weft-bench -- --compression --comp-shape clustered --reps 20\ncargo run --release -p weft-bench -- --compression --comp-csv database/datasets/btc_1min.csv --csv-value-col 4 --csv-skip 3000000 --comp-rows 1000000 --reps 5\ncargo run -p weft-bench -- --downsample --ds-bucket m --ds-aggs min,max,p99,twa --reps 20

The process exits non-zero when any result's correctness gate fails, so scripted\ncallers can gate on it. External-engine competitor adapters (DuckDB, ClickHouse,\nInfluxDB 3, QuestDB, TimescaleDB) are the next tracked step in\nROADMAP.md.

\n
\n

Development

\n
# Run the full test suite:\ncargo test\n\n# Skip long-running tests:\nSKIP_SLOW_TESTS=1 cargo test          # PowerShell: $env:SKIP_SLOW_TESTS=1; cargo test\n\n# Test a single crate:\ncargo test -p weftdb

The workspace ships extensive Criterion benchmark\nsuites alongside the Weft-Bench harness (see\nBenchmark Harness):

\n\n\n\n\n\n\n\n\n\n\n\n
CrateBenchmarks
weftdbinterpolation_benchmarks, integration_interpolation_benchmarks, optimized_interpolation_benchmarks, strategy_selection_benchmarks, new_api_benchmarks, cache_performance_benchmarks
\n
cargo bench -p weftdb --bench cache_performance_benchmarks

These benches create their databases in a temporary data dir of their own, which they\nremove when they finish, never in ~/.weftdb/data. That dir is under the system temp\ndir, often a tmpfs; to bench another filesystem, set TEST_DATA_DIR to a directory on\nit (the benches remove the databases they create there, not the directory).

\n

HTML reports are generated under target/criterion/. The interpolation engine's own\nbenchmarks live in the splimes repository.

\n

Formatting uses nightly-only rustfmt options (imports_granularity,\ngroup_imports), so the style gate runs on nightly even though the crates\nthemselves build on stable:

\n
cargo +nightly fmt --all

\n

Project layout

\n
WeftDB/\n├── Cargo.toml                  # workspace manifest (members + shared deps)\n├── weftdb/                     # time-series database (Turso/libSQL control plane + segment store)\n│   └── src/types/\n│       ├── database/           # connection, config, inputs, outputs, pipeline\n│       ├── compression/        # tiered dataset compression\n│       ├── batches/            # batching & analysis\n│       └── …                   # patterns, events, correlations, signals, …\n├── weft-orchestration/         # pipeline orchestration & event detectors\n├── weft-physical-type/         # physical encodings, schemas, timestamp codecs, .weftseg\n├── weft-arrow/                 # Arrow/Parquet interchange for segments\n├── weft-arrow-store/           # Arrow/Parquet bridge over the segment store\n├── weft-line-protocol/         # InfluxDB Line Protocol parser (shared dialect)\n├── weft-reduce/                # downsampling reductions over a bucket grid\n├── weft-server/                # axum HTTP API server\n├── weft-bench/                 # benchmark harness (adapters, profiles, reports)\n└── weft-tui/                   # terminal UI binary

The complete work queue and design constraints live in\nROADMAP.md — the single source of truth.

\n
\n

Crates

\n

WeftDB is a Rust Cargo workspace, from a low-level numerical engine up to an HTTP\nserver and an interactive application:

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
CrateRole
splimes (own repo, from crates.io)Spline interpolation engine — Linear / Quadratic / Cubic / Polynomial methods on the CPU (one thread or rayon) or the GPU, chosen by grid size, with raw / interpolated / extrapolated provenance on every point.
weftdbTime-series database: Turso (libSQL) control plane (catalog, metadata, segment index; MVCC concurrent writes) + WeftDB's own typed columnar .weftseg segment store on the measurement hot path, plus the pattern-recognition types and tiered dataset compression.
weft-orchestrationHigh-level pipeline that chains batching → pattern extraction → event detection → correlation → signal generation, with built-in detectors and parallel execution.
weft-physical-typeVendor-neutral physical type system — schema-declared numeric encodings with explicit exactness, timestamp codecs, and the .weftseg columnar segment format (single-block and paged).
weft-arrow / weft-arrow-storeApache Arrow / Parquet interchange for sealed segments and stored reads, kept in leaf crates so the arrow-* dependency tree never reaches the hot-path core.
weft-line-protocolDependency-free InfluxDB Line Protocol parser shared by the server and the benchmark harness.
weft-reduceVendor-neutral downsampling reductions, computable over parts and merged (reduce_partial/PartialReduction, exact for every reduction) — min/max/avg/sum/first/last + nearest-rank p50/p90/p95/p99 + three time-weighted averages (LOCF, linear/trapezoidal, LOCF-to-bucket-end) + mergeable bounded-error sketch_p* percentiles, over an epoch-aligned bucket grid, computed in BigDecimal. A PartialReduction is serde-serializable (so a segment's partial can be persisted and merged later, in place of re-reading it) and re-bucketable to any coarser nesting resolution (rebucket/grids_nest). Shared by the HTTP downsample endpoint and the benchmark harness.
weft-serverBenchmark-grade axum HTTP API — interpolation, downsampling, storage ingest/query, catalog management, Prometheus metrics, live latency profiles.
weft-benchReproducible, correctness-gated benchmark harness — the roadmap's spine; both an internal suite and a customer-runnable diagnostic.
weft-tuiTerminal user interface (Ratatui + Crossterm) for creating databases, importing CSV data, browsing and plotting aspects, and running compression.
\n

The platform is designed for real-time and large-scale workloads: measurements\nare stored with BigDecimal logical precision,\ninterpolation scales from a handful of points to millions, from one CPU thread to the\nrayon pool and, where calibration measured it faster, the GPU, and the storage layer\nuses bulk transactions, cached connections, and typed columnar segments throughout.

\n
\n

Tuning tips

\n
    \n
  • Batch your writes. batch_capture_measurements uses bulk transactions and is\ndramatically faster than single inserts.
  • \n
  • Connections are cached. The database layer reuses connections through a\nTTL/LRU cache (default: 50 connections, 30-minute TTL) and starts MVCC\nBEGIN CONCURRENT transactions automatically, with retry + backoff on\ntransient failures.
  • \n
  • Calibrate the backends once. weft-server does it in the background at startup; a program\nembedding the libraries calls splimes::calibrate() (or splimes::prewarm_gpu()\nplus splimes::set_auto_thresholds) once, off any latency-critical path. Without\nit, interpolation never uses the GPU.
  • \n
  • Pick sensible batch sizes for pipelines (typically 24–100 for hourly data).
  • \n
  • Let the engine choose. Backend::Auto (behind analyze_range and every\nendpoint) already selects the backend by grid size — overriding is rarely necessary.
  • \n
  • Pipeline pattern loading is memory-aware: batch sizes adapt to available RAM to\navoid exhaustion on large datasets.
  • \n
\n
\n

Roadmap

\n

ROADMAP.md is the single source of truth for everything planned,\nin progress, and done — a phase-based [ ]/[x] checkbox queue organized around\nthe benchmark-led commercial thesis (north star: dollars per billion\ninterpolated output points at a p95 latency target). Shipped capabilities are\ndocumented here as features; every performance claim links to a benchmark\nartifact.

\n
\n

License

\n

Copyright (c) 2025-2026 Justin Icenhour.

\n

Licensed under either of

\n\n

at your option.

\n

Two crates include code adapted from Apache-2.0 projects — the Chimp codecs in\nweft-physical-type and the DDSketch quantile sketch in weft-reduce. Those portions\nstay under the Apache License 2.0 whichever option you choose. NOTICE lists\nthem, and each crate's THIRD-PARTY-NOTICES file carries the attribution and the\nlicense text.

\n

Contribution

\n

Unless you explicitly state otherwise, any contribution intentionally submitted for\ninclusion in the work by you, as defined in the Apache-2.0 license, shall be dual\nlicensed as above, without any additional terms or conditions.

\n

Every pull request also needs either a DCO sign-off on each commit or a signed CLA; see\nContribution terms.

\n", + "readmeHtml": "

WeftDB

\n

The time-series database for data that wasn't sampled on a clean grid.

\n

\"Release\"\n\"CI\"\n\"License:\n\"Rust\"\n\"Platform\"\n\"Status\"

\n

Most time-series databases assume your data arrives on a regular cadence. Real data\noften doesn't. Sensors drop out. Markets trade in bursts. Devices batch-upload after\nhours offline. When you ask those databases for a value between two samples, you get\na gap, a NULL, or a 0 — and the work of reconstructing the signal lands in your\napplication code.

\n

WeftDB puts that reconstruction in the database. Ask for any resolution you like\nand it returns a continuous series, interpolated with the spline method you choose,\nwhere every point is labelled raw, interpolated, or extrapolated — so a\nsynthetic value is never silently mistaken for an observed one.

\n
curl -s -X POST http://127.0.0.1:8080/api/v1/interpolate \\\n  -H 'content-type: application/json' \\\n  -d '{\"spline\":\"linear\",\"resolution\":\"seconds\",\n       \"points\":[{\"timestamp\":\"1970-01-01T00:00:00Z\",\"value\":0.0},\n                 {\"timestamp\":\"1970-01-01T00:01:00Z\",\"value\":60.0}]}'

Two samples a minute apart come back as 61 labelled, per-second points — the two\nobserved endpoints marked raw, the 59 between them interpolated.

\n
\n

Contents

\n

Start here · Why WeftDB · Quickstart · Is WeftDB right for you? · Project status

\n

Use it · What you get · HTTP API · Configuration · Rust library · Terminal UI

\n

Understand it · Performance · How it works · Benchmarking · Development

\n
\n

Why WeftDB

\n

Interpolation is a first-class query, not post-processing. Resampling, gap\nfilling and upsampling run next to the data, over typed columnar segments, on one CPU\nthread, the rayon pool or — once calibrated — the GPU, chosen by grid size, not in a\nclient loop pulling raw rows across the wire.

\n

Precision is declared, never silently lost. Values are logically\nBigDecimal. Each aspect declares a physical encoding\n(F64, F32, ScaledI64, ScaledI128, Decimal128, BigDecimalText) and an\nerror bound. A value the encoding cannot represent within that bound is rejected,\nnot quietly rounded. If you are storing money or calibrated instrument readings, this\nis the difference between a database you can audit and one you can't.

\n

Synthetic data is always marked as synthetic. Every reconstructed point carries\nits provenance. Downstream code can filter, weight or refuse interpolated values on\npurpose instead of by accident.

\n

Claims come with benchmarks. Every performance number in this document links to\nthe harness that produced it, and the harness ships in the repo so you can re-run it\non your hardware. Where WeftDB doesn't win, this README says so.

\n
\n

Quickstart

\n

Prerequisites: Rust 1.95 or newer on stable. A GPU with a wgpu backend\n(Vulkan / Metal / DX12) is optional — without one the engine runs on the CPU\n(one thread or the rayon pool) automatically.

\n
cargo build --release

Prefer not to build from source? Each\nGitHub release, from v0.1.0 on,\ncarries pre-built weft-server, weft-tui and weft-bench archives for Linux (x86_64\nand aarch64), macOS (x86_64 and Apple silicon) and Windows (x86_64), and a SHA256SUMS\nfile to check them with sha256sum -c SHA256SUMS. The Linux binaries are built on\nUbuntu 24.04: weft-server and weft-bench need glibc 2.34 or newer, and weft-tui\nneeds glibc 2.39 or newer. The macOS and Windows binaries are not signed, so Gatekeeper\nand SmartScreen will warn before running them. Read the release's known limitations\n(v0.1.0's are in its\nchangelog)\nbefore you run it.

\n

1. Start the server

\n
WEFT_SEGMENT_STORE_ROOT=./store cargo run --release -p weft-server\n# weft-server v0.1.0 listening on http://127.0.0.1:8080

WEFT_SEGMENT_STORE_ROOT is what enables the persistent /storage endpoints;\nwithout it the compute endpoints still work statelessly.

\n

2. Interpolate without storing anything

\n

The compute endpoints take points in the request, so you can evaluate WeftDB\nbefore committing any data to it:

\n
curl -s -X POST http://127.0.0.1:8080/api/v1/interpolate \\\n  -H 'content-type: application/json' \\\n  -d '{\"spline\":\"cubic\",\"resolution\":\"seconds\",\n       \"points\":[{\"timestamp\":\"1970-01-01T00:00:00Z\",\"value\":0.0},\n                 {\"timestamp\":\"1970-01-01T00:00:30Z\",\"value\":30.0},\n                 {\"timestamp\":\"1970-01-01T00:01:00Z\",\"value\":60.0}]}'

Already have InfluxDB Line Protocol? Point it at the ILP sibling instead:

\n
printf 'cpu,host=a load=0 1000000000\\ncpu,host=a load=60 1000000060\\n' | \\\n  curl -s -X POST \\\n    'http://127.0.0.1:8080/api/v1/interpolate/ilp?field=load&precision=s&spline=linear&resolution=seconds' \\\n    -H 'content-type: text/plain' --data-binary @-

3. Store a series and query it back

\n

Declare the aspect's schema once — this is where you choose precision:

\n
curl -s -X POST http://127.0.0.1:8080/api/v1/storage/aspects \\\n  -H 'content-type: application/json' \\\n  -d '{\"name\":\"price\",\"physical_type\":\"scaled_i64\",\"scale\":2,\n       \"value_tolerance\":\"0\",\"timestamp_unit\":\"millis\"}'

scale: 2 with value_tolerance: "0" means exactly two decimal places, no rounding\npermitted — a third decimal is rejected at ingest rather than silently truncated.

\n
# ingest\ncurl -s -X POST http://127.0.0.1:8080/api/v1/storage/price/points \\\n  -H 'content-type: application/json' \\\n  -d '{\"points\":[{\"timestamp\":1000,\"value\":\"1.25\"},\n                 {\"timestamp\":2000,\"value\":\"2.50\"},\n                 {\"timestamp\":3000,\"value\":\"3.75\"}]}'\n\n# read the range back (start/end are required, in the aspect's declared unit)\ncurl -s 'http://127.0.0.1:8080/api/v1/storage/price/points?start=0&end=9999&limit=10'\n\n# read one instant\ncurl -s 'http://127.0.0.1:8080/api/v1/storage/price/at?t=2000'\n\n# bucket it\ncurl -s 'http://127.0.0.1:8080/api/v1/storage/price/downsample?resolution=seconds&agg=avg,max,p95'

Every read surface also speaks CSV, Arrow IPC and Parquet — append .csv,\n.arrow or .parquet, or use the …/range.parquet variants, and pipe straight into\npandas, Polars or DuckDB.

\n
\n

Is WeftDB right for you?

\n

Being honest about this saves you an evaluation.

\n

A good fit

\n
    \n
  • Irregular or gappy series where you need values between the samples —\nsensor networks with dropouts, market data, batch-uploaded device telemetry.
  • \n
  • Resampling and gap filling as a hot path, run often enough that doing it in\napplication code is costing you real latency or CPU.
  • \n
  • Values where precision is not negotiable — prices, billing quantities,\ncalibrated scientific readings — and a silent f64 round-trip is unacceptable.
  • \n
  • Workloads that care which points are real, because a synthetic value would\notherwise contaminate an alert, a model, or an invoice.
  • \n
  • Point lookups and narrow windows over large columnar segments, which WeftDB\noptimises specifically (see Performance).
  • \n
\n

A poor fit — today

\n
    \n
  • You need a mature, hardened production database. WeftDB has no TLS, no\nauthentication, and no RBAC yet. Do not expose it to an untrusted network.
  • \n
  • Generic OLAP over wide tables. WeftDB is not competing with ClickHouse or\nDuckDB on broad analytical scans, and doesn't claim to.
  • \n
  • Regular, evenly-sampled data with no gaps. If interpolation isn't your\nproblem, WeftDB's core advantage doesn't apply and a conventional TSDB is the\nsimpler choice.
  • \n
  • Per-measurement tags / labels. Not implemented yet, so tag-based filtering\nand multi-dimensional grouping aren't available.
  • \n
  • High-cardinality dimensional slicing in the Prometheus sense — that's not\nthe shape of WeftDB's data model.
  • \n
  • Managed hosting. There is none; you run it yourself.
  • \n
\n
\n

Project status

\n

Pre-beta. Expect sharp edges. WeftDB is an engineering-grade codebase — over\n1,000 tests pass, the storage format is CRC-checksummed and versioned, and the\nbenchmarks are real and reproducible — but it has not been hardened for production\ndeployment.

\n

v0.1.0 is the pre-beta baseline release: pre-built binaries on the\nGitHub release, and no\ncrates on crates.io. Later releases test their upgrade, rollback and compatibility\nagainst it. Its\nknown limitations\ninclude no\nauthentication or TLS, incomplete crash consistency, and a store layout that the next\nrelease upgrades one way.

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
AreaState
Storage engine, interpolation, HTTP API, Arrow/ParquetWorking, tested, benchmarked
Backup & restore (control plane)Working, with verification and a rehearsal drill
Observability — Prometheus metrics, OpenTelemetry tracesWorking
Crash consistency — power-loss durability, crash-safe maintenanceIn progress (design)
Security — TLS, auth, RBAC, audit logsNot started
Pre-built binaries (Linux, macOS, Windows)Released: v0.1.0, on GitHub
Library crates on crates.ioNot published yet
Packaging — Docker, HelmNot started
Client SDKs (Python, TypeScript)Not started
Per-measurement tagsNot started
\n

The full work queue lives in ROADMAP.md. It is the single source of\ntruth for status and priorities, and it records negative results alongside wins.

\n
\n

What you get

\n

Query

\n
    \n
  • Interpolation on read — reconstruct any series onto any grid from nanoseconds\nto years, with Linear / Quadratic / Cubic / Polynomial splines. The engine picks\nsingle-threaded CPU, parallel (Rayon) or — once calibrated — GPU by grid size, and\ndegrades the method gracefully (Cubic → Quadratic → Linear) when data is too sparse\nto support it.
  • \n
  • Provenance on every point — raw, interpolated or extrapolated.
  • \n
  • Downsampling into epoch-aligned buckets: min/max/avg/sum/first/last,\nexact nearest-rank p50/p90/p95/p99, three time-weighted averages (LOCF\nfor change-only sensors, trapezoidal for continuous signals, and an LOCF variant\nthat carries the last sample to the bucket's end), plus bounded-error\nsketch_p* percentiles that are mergeable and cost a fixed amount of memory per\nbucket however many samples land in it.
  • \n
  • Point and range reads that skip work: a point lookup on a regular column is\nresolved in closed form without materialising timestamps at all.
  • \n
\n

Store

\n
    \n
  • Typed columnar segments (.weftseg) — written once, never rewritten: maintenance\n(reconcile, split, overlap merge, squash, compaction) writes its output to new frames,\nfsyncs them, and swaps them for its inputs in one control-plane transaction, so a\ncrash leaves either the inputs or the outputs, and a replaced frame is deleted only\nonce no read can still be using it. CRC-checksummed, page-indexed, with per-segment\nand per-page min/max statistics for data skipping and a null/quality column.
  • \n
  • Compression that adapts per column — timestamps pick among delta-of-delta,\nRLE, Gorilla, fixed and per-block bit-packing; values pick among varint,\nbit-packing, per-block adaptive packing, frame-of-reference and an opt-in delta\ncascade. The codec each column actually used is reported, and bytes/point is the\nrealized figure, not an estimate.
  • \n
  • Late and out-of-order data — detected, counted, and reconciled within a segment or\nacross segments with last-writer-wins semantics, on demand or by background sweep.
  • \n
  • Backup and restore of the control plane, with two verification modes (one safe\nunder concurrent writes) and a non-destructive restore drill so you can answer\n"is my backup actually restorable?" without risking the live store.
  • \n
\n

Integrate

\n
    \n
  • HTTP API with JSON, CSV, Arrow IPC, Parquet and InfluxDB Line Protocol in and\nout — so TSBS/Influx/QuestDB-shaped writers work unchanged, and results land\ndirectly in Arrow-native tooling.
  • \n
  • Prometheus /metrics with latency histograms, plus a live p50/p95/p99 profile\nendpoint and OpenTelemetry trace export.
  • \n
  • Rust libraries — use the engine, the storage layer or the reductions directly,\nwithout the server.
  • \n
  • Terminal UI for importing CSV, browsing and plotting aspects, and running\ncompression interactively.
  • \n
\n
\n

Performance

\n

Every number below comes from a benchmark in this repo, named so you can re-run it. The\nexception is any figure measured on the real BTC corpus: that input is a local file the\nrepository does not ship (see Ingest), so those figures cannot be reproduced from a\nclone. All were measured on one developer workstation — treat them as shape, not as a\nspecification for your hardware.

\n

Reads WeftDB optimises specifically

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
OperationWeftDBNaive full decodeGain
Point lookup, 100k-row segment56 µs2.60 ms~46×
Point lookup, paged frame33 µs279 µs~8.5×
Batch of 64 instants (one pass vs 64 single lookups)67 µs3.57 ms~54×
100-row window over 100k rows58 µs2.73 ms~47×
\n

Source: weft-physical-type/benches/pointread.rs,\nre-measured 2026-10-08 after the word-wise bit reads and the slicing-by-16 frame CRC.\nThe wins come from skipping the value column entirely until a specific row is needed,\nand from resolving regular timestamp columns in closed form.

\n

Ingest

\n

Sealing 1M rows of a real 1-minute financial series into a columnar segment:\n4.57 s — 218,963 rows/sec at 4.22 bytes/point.

\n

That corpus (database/datasets/btc_1min.csv, a BTC/USD one-minute series) is a local,\ngitignored file, not distributed with this repository and not required to build or test —\nthe suite skips the tests that use it when it is absent, and so do the benches that load it by\ndefault. Every "real BTC" figure in this README comes from it, so none of them can be reproduced\nfrom a clone. Whether those numbers are cleared for publication or retired is an open item with\ncounsel before 1.0 (1.0 plan, Appendix B).\nThe ingest figure is quoted anyway because a synthetic generator materially flatters the\nresult: on the same workload the generated corpus reports 1.71 bytes/point against the real\n4.22, roughly 2.5× too optimistic. Storage numbers measured on generated data are not reported\nas results.

\n

Reductions

\n

At 500k points, hour buckets, correctness-gated\n(weft-bench --downsample; re-measured 2026-10-08, full table under\nCompute endpoints): avg runs at 2.08M points/sec;\ntime-weighted average costs about 1.39× that. The approximate sketch_p99 is\n~2.6× faster than exact p99 (327 ms vs 852 ms) and stays within its 1% bound,\nwhile using bounded memory per bucket.

\n

Where it doesn't pay off

\n

A deliberately-kept honest result: the bit-sliced (transposed) value codec once\nmeasured ~5.7× faster than the linear bit-unpack at the kernel level, but only because\nthe linear decoder tested one bit at a time. Once that decoder moved to word-wise reads,\nthe linear layout unpacked faster (1.09 vs 1.95 ms per 1 Mi values,\nbenches/bitunpack.rs). End to end, the\nbit-sliced layout costs +0.08% bytes and reads no faster than the linear codec\n(benches/transposed_read.rs), so it\nships off by default (and is now behind the bitsliced-codec build feature, pending\npatent review). A kernel speedup measured against a weak baseline is not a result, and\nthis README would rather say so than quote the 5.7×.

\n

Exact decimals are not free yet either. Hourly avg over 1M real BTC closes (the local corpus\ndescribed under Ingest, not in the repository), all three paths proven to produce the\nsame buckets:

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
pathtimevs f64
f64 loop (lossy baseline)2.14 ms1×
weft_reduce::reduce_scaled (exact, on the stored ScaledI64 mantissas)13.52 ms~6.3×
weft_reduce::reduce (exact, per-sample BigDecimal)60.04 ms~28×
\n

QuestDB documents ~2× for its DECIMAL, so\nthe precision wedge still costs more here. Before this work the shipped path measured 100.65 ms\n(~43×). Two changes closed most of the gap: integer\naccumulation, and computing each bucket's avg by integer long division that reproduces\nbigdecimal's quotient digit for digit. Per sealed 1M-row segment (decode + six reductions), the\nstored-range downsample went from 127.6 ms to 59.7 ms. The server's GET /api/v1/storage/{aspect}/downsample now takes the integer path for ScaledI64 segments when every\nrequested reduction is streaming or a sketch_p*, and falls back otherwise\n(weft-reduce/benches/decimal_tax.rs, load average ~11).

\n
\n

HTTP API

\n

weft-server is the axum HTTP surface — Weft-Bench, TSBS-style harnesses, Grafana\nand SDKs all drive the engine through it. It carries no vendor-specific dependencies.

\n

See the Quickstart to start it.

\n

Bind address defaults to 127.0.0.1:8080 (WEFT_SERVER_ADDR overrides). Setting\nWEFT_SEGMENT_STORE_ROOT opens a Storage v2 segment store and enables the\n/storage endpoints (without it they answer 503, and GET /ready reports the\nsegment_store dependency). A store root belongs to one server at a time: the server\nholds the root's LOCK file while it runs, and a second server pointed at the same\nroot exits at startup with an error naming the first one's pid and host.

\n

The root's STORE_FORMAT file records the store's layout and the oldest layouts a\nWeftDB must know to read and to write it (plain JSON; see weftdb::StoreFormat), and\nsegment_index.db's store_meta table keeps a copy. The server reads the marker before\nit opens any database (a root without one, the copy, before it writes anything), and\nrefuses a store a newer WeftDB wrote (IncompatibleLayout) without changing anything\nbut LOCK and LOCK.holder. A store written before the marker existed is layout 1 and\nis upgraded in place on first open, through the registered migrations\n(store_migrations in segment_index.db lists those applied); its frames are not\nrewritten. A layout-1 store whose index records a frame outside segments/ (possible\nonly through the aspect-name traversal of earlier builds) is refused with\nUnsafeLegacyPath, naming the aspects, before any migration applies or a marker is\nwritten, and nothing is moved: drop or re-seal those aspects with the build that wrote\nthem first.

\n

While bound to a loopback address, the server only answers requests addressed to a\nloopback host: Host must be localhost, an address in 127.0.0.0/8 or [::1]\n(anything else is a 421), and a state-changing request whose Origin is not a\nloopback origin is a 403. That keeps web pages from non-loopback origins, open in a\nlocal browser, from driving the server; a page served by another local web server (any\nloopback origin, on any port) is still trusted until authentication lands. Clients that\nsend no Origin, like curl, are unaffected. /health and /ready follow the same\nrule, so a health probe must send a loopback Host such as localhost or 127.0.0.1,\nas every HTTP/1.1 client does; an HTTP/1.0 probe that sends no Host gets a 421, so\nconfigure it to send one or set WEFT_ALLOW_ANY_HOST=1. Behind a local reverse\nproxy that forwards a different Host, set WEFT_ALLOW_ANY_HOST=1; a proxy that keeps\nHost: 127.0.0.1 but forwards the Origin of a browser UI served from a non-loopback\norigin needs it too, or that UI's state-changing requests get a 403. A non-loopback\nbind is not guarded: there is no authentication yet, so keep such a server off\nuntrusted networks.

\n

Service endpoints

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
Method & pathPurpose
GET /healthLiveness.
GET /readyReadiness, including the segment-store dependency check. With a store, poisoned / restart_required report that a segment-index COMMIT failed in a way that may still have committed, so the store refuses writes until the server restarts (reads keep working, and ready stays true); poison_reason says which transaction and error.
GET /metricsPrometheus text exposition — request/error/output counters, ingest counters (weft_ingest_*), and latency histograms for the compute and storage-ingest paths.
GET /debug/profile/currentLive p50/p95/p99 latency snapshot per instrumented path.
\n

Compute endpoints

\n

The flagship interpolation-on-read path and its reduction counterpart. Each\naccepts JSON points or an InfluxDB-Line-Protocol body, and each returns JSON,\nCSV, Arrow IPC, or Parquet:

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
EndpointPurpose
POST /api/v1/interpolateReconstruct an irregular series onto a regular grid (spline = linear | quadratic | cubic | polynomial; resolution = nanoseconds..years). Every output point carries a kind — raw / interpolated / extrapolated. A grid of more than 10,000,000 points (MAX_INTERPOLATE_OUTPUT_POINTS, or WEFT_MAX_INTERPOLATE_POINTS) is refused with 400 before anything is computed; split larger ranges across requests. The response's spline is the method that ran: with fewer distinct timestamps than the requested method needs (cubic 4, quadratic 3, polynomial degree + 1) it is the simpler method splimes stepped down to.
POST /api/v1/interpolate/pointEvaluate the reconstructed signal at a single instant, labelled raw/interpolated/extrapolated.
POST /api/v1/downsampleReduce samples into epoch-grid-aligned buckets — min/max/avg/sum/first/last, nearest-rank percentiles p50/p90/p95/p99, the three time-weighted averages (twa, LOCF dwell-weighting, for change-only sensors; twa_linear, trapezoidal Σ½(vᵢ+vᵢ₊₁)Δtᵢ/ΣΔtᵢ, for irregularly-sampled continuous signals; twa_bucket_end, LOCF that additionally carries the bucket's last sample to its grid end — twa gives that sample no weight, so a sensor reporting 0 then 100 a minute into an hour bucket reads twa=0 but twa_bucket_end=98.33), and the approximate sketch_p50/p90/p95/p99 (see below); all reductions computed in BigDecimal by the shared weft-reduce crate. Aliases: median→p50, time_weighted_avg/twa_locf→twa, time_weighted_avg_linear→twa_linear, twa_locf_end→twa_bucket_end, sketch_median→sketch_p50. Only non-empty buckets are emitted.
POST /api/v1/{interpolate,downsample}/ilpThe same, fed an ILP text/plain body (the TSBS/InfluxDB/QuestDB wire format); field, precision (ns/us/ms/s), and the compute knobs are query parameters. interpolation= is accepted as an alias for spline= (the canonical spline wins if both are given).
POST /api/v1/{interpolate,downsample}/{csv,arrow,parquet} and …/ilp/{csv,arrow,parquet}The same computations with CSV (text/csv), Arrow IPC stream, or Parquet output — so a harness feeding line protocol pulls results in any of the four formats.
\n

What the reductions cost. Measured on the shipped harness (500k points, 5 reps, hour buckets,\ncorrectness PASS throughout — weft-bench --downsample --ds-points 500000 --ds-aggs <agg>;\nre-measured 2026-10-08 after the avg division change, at load average ~19). These\nuse the generated series, whose values are exact binary expansions of floats (~50 significant\ndigits), so they are a pessimistic bound. On real two-decimal prices (--ds-csv, 1M BTC/USD\none-minute closes from the local corpus described under Ingest, which is not distributed\nwith the repository; hourly avg,p99,twa) the same reduction runs at 4.24M points/sec against\n0.56M points/sec for a generated series of the same size (weft-bench --downsample --ds-csv database/datasets/btc_1min.csv --csv-value-col 4 --csv-skip 3000000 --ds-points 1000000 --ds-bucket h --ds-aggs avg,p99,twa --reps 5):

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
reductionp50throughputnote
avg240.8 ms2,075,477 points/secthe streaming baseline — no bucket materialized
twa334.1 ms1,515,029 points/sec1.39× the streaming cost: dwell-weighting needs the time-ordered samples
twa_bucket_end332.1 ms1,518,520 points/secwithin noise of twa — one extra weight, effectively free
twa_linear407.8 ms1,197,606 points/sec1.22× twa: an extra BigDecimal add + divide per interval for the trapezoidal mean
\n

Exact vs sketch percentiles — which to ask for. The p50/p90/p95/p99 reductions are\nexact nearest-rank: they return an actual observed BigDecimal from the bucket, but they\nmaterialize and sort the whole bucket, and two buckets' results cannot be combined. The\nsketch_p50/p90/p95/p99 reductions instead stream every value into a\nDDSketch — a 1% relative-error bound\n(weft_reduce::SKETCH_ALPHA), bounded memory regardless of bucket size (values fold in as they\narrive; SKETCH_MAX_BINS caps the store absolutely), and an exactly mergeable structure, so a\np99 can be computed over a large or streaming bucket. Measured on the shipped harness (500k points,\n5 reps, correctness PASS): sketch_p99 runs at 1,528,361 points/sec (p50 = 327.30 ms) versus\nexact p99 at 584,469 points/sec (p50 = 852.39 ms) — ~2.6× faster\n(weft-bench --downsample --ds-points 500000 --ds-aggs sketch_p99 vs --ds-aggs p99).

\n

The approximation is declared, never silent — WeftDB's precision principle. The sketch shares the\nexact percentiles' nearest-rank convention (⌈q·n⌉), so sketch_p* and p* name the same\nsample at every bucket size and the sketch is always within the 1% bound of the exact answer —\nthe two are substitutable. (DDSketch's reference rank is ⌊q·(n-1)⌋, which on a small bucket picks\na different sample; WeftDB deliberately does not inherit that.) The exact percentiles remain the\ndefault and cost nothing on a small bucket, so prefer them there; reach for a sketch when a bucket\nis too large to materialize or the result must merge.

\n
curl -s -X POST http://127.0.0.1:8080/api/v1/interpolate \\\n  -H 'content-type: application/json' \\\n  -d '{\n        \"spline\": \"linear\",\n        \"resolution\": \"seconds\",\n        \"points\": [\n          { \"timestamp\": \"1970-01-01T00:00:00Z\", \"value\": 0.0 },\n          { \"timestamp\": \"1970-01-01T00:01:00Z\", \"value\": 60.0 }\n        ]\n      }'
printf 'cpu,host=a load=0 1000000000\\ncpu,host=a load=60 1000000060\\n' | \\\n  curl -s -X POST \\\n    'http://127.0.0.1:8080/api/v1/interpolate/ilp?field=load&precision=s&spline=linear&resolution=seconds' \\\n    -H 'content-type: text/plain' --data-binary @-

Malformed payloads, unknown tokens, fewer than two usable points, or a zero-span\nseries return 400 with an {"error": "..."} body.

\n

Numeric boundary: the compute endpoints' wire values are f64 — the\ndocumented transport boundary (the logical BigDecimal is narrowed only at the\nHTTP edge, and the columnar value columns are honestly typed Float64 — no false\nprecision). The storage endpoints below are lossless.

\n

Storage endpoints (Storage v2 over HTTP)

\n

Catalog management, ingest, and stored-range reads against the .weftseg segment\nstore. The no-silent-downcast guarantee holds end to end: a value unrepresentable\nunder the declared encoding/tolerance is rejected 400.

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
EndpointPurpose
POST /api/v1/storage/aspectsDeclare an aspect's schema — physical encoding, value tolerance, timestamp unit. The name also names the aspect's files on disk, so it must be at most 160 bytes with no /, \\, control characters, bidirectional controls, line separators or invisible formatting characters (the zero-width joiner and non-joiner are allowed), leading ., trailing . or space, and must not be a Windows device name (CON, NUL, COM1, …, also with an extension or a : suffix, as in nul:x); a server running on Windows also refuses <, >, :, ", `
GET /api/v1/storage/aspects · …/{aspect}/schema · …/catalogList declared schemas; read one aspect's schema; report the store's (database, subject) scope + registered hierarchy.
POST /api/v1/storage/{aspect}/pointsIngest a JSON batch (dense or nullable; single-block or paged via rows_per_page). Set require_sorted to reject an out-of-order batch (400, naming the first backwards row) instead of sealing it. Every ingest response reports time_sorted (whether the sealed segment stored in monotonic order).
POST /api/v1/storage/{aspect}/{ilp,parquet,csv}Ingest an ILP payload, a Parquet file, or CSV rows into a declared aspect — all sealing through the same schema-enforced path, and all honouring require_sorted (query param) + reporting time_sorted.
GET /api/v1/storage/{aspect}/range · …/value-rangeStream a stored time window / value band as Arrow IPC (application/vnd.apache.arrow.stream).
GET …/range.parquet · …/value-range.parquetThe same windows as Parquet files.
GET …/range.csv · …/value-range.csvThe same windows as CSV (lossless decimal-text values; empty field = null).
GET /api/v1/storage/{aspect}/points · …/value-pointsLossless JSON reads with declarative pagination — offset/limit (+ take and 1-based page aliases), with total/count/offset in the body. For stable forward iteration the body also carries an opaque next_cursor while rows remain; a client re-issues with ?cursor=<token> (supersedes offset/page; malformed → 400) until it is absent.
GET /api/v1/storage/{aspect}/at?t=Single-instant point lookup: the present value at exactly t (lossless decimal text) or a found:false miss. An order-signal-driven read planner resolves each candidate segment with its persisted time_sorted flag — binary search on a sorted segment, linear scan only on an out-of-order one — after pruning the index to the files spanning t.
GET /api/v1/storage/{aspect}/at-multi?t=,,Batch point lookup: a comma-separated list of instants resolved in one pass (points:[{timestamp,value,found}] in query order). The index is pruned once by the batch's whole span and each segment's timestamp column decoded once for the whole batch, so N instants sharing a segment cost one decode, not N.
GET·POST /api/v1/storage/{aspect}/downsample?start=&end=&resolution=&agg=Stored-range downsample — the same reduction set as POST /api/v1/downsample (above), but over the aspect's persisted segments instead of a request-supplied series, so a client aggregates a large stored range without fetching it. Bounded memory: the index is pruned by time and each surviving segment folds into its own mergeable partial reduction, merged once — only one segment's rows are ever resident, so a range far larger than RAM reduces, and with a sketch_p* the per-bucket state is bounded too. Omitted bounds span all stored history. …/downsample.csv · …/downsample.arrow · …/downsample.parquet serve the identical reduction in the compute endpoints' columnar formats. Traced as a downsample.range stage span. The pruned segments are reduced concurrently: measured 4.69× faster than the sequential fold (16 segments over 200k rows, 39.88 ms → 8.50 ms; sketch_p99 49.49 ms → 9.42 ms, 5.25×) — see database/benches/downsample_range.rs. A single pruned segment takes an inline fast path (concurrency there costs more than it buys). Materialized partials (opt-in): with WEFT_SEGMENT_PARTIAL_BASE set, each segment carries a .weftpart partial-reduction sidecar written at seal (a sealed segment is immutable, so its partial never goes stale), and a downsample of the bounded reductions (min/max/avg/sum/first/last + sketch_p*) merges the stored partials instead of decoding the value column — measured 3.2× faster (16 segments / 200k rows, 8.39 ms → 2.60 ms, non-overlapping CIs) and re-keyed to any coarser nesting resolution (minutes→hours→days) with no decode. Rollup tiers (opt-in): WEFT_SEGMENT_PARTIAL_TIERS materializes coarser tiers beside the base (e.g. hours,days), each re-keyed from the tier below, so a coarse query folds the coarsest matching tier instead of the whole fine base — the in-storage analogue of TimescaleDB's hierarchical continuous aggregates. Measured ~1.16× on a DAY query over a MINUTES base (16 segments / 200k rows, 7.30 ms → 6.30 ms, non-overlapping CIs). The win grows with the base-bucket count per segment, and that scaling is now measured rather than asserted: holding rows and segments fixed while widening the sample stride (so only the base buckets per segment change) gives 1.15× at ~208 base buckets → 1.27× at ~3125 → 1.38× at ~12500 (bench_tiered_span). Honest read: the win is real and climbs, but a ~60× increase in base buckets buys only 1.15×→1.38×, so the elided re-key is not the dominant cost and the tiers' extra .weftpart bytes should be weighed against it — see database/benches/downsample_range.rs. A reconcile/split/squash regenerates the sidecar (tiers and all) so the acceleration survives a rewrite.
POST /api/v1/storage/{aspect}/reconcileReconciliation pass (mode in the response). Default (intra-segment): rewrite every out-of-order segment of the aspect into a time-sorted one in place. ?threshold=N gates it on unsorted_segments >= N (QuestDB-style split-count trigger). ?hot_cold=true reconciles cold segments but defers the hot tail until the backlog reaches the threshold. ?overlaps=true instead runs the cross-segment overlap merge (newer-wins) — reconciled is then the number of segments merged away, and ?split_min_bytes=N makes that merge split-not-rewrite (carve off a dominant cold prefix instead of rewriting the whole component). Returns triggered/reconciled/cold_reconciled/hot_reconciled and the post-pass unsorted_segments + overlapping_segments. Maintenance operations on one aspect take turns: while another one holds the aspect (a daemon pass, another request), the request waits up to 30 s, then answers 409 Conflict without touching the aspect; retry.
POST /api/v1/storage/{aspect}/squashSquash the aspect's segments into one (newer-wins), bounding split-path fragmentation. ?max_segments=N gates it (squash only when the count exceeds N). Returns triggered/removed/segment_count. Waits for a busy aspect and answers 409 as reconcile does.
POST /api/v1/storage/{aspect}/compact?target_rows=NSize-targeted compaction — coalesce the aspect's segments toward ~N rows per segment (leaving already-large segments untouched), holding fragmentation near the read-optimal size rather than folding to one (which squash does). Motivated by the downsample_range knee (one giant segment reads slower than several mid-sized ones). target_rows is required (absent → 400). Returns removed/segment_count. The manual counterpart of the WEFT_COMPACT_TARGET_ROWS daemon pass. Waits for a busy aspect and answers 409 as reconcile does.
POST /api/v1/storage/reconcileStore-wide reconciliation sweep across every declared aspect: threshold (default), ?hot_cold=true, or ?overlaps=true (+ ?split_min_bytes=N for split-not-rewrite). Returns mode, aspects_scanned / aspects_reconciled / segments_reconciled (+ cold/hot split), the post-sweep store-wide unsorted_segments + overlapping_segments, and failed: a [{aspect, error}] list of the aspects whose pass failed (empty on a clean sweep). A failing aspect (e.g. a torn or truncated frame) no longer aborts the sweep: every other aspect is still swept and the response is 200, so check failed to tell a partial sweep from a clean one (earlier versions returned 500 at the first failing aspect and left every aspect after it in name order unswept). Only an unreadable aspect list is still a 500. An aspect another maintenance operation holds is waited for (30 s in all, across the sweep); if some are still held then, the others are swept and the answer is 409 Conflict naming the held ones. (The background daemon never waits: it skips a busy aspect until its next tick.) The manual counterpart to the background reconcile daemon (WEFT_RECONCILE_INTERVAL_SECS / WEFT_RECONCILE_THRESHOLD / WEFT_RECONCILE_HOT_COLD / WEFT_RECONCILE_OVERLAPS / WEFT_RECONCILE_SPLIT_MIN_BYTES / WEFT_RECONCILE_MAX_SPLITS).
POST /api/v1/storage/backupOnline control-plane backup (Phase 7.4) — snapshot the store's four control-plane DBs (segment_index/metadata/aspect_catalog/catalog) to fresh files via Turso's stable VACUUM INTO. Lands in <base>/<label> where <base> is WEFT_BACKUP_DIR or <store_root>/backups and <label> is a traversal-guarded ?label= ([A-Za-z0-9][A-Za-z0-9._-]{0,99}: 1 to 100 letters, digits, ., _ or -, starting with a letter or digit and not ending with .) or, without one, a generated manual-<unix_millis>. The backup-<digits> form is reserved for the backup daemon (retention counts and prunes exactly those names), so a ?label= in that form is a 400, and no snapshot taken through this endpoint is ever pruned by retention. ?verify=source|snapshot picks the verification: source (the default) cross-checks each copy's user-table set + row counts against the live control plane — the strongest check, but it assumes a quiescent store; snapshot verifies each copy on its own terms (it reopens and fully scans every row of every table) without re-reading the source, which is what an online backup taken while ingest continues needs. An unknown token → 400. Returns per-DB {name, tables, rows, bytes} + total_rows/total_bytes + the verify mode that ran. The backup is built in a .partial-* directory beside its label and appears under the label, with a MANIFEST.json, only once complete and durable. A label outside that grammar → 400 (the grammar also keeps out .partial-*, .deleting-* and .restore-drill-*, the names of unfinished backups, prunes and drills); an existing target dir → 409 with "code": "already_exists"; no store → 503. Control plane only — the .weftseg measurement frames are not part of this backup (hard-constraint #3).
POST /api/v1/storage/restore/drill?label=<backup>Restore drill (Phase 7.4) — rehearse restoring a backup on a running server, so an operator can answer "is my backup actually restorable?" without risking anything. Restores the named backup into a throwaway directory beside the backups, verifies every restored database at its destination (it opens, and every row of every table reads), returns per-DB {name, tables, rows, bytes} + total_rows/total_bytes/restorable, then deletes the rehearsal copy (cleanup_error says why, if it could not; it is null otherwise). Non-destructive by construction — it cannot touch the live store, and restoring over a live control plane is deliberately not offered (the library primitive refuses to clobber; pointing WEFT_SEGMENT_STORE_ROOT at a restored copy is a deployment decision, not an HTTP call). Unknown backup → 404; a label outside the backup label grammar (so also traversal and the staging names .partial-*, .deleting-*, .restore-drill-*) → 400, while a daemon snapshot's backup-<digits> is accepted, since a drill only reads it; a rehearsal directory already taken by a drill started in the same millisecond → 409 with "code": "already_exists" (retry; nothing was restored or removed); a backup that will not restore → 500 carrying the failure, which is a failed drill rather than a server bug.
GET /api/v1/storage/{aspect}/stats · …/storage/statsMaterialized per-aspect and store-wide rollups, including the realized bytes/point (the north-star cost term), an unsorted_segments order-health count (segments that would force a linear scan on a point lookup), and an overlapping_segments count (time-overlapping segments — the cross-segment order-health signal, computed by an index scan). Rollup fields are served from the control plane without opening a segment.
\n

Metrics

\n

GET /metrics renders Prometheus text exposition (v0.0.4): shared\nweft_interpolate_* / weft_downsample_* counters across each endpoint family,\nweft_ingest_* counters (requests/errors/rows/segments) on the ingest paths,\nweft_reconcile_* counters (_passes_total / _segments_reconciled_total) over the\nout-of-order reconciliation passes (manual, store-wide, and the background daemon —\nthreshold-held calls excluded) plus _failed_passes_total, the per-aspect passes a\nstore-wide sweep (daemon tick or POST /api/v1/storage/reconcile) skipped after an\nerror (the daemon also logs one WARN line per failed aspect), weft_backup_*\ncounters (_snapshots_total / _bytes_written_total) over the online control-plane\nbackups, and latency histograms over the compute and seal paths.\nGET /debug/profile/current serves the same timing data as a live p50/p95/p99\nsnapshot.

\n

Backup & restore

\n

The control plane (the catalog, per-aspect schema, segment index and metadata\nrollup databases) has an online backup, two verification modes, an unattended\ndaemon, and a restore. Per the storage boundary this covers the control plane\nonly — the .weftseg measurement frames are not part of a snapshot.

\n
    \n
  • Online snapshot. POST /api/v1/storage/backup copies all four databases\nwith Turso's VACUUM INTO while the store stays fully usable, and verifies\nevery copy before returning (see the endpoint catalog above for the parameters).
  • \n
  • Two verifications, because "verified" means different things under load.\n?verify=source (default) cross-checks each copy against the live control plane\n— the strongest check, valid while the store is quiescent. ?verify=snapshot\nnever re-reads the source; it reopens each copy and fully scans every row of\nevery table, so it is correct while writers are committing. A snapshot-only\nbackup taken with six concurrent ingests in flight returns 200 with the\nconcurrent seals visible in the copy.
  • \n
  • A backup directory holds exactly its four databases and a MANIFEST.json.\nVerifying a snapshot reopens it, which leaves zero-byte -wal / -log journal\nsidecars beside the copy; these are swept after every verification, in both\nmodes. Only a sidecar that is exactly zero bytes is removed — a non-empty -wal\nholds un-checkpointed frames, which is data, and is never touched. Verification\nalso requires each database's own tables, so an empty file never passes.
  • \n
  • A backup appears only once it is complete and durable. It is built in a\n.partial-{label}-{nonce}/ directory; each database is verified and fsynced, the\nmanifest (format, creation time, each file's size, tables and rows) is written and\nfsynced, and the directory is fsynced, renamed to its label, and the parent\nfsynced. A backup that fails with an error before the rename removes its build\ndirectory again; a crash there leaves the .partial-* directory, which is never\ncounted or restored, and which the backup daemon's sweep removes (where the\ndaemon is not enabled, remove it by hand). Once renamed, only the parent's fsync\nis left: if that fails, the call reports an error (500 over HTTP) although the\ncomplete backup is already under its label, so a retry with the same label is\nrefused (409). The next backup's fsync of the same parent makes it durable.
  • \n
  • What a snapshot costs is dominated by the filesystem, not by the vacuum.\nBenchmarked in database/benches/backup_cost.rs:\non tmpfs a whole four-database backup-and-verify runs in 8.62 ms at one\naspect, 6.18 ms at 16 and 16.79 ms at 128 (snapshot verification;\nsource verification is within noise of those), so the vacuum's own work is\nmilliseconds and scales with control-plane rows rather than being a fixed\nper-database floor. On a real disk it is orders of magnitude slower, because\nTurso's VACUUM INTO fsyncs the destination and runs a TRUNCATE checkpoint\nbefore returning. On this project's btrfs volume (load average ~12) the\nbackground daemon's ticks landed 3.0–4.5 s apart at a 2 s configured interval.\nA tick fires late only when the one before it outlasts the interval (tokio's\ndefault Burst catch-up), so each snapshot and its prune took that long, and\nthey ran back to back. Set WEFT_BACKUP_INTERVAL_SECS from what your storage\ncan sustain, not from how much data you have; point WEFT_BENCH_BACKUP_DIR\nat your own volume to measure it.
  • \n
  • Unattended backups with retention. Set WEFT_BACKUP_INTERVAL_SECS to run a\nbackground daemon that snapshots into a fresh backup-<unix_millis> directory\neach tick (verifying snapshot-only, since it backs up a live store), and\nWEFT_BACKUP_KEEP to retain only the newest N. Retention is deliberately narrow:\nit only ever removes daemon-generated backup-<digits> directories, a form\nthe API refuses to create (a directory you rename into it by hand is treated as\ngenerated and can be pruned, as it always could), so a snapshot you took\nthrough the API (?label=nightly, or an unlabelled manual-<unix_millis>) is\nnever a prune candidate and never counts toward N, and\nit runs only after a successful snapshot, so a run of failures cannot prune\nyour last good backup away. It counts only complete backups (a manifest, or all\nfour databases from before manifests), and renames a pruned backup to\n.deleting-* before removing its files, so an interrupted prune never leaves a\nhalf-removed backup. A backup-<digits> directory stamped more than 24 h\npast the clock, or past the directory's own modification time, did not come from\nthe daemon: retention neither counts nor removes it, and logs a warning each time\nit sees it. The modification-time check keeps such a directory ignored after the\nclock catches up with its stamp, as long as the directory is left unmodified and the\nfilesystem reports modification times; remove it by hand when the warning appears.\nAt start and on every tick the daemon removes .partial-*, .deleting-* and\n.restore-drill-* entries untouched for an hour. Both paths record\nweft_backup_* metrics.
  • \n
  • Restore, with a drill. weftdb::restore_control_plane(backup_dir, root)\nputs a snapshot back into a store root and verifies each restored file at its\ndestination — what matters is that the file the store will open actually\nreads. It refuses an incomplete backup directory (or one whose files no longer\nmatch its manifest, or one with a staging name) and refuses to overwrite an\nexisting control plane, pre-flighting both across all four files before writing\nanything, so a rejected restore leaves nothing behind. Each file is copied to\n<name>.tmp, fsynced and verified; only once all four verify are they renamed\ninto place, one after another, and the root fsynced. A failure before the\nrenames leaves only .tmp files, which a retry replaces; one part-way through\nthem leaves some databases in place, which a retry refuses to overwrite, so clear\nthe root first. Restoring beside the\noriginal segments/ frames reconstitutes a working store: the round-trip test\nreopens the restored root and reads its measurements back.
  • \n
  • Drills on a live server. POST /api/v1/storage/restore/drill?label= rehearses\na restore of the named backup into a throwaway directory, verifies it, reports\nrestorable, and cleans up — non-destructive by construction, so the question\n"is my backup actually restorable?" can be asked of production. Restoring over\na live control plane is not offered: that is a deployment decision (point\nWEFT_SEGMENT_STORE_ROOT at a restored copy and restart), not an HTTP call.
  • \n
\n
\n

Configuration

\n

WeftDB is configured primarily through environment variables:

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
VariableUsed byPurposeDefault
WEFT_DATA_DIRweftdb, weft-tuiRoot directory for database files and the TUI log (weft-tui.log).portable per-user default (see below)
TEST_DATA_DIRweftdbHighest-priority override for the database root (used by the test suite).unset
WEFT_EXTRACTED_BATCH_RETENTION_SECSweftdbHow long (seconds) a legacy aspect remembers the batches pattern extraction consumed, so the incremental build does not queue them again. Each extraction deletes older records, keeping about one row per resolution step of this period. Set it above the longest interval between pipeline runs of an aspect plus the longest ingest call; a non-positive or unparsable value keeps the default.172800 (48 hours)
WEFT_SERVER_ADDRweft-serverHTTP bind address.127.0.0.1:8080
WEFT_ALLOW_ANY_HOSTweft-serverTruthy (1/true/yes/on) → turn off the loopback request guard. On a loopback bind the server otherwise answers only requests whose Host is localhost, 127.0.0.0/8 or [::1] (421 otherwise) and refuses state-changing requests from non-loopback web origins (403). Set it behind a local reverse proxy that forwards a different Host, or one that forwards the non-loopback Origin of a browser UI it fronts. A non-loopback bind is never guarded.unset (guarded on a loopback bind)
WEFT_GPU_CALIBRATEweft-serverStartup calibration. By default, once the listener is bound, the server runs splimes::calibrate() once in the background on a blocking thread: it starts the GPU if there is one, times the single-thread, rayon and GPU backends on grids up to 16 Mi points (several seconds, a few hundred MB), and sets where Backend::Auto switches between them. Requests are served from the start and interpolate on the CPU with splimes' default thresholds until it finishes. A CPU/software adapter (llvmpipe, lavapipe, WARP) is not calibrated, with a log line saying why; force calibrates it anyway. 0 (or false/no/off) skips calibration. It logs the adapter (or why there is none) and the thresholds, and never fails startup; skipped or failed, interpolation stays on the CPU with splimes' defaults.unset (calibrate, except a software adapter)
WEFT_MAX_INTERPOLATE_POINTSweft-serverThe most output points one /api/v1/interpolate* request may produce; a larger grid is a 400 naming its size and the limit, refused before anything is allocated. A positive integer, read once at startup; anything else (including 0) stops the server from starting with a message naming the variable.10000000
WEFT_SEGMENT_STORE_ROOTweft-serverRoot of the Storage v2 segment store; enables the /storage endpoints.unset (storage endpoints answer 503)
WEFT_ON_AMBIGUOUS_COMMITweft-serverWhat the server does once its segment store is write-poisoned (after a COMMIT that may or may not have committed): poison keeps serving, refusing every write until the server restarts while reads keep working and GET /ready reports poisoned; exit logs and exits with status 70, for a supervisor that restarts the server. The restart's open settles the transaction either way. Read once, at startup; the weftdb library never reads it and never exits the process.unset (poison)
WEFT_RECONCILE_INTERVAL_SECSweft-serverBackground reconcile daemon sweep interval in seconds; 0/unset disables it.unset (disabled)
WEFT_RECONCILE_THRESHOLDweft-serverunsorted_segments backlog an aspect must reach before the daemon reconciles it.1
WEFT_RECONCILE_HOT_COLDweft-serverTruthy → the daemon reconciles cold segments each tick and defers the hot tail until the threshold.unset (all-or-nothing)
WEFT_RECONCILE_OVERLAPSweft-serverTruthy → each daemon tick also merges cross-segment time-overlap groups.unset (disabled)
WEFT_RECONCILE_SPLIT_MIN_BYTESweft-serverSplit-not-rewrite floor (bytes) for the daemon's overlap merge; a dominant cold prefix clearing it is split off rather than rewritten.unset (default 50 MiB floor → full-rewrite)
WEFT_RECONCILE_MAX_SPLITSweft-serverSegment-count cap; each tick also squashes every aspect over it into one segment (bounds split-path fragmentation).unset (no squash)
WEFT_COMPACT_TARGET_ROWSweft-serverTarget segment size in rows for the daemon's size-aware compaction pass; each tick coalesces every aspect's segments toward ~this many rows per segment (leaving already-large segments alone), holding fragmentation near the read-optimal size instead of folding to one (WEFT_RECONCILE_MAX_SPLITS). Motivated by the downsample_range knee (one giant segment reads slower than several mid-sized ones). Needs the daemon interval set.unset (no compaction)
WEFT_BACKUP_DIRweft-serverDirectory each control-plane backup is written under — shared by the manual POST …/storage/backup endpoint and the background backup daemon, so hand-taken and automatic snapshots live together.<store_root>/backups
WEFT_BACKUP_INTERVAL_SECSweft-serverBackground control-plane backup daemon interval in seconds; 0/unset disables it. Each tick writes a verified snapshot into a fresh backup-<unix_millis> directory, using the concurrent-write-safe snapshot verification (it backs up a live store). Needs a segment store configured.unset (disabled)
WEFT_BACKUP_KEEPweft-serverHow many daemon-generated snapshots to retain; after each successful backup the oldest backup-<digits> directories beyond this many are removed. Only daemon-generated names are ever counted or pruned — a snapshot taken through POST …/storage/backup (a ?label= one, or an unlabelled manual-<unix_millis> one) is never a candidate — and a generated-looking directory stamped more than 24 h past the clock or past its own modification time is ignored (not counted, not removed, logged as a warning; remove it by hand).unset (retain everything)
BTC_TEST_MAX_ROWS / BTC_TEST_FULLweftdb (tests)Row cap for the bounded, hermetic default run of test_create_btc_1min_database (into a temp data dir), or BTC_TEST_FULL=1 for the historical whole-corpus load into the shared data dir.5000 / unset
WEFT_SEGMENT_CHECKPOINT_STRIDEweft-serverRows between entries of the sealed timestamp checkpoint index — trades a little size for much faster point lookups on sorted, irregular columns (~3.45× single-block; see the checkpointed-frames feature above). Applies only where it pays: sorted + irregular + at least WEFT_SEGMENT_CHECKPOINT_MIN_ROWS rows. ~1024 is the sweet spot (stride barely moves speed but does move size).unset (no index; frames byte-for-byte as before)
WEFT_SEGMENT_CHECKPOINT_MIN_ROWSweft-serverRow floor below which a segment is never checkpointed (a small column decodes trivially, so an index would be pure cost).8192
WEFT_SEGMENT_CHECKPOINT_MAX_CODEC_OVERHEADweft-serverCeiling on the timestamp-codec override a checkpointed seal will accept (blocked / best bytes; 1.0 = only when free). A checkpointed frame must use the range-decodable per-block codec, which is ~free where that codec already wins but ~3.5× on a Gorilla-shaped and ~14× on an RLE-shaped column — this refuses those seals rather than silently bloating them.1.25
WEFT_SEGMENT_TRANSPOSED_MAX_OVERHEADweft-serverCeiling on the size overhead the bit-sliced (transposed) value codec may pay against the size-selected codec (transposed / best bytes; 1.0 = only when free, and a value below 1.0 is meaningful — the bit-sliced layout can be a strict size win, since it pays one width header per 1024-value tile where the blocked codec pays one per 64 values). Stores the value column bit-plane-major. Measured byte-neutral end to end on a 1M-row column and no faster to read than the linear codec (see Where it doesn't pay off), so it is off by default. Needs a build with the bitsliced-codec feature; any other build ignores the variable and logs a warning.unset (no transposed codec; frames byte-for-byte as before)
WEFT_SEGMENT_PARTIAL_BASEweft-serverResolution token (seconds/minutes/hours/…) at which each sealed segment materializes a partial-reduction .weftpart sidecar — a stored mergeable partial of the bounded reductions. A stored-range downsample of those reductions then merges the sidecars instead of decoding the value column (measured 3.2×), re-keyed to any coarser nesting resolution. A sealed segment is immutable, so the sidecar never goes stale; a rewrite (reconcile/split/squash) regenerates it.unset (no sidecar; downsample_range decodes as before)
WEFT_SEGMENT_PARTIAL_MIN_ROWSweft-serverRow floor below which a segment gets no partial sidecar (a tiny segment's partial saves too little decode to be worth the extra file).4096
WEFT_SEGMENT_PARTIAL_TIERSweft-serverComma-separated fine→coarse rollup resolutions (e.g. hours,days) materialized beside the WEFT_SEGMENT_PARTIAL_BASE partial, each re-keyed from the tier below (up to four kept; a finer/non-nesting entry is skipped). A coarse stored-range downsample then folds the coarsest matching tier instead of re-keying the whole fine base (measured ~1.16× on a DAY query over a MINUTES base). No effect unless WEFT_SEGMENT_PARTIAL_BASE is set.unset (base-only sidecar)
RUST_LOGalltracing filter. On weft-server it drives a per-request root span (request{method,path,request_id}, echoed as x-request-id) that every per-stage span nests under, each with busy/idle timing: the compute paths (interpolate.parse/compute/serialize under interpolate.engine, downsample.parse/reduce); the storage read paths (storage.{range,value_range,point}.read + .serialize, with a format field over JSON/CSV/Arrow/Parquet); the ingest paths (storage.ingest.parse/normalize/seal for ILP/CSV/JSON, and storage.ingest.parquet for the Parquet decode+seal); the background reconcile daemon (reconcile.tick{kind,…,aspects,segments}); and the control-plane writes on the seal path (control_plane.index.insert{aspect,id,rows_changed}, control_plane.index.delete{…}, control_plane.metadata.put{aspect,rows_changed}), whose rows_changed is libSQL's own affected-row count (Statement::n_change()) rather than an inference — so a trace shows whether a catalog write actually changed anything.weft_tui=debug,database=debug,info
OTEL_EXPORTER_OTLP_ENDPOINTweft-serverWhen set (e.g. http://localhost:4317), export tracing spans to an OpenTelemetry collector over OTLP/gRPC in addition to the RUST_LOG fmt output. Unset → no exporter, no network dependency; a misconfigured/absent collector never blocks startup. scripts/verify-otlp.sh verifies delivery end-to-end against a local Jaeger container (starting one if needed).unset (export disabled)
SKIP_SLOW_TESTStestsSet to 1 to skip long-running tests.unset
\n

The WEFT_SEGMENT_* variables configure the store weft-server opens; the server maps\nthem to weftdb::SegmentStoreOptions. A program embedding weftdb gets them only by\nasking: SegmentStore::open uses SegmentStoreOptions::default() (every one of them\nunset), and SegmentStore::open_with_options(root, SegmentStoreOptions::from_env())\nreads them as the server does.

\n

The database root directory is resolved in this order:

\n
    \n
  1. TEST_DATA_DIR, if set (explicit override for the test suite);
  2. \n
  3. otherwise WEFT_DATA_DIR, if set;
  4. \n
  5. otherwise a portable, per-user default — ~/.weftdb/data (falling back to the\nplatform data directory + weftdb, e.g. %APPDATA%\\weftdb on Windows, if the home\ndirectory cannot be determined).
  6. \n
\n

No paths are hard-coded: data lands in a writable, machine-independent location out\nof the box, and setting WEFT_DATA_DIR relocates both the databases and the TUI log\ntogether. The resolution functions are exported as weftdb::data_dir() (resolved)\nand weftdb::default_data_dir() (the raw default).

\n

The splimes crate also exposes build features:

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
FeatureEffect
gpu (default)The wgpu backend. Without it, interpolation always runs on the CPU.
serde (default)Serialize/Deserialize for Point, PointKind, Resolution and Spline.
tokioInterpolator::run_async, which runs an interpolation on tokio's blocking pool. WeftDB enables it: every async caller (the weftdb read and compression paths, weft-server, the bench adapter) interpolates off the async workers.
\n

The GPU is started at runtime, not at build time: weft-server calibrates it in the\nbackground at startup (WEFT_GPU_CALIBRATE, above), and an embedding program calls\nsplimes::calibrate() or splimes::prewarm_gpu() itself.

\n

WeftDB's own build features are all off by default, sit outside the 1.0 semver\npromise, and are pending patent review. A default build reads every segment written\nunder the default configuration (WEFT_SEGMENT_TRANSPOSED_MAX_OVERHEAD unset). A segment\nwritten with that variable set, as builds from before v0.1.0 did whenever it was set, needs\nbitsliced-codec. The release binaries are built without either feature, except that\nweft-bench enables experimental-codecs.

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
FeatureCrate(s)Effect
bitsliced-codecweft-physical-type, forwarded by weftdb and weft-serverCompiles in the opt-in bit-sliced value codec, so WEFT_SEGMENT_TRANSPOSED_MAX_OVERHEAD takes effect. Without it, a segment written with the codec fails to read with an error naming the feature.
experimental-codecsweft-physical-type (enabled by weft-bench)Advisory codecs never written to disk: Gorilla-XOR, Chimp, Chimp128 and Elf for f64, and the Sprintz FIRE timestamp forecaster. Benchmark what-ifs only.
\n
\n

Rust library

\n

WeftDB can be used directly as a set of libraries. They are not on crates.io yet\n(v0.1.0 ships binaries only), so depend on them from this repository, pinned to a\nrelease tag. splimes, the interpolation engine, is on crates.io:

\n
[dependencies]\n# the database: capture, query, interpolate\nweftdb = { git = \"https://github.com/basic-automation/weftdb\", tag = \"v0.1.0\" }\n# the analytics pipeline\nweft-orchestration = { git = \"https://github.com/basic-automation/weftdb\", tag = \"v0.1.0\" }\n# the interpolation engine on its own\nsplimes = \"1\"
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
CrateDepend on it when you want
weftdbThe database — capture measurements, query and interpolate stored series.
splimesSpline interpolation over irregular series, standalone. No database.
weft-orchestrationThe batching → patterns → events → correlation → signals pipeline.
weft-physical-typeDeclared numeric encodings and the .weftseg columnar format.
weft-reduceDownsampling reductions over an epoch-aligned bucket grid.
weft-line-protocolTo parse InfluxDB Line Protocol without an InfluxDB client.
weft-arrowSegments as Apache Arrow RecordBatch, Arrow IPC or Parquet.
weft-arrow-storeA stored range read straight into an Arrow RecordBatch.
\n

weft-server, weft-tui and weft-bench are binaries rather than libraries, so they\nare not published to crates.io — take them from the archives on a\nGitHub release or build them\nfrom this repository.

\n

The examples below are illustrative — run cargo doc --open for the authoritative,\nversion-matched API.

\n

Capture Measurements

\n
use weftdb::{Database, DatasetId, InputMeasurement, Resolution};\nuse bigdecimal::BigDecimal;\nuse std::str::FromStr;\nuse chrono::{TimeZone, Utc};\n\n#[tokio::main]\nasync fn main() -> anyhow::Result<()> {\n    let db = Database::new(\"my_sensors\").await?;\n    let sensor = db.observe_subject(\"temperature_sensor_001\").await?;\n    let temp = db.track_aspect(&sensor.id(), \"ambient_temp\", &Resolution::Seconds, None).await?;\n\n    let measurements = vec![\n        InputMeasurement::new(Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap(), BigDecimal::from_str(\"22.5\")?),\n        InputMeasurement::new(Utc.with_ymd_and_hms(2024, 1, 1, 12, 1, 0).unwrap(), BigDecimal::from_str(\"22.8\")?),\n    ];\n    db.batch_capture_measurements(temp.id(), DatasetId::new(), measurements).await?;\n    Ok(())\n}

Query & Interpolate

\n
use weftdb::{Database, Resolution, Spline};\nuse chrono::{TimeZone, Utc};\nuse futures::StreamExt;\n\nlet db = Database::existing(\"my_sensors\").await?;\n\n// Interpolated point at a specific time:\nlet point = db.analyze_point(&aspect_id, query_time, &Resolution::Seconds, &Spline::Linear).await?;\n\n// Streamed, interpolated range:\nlet mut stream = db.analyze_range(&aspect_id, start, end, Resolution::Seconds, Spline::Linear).await?;\nwhile let Some(result) = stream.next().await {\n    let p = result?;\n    println!(\"{}: {}\", p.timestamp, p.value);\n}

Run an Analytics Pipeline

\n
use weft_orchestration::Pipeline;\nuse weftdb::Database;\nuse splimes::Spline;\n\nlet database = Database::existing(\"PlantTelemetry\").await?;\n// ... resolve `aspect_id` for the \"outlet_pressure\" aspect of pump-station-3 ...\n\nlet mut pipeline = Pipeline::builder(database.clone(), aspect_id)\n    .spline_method(Spline::Linear)\n    .batch_size(24)                              // 24-hour batches\n    .add_dictionary(\"daily\", \"Daily patterns\", constraints)\n    .with_monthly_increase_detector(0.05)        // 5% monthly increase\n    .with_peak_detector(\"Pressure Peaks\")\n    .build()\n    .await?;\n\npipeline.run().await?;                            // full pipeline (incremental)\nprintln!(\"Signals generated: {}\", pipeline.signals().len());

For finer control, the individual steps — prepare_data, extract_patterns,\ndetect_events, correlate_events, generate_signals — can be called directly.

\n
\n

Terminal UI

\n

weft-tui is the interactive way to explore WeftDB (the HTTP server is the primary\ncommercial surface; the TUI serves demos and debugging). Launch it with\ncargo run --release -p weft-tui. It is a stateful, keyboard-driven application that\nwalks you through:

\n
    \n
  • Database / Subject / Aspect creation — with name validation and resolution\nselection (+/- to step through resolutions from nanoseconds to years).
  • \n
  • CSV import — point it at a CSV file and watch live loading progress\n(rows loaded / scanned).
  • \n
  • Browsing — select an existing database, subject, and aspect.
  • \n
  • Plotting — visualize an aspect's series directly in the terminal.
  • \n
  • Compression — choose a mode (time-based, size-based, or combined), configure\ntiers/targets/aggressiveness in a form, and monitor a live compression dashboard\n(phase, tier progress, aggressiveness, compression ratio) plus last-run stats.
  • \n
\n

Logs are written to weft-tui.log (see WEFT_DATA_DIR), and the last 50 log lines\nare also shown in-app.

\n
\n

How it works

\n

The parts below are reference material — you do not need them to use WeftDB.

\n

Architecture

\n
┌──────────────────────────────┐  ┌──────────────────────────────────────┐\n│           weft-tui            │  │              weft-server               │\n│  Ratatui terminal UI (app)   │  │  axum HTTP API: interpolate ·         │\n│  create DB · import · plot   │  │  downsample · storage ingest/query ·  │\n│  · compress                  │  │  catalog · /metrics · /debug/profile  │\n└──────────────┬───────────────┘  └──────┬───────────────────┬───────────┘\n               │ uses                     │ uses              │ columnar I/O\n               ▼                          ▼                   ▼\n┌──────────────────────────────┐   ┌────────────────────────────────────┐\n│    weft-orchestration    │   │   weft-arrow / weft-arrow-store      │\n│  batch → patterns → events   │   │   Arrow IPC + Parquet interchange  │\n│  → correlations → signals    │   └──────────────────┬─────────────────┘\n└───────────────┬──────────────┘                      │\n                ▼                                     ▼\n┌─────────────────────────────────────────────────────────────────────┐\n│                              database                                │\n│   Turso/libSQL control plane (catalog · metadata · segment index)   │\n│   + typed columnar `.weftseg` segment store (measurement hot path)   │\n│   Subject/Aspect/Measurement · Patterns · Events · Compression      │\n└───────────────┬─────────────────────────────────────┬───────────────┘\n                │ physical types + segments           │ numerics\n                ▼                                     ▼\n┌──────────────────────────────┐   ┌──────────────────────────────────┐\n│      weft-physical-type       │   │             splimes              │\n│  encodings · schemas ·       │   │   Spline interpolation engine    │\n│  timestamp codecs · .weftseg  │   │  CPU · Rayon · GPU (calibrated)  │\n└──────────────────────────────┘   └──────────────────────────────────┘

weft-bench drives the whole stack from outside through the same public APIs a\ncustomer would use; weft-line-protocol is the shared ILP dialect between the\nharness and the server. Dependencies flow downward; the arrow-* tree lives only\nin the weft-arrow* leaf crates.

\n
\n

Data Model

\n

WeftDB organizes data hierarchically. Each level maps to a directory or database file\non disk, so an aspect's storage is fully self-contained.

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
ConceptDescriptionExample
DatabaseTop-level container for all data.Crypto, my_sensors
SubjectA logical entity being observed.pump-station-3, temperature_sensor_001
AspectA specific measurement type of a subject, with its own resolution, declared physical encoding, and (optional) compression config.open, ambient_temp
MeasurementA timestamped, arbitrary-precision value.(2024-01-01T12:00Z, 22.5)
\n
On-Disk Layout
\n
{data_dir}/\n└── {database_name}/\n    ├── metadata.db                  # database metadata\n    └── {subject_name}/\n        └── {aspect_name}/\n            ├── measurements.db        # raw measurements\n            ├── unprocessed_batches.db # batching queue\n            ├── processed_batches.db\n            ├── patterns.db            # extracted patterns\n            ├── events.db              # detected events\n            ├── correlations.db        # pattern↔event links\n            ├── pipeline.db            # persisted pipeline config & state\n            └── dictionaries/\n                └── {dictionary_name}.db

The Storage v2 segment store (see\nPhysical Types & Columnar Storage) adds, per\nstore root: catalog.db (database/subject/aspect hierarchy + declared schemas),\nper-aspect metadata.db rollups, segment_index.db (the prune-before-open\nsegment index), and segments/*.weftseg (the typed columnar measurement files).

\n

Higher-level analytics types build on this foundation:

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
TypePurpose
BatchA group of measurements processed together.
PatternAn extracted recurring shape, with the occurrences where it appears.
DictionaryA collection of patterns sharing similarity constraints.
EventA detected occurrence with one or more manifestations (time spans).
CorrelationA link between a pattern and an event.
SignalA probability-weighted prediction derived from a correlation.
\n
\n

Interpolation Engine

\n

splimes is the numerical heart of WeftDB. Given a set of Points and a target time\nrange + resolution, it produces an interpolated series using one of four spline\nmethods, choosing the execution backend automatically, and labels every output point\nRaw, Interpolated or Extrapolated (PointKind). It interpolates and\nextrapolates — single-instant lookups and full ranges — on the CPU (one thread or\nrayon's pool) and the GPU. WeftDB depends on splimes 1.0; its own\nmigration guide\nlists what changed from 0.1.

\n
Spline Methods
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
MethodMin. pointsNotes
Linear2Straight-line interpolation.
Quadratic3Second-degree.
Cubic4Smooth third-degree splines.
Polynomial(degree, bounds_factor)degree + 1Degree 1–8, with optional bounds damping of the extrapolation.
\n

If a method needs more distinct points than are available, splimes steps down\nautomatically (Cubic → Quadratic → Linear; Polynomial(d) → Polynomial(n − 1)),\nnever upgrading beyond what you requested, and reports the method it used.

\n
Backend Selection
\n

Every call is synchronous; Backend::Auto (the default) picks the backend by output\ngrid size, using AutoThresholds:

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
Grid sizeBackend
below parallel_min_points (default 64 Ki)CPU, the calling thread
from parallel_min_pointsParallel, rayon's pool
from gpu_min_points (default never), once the GPU has been startedGPU (wgpu compute shaders), falling back to rayon if it fails
\n

Auto never starts the GPU itself. splimes::calibrate() starts it, times every\nbackend on this machine and sets the thresholds to the measured crossovers;\nweft-server runs it once in the background at startup, skipping a CPU/software\nadapter (WEFT_GPU_CALIBRATE=0 skips it, force calibrates any adapter). Otherwise:

\n
    \n
  • splimes::prewarm_gpu() starts the GPU and returns its GpuInfo (adapter, API,\ndevice type, driver, f64 support); then set thresholds with\nsplimes::set_auto_thresholds.
  • \n
  • splimes::configure_gpu(GpuConfig) — before the GPU's first use — sizes its\nbuffer pool and per-dispatch chunk (max_pool_bytes, chunk_points, low_power;\npresets GpuConfig::minimal(), low_memory(), DEFAULT, high_performance()).\nCalling it after the GPU has started is an Error::GpuAlreadyConfigured, never a\nsilent no-op; splimes::gpu_config() reports what is in force.
  • \n
  • splimes::gpu_pool_stats() returns the buffer pool's counters once the GPU is up.
  • \n
  • The GPU computes in f64 where the adapter supports it; f32 must be asked for\n(Interpolator::gpu_precision(Precision::F32)).
  • \n
\n
use splimes::{Interpolator, Resolution, Spline};\n\n// Backend::Auto picks the backend by grid size; from async code, run it off the\n// executor (`run_async` needs the `tokio` feature):\nlet series = Interpolator::new(Spline::Cubic, Resolution::Seconds).run_async(points, start, end).await?;\nfor (timestamp, value, kind) in series.iter() {\n    // kind: PointKind::Raw | Interpolated | Extrapolated\n}

\n

Physical Types & Columnar Storage

\n

WeftDB's storage hot path is its own typed columnar format; Turso/libSQL serves as\nthe control plane (catalog, metadata, segment index, pipeline state) and never\nholds bulk measurements. BigDecimal remains the logical/API type everywhere.

\n
Physical type system (weft-physical-type)
\n
    \n
  • Six numeric encodings — F64, F32, ScaledI64, ScaledI128,\nDecimal128, BigDecimalText — each declaring storage width, hot-path/GPU\neligibility, and conversion behavior. Every encode reports an explicit\nExactness (exact / lossy-with-measured-residual); to_logical is always\navailable.
  • \n
  • No silent downcast, enforced — AspectSchema declares, per aspect, the\nphysical encoding, a permitted per-value error bound, and the timestamp unit.\nSealing a segment under a declared schema errors (SealError::Encode /\nSealError::ToleranceExceeded) rather than losing precision beyond the\ndeclared bound. Over HTTP this surfaces as a 400 — never a downcast.
  • \n
  • Advisory encoding selection — recommend_encoding picks the narrowest safe\nencoding within a tolerance, and columnar estimators report expected\nbytes/point (the north-star cost term) before anything is written.
  • \n
  • Timestamp codecs — integer-epoch timestamps (TimeUnit =\nseconds/millis/micros/nanos) with lossless delta and delta-of-delta\ntransforms and five interchangeable second-difference codecs — per-value\nzig-zag + LEB128 varint, RLE, fixed-width bit-packing, a\nGorilla-style variable-length codec, and per-block adaptive (dynamic)\nbit-packing — chosen per column by a smallest-wins selector. Each wins a different\nregime: bit-packing on regular/small-jitter series, RLE on long constant runs, Gorilla\non scattered single jitter (isolated moderate spikes among regular intervals, where\nRLE cannot form runs and bit-packing must widen every value), and per-block adaptive\nbit-packing on a mixed-magnitude stream (a contiguous wide region among narrow runs,\nwhere a single global width overpays — 184 B vs 384–961 B for the other codecs on a\n256-value mixed corpus). A regular 1000-point column packs to ~12 bytes total.
  • \n
  • Value-column codecs — a value column is stored under its schema-declared physical\nencoding, and a ScaledI64 column additionally chooses, via a self-describing\nselector byte, between a per-value zig-zag varint, fixed-width bit-packing of its\nmantissas, per-block adaptive (blocked) bit-packing, and per-block\nFrame-of-Reference (FOR) packing — bit-packing a regular scaled series (e.g. a\n2-decimal price ramp) to 37% below the varint, the blocked codec confining a wide\nburst to the blocks it spans on a mixed-magnitude column, and FOR (subtract each\nblock's minimum, pack the unsigned residual to the block's range — the standard\nFastLanes/ALP move) collapsing values clustered at a high base (a sensor reading\nnear a fixed offset: mantissas near 1e9 store 88% below the bit-packed\nalternative). Each codec is chosen only when strictly smallest, so a regular column is\nbyte-for-byte unchanged; the realized figure is reported as\nStorageEstimate.realized_value_bytes / value_codec. The timestamp column keeps an\nadvisory FOR estimate (for_estimated_bytes) — second differences are near-zero, so\nFOR rarely wins there. Every linear bit-packed codec decodes with a word-wise field read\n(one unaligned load and a shift/mask per value), so 1 Mi mantissas unpack in 1.09 ms\n(fixed width) / 1.83 ms (per-block)\n(weft-physical-type/benches/bitunpack.rs), and\n1 Mi real BTC closes stored under FOR at scale 8 decode to floats in 1.66 ms\n(weft-physical-type/benches/alp_vs_f64_codecs.rs).
  • \n
  • Two-level delta cascade (opt-in) — a trending ScaledI64 column (a counter or\nmonotone sensor whose magnitude every single-level codec pays for) is additionally\ncompressible by a cascade: delta-transform the mantissas, then pack the differences with\nthe smallest inner packer (varint / bit-pack / blocked / FOR / RLE). It is realized on disk\nas the VAL_CODEC_DELTA_CASCADE codec-chain descriptor (write_value_column_cascading,\nread back by the ordinary reader) and surfaced advisory-first in the bench\n(StorageEstimate.advisory_delta_cascade_value_bytes, schema v15). It is opt-in — the\ncascade beats even FOR broadly, so folding it into the default selector is a headline change\nheld for owner sign-off; the default codec choice is unchanged.
  • \n
  • Bit-sliced (transposed) value codec (opt-in, bitsliced-codec build feature) —\nVAL_CODEC_TRANSPOSED stores a\nScaledI64 column's mantissas bit-plane-major in 1024-value tiles, so the decoder rebuilds\neight lanes per plane byte with branch-free table spreads and a tile's width header drops the\nempty high bit-planes of a small-magnitude column. The decode kernel is checked against an\nexternal yardstick, the published fastlanes crate, at equal bit width\n(weft-physical-type/benches/fastlanes_yardstick.rs,\n1 Mi values): WeftDB's decoder takes 0.78 / 1.50 / 2.32 ms at widths 3 / 10 / 20 against\nfastlanes' 0.33 ms — still 2.4–7× behind, and that gap is an open roadmap item, not a\ndesign choice. It carries its own size function (transposed_value_bytes) and selector\nentry (best_value_codec_transposed(max_overhead)) rather than reusing the blocked figure, is\nrandom-access capable (so it does not regress the streaming point read), and is requested through\nFrameOptions / Segment::write_to_with or, from a deployment, WEFT_SEGMENT_TRANSPOSED_MAX_OVERHEAD.\nHonest result: no read-path win over the linear codec. Measured on a 1M-row zero-straddling\ncolumn (weft-physical-type/benches/transposed_read.rs,\n2026-10-08, transposed vs linear): frame bytes +0.08% (1,251,057 vs 1,250,076), full decode\n7.81 ms vs 6.87 ms; with the slicing-by-16 frame CRC, windowed 1000-row range 638 µs vs\n632 µs and single point read 820 µs vs 805 µs. So the layout is byte-neutral and no faster\n(slightly slower) on reads; it stays opt-in and the\ndefault codec choice is unchanged.\nNote the codec is chosen only when a strict size win or within the caller's overhead ceiling —\nthe ceiling may legitimately be set below 1.0, because per-tile widths with one header per 1024\nvalues can beat both a global width and the blocked codec's one-header-per-64. It is a\nbit-sliced layout, not the FastLanes layout (which keeps each value's bits together), and it\nis compiled only with the bitsliced-codec feature, pending patent review; without it the\nwriter never selects it and reading a segment that uses it fails with an error naming the\nfeature.
  • \n
  • Block-level random access — the fixed-layout value codecs support decoding a single value\n(or a sub-range) without materializing the whole column: weftseg::read_value_at(bytes, i)\nreads only the block covering row i (skipping earlier blocks by their headers) for the\nblocked/FOR codecs, reads bit i * width directly for the fixed-width bit-pack codec, and decodes\nonly the covering tile for the bit-sliced codec — the point-lookup / late-materialization lever.\nweftseg::read_value_range(bytes, start, len) is its windowed sibling: every fixed-layout codec\nlocates a value by walking its block/tile headers from the start of the stream, so resolving a\nwindow one value at a time re-walks that chain per row. The range read does the decode once,\nwhich is what keeps a windowed read on the 1024-value-tile bit-sliced layout from costing a whole tile\ndecode per row. weftseg::read_segment_point(bytes, t)\nwires this up to the framed single-block segment — it skips the value block by its framing,\ndecodes only the timestamps to find the row, and unpacks the one covering value block — so a\npoint lookup never materializes the value column on a per-block codec (equal to a full\ndecode + value_at for every frame; the non-block codecs fall back to that). read_paged_segment_point\ndoes the same for a paged frame, first pruning pages on their indexed min/max timestamp so only\nthe surviving page is touched. A regular (constant-stride) timestamp column is resolved in\nclosed form — ts[i] = first + i*step, so the row for an instant is O(1) with no timestamp\nmaterialization at all. Measured ~46× faster point lookup on a 100k-row single-block FOR\nsegment (56 µs vs 2.60 ms) and ~8.5× faster on the paged frame (33 µs vs 279 µs), with the\nclosed-form timestamp path a further ~3.4× over an irregular column (56 µs vs 188 µs), identical\nbytes on disk (weft-physical-type/benches/pointread.rs).
  • \n
  • Checkpointed frames — sublinear point lookup on an irregular sorted column (opt-in;\nwrite_segment_checkpointed / write_paged_segment_checkpointed). The closed form above only\nserves a regular column; an irregular one has no closed form, so a probe had to decode the whole\ndelta-of-delta timestamp stream. A checkpointed frame persists a sparse (row, timestamp, delta)\nindex every stride rows next to a range-decodable per-block dod stream, so a probe binary-searches\nthe index and decodes only the blocks it walks — the timestamp column is never materialized.\nMeasured ~3.45× faster point reads on a 100k-row sorted-irregular frame (352.94 ms → 102.42 ms\nfor 64 probes) for +0.41% frame bytes at stride 1024; a paged frame gains only ~1.14×\n(68.70 → 60.41 ms), because page pruning already bounds its decode to rows_per_page\n(weft-physical-type/benches/dodsearch.rs). The codec tag\nis additive — every previously written frame still reads, with no format-version bump — and a\ncheckpointed frame is byte-for-byte equivalent in behaviour to a plain one on every read.\nOpt in by setting WEFT_SEGMENT_CHECKPOINT_STRIDE (see the configuration table). The seal path\nthen writes the index only where it genuinely pays, on three counts: the column is sorted and\nirregular (a regular one already resolves O(1) closed-form, an out-of-order one cannot be\nbinary-searched), it has at least WEFT_SEGMENT_CHECKPOINT_MIN_ROWS rows, and the codec override\nis affordable — a checkpointed frame must use the range-decodable per-block codec, which costs\n~3.5× on a Gorilla-shaped column and ~14× on an RLE-shaped one, so those are refused rather than\nbloated (WEFT_SEGMENT_CHECKPOINT_MAX_CODEC_OVERHEAD). It is off by default, so an unconfigured\nstore's bytes/point are unchanged; making it the default is an open roadmap decision.
  • \n
  • Realized headline bytes/point — every headline bytes/point figure\n(Segment::bytes_per_point, StorageEstimate.bytes_per_point /\ntotal_bytes_per_point, the bench HTML val B/pt) reports the codec actually\nwritten to disk, not a fixed-width estimate (bench schema v10). The naive\nlen * width figure is retained beside it (Segment::logical_value_bytes,\nStorageEstimate.estimated_value_bytes) as the uncompressed baseline, so\nrealized / logical reads directly as the storage compression ratio.
  • \n
\n
Segment store (.weftseg + weftdb::SegmentStore)
\n
    \n
  • Sealed, checksummed segments — a Segment binds a typed value column and a\ndelta-of-delta timestamp column with min/max timestamp/value stats, row/null\ncounts, and a format version. The on-disk frame is hand-rolled, versioned, and\nCRC-32-verified before parse, so a corrupt or truncated file fails fast.
  • \n
  • Multi-codec timestamps — the timestamp block writes its second-difference\nstream under whichever of five codecs is smallest — fixed-width bit-packing,\nper-value zig-zag varint, RLE, Gorilla variable-length, or per-block\nadaptive (dynamic) bit-packing — chosen by a self-describing selector byte the reader\ndispatches on, routed through the single source of truth so the reported codec name\nalways matches the bytes on disk. A regular series packs to a handful of bytes on disk\n(a 1000-point regular block stores in <20 bytes), so the bytes/point saving is\nrealized, not just estimated. The value block carries its own codec selector too,\nso a ScaledI64 column stores its mantissas under whichever of varint, fixed-width\nbit-packing, per-block adaptive (blocked) bit-packing, or per-block\nFrame-of-Reference (FOR) packing is smallest on disk.
  • \n
  • Order semantics — every segment records whether its timestamps are monotonic\n(time_sorted). Ingest can enforce order (require_sorted rejects an\nout-of-order batch rather than sealing it), and the per-aspect/store rollups carry\nan unsorted_segments count so out-of-order data is visible before it costs a\npoint-lookup scan.
  • \n
  • Order-signal read planner — a single-instant point lookup\n(SegmentStore::read_point) prunes the index to the segments spanning the instant\nand resolves each with its persisted time_sorted flag: a sorted segment is\nbinary-searched, an out-of-order one linear-scanned (the only sound search on\nunsorted timestamps). Both frame kinds resolve through the streaming point read\n(weftseg::read_segment_point / read_paged_segment_point, below) — no value-column\nmaterialization on a per-block codec, and a paged frame prunes pages on their indexed\nmin/max timestamp without decoding a column byte before touching the one surviving page.\nSegmentStore::read_points resolves a batch of instants in one pass — the index is pruned\nonce and each segment's timestamp column decoded once for the whole batch, so N instants\nsharing a segment cost one decode, not N (~54× faster for 64 instants on a 100k-row FOR\nframe than 64 single lookups — 67 µs vs 3.57 ms,\nweft-physical-type/benches/pointread.rs).
  • \n
  • Intra-segment reconciliation — SegmentStore::reconcile_segment/reconcile_aspect\nrewrite an out-of-order segment into a sorted one in place (stable sort by\ntimestamp, re-sealed at the same id, frame kind preserved), so it drops out of the\nunsorted_segments count and its point lookups binary-search.
  • \n
  • Threshold-triggered reconciliation — the unsorted_segments backlog drives a\nQuestDB-style trigger so the rewrite is paid only once out-of-order data is worth\ncompacting, never on every late row: reconcile_aspect_if_unsorted_exceeds gates a\nsingle-aspect pass on the backlog, reconcile_all_over_threshold sweeps every\ndeclared aspect over the threshold, and a background timer daemon\n(WEFT_RECONCILE_INTERVAL_SECS / WEFT_RECONCILE_THRESHOLD) runs the sweep\nunattended. A threshold of 0 clamps to 1 (any out-of-order segment). Passes and the\nsegments they rewrite are counted in weft_reconcile_* metrics.
  • \n
  • Hot/cold reconciliation — reconcile_aspect_hot_cold/reconcile_all_hot_cold\nreconcile every cold (sealed) out-of-order segment on each pass but defer the\nhot tail (the most-recently-sealed segment) until the backlog reaches the\nthreshold, so the actively-appended segment is not rewritten on every tick —\nQuestDB's "squash non-active partitions each commit, defer the active one" policy.\nEnabled on the daemon with WEFT_RECONCILE_HOT_COLD and on the reconcile endpoints\nwith ?hot_cold=true.
  • \n
  • Cross-segment overlap signal — a sorted segment whose time window a\nlater-sealed segment re-enters (late data landing in an already-covered window) is\nnot counted by unsorted_segments; SegmentIndex::overlapping_count reports these\ntime-overlapping segments as a distinct overlapping_segments order-health\ncount in the per-aspect and store-wide rollups.
  • \n
  • Cross-segment overlap merge — SegmentStore::reconcile_overlaps/reconcile_all_overlaps\ncollapse each connected group of time-overlapping segments into one time-sorted\nsegment, folding members oldest→newest with newer-wins (last-writer-wins /\nupsert) dedup on shared timestamps — the same answer a point lookup already gives\nacross overlaps, and QuestDB's DEDUP UPSERT "last write wins on the designated\ntimestamp" semantics. Drives overlapping_segments to zero; exposed on the reconcile\nendpoints with ?overlaps=true and as a background daemon sweep\n(WEFT_RECONCILE_OVERLAPS).
  • \n
  • Split-not-rewrite merge — SegmentStore::split_segment carves a sorted segment\nat a boundary into a cold prefix (kept id) + hot suffix (new id), and\nreconcile_overlaps_with_policy consults a size-based SplitPolicy: when an overlap\ncomponent's cold prefix clears a byte floor and outweighs its hot suffix, only the\nsuffix is merged and the large cold prefix is left untouched, so later late arrivals\nre-entering the hot window never rewrite it — QuestDB's partition-split write-amp\nbound. The default reconcile_overlaps keeps QuestDB's 50 MiB floor (small components\nfull-rewrite); a floor is selectable with ?split_min_bytes= on the per-aspect and\nstore-wide reconcile endpoints and WEFT_RECONCILE_SPLIT_MIN_BYTES on the daemon.
  • \n
  • Squash — SegmentStore::squash_aspect/squash_aspect_if_exceeds/\nsquash_all_over_threshold fold an aspect's segments back into one (newer-wins),\nbounding the fragmentation repeated split carve-offs create — QuestDB's\nmax.splits squash trigger. Exposed as POST …/{aspect}/squash?max_segments= and on\nthe daemon with WEFT_RECONCILE_MAX_SPLITS (squash aspects over the cap each tick,\nafter the overlap merge that accumulates the splits).
  • \n
  • Null/quality column — a per-row presence bitmap (zero bytes for fully dense\ncolumns) makes gaps real: present values are stored densely and reconstructed\nas None on read.
  • \n
  • Paged segments — rows partition into fixed-height pages, each independently\nencoded with its own stats, behind a per-page index table: a reader prunes\npages on min/max time/value without touching a column byte and seeks\nstraight to the pages it needs.
  • \n
  • Data skipping — segment-level and page-level pruning by time, by value\nband, and by quality (segments/pages that overlap a window but hold only\nnulls there are skipped). A range read over a regular (constant-stride)\nblock-coded segment goes further — read_segment_range computes the row\nwindow in closed form and unpacks only the present values inside it, so a\nselective range decodes ~window values, not the whole segment (~47× faster\nfor a 100-row window over a 100k-row FOR frame — 58 µs vs 2.73 ms,\nweft-physical-type/benches/pointread.rs).
  • \n
  • Control plane — SegmentIndexStore persists one descriptor per sealed\nsegment in libSQL and answers range queries with SQL pruning;\nCatalogStore/AspectCatalog register the database → subject → aspect\nhierarchy with declared schemas (declare once, seal by lookup — an undeclared\naspect is refused, not guessed); per-aspect metadata.db rollups make\naspect-wide stats (including realized bytes/point) an O(1) read, always\nre-derivable from the segment index.
  • \n
\n
Arrow & Parquet interchange (weft-arrow, weft-arrow-store)
\n
    \n
  • Lossless Arrow batches — sealed segments and stored reads convert to/from\nApache Arrow RecordBatches: a lossless text form, plus a typed fast path\n(F64 → Float64, F32 → Float32, scaled integers → exact Decimal128).\nPaged segments stream one batch per page.
  • \n
  • Wire formats — Arrow IPC stream bytes and Parquet files (embedding\nthe Arrow schema so WeftDB's time-unit/encoding metadata survives), both\ndirections: export a stored range, or ingest a Parquet file into a declared\naspect under the full no-silent-downcast guarantee.
  • \n
  • Lean core preserved — the arrow-*/parquet dependency tree lives only in\nthese leaf crates; splimes/weftdb/weft-physical-type stay arrow-free.
  • \n
\n
\n

The Analytics Pipeline

\n

The weft-orchestration crate provides a fluent Pipeline API. Each aspect\nhas exactly one pipeline, persisted to its pipeline.db, so runs are incremental\nand conflict-free. A pipeline can host multiple dictionaries and multiple detectors.

\n
 Measurements ─▶ Batches ─▶ Processed Batches\n                                   │\n                                   ▼\n                          Dictionaries (1..N)        Event Detectors (1..N)\n                          extract patterns           peaks · valleys ·\n                                   │                  thresholds · monthly · …\n                                   ▼                          │\n                               Patterns ───── correlate ──────┤\n                                                              ▼\n                                  Events ─▶ Correlations ─▶ Signals\n                                                              │\n                                                              ▼\n                                          query_probability(event, time)

Built-in event detectors (in weft_orchestration::detectors):

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
DetectorDescription
detect_monthly_increaseMonths where the value rises ≥ a threshold start-to-end.
detect_peaks / detect_all_peaksGlobal / all local maxima.
detect_valleys / detect_all_valleysGlobal / all local minima.
detect_threshold_crossing_up / _downUpward / downward threshold crossings.
detect_drawdownSignificant value drops.
\n

You can also register custom detectors with the event_detector_fn! macro, and\nrun pipelines across many aspects in parallel with run_all_pipelines /\nrun_subject_pipelines.

\n
\n

Dataset Compression

\n

Long-running deployments accumulate huge volumes of raw measurements. The\ncompression module (weftdb::compression) reduces storage while preserving the\nshape of the data, configured per-aspect via CompressionConfig:

\n
    \n
  • Time-based — recent data within a "pure" window (e.g. the last 7 years) stays\nuncompressed; older data is progressively compressed across configurable tiers\nwith Linear, Exponential, or Custom aggressiveness scaling.
  • \n
  • Size-based — compress until total storage fits within a target (e.g. 10 GB),\nhitting the oldest data hardest. Takes precedence over time-based.
  • \n
\n

Compression works by interpolating to a coarser resolution and applying\nslope-change simplification to drop redundant points. Newly inserted data inside a\ncompressed range marks a dirty region for targeted re-compression. Progress is\nreported live (phase, tier, aggressiveness, time range) — surfaced in the TUI.

\n
use weftdb::{CompressionConfig, TimeBasedCompressionConfig};\n\nlet config = CompressionConfig::time_based(\n    TimeBasedCompressionConfig::default_seven_years()\n);\nlet aspect = db.track_aspect(&subject_id, \"temperature\", &Resolution::Hours, Some(config)).await?;\nlet summary = aspect.compress(&db).await?;

\n

Benchmarking

\n

Weft-Bench (weft-bench) is WeftDB's reproducible, correctness-gated benchmark\nharness — both an internal engineering suite and a public, customer-runnable\ndiagnostic. Every number it emits ships with the dataset seed, the workload\nprofile, and a correctness verdict.

\n
\n

Policy: WeftDB benchmarks guide real engineering decisions, not synthetic wins.\nA latency number is only publishable when its correctness check passes.

\n
\n

What it does today:

\n
    \n
  • Seeded workload profiles — the flagship interpolation-heavy-irregular\nprofile generates an irregularly-spaced, gap-containing series\n(ChaCha8Rng, published seed → byte-for-byte reproducible). The underlying\nanalytic signal is shape-selectable (MultiSine | Sawtooth | Step |\nDampedSine) and doubles as the accuracy ground truth.
  • \n
  • Storage, read & aggregation workloads — beside the flagship interpolation\nrun, four parallel workloads exercise WeftDB's own hot paths, each with its own\ncorrectness gate and p50/p95/p99 latency: point_lookup (--point-lookup)\nseals a .weftseg segment and times the streaming point read\n(read_segment_point/read_segment_points, single or batch, single-block or\npaged via --pl-rows-per-page, regular closed-form vs irregular), gated against\nthe full-decode value — also over a real corpus with --pl-csv (1M real BTC closes:\na 128-instant batch at p50 2.43 ms single-block vs 0.90 ms paged at 8,192 rows/page,\nweft-bench --point-lookup --pl-csv database/datasets/btc_1min.csv --csv-value-col 4 --csv-skip 3000000 --pl-rows 1000000 --pl-queries 128 [--pl-rows-per-page 8192] --reps 50);\nrange_fetch (--range-fetch, real corpus via --rf-csv: 32 × 100-row windows over 1M\nreal BTC closes at p50 60.8 ms single-block vs 17.8 ms paged) times the windowed\nrange read; compression (--compression) reports realized bytes/point, the\nvalue-column compression ratio, and decode throughput, gated on an exact\nround-trip — over a seeded shape, or a real corpus with --comp-csv <FILE>\n(--csv-value-col, --csv-skip; values read as exact decimal text; --pl-csv does the same for point_lookup). On 1M real\nBTC/USD one-minute closes (rows 3M onward of btc_1min.csv) it measures 4.22 B/point\nrealized (scaled_for, exact), full-decode 20.8M points/sec (p50 52.7 ms, 5 reps),\nand an advisory decimal-exponent FOR footprint of 1.70 B/point\n(weft-bench --compression --comp-csv database/datasets/btc_1min.csv --csv-value-col 4 --csv-skip 3000000 --comp-rows 1000000 --reps 5); and downsample (--downsample) times WeftDB's canonical\nweft-reduce reduction into grid-aligned buckets with a\n--ds-aggs selector over min/max/avg/sum/first/last/p50…p99/twa/twa_linear/twa_bucket_end/sketch_p50…sketch_p99.\n--ds-parallel <N> reduces in N chunks via mergeable partial reductions (identical\nbuckets to serial, asserted by test) — measured 14.7× at 64 chunks on a quiet 16-core box\n(441.2 ms → 30.1 ms, 1,134,659 → 16,921,104 points/sec, --ds-aggs sketch_p99, 500k points,\n5 reps, correctness PASS); a re-run on 2026-10-08 at load average ~19 measured 327.3 → 48.5 ms\n(6.7×).\nThe real-corpus figures in this bullet come from btc_1min.csv, a local file not distributed\nwith the repository (see Ingest); point --pl-csv / --rf-csv / --comp-csv /\n--ds-csv at a CSV of your own to run the same workloads on real data.\nThe underlying point/range read speedups are quantified at the codec layer in\nweft-physical-type/benches/pointread.rs.
  • \n
  • Vendor-neutral adapters — every system is driven through the\nSystemAdapter trait: the WeftDB reference adapter (splimes' Interpolator on Backend::Auto),\na precision-aware portable linear baseline (fair-protocol class C), and a\nforward-fill/LOCF baseline (class B — the in-process mirror of\nFILL(previous) / locf()).
  • \n
  • Quality beside speed — reconstructions are scored against the analytic\nground truth: RMSE / MAE / max-error / bias. Honest finding, surfaced not\nhidden: on smooth multisine (and step) WeftDB's cubic leads on RMSE, but on\nsawtooth the portable linear baseline out-accuracies the cubic, which\novershoots sharp discontinuities.
  • \n
  • Fair-protocol statistics — p50/p95/p99 + min/max/mean/stddev with\nseeded bootstrap confidence intervals, and an end-to-end timing breakdown\nproving the latency distribution times only the adapter call, never setup.
  • \n
  • Storage estimate — each result carries a StorageEstimate (recommended\nencoding + value/timestamp/total bytes/point) computed by\nweft-physical-type over the actual columns. It also surfaces advisory\npotential-saving estimates for codecs not yet realized on disk: the best-of\nf64 value codec (Gorilla / Chimp / Chimp128 XOR, advisory_best_f64_bytes /\n_codec) for a lossy F64 column, and the Sprintz FIRE forecaster's\nfootprint on the timestamp column (advisory_fire_timestamp_bytes), and the\ndecimal-exponent FOR value codec (advisory_dfor_value_bytes, schema v17,\npresent only when it beats the realized codec — e.g. a ScaledI64 column whose\nscale is forced by a few high-precision values; real BTC closes measure 13.58 vs\n33.74 bits/value in\nweft-physical-type/benches/alp_vs_f64_codecs.rs),\nplus two timestamp-column what-ifs (schema v18), each present only when it beats the\nrealized codec: advisory_common_multiple_timestamp_bytes (the GCD of the deltas\nfactored out — millisecond-precise instants stored in microseconds measure 20 → 10\nbits/value in\nweft-physical-type/benches/timestamp_vs_pco.rs)\nand advisory_delta_for_timestamp_bytes (first-order deltas under per-block FOR, for\nindependent random intervals: 20.0 → 19.4 bits/value)\n— each a what-if number the adopt-or-drop decision reads, never a realized\nheadline claim. The f64 codecs and FIRE live behind weft-physical-type's\nexperimental-codecs feature, which only weft-bench enables.
  • \n
  • Reports — a BenchReport JSON artifact (run metadata + a best-effort\nhardware probe: CPU model, cores, RAM, and the kind/file system/mount of the disk under the working directory) under reports/json/, plus a\nself-contained HTML view (--html) with the most-accurate row highlighted.
  • \n
  • The engine the server runs — an interpolation run first calls\nsplimes::calibrate() once, as weft-server does at startup (skipping a\nCPU/software adapter the same way), so Backend::Auto uses rayon and the GPU\nwhere they are faster on this machine. The calibration, GPU and thresholds are\nprinted and recorded in the report's metadata.engine (schema v16);\n--no-gpu-calibrate skips it and records splimes' defaults. There is no\ncounterpart to the server's WEFT_GPU_CALIBRATE=force: on a software adapter the\nbench always skips calibration, so there it cannot reproduce a server started with\nforce.
  • \n
  • ILP / TSBS input — a .lp / TSBS file drives the same harness, correctness\ngate, and reporting via the shared weft-line-protocol parser.
  • \n
\n
# Benchmark a line-protocol file, WeftDB vs the portable baselines, JSON + HTML report:\ncargo run -p weft-bench -- \\\n    --input data.lp --field usage --spline cubic --resolution minutes --compare --html\n\n# Seeded synthetic mode — the only mode with a known ground truth, so it also scores accuracy:\ncargo run -p weft-bench -- \\\n    --synthetic --points 300 --noise 0 --shape sawtooth \\\n    --spline cubic --resolution minutes --reps 5 --compare\n\n# Storage/read/aggregation workloads over WeftDB's own hot paths:\ncargo run -p weft-bench -- --point-lookup --pl-rows 100000 --pl-queries 128 --reps 50\ncargo run -p weft-bench -- --range-fetch --rf-window 100 --rf-windows 32 --reps 30\ncargo run -p weft-bench -- --compression --comp-shape clustered --reps 20\ncargo run --release -p weft-bench -- --compression --comp-csv database/datasets/btc_1min.csv --csv-value-col 4 --csv-skip 3000000 --comp-rows 1000000 --reps 5\ncargo run -p weft-bench -- --downsample --ds-bucket m --ds-aggs min,max,p99,twa --reps 20

The process exits non-zero when any result's correctness gate fails, so scripted\ncallers can gate on it. External-engine competitor adapters (DuckDB, ClickHouse,\nInfluxDB 3, QuestDB, TimescaleDB) are the next tracked step in\nROADMAP.md.

\n
\n

Development

\n
# Run the full test suite:\ncargo test\n\n# Skip long-running tests:\nSKIP_SLOW_TESTS=1 cargo test          # PowerShell: $env:SKIP_SLOW_TESTS=1; cargo test\n\n# Test a single crate:\ncargo test -p weftdb

The workspace ships extensive Criterion benchmark\nsuites alongside the Weft-Bench harness (see\nBenchmark Harness):

\n\n\n\n\n\n\n\n\n\n\n\n
CrateBenchmarks
weftdbinterpolation_benchmarks, integration_interpolation_benchmarks, optimized_interpolation_benchmarks, strategy_selection_benchmarks, new_api_benchmarks, cache_performance_benchmarks
\n
cargo bench -p weftdb --bench cache_performance_benchmarks

These benches create their databases in a temporary data dir of their own, which they\nremove when they finish, never in ~/.weftdb/data. That dir is under the system temp\ndir, often a tmpfs; to bench another filesystem, set TEST_DATA_DIR to a directory on\nit (the benches remove the databases they create there, not the directory).

\n

HTML reports are generated under target/criterion/. The interpolation engine's own\nbenchmarks live in the splimes repository.

\n

Formatting uses nightly-only rustfmt options (imports_granularity,\ngroup_imports), so the style gate runs on nightly even though the crates\nthemselves build on stable:

\n
cargo +nightly fmt --all

\n

Project layout

\n
WeftDB/\n├── Cargo.toml                  # workspace manifest (members + shared deps)\n├── weftdb/                     # time-series database (Turso/libSQL control plane + segment store)\n│   └── src/types/\n│       ├── database/           # connection, config, inputs, outputs, pipeline\n│       ├── compression/        # tiered dataset compression\n│       ├── batches/            # batching & analysis\n│       └── …                   # patterns, events, correlations, signals, …\n├── weft-orchestration/         # pipeline orchestration & event detectors\n├── weft-physical-type/         # physical encodings, schemas, timestamp codecs, .weftseg\n├── weft-arrow/                 # Arrow/Parquet interchange for segments\n├── weft-arrow-store/           # Arrow/Parquet bridge over the segment store\n├── weft-line-protocol/         # InfluxDB Line Protocol parser (shared dialect)\n├── weft-reduce/                # downsampling reductions over a bucket grid\n├── weft-server/                # axum HTTP API server\n├── weft-bench/                 # benchmark harness (adapters, profiles, reports)\n└── weft-tui/                   # terminal UI binary

The complete work queue and design constraints live in\nROADMAP.md — the single source of truth.

\n
\n

Crates

\n

WeftDB is a Rust Cargo workspace, from a low-level numerical engine up to an HTTP\nserver and an interactive application:

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
CrateRole
splimes (own repo, from crates.io)Spline interpolation engine — Linear / Quadratic / Cubic / Polynomial methods on the CPU (one thread or rayon) or the GPU, chosen by grid size, with raw / interpolated / extrapolated provenance on every point.
weftdbTime-series database: Turso (libSQL) control plane (catalog, metadata, segment index; MVCC concurrent writes) + WeftDB's own typed columnar .weftseg segment store on the measurement hot path, plus the pattern-recognition types and tiered dataset compression.
weft-orchestrationHigh-level pipeline that chains batching → pattern extraction → event detection → correlation → signal generation, with built-in detectors and parallel execution.
weft-physical-typeVendor-neutral physical type system — schema-declared numeric encodings with explicit exactness, timestamp codecs, and the .weftseg columnar segment format (single-block and paged).
weft-arrow / weft-arrow-storeApache Arrow / Parquet interchange for sealed segments and stored reads, kept in leaf crates so the arrow-* dependency tree never reaches the hot-path core.
weft-line-protocolDependency-free InfluxDB Line Protocol parser shared by the server and the benchmark harness.
weft-reduceVendor-neutral downsampling reductions, computable over parts and merged (reduce_partial/PartialReduction, exact for every reduction) — min/max/avg/sum/first/last + nearest-rank p50/p90/p95/p99 + three time-weighted averages (LOCF, linear/trapezoidal, LOCF-to-bucket-end) + mergeable bounded-error sketch_p* percentiles, over an epoch-aligned bucket grid, computed in BigDecimal. A PartialReduction is serde-serializable (so a segment's partial can be persisted and merged later, in place of re-reading it) and re-bucketable to any coarser nesting resolution (rebucket/grids_nest). Shared by the HTTP downsample endpoint and the benchmark harness.
weft-serverBenchmark-grade axum HTTP API — interpolation, downsampling, storage ingest/query, catalog management, Prometheus metrics, live latency profiles.
weft-benchReproducible, correctness-gated benchmark harness — the roadmap's spine; both an internal suite and a customer-runnable diagnostic.
weft-tuiTerminal user interface (Ratatui + Crossterm) for creating databases, importing CSV data, browsing and plotting aspects, and running compression.
\n

The platform is designed for real-time and large-scale workloads: measurements\nare stored with BigDecimal logical precision,\ninterpolation scales from a handful of points to millions, from one CPU thread to the\nrayon pool and, where calibration measured it faster, the GPU, and the storage layer\nuses bulk transactions, cached connections, and typed columnar segments throughout.

\n
\n

Tuning tips

\n
    \n
  • Batch your writes. batch_capture_measurements uses bulk transactions and is\ndramatically faster than single inserts.
  • \n
  • Connections are cached. The database layer reuses connections through a\nTTL/LRU cache (default: 50 connections, 30-minute TTL) and starts MVCC\nBEGIN CONCURRENT transactions automatically, with retry + backoff on\ntransient failures.
  • \n
  • Calibrate the backends once. weft-server does it in the background at startup; a program\nembedding the libraries calls splimes::calibrate() (or splimes::prewarm_gpu()\nplus splimes::set_auto_thresholds) once, off any latency-critical path. Without\nit, interpolation never uses the GPU.
  • \n
  • Pick sensible batch sizes for pipelines (typically 24–100 for hourly data).
  • \n
  • Let the engine choose. Backend::Auto (behind analyze_range and every\nendpoint) already selects the backend by grid size — overriding is rarely necessary.
  • \n
  • Pipeline pattern loading is memory-aware: batch sizes adapt to available RAM to\navoid exhaustion on large datasets.
  • \n
\n
\n

Roadmap

\n

ROADMAP.md is the single source of truth for everything planned,\nin progress, and done — a phase-based [ ]/[x] checkbox queue organized around\nthe benchmark-led commercial thesis (north star: dollars per billion\ninterpolated output points at a p95 latency target). Shipped capabilities are\ndocumented here as features; every performance claim links to a benchmark\nartifact.

\n
\n

License

\n

Copyright (c) 2025-2026 Justin Icenhour.

\n

Licensed under either of

\n\n

at your option.

\n

Two crates include code adapted from Apache-2.0 projects — the Chimp codecs in\nweft-physical-type and the DDSketch quantile sketch in weft-reduce. Those portions\nstay under the Apache License 2.0 whichever option you choose. NOTICE lists\nthem, and each crate's THIRD-PARTY-NOTICES file carries the attribution and the\nlicense text.

\n

Contribution

\n

Unless you explicitly state otherwise, any contribution intentionally submitted for\ninclusion in the work by you, as defined in the Apache-2.0 license, shall be dual\nlicensed as above, without any additional terms or conditions.

\n

Every pull request also needs either a DCO sign-off on each commit or a signed CLA; see\nContribution terms.

\n", "releases": [ { "tag": "v0.1.0", @@ -479,7 +479,7 @@ }, "Nanna": { "repo": "Nanna", - "fetchedAt": "2026-10-10T05:05:53.501Z", + "fetchedAt": "2026-10-11T03:21:15.732Z", "description": "A high-performance, always-on personal AI presence written in Rust — local-first, runs on your own machine with a small open model on a single consumer GPU", "htmlUrl": "https://github.com/basic-automation/Nanna", "homepage": null, @@ -499,10 +499,17 @@ "license": "MIT", "defaultBranch": "master", "createdAt": "2026-02-01T18:08:05Z", - "pushedAt": "2026-10-09T22:05:03Z", + "pushedAt": "2026-10-10T23:09:00Z", "archived": false, "readmeHtml": "

🌙 Nanna

\n
\n

As the moon illuminates what the sun cannot see, so shall I illuminate what you cannot reach.

\n
\n

A personal AI presence that runs entirely on your machine. Nanna is a calm, capable assistant written in Rust — not a chatbot, but a presence. It runs as a headless daemon on your own hardware, thinks with a small open model on a single consumer GPU, remembers across sessions, and reaches you on any channel.

\n

\"Download\"\n\"Build\n\"Tests\"\n\"License:

\n

Status: 🧪 Public Beta (0.3.x) · Windows x64 and Linux x64 installers · macOS builds from source

\n
\n

⚡ Quick Start (5 Minutes)

\n

1. Install Ollama

\n

Download and install Ollama, then pull the recommended model:

\n
ollama pull qwen3.5:9b

2. Download Nanna

\n

Get the latest installer from Releases\n(every release is currently marked Pre-release — take the newest one):

\n
    \n
  • Windows: Nanna_x.y.z_x64-setup.exe
  • \n
  • Linux: Nanna_x.y.z_amd64.AppImage or Nanna_x.y.z_amd64.deb
  • \n
\n

3. Run It

\n

Launch Nanna. On first run, it seeds its default tools into the user data directory —\n~/.local/share/nanna/tools on Linux, ~/Library/Application Support/nanna/tools on macOS,\n%APPDATA%\\nanna\\data\\tools on Windows. To keep Nanna's data somewhere else, choose a folder in\nSettings → Data → Data location; it applies when the daemon next starts, and existing data is\nnot moved.

\n
\n

Note: Binaries are not yet code-signed. Windows SmartScreen will warn — click More info → Run anyway.

\n
\n

4. (Optional) Add Cloud Keys

\n

For cloud model access, go to Settings → Models and add your API keys:

\n
    \n
  • Anthropic, OpenAI, or OpenRouter
  • \n
\n

A fully local run needs none.

\n
\n

📋 System Requirements

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
ComponentMinimumRecommended
OSWindows 10 x64 / Linux x64Windows 11 x64 / recent Linux x64
RAM8 GB16 GB
GPU—8+ GB VRAM (for local inference)
Disk500 MB2 GB (with models)
RuntimeOllamaOllama + GPU drivers
\n

macOS: build from source (see Building from Source).

\n
\n

📸 Screenshots

\n\n\n\n\n\n

Screenshots coming soon — see the GUI in action by downloading the beta.

\n
\n

Capability Matrix

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
FeatureStatusRequires
Desktop GUI✅ StableWindows x64, Linux x64 (macOS: build from source)
CLI Chat✅ StableTerminal
Fully Local Inference🚧 In DevelopmentGPU with 8+ GB VRAM (P12 milestone)
Ollama Backend✅ StableOllama installed
Cloud Providers✅ StableAPI key (Anthropic/OpenAI/OpenRouter)
Telegram Channel✅ StableBot token
Discord Channel✅ StableBot token
Slack Channel✅ StableApp credentials
Signal Channel✅ StableSignal CLI bridge
WhatsApp Channel✅ StableWhatsApp Business API
Cognitive Memory✅ Stable—
Tool System (47 tools, all wired)✅ StableSome need a model, key, browser or display — see below
MCP Client✅ Stable (stdio and Streamable HTTP servers)A server listed under [mcp]
Auto-Update✅ StableInternet connection
\n
\n

What Works Today

\n
    \n
  • The board (early) — each workspace has a card board (To do, Waiting, In progress, Done). One\nline adds a card: Ship the fix #release p2 @builder friday {march 30} sets the label, priority,\nassignee, date and deadline, and the rest is the title. The board's router completes what you\nleft out, an agent assigned a card works it in the background, and the card's thread shows its\nprogress, questions and verdict as they are posted. From a card you can post on its thread,\nreassign it, edit its fields, see what it waits on and add sub-cards, start, stop or resume an\nagent's run, and mark it done (it stays open if its acceptance check fails, and says why).\nInbox and Upcoming list what is assigned to you across every board; Members is where\nyou add agents and give them models and capabilities; and a card that comes due, goes overdue,\nor is handed to you with a question shows up in your notifications. Cards are not left stuck:\none an agent stops working on goes back to the router after half an hour, a rejected API key\nbecomes a question to you instead of a failed card, and a rate-limited run waits and tries\nagain without counting as a failure.
  • \n
  • Long-horizon autonomy — Mission mode drives multi-hour builds from a single prompt with automatic recovery from failures
  • \n
  • Headless daemon + GUI — Runs as a Windows service with WebSocket IPC; the Tauri GUI attaches as a client
  • \n
  • Streaming chat — Real-time responses with tool calling, thinking visualization, and context compression.\nAttached images reach the model on every provider (Anthropic, Ollama, OpenAI-compatible); a file it\ncannot read is named to it rather than dropped. Replies survive any script — emoji, accents, CJK — however\nthe network splits them. Stop ends the running turn, and your next message is answered on its own; work you\nstopped is resumed only when your message asks for it. End-to-end tested against a scripted model on every\npull request (crates/nanna-client/tests/e2e_daemon.rs).
  • \n
  • Cognitive memory — FSRS-6 spaced repetition with semantic recall and consolidation ("dreaming"),\nwith drift protection: what you stated is kept in your words and never paraphrased away, and a\nsummary is never re-summarized (measured).\nWith no embedding provider, recall falls back to matching words — and says so — instead of reporting\nthat nothing was ever saved
  • \n
  • LLM routing — Local-first with optional cloud escalation; native prompt caching (50–80% savings)
  • \n
  • 47 filesystem tools, all of them wired — file, shell, web, code-search, memory, task and\ncuriosity tools work with no setup. A tool whose daemon-side service is missing is withheld from\nthe model rather than offered and left to fail, and the daemon says at boot which ones and why —\nso the count you get is the count that works. Four groups need something present to register:\nvision (analyze_image, describe_image, ocr, and OCR for scanned PDFs) wants a\nvision-capable model in [memory] ocr_model_priority; speech (text_to_speech, transcribe)\nwants an OpenAI key; browsing (browser_action, browser_evaluate, browser_extract,\nbrowser_screenshot) wants Chromium or Chrome installed; and screenshot wants a desktop\ncapture tool (grim, spectacle, maim, scrot, import, or macOS screencapture) and a\ndisplay session. Reminders (remind, list_reminders, cancel_reminder) need nothing: a\ndue reminder is posted into the conversation that set it and shows up live in an open chat; it\nsurvives a restart, and one that came due while Nanna was not running is delivered at the next\nboot and says how late it is. Also includes find_files for locating a file by glob when you know its name but not\nits path.
  • \n
  • Nanna can write its own tools — create_tool authors a new JS/TS tool into the data directory\nand registers it live, callable in the same conversation with no restart; edit_tool changes one,\nrefusing an edit that matches zero or several places rather than guessing, and re-registering on\nsuccess; list_user_tools shows what has been authored.
  • \n
  • Asks when it is unsure — ask_user posts a clarifying question into the conversation (the app, or the chat app you wrote from) and waits up to half an hour for your reply, which the running turn picks up and continues with; no reply, and it carries on with its best judgement and says what it assumed.
  • \n
  • Your file rules stay yours — a rule you declared ("don't touch tests/") blocks write_file/edit_file; when it really stands in the way, lift_invariant asks you, quoting your own words, and lifts it only on a clear yes.
  • \n
  • Reads what it is shown — read_pdf falls back to model OCR for image-only pages when a vision\nmodel is configured, and reports which of four things happened (no OCR configured, OCR ran, the\ndocument has no images to read, or no page was missing text) instead of returning an empty string\nfor all of them. Screenshots and generated speech are written to files and their paths returned,\nnot summarised as a byte count and discarded.
  • \n
  • Five channels — Telegram, Discord, Slack, Signal, WhatsApp. Each chat gets its own conversation, answers (and reminders set from the chat) come back to it, /model <name> pins that conversation to a model (/model default undoes it), and /status, /stop and /new (start the chat over) work from the chat app. Inbound webhooks fail closed: every route verifies its provider signature or shared secret before the payload reaches the agent, and a channel with no credential configured refuses to serve (503) rather than accepting anonymous requests. Discord and Slack captures also expire on a 5-minute replay window. nanna init mints the Telegram secret and prints the setWebhook call.
  • \n
  • Traceable logs — every daemon log line names the conversation, step, model call and tool call it came\nfrom, and each call's duration, tokens and outcome are logged when it ends; the GUI's Logs page search\nmatches that context, so pasting a session id shows just that conversation
  • \n
  • Tool audit trail — one JSON line per tool call (including refused and not-found ones), recorded at the registry chokepoint so every caller is covered; argument values stay out by default
  • \n
  • Repo-aware context — when the workspace is a git repository, each turn sees a bounded snapshot of the branch, uncommitted paths, and recent commits, so the agent knows what work is already in flight before it edits
  • \n
  • Undo for file writes — before write_file, edit_file or file_buffer changes a file, its previous content is saved outside your project (in Nanna's data directory, per conversation), and the file_history tool lists those checkpoints and puts a file back — including removing one a write created. A restore is itself undoable. Bounded: the 100 most recent checkpoints per conversation plus each file's first version, 256 MiB per conversation, 1 GiB overall; changes made through exec are not tracked.
  • \n
  • Per-edit diffs — every edit_file call records a bounded before/after view of what it changed, shown in the run timeline and kept with the session, so you can see what an unattended run did after the fact
  • \n
  • Conversation and memory export — nanna export <session-id> writes a session out as a readable Markdown transcript (tool calls, edits and all) or, with --format json, as the complete stored session; nanna export --memories does the same for everything Nanna remembers, with each memory's provenance and FSRS state
  • \n
  • MCP servers — list MCP servers under [mcp] (a command to spawn, or a Streamable HTTP\nurl with an optional bearer token from the keyring) and the daemon starts them at boot, in the\nbackground so a slow first npx download never delays startup; their tools appear to the model as\nmcp__<server>__<tool>. A server that fails to start is logged by name and the rest still start, and\nthe servers are shut down with the daemon. Both protocol eras work: a current (2026-07-28)\nserver is detected with server/discover and spoken to without a handshake, an older one falls back\nto initialize — over stdio and over HTTP, verified against the official TypeScript SDK's servers of\neach kind; a URL that only speaks the deprecated 2024 HTTP+SSE transport is detected and used too.
  • \n
  • Nanna's tools for other MCP clients — nanna mcp serve speaks MCP over stdio (for Claude Code,\nClaude Desktop, editors). With the daemon running it serves the daemon's live tools — memory, files,\nshell, web, code and the MCP servers Nanna itself is connected to — each call executed by the daemon;\nwithout one (or with --standalone) it serves the local skills only.
  • \n
  • Auto-updates — Background update checks with user-initiated install
  • \n
\n
\n

Installation

\n

Windows

\n
    \n
  1. Download the installer from Releases
  2. \n
  3. Run Nanna_x.y.z_x64-setup.exe
  4. \n
  5. Accept the SmartScreen warning (More info → Run anyway)
  6. \n
  7. Launch from Start Menu
  8. \n
\n

Uninstall:

\n
    \n
  1. Settings → Apps → Installed apps → Nanna → Uninstall
  2. \n
  3. Delete %APPDATA%\\nanna\\ to remove all data
  4. \n
\n

macOS

\n

Build from source (see below). After building:

\n
    \n
  1. Copy Nanna.app to /Applications
  2. \n
  3. First launch: right-click → Open (bypasses Gatekeeper)
  4. \n
\n

Uninstall:

\n
    \n
  1. Drag Nanna.app to Trash
  2. \n
  3. Delete ~/Library/Application Support/nanna/
  4. \n
\n

Linux

\n

Build from source, or use the AppImage/deb from Releases:

\n

AppImage:

\n
chmod +x Nanna_x.y.z_amd64.AppImage\n./Nanna_x.y.z_amd64.AppImage

Debian/Ubuntu:

\n
sudo apt install ./Nanna_x.y.z_amd64.deb

Uninstall:

\n
    \n
  • AppImage: Delete the file
  • \n
  • deb: sudo apt remove nanna
  • \n
  • Data: Delete ~/.config/nanna/ and ~/.local/share/nanna/
  • \n
\n
\n

Troubleshooting

\n

Start here: nanna doctor

\n
nanna doctor

Checks the configuration and, for anything it finds, prints the fix rather than\njust the verdict — a missing tools directory, a [infer] section that names no\nmodel, a clustering configuration that would merge unrelated memories, a\nhand-edited summarization model with no provider prefix that will be sent to the\nwrong provider (and how to write it, e.g. ollama/qwen3). It also says how the\nlast daemon ended: still running, stopped cleanly, or died, and by which signal\nwhen the app saw it (daemon.last_exit). Exits non-zero when something is\nactually broken, so it also works from a script or a health probe.

\n

By default it is offline: no provider call, no network probe, no keyring\nread. That makes it fast and safe to run anywhere, and it means a clean report\nsays your configuration is sound — not that a provider is reachable.

\n
nanna doctor --online

Adds the one probe that needs no credential: your Ollama server\n([memory].ollama_host, the one server chat, embeddings and summaries all use)\nis asked whether it is answering and whether it has every Ollama model you\nconfigured for any of them — a model counts when chat, the embedders or the\nsummarizers would send it there — and a missing one is reported with the\nollama pull that fixes it. It never tests a provider API key — that would mean\nreading the keyring and sending the key off the machine.

\n

API Key Invalid

\n
    \n
  • Verify the key in Settings → Models
  • \n
  • Check that you're using the correct provider's key format
  • \n
  • Ensure the key has sufficient credits/quota
  • \n
\n

Ollama Not Running

\n
# Check if Ollama is running\nollama list\n\n# Start Ollama (it runs as a service by default)\nollama serve

Daemon Not Responding

\n
# Check if the daemon is running (add --port N if you started it with one)\nnanna daemon status\n\n# Restart the daemon\nnanna daemon restart\n\n# Check the health endpoint\ncurl http://127.0.0.1:5148/health

Port Already in Use

\n

Default ports: IPC 5149, Health 5148

\n
# Find what's using the port (Windows PowerShell)\nGet-NetTCPConnection -LocalPort 5149\n\n# Kill the process or change the port in config

Windows Defender Warning

\n

The binaries are not yet code-signed. To run:

\n
    \n
  1. Click More info on the SmartScreen dialog
  2. \n
  3. Click Run anyway
  4. \n
\n

Or add an exclusion in Windows Security → Virus & threat protection → Exclusions.

\n

macOS "App is Damaged" or Blocked

\n
# Remove quarantine attribute\nxattr -cr /Applications/Nanna.app\n\n# Or right-click → Open on first launch

Linux WebKitGTK Missing

\n

Tauri requires WebKitGTK. Install it:

\n
# Debian/Ubuntu\nsudo apt install libwebkit2gtk-4.1-dev\n\n# Fedora\nsudo dnf install webkit2gtk4.1-devel\n\n# Arch\nsudo pacman -S webkit2gtk-4.1

GPU Not Detected (Local Inference)

\n
    \n
  • Ensure GPU drivers are up to date
  • \n
  • Verify Vulkan support: vulkaninfo
  • \n
  • Check VRAM: local inference needs 8+ GB
  • \n
  • Fall back to CPU: set device = "cpu" in config
  • \n
\n
\n

Configuration

\n

Config lives at:

\n
    \n
  • Windows: %APPDATA%\\nanna\\config.toml
  • \n
  • macOS: ~/Library/Application Support/nanna/config.toml
  • \n
  • Linux: ~/.config/nanna/config.toml
  • \n
\n
[general]\nname = \"Nanna\"\n\n[llm]\nprovider = \"ollama\"       # ollama | anthropic | openai | openrouter\nmodel = \"qwen3.5:9b\"\n# prompt_cache_ttl = \"1h\" # Anthropic only: \"5m\" (default) or \"1h\" — 1h keeps the prompt\n#                         # cache warm across 5-60 min gaps, at 2x the cache-write price\n\n[server]\nport = 3000               # `nanna server` port; the PORT env var or --port override it\n# webhook_secret = \"...\"  # /webhooks/generic's shared secret. Kept in the keyring: one written\n#                         # here is filed there when the file loads, and the next save drops\n#                         # the line. Or set NANNA_WEBHOOK_SECRET.\n\n[[mcp.servers]]           # repeat per server; tools show up as mcp__files__<tool>\nname = \"files\"\ncommand = \"npx\"\nargs = [\"-y\", \"@modelcontextprotocol/server-filesystem\", \"/home/me/notes\"]\n# enabled = false         # keep the entry without starting it\n# secret_env = [\"GITHUB_PERSONAL_ACCESS_TOKEN\"]  # values from the keyring, see below\n\n[[mcp.servers]]           # a remote server: url instead of command\nname = \"notion\"\nurl = \"https://mcp.example.com/mcp\"\n# bearer_secret = \"NOTION_TOKEN\"  # sent as Authorization: Bearer; stored like secret_env

Edits to config.toml apply to a running daemon within a couple of seconds (models and\nproviders, scheduler switches); channel, MCP and webhook settings are read at startup. A file\nthat does not parse is logged and the running configuration is kept.

\n

MCP servers inherit the daemon's environment. There is deliberately no env table in\nconfig.toml: a token a server needs is named in secret_env and stored with\nnanna mcp secret set <server> <VAR> (prompted, or read from stdin — never a command-line\nargument). A url server's token works the same way through bearer_secret. The value lives in\nthe OS keyring (or, with no keyring on the machine, an encrypted 0600 file) and only that server\nreceives it; a server\nwhose secret is missing is not started, and system.status / the Tools page say which command sets it.

\n

Channel secrets are kept the same way. nanna init and the Channels page store bot and app\ntokens and signing and webhook secrets in the keyring; config.toml keeps the channel's section,\nwhich turns it on, and its other settings. A secret found in config.toml (written there by hand,\nor by an older version) is filed in the keyring when the file is loaded and leaves the file at the\nnext save, so the line can simply be deleted. For a channel that has a section, a secret\ncan also come from the environment: TELEGRAM_BOT_TOKEN, TELEGRAM_WEBHOOK_SECRET,\nDISCORD_BOT_TOKEN, SLACK_BOT_TOKEN, SLACK_APP_TOKEN, SLACK_SIGNING_SECRET,\nSIGNAL_WEBHOOK_SECRET, WHATSAPP_ACCESS_TOKEN, WHATSAPP_VERIFY_TOKEN, WHATSAPP_APP_SECRET.

\n

Environment Variables:

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
VariablePurpose
ANTHROPIC_API_KEYAnthropic models
OPENAI_API_KEYOpenAI models + embeddings
OPENROUTER_API_KEYOpenRouter models
BRAVE_API_KEYWeb search tool
TELEGRAM_BOT_TOKENTelegram channel's bot token, with TELEGRAM_WEBHOOK_URL / TELEGRAM_WEBHOOK_SECRET; a [channels.telegram] section keeps every setting they don't name (allowed_users included)
DISCORD_BOT_TOKENDiscord channel's bot token, with DISCORD_APPLICATION_ID / DISCORD_PUBLIC_KEY (all three needed when there is no [channels.discord] section)
NANNA_WEBHOOK_SECRETnanna server's /webhooks/generic shared secret (used over the keyring's)
NANNA_CONFIG_PATHLoad config from this file instead of the default location above
\n

Ports: Health HTTP 5148 (/health, /status, and Prometheus /metrics) · WebSocket IPC 5149

\n

NANNA_CONFIG_PATH is useful for running a second instance, or for trying a\nconfiguration without touching the one you use day to day — pair it with the\ndaemon's --data-dir to isolate the database as well:

\n
NANNA_CONFIG_PATH=/tmp/try.toml nanna-daemon --data-dir /tmp/try-data run

\n

Building from Source

\n
git clone https://github.com/basic-automation/Nanna.git\ncd Nanna\n\n# Build\ncargo build --release\n\n# Run CLI\ncargo run -- chat\n\n# Run daemon\ncargo run -- daemon start

GUI (Tauri + Nuxt)

\n
cd gui\npnpm install\npnpm run tauri:dev      # Development\npnpm run tauri:build    # Production

Requirements:

\n
    \n
  • Rust via rustup — the pinned nightly toolchain in rust-toolchain.toml\nis installed automatically on the first cargo command
  • \n
  • Node.js 22+ and pnpm (for the GUI)
  • \n
\n

Linux: building the GUI additionally needs WebKitGTK (webkit2gtk-4.1) and its development\nheaders — see Linux WebKitGTK Missing. libc is pinned below 0.2.187\nbecause newer versions break the vendored Python runtime; leave that pin in place when updating\ndependencies.

\n
\n

Privacy & Data

\n

See PRIVACY.md for full details.

\n

Local storage:

\n
    \n
  • Config: config.toml
  • \n
  • Database: nanna.db (sessions, memory, tasks)
  • \n
  • Credentials: OS keyring (encrypted)
  • \n
\n

Taking your data with you: nanna export <session-id> (Markdown, or --format json\nfor the complete stored session) and nanna export --memories — the daemon must be running\n(--daemon ws://host:port for one not on the default port).

\n

What's sent externally (when configured):

\n
    \n
  • Chat messages → your chosen LLM provider
  • \n
  • Embeddings → OpenAI (if enabled)
  • \n
  • Web searches → Brave Search (if enabled)
  • \n
  • Channel messages → respective platforms
  • \n
\n

Fully offline mode: Use Ollama with no API keys configured.

\n
\n

Contributing & Community

\n\n
\n

Architecture

\n
\nClick to expand technical details

21 workspace crates + Tauri app, layered by dependency:

\n
nanna/\n├── src/main.rs              # Entry point + CLI\n├── crates/\n│   ├── nanna-simd/          # SIMD vector ops (AVX-512/AVX2/NEON)\n│   ├── nanna-gpu/           # GPU compute (wgpu)\n│   ├── nanna-memory/        # Vector store + FSRS-6 memory + dreaming\n│   ├── nanna-storage/       # Turso persistence (embedded SQLite-compatible)\n│   ├── nanna-llm/           # Inference routing: local + cloud\n│   ├── nanna-tools/         # Tool system (filesystem JS/TS skills)\n│   ├── nanna-scripting/     # Boa (JS) engine + embedded Python\n│   ├── nanna-workspace/     # Workspace detection + context\n│   ├── nanna-channels/      # Channel listeners + router\n│   ├── nanna-browser/       # Browser control (CDP/Playwright)\n│   ├── nanna-agent/         # Agent loop, mission harness, swarm\n│   ├── nanna-mcp/           # Model Context Protocol client/server\n│   ├── nanna-daemon/        # Background service + WebSocket IPC\n│   ├── nanna-client/        # Daemon client library\n│   ├── nanna-server/        # HTTP server + webhooks\n│   ├── nanna-config/        # TOML config + credentials\n│   ├── nanna-core/          # Orchestration, scheduler, registry\n│   ├── nanna-numeric/       # Lossless numeric conversions (no `as` casts)\n│   ├── nanna-proc/          # Child-process containment (tree kills, Job Objects)\n│   ├── nanna-timeline/      # Episodic event timeline for memory\n│   └── nanna-bench/         # Criterion benchmark harness\n└── gui/                     # Tauri 2 + Nuxt 4 frontend

Key patterns:

\n
    \n
  • Daemon owns all state — sessions, memory, config, tools, scheduler
  • \n
  • Channels are control-plane clients — GUI included; capabilities determine rendering, not access
  • \n
  • Agent loop — message → LLM → tools → iterate until done
  • \n
  • Mission harness — durable tasks with acceptance checks
  • \n
\n

\n

Performance

\n
\nClick to expand benchmark details
    \n
  • SIMD is the workhorse — AVX-512/AVX2 cosine similarity ~0.1µs per 768-dim vector
  • \n
  • GPU for scale — wgpu engages only above 50k vectors
  • \n
  • Zero-copy hot paths — fat LTO release builds
  • \n
\n

Local model benchmark (RTX 4070 Ti SUPER 16 GB):

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
ModelSmoke (5 tasks)Endurance (42 tasks)Wall Clock
ornith:9b5/537/421.02 h
qwen3.5:9b5/525/422.11 h
gemma4:e4b-it-qat5/56/424.50 h
\n

Long-horizon chat path (4 h per model, 42-test dependency ladder, v0.3.8-beta.13).\nMissions run through the product's own chat path, not a bespoke harness. Final is the\nscore at the 4-hour mark — the number that says whether work built early still exists at\nthe end.

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
ModelPeak4 h finalInterjectionsvs. previous series (final)
ornith:latest40/4236/4200/42 → 36/42
qwen3.5:9b26/4226/4200/42 → 26/42
gemma4:e4b-it-qat11/425/42016/42 → 5/42
lfm2.50/420/4220/42 (capability floor)
\n

The previous series' two strongest models peaked at 30 and 41 and both ended at zero —\npeaks were built, then rewritten away when the run continued past a context boundary.\nHolding the peak was the target of the P23 work, and the top two legs now end at or near\ntheir peak with no human intervention. ornith's 40 also beats its best headless run (37).\nA fifth leg (ministral-3:8b) is excluded: the daemon hit a genuine panic mid-run, and a\ndead daemon never gets a score.

\n

See ROADMAP.md and bench/BASELINE.md for methodology,\nper-leg trajectories, and the findings queued from this series.

\n

\n

The Lore

\n

Nanna (𒀭𒋀𒆠, also Sîn) was the Sumerian god of the moon and patron deity of Ur — one of humanity's first great cities. The moon doesn't create light; it reflects the sun's, transforming it into something gentler, something you can look at directly.

\n

The crate hierarchy mirrors the ziggurat of Ur: each level built upon the last.

\n
\n

"I am the light that finds you in darkness,\nthe memory that outlives the flesh,\nthe patient watcher of endless cycles.\nI am Nanna. I am here."

\n
\n

License

\n

MIT — see LICENSE

\n", "releases": [ + { + "tag": "v0.3.42-beta.51", + "title": "Nanna v0.3.42-beta.51", + "url": "https://github.com/basic-automation/Nanna/releases/tag/v0.3.42-beta.51", + "publishedAt": "2026-10-10T22:37:47Z", + "prerelease": true + }, { "tag": "v0.3.41-beta.50", "title": "Nanna v0.3.41-beta.50", @@ -530,53 +537,46 @@ "url": "https://github.com/basic-automation/Nanna/releases/tag/v0.3.38-beta.47", "publishedAt": "2026-10-05T14:09:30Z", "prerelease": true - }, - { - "tag": "v0.3.37-beta.46", - "title": "Nanna v0.3.37-beta.46", - "url": "https://github.com/basic-automation/Nanna/releases/tag/v0.3.37-beta.46", - "publishedAt": "2026-10-04T13:19:15Z", - "prerelease": true } ], "latestRelease": null, "download": { - "tag": "v0.3.41-beta.50", - "url": "https://github.com/basic-automation/Nanna/releases/tag/v0.3.41-beta.50", - "publishedAt": "2026-10-09T21:49:23Z", + "tag": "v0.3.42-beta.51", + "url": "https://github.com/basic-automation/Nanna/releases/tag/v0.3.42-beta.51", + "publishedAt": "2026-10-10T22:37:47Z", "prerelease": true, "assets": [ { "name": "nanna-daemon", - "url": "https://github.com/basic-automation/Nanna/releases/download/v0.3.41-beta.50/nanna-daemon", - "size": 78378176 + "url": "https://github.com/basic-automation/Nanna/releases/download/v0.3.42-beta.51/nanna-daemon", + "size": 78481328 }, { "name": "nanna-daemon.exe", - "url": "https://github.com/basic-automation/Nanna/releases/download/v0.3.41-beta.50/nanna-daemon.exe", - "size": 72776704 + "url": "https://github.com/basic-automation/Nanna/releases/download/v0.3.42-beta.51/nanna-daemon.exe", + "size": 72874496 }, { - "name": "Nanna_0.3.41_amd64.AppImage", - "url": "https://github.com/basic-automation/Nanna/releases/download/v0.3.41-beta.50/Nanna_0.3.41_amd64.AppImage", - "size": 123791864 + "name": "Nanna_0.3.42_amd64.AppImage", + "url": "https://github.com/basic-automation/Nanna/releases/download/v0.3.42-beta.51/Nanna_0.3.42_amd64.AppImage", + "size": 124013048 }, { - "name": "Nanna_0.3.41_amd64.deb", - "url": "https://github.com/basic-automation/Nanna/releases/download/v0.3.41-beta.50/Nanna_0.3.41_amd64.deb", - "size": 47375888 + "name": "Nanna_0.3.42_amd64.deb", + "url": "https://github.com/basic-automation/Nanna/releases/download/v0.3.42-beta.51/Nanna_0.3.42_amd64.deb", + "size": 47588900 }, { - "name": "Nanna_0.3.41_x64-setup.exe", - "url": "https://github.com/basic-automation/Nanna/releases/download/v0.3.41-beta.50/Nanna_0.3.41_x64-setup.exe", - "size": 33247785 + "name": "Nanna_0.3.42_x64-setup.exe", + "url": "https://github.com/basic-automation/Nanna/releases/download/v0.3.42-beta.51/Nanna_0.3.42_x64-setup.exe", + "size": 33333471 } ] } }, "splimes": { "repo": "splimes", - "fetchedAt": "2026-10-10T05:05:46.059Z", + "fetchedAt": "2026-10-11T03:21:10.840Z", "description": "Spline interpolation over irregularly sampled time series, on SIMD/parallel CPU or GPU (wgpu).", "htmlUrl": "https://github.com/basic-automation/splimes", "homepage": "https://docs.rs/splimes", @@ -592,11 +592,11 @@ ], "stars": 0, "forks": 0, - "openIssues": 1, + "openIssues": 2, "license": "MIT", "defaultBranch": "main", "createdAt": "2026-10-05T19:18:10Z", - "pushedAt": "2026-10-09T09:28:18Z", + "pushedAt": "2026-10-10T08:49:34Z", "archived": false, "readmeHtml": "

\n \"splimes\"\n

\n \"crates.io\"\n \"docs.rs\"\n \"CI\"\n \"MSRV\n \"License:\n

Resample irregularly sampled time series onto a regular grid.

\n

You have points that arrived whenever they arrived; you want a value every second,\nminute or day. splimes fits a local spline through the points, evaluates it on the\ngrid, and labels every output point as an observation, an interpolation or an\nextrapolation.

\n
    \n
  • Methods: linear, quadratic, cubic and polynomial (degree 1 to 8). Each is\nprecisely defined, inside the data and outside it, and every backend computes the\nsame thing.
  • \n
  • Backends: one CPU thread, rayon's thread pool, or the GPU via\nwgpu (Vulkan, Metal, DX12), picked per call by size or chosen\nexplicitly. A GPU failure never panics: automatic selection reruns on the CPU and\ntells you why.
  • \n
  • Values: BigDecimal or f64. Computation is in f64 (or f32 on the GPU, if\nyou ask), against a published, tested error bound. Points that land on an input\nreturn that input exactly, every digit intact.
  • \n
  • Provenance: each output point is Raw, Interpolated or Extrapolated.
  • \n
  • Honest reporting: the result says which method and backend actually ran. If\nthere are too few points for the method you asked for, it steps down (cubic →\nquadratic → linear) and says so, or refuses if you'd rather it didn't.
  • \n
\n

Quick start

\n
[dependencies]\nsplimes = \"1\"\nbigdecimal = \"0.4\"\nchrono = \"0.4\"
use bigdecimal::BigDecimal;\nuse chrono::{TimeZone, Utc};\nuse splimes::{Point, PointKind, Resolution, Spline};\n\nfn main() -> Result<(), splimes::Error> {\n    let at = |secs: i64| Utc.timestamp_opt(1_700_000_000 + secs, 0).unwrap();\n\n    // Four readings, unevenly spaced.\n    let readings = [\n        Point::new(at(0), BigDecimal::from(10)),\n        Point::new(at(7), BigDecimal::from(14)),\n        Point::new(at(19), BigDecimal::from(11)),\n        Point::new(at(30), BigDecimal::from(20)),\n    ];\n\n    // A value every second, with a cubic spline, a little past the last reading.\n    let series = splimes::interpolate(&readings, at(0), at(33), Resolution::Seconds, Spline::Cubic)?;\n\n    for (timestamp, value, kind) in series.iter().take(3) {\n        println!(\"{timestamp} {value} {kind}\");\n    }\n    assert_eq!(series.kinds()[7], PointKind::Raw);\n    assert_eq!(series.kinds()[32], PointKind::Extrapolated);\n    Ok(())\n}

For more control, configure an Interpolator:

\n
use chrono::{TimeDelta, TimeZone, Utc};\nuse splimes::{Backend, Interpolator, Resolution, Spline};\n\nlet t0 = Utc.timestamp_opt(1_700_000_000, 0).unwrap();\nlet timestamps = [t0, t0 + TimeDelta::seconds(4), t0 + TimeDelta::seconds(9)];\nlet values = [1.0, 3.5, 2.0];\n\nlet series = Interpolator::new(Spline::Quadratic, Resolution::Milliseconds)\n    .backend(Backend::Parallel) // or Auto (the default), Cpu, Gpu\n    .exact(true)                // error instead of stepping down to a simpler method\n    .run_f64(&timestamps, &values, t0, t0 + TimeDelta::seconds(9))?; // f64 in, f64 out\n\nassert_eq!(series.len(), 9_001);\nassert_eq!(series.spline(), Spline::Quadratic);\n# Ok::<(), splimes::Error>(())

From async code, spawn (or spawn_f64) runs the work on rayon's pool and returns a\nfuture that any executor can await, with no feature flag:\ninterpolator.spawn(points, start, end).await?. Dropping the future cancels work that\nhasn't started, and with the GPU it waits for work that has, so the process can exit\nsafely; spawn's docs say when the GPU is used and the cases that don't wait. With the\ntokio feature, run_async uses tokio's blocking pool instead.

\n

Accuracy

\n

Every backend computes from the same normalised inputs, with every time difference\ntaken exactly, and tests/contract.rs checks each one against an exact 60-digit\nreference. Each result is as accurate as perturbing every input value by 10⁻¹³ of\nthe value range allows in f64 (CPU or GPU), or 10⁻⁵ in GPU f32 — for every\nmethod, inside the data and out, however long the series. Where the problem is\nill-conditioned (very irregular spacing, high degree, far extrapolation) the error grows\nexactly as much as the method amplifies such a perturbation, and no more. The\ncrate documentation\nhas the precise statement. It is part of the semver promise.

\n

Performance

\n

On a 16-core Ryzen 9 7950X3D with an RTX 4070 Ti SUPER, a cubic resample of 4,096\nirregular points onto a 16.7-million-point grid takes about 100 ms on rayon's pool,\nagainst 670 ms on one thread, and a million points about 7–12 ms. Building the output\n(timestamps, values, provenance) costs the same whichever backend computed the values,\nand it dominates, so the GPU gains little: here it at best ties rayon in f64 and wins\nmodestly in f32 at a million points and more. Backend::Auto therefore uses the GPU\nonly once splimes::calibrate() has started it and measured that it pays off on your\nmachine. BENCHMARKS.md has the numbers and how to reproduce them.

\n

Features

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
FeatureDefaultEffect
gpuyesThe wgpu backend. Without it, Backend::Gpu returns Error::GpuUnavailable, and the build drops wgpu entirely.
serdeyesSerialize/Deserialize for Point, PointKind, Resolution and Spline.
tokionoInterpolator::run_async and run_f64_async.
\n

Stability

\n

splimes 1.x follows semantic versioning. Covered: the public\nAPI, what each method computes (beyond rounding within the published bound), and the\nserde formats. The minimum supported Rust version is 1.95; raising it is a\nminor-version change, announced in the changelog, and only to a\nversion at least six months old. Upgrading from 0.1? See MIGRATING.md.

\n

splimes is the interpolation engine behind WeftDB,\nbut it has no dependency on the database and works on its own.

\n

Contributing

\n

cargo test runs anywhere. Without a GPU, the GPU tests skip themselves; set\nSPLIMES_REQUIRE_GPU=1 to make a missing GPU a failure instead, or\nSPLIMES_REQUIRE_GPU_F64=1 to require one with f64 support too (CI sets that, on a\nsoftware Vulkan driver). Formatting uses nightly rustfmt: cargo +nightly fmt.

\n

License

\n

MIT; see LICENSE.

\n", "releases": [ @@ -629,7 +629,7 @@ }, "download": null, "crateVersion": "1.1.0", - "crateDownloads": 256, + "crateDownloads": 258, "crateUrl": "https://crates.io/crates/splimes", "docsUrl": "https://docs.rs/splimes" } From 5e60bd11cf0c4ca22ef9204f552f04f9bf3e58de Mon Sep 17 00:00:00 2001 From: Justin Icenhour Date: Sat, 10 Oct 2026 23:40:43 -0500 Subject: [PATCH 7/7] fix(scroll): a restored position is a jump, not an animation The real cause of main's red check:navigation, which 9cba524 only widened the slack around: the PR's own CI then landed a reload of Skidbladnir's README 21 px past the reader's place. vue-router restores a saved position (Back, Forward, and a reload from history.state.scroll) with a bare scrollTo({ left, top }), so the site's `scroll-behavior: smooth` made each an animation from the top, racing the README fold's own instant restore. Fast machines won the race; slow ones did not. Reproduced with the CPU slowed 6x: all 16 reload cases failed, 7-34 px past. app/router.options.ts keeps Nuxt's scrollBehavior (reached by path, it is not exported, so a move breaks the build loudly) and marks a saved position `instant`, as a browser's own restoration is. Hash links still scroll smoothly. A client plugin could not do this: Nuxt's router plugin resets router.options.scrollBehavior after the first navigation. - app/router.options.ts (new) - scripts/check-navigation.mjs: slack back to 4 px; a failed reload check prints a frame-by-frame trace of the page's first seconds (scroll calls, position, height, README offset, font state, shifts), which is how this was found - app/composables/useReadmeFold.ts: comment, vue-router does restore on reload - README.md, ROADMAP.md Checks: typecheck, 273 tests, build; 42 routes identical; check, a11y, a11y:browser, a11y:firefox clean; check:navigation 144/144 in Chromium and Firefox at 4 px, and 144/144 in Chromium with the CPU slowed 6x at a 1 px slack (16 failures before this change). Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 6 +++-- ROADMAP.md | 28 +++++++++++--------- app/composables/useReadmeFold.ts | 14 +++++----- app/router.options.ts | 32 +++++++++++++++++++++++ scripts/check-navigation.mjs | 45 ++++++++++++++++++++++---------- 5 files changed, 89 insertions(+), 36 deletions(-) create mode 100644 app/router.options.ts diff --git a/README.md b/README.md index 3a74aec..e349fd8 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ it is flat. | --- | --- | | `/` | The pitch, live project stats, and the catalogue | | `/projects` | Every public project | -| `/projects//about` | A marketing page per project: hero, why it exists, features, a worked example, the recent releases, and the repo's README folded away underneath — once opened, it stays open for Back, Forward and a reload, at the place the visitor was reading, and its images arrive with their sizes, so nothing jumps as they load. A desktop app's page also gets a download section: a direct link per platform to the newest release's installers. `/projects/` redirects here | +| `/projects//about` | A marketing page per project: hero, why it exists, features, a worked example, the recent releases, and the repo's README folded away underneath — once opened, it stays open for Back, Forward and a reload, at the place the visitor was reading (put back in one jump, as a browser restores a page — the site's smooth scrolling is for following a link, not for returning), and its images arrive with their sizes, so nothing jumps as they load. A desktop app's page also gets a download section: a direct link per platform to the newest release's installers. `/projects/` redirects here | | `/projects//blog` | That project's news, and `/projects//blog/` for one post | | `/news` | The organization's own news, for what is about Basic Automation rather than one tool | | `/admin` | The post editor — behind a login the app checks itself, and absent over Tor (below) | @@ -248,7 +248,9 @@ two engines do differ. `npm run check:navigation` drives what a visitor's own navigation does to each about page's folded README: Back, Forward and a reload return a reader to the place they were reading, a visit by link starts folded, and a link to a README heading lands on it, at both widths, in Chromium or -(`--firefox`) Firefox. All of these walk every page the sitemap lists, so a new +(`--firefox`) Firefox. A reload it fails prints a frame-by-frame trace of +the page's first seconds — every scroll call, scroll position and page height — +so a failure on CI says where the pixels went. All of these walk every page the sitemap lists, so a new project is audited the moment it is published. Colour contrast is measured separately and more directly by `npm run contrast`. diff --git a/ROADMAP.md b/ROADMAP.md index b0e9af3..b6b53e1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -923,8 +923,8 @@ record. There is no run log. (`scripts/check-navigation.mjs`), in CI in both engines. None of it runs on the server, so nothing else could see it break. On every about page whose README has a heading: unfold and read down, then Back, Forward and - Back, and a reload all return to the same place (±12 px, half a line, - of where the reader was once the README's lazy images settled); a visit by link + Back, and a reload all return to the same place (±4 px of where the + reader was once the README's lazy images settled); a visit by link starts folded at the top; a cold load of `about#` lands on the heading; a client navigation to it shows it; a fold the visitor closed stays closed on Back. At 1280 and 390 px, 128 checks per engine, green @@ -939,17 +939,19 @@ record. There is no run log. number, which a fresh document load can reuse, so a cold link to a heading went to an older reading position instead; it is now read only on a reload or Back/Forward. -- [x] …and `check:navigation` stopped failing main on a few pixels. The run - on main after #20 merged went red on one case: a reload of onyums' - README at 390 px landed at 5,425 against 5,418 left, outside a ±4 px - slack. Reproduced locally at +2 px on about one reload in three. The - site's own `scrollTo` puts the page exactly where it was left; the - extra pixels arrive in the next frame with no script scrolling (every - scrolling call hooked), the page height and the README's offset - unchanged, and with `history.scrollRestoration` manual or - `overflow-anchor: none` alike — somewhere in the browser itself. The - slack is now half a line of body text (12 px). The bug the check - exists for was 3,300 px. Both engines 144/144. +- [x] A restored scroll position is a jump, not an animation. `main` went red + after #20 on `check:navigation`: a reload of onyums' README at 390 px + landed 7 px past the reader's place, and this branch's first CI run + 21 px past on Skidbladnir's. The site's `scroll-behavior: smooth` + applied to vue-router's own restore, a bare `scrollTo({ left, top })` + on Back, Forward and reload, so each was an animation from the top, + racing the README fold's instant restore — on a fast machine it lost + by nothing, on a slow one it did not. With the CPU slowed 6× all 16 + reload cases failed (7–34 px past). `app/router.options.ts` keeps + Nuxt's `scrollBehavior` and marks a saved position `instant`, as a + browser's own restoration is; the same 6× run passes all 144 with the + slack at 1 px. A failed reload check now prints a frame-by-frame trace + of the page's first seconds, which is how this was found. - [x] The browser accessibility pass ran axe on a page still scrolling. The site scrolls smoothly, so `check-a11y-browser.mjs`'s bare `scrollTo` calls only started animations: its reading pass stalled about a screen diff --git a/app/composables/useReadmeFold.ts b/app/composables/useReadmeFold.ts index f149e62..00d3c42 100644 --- a/app/composables/useReadmeFold.ts +++ b/app/composables/useReadmeFold.ts @@ -45,13 +45,13 @@ export function useReadmeFold(readmeHtml: Readonly>) { // or the browser opened it for a fragment. Vue writes the bound value over // the element as it hydrates, so starting from `false` shut it under them. const open = ref(nuxtApp.isHydrating ? foldInDocument() : wanted()) - // A reload: the browser restores the scroll position against the page as - // the server sent it, folded, so a visitor reading the README lands short - // of where they were. Neither it nor vue-router keeps the position across - // a reload (`history.state.scroll` is `false` then), and Chromium drops a - // `replaceState` made while the page unloads, so it goes to session - // storage, keyed by the history entry. A convenience: if storage is - // refused, a reload just lands where it did before. + // A reload: the browser, and vue-router from `history.state.scroll`, restore the scroll + // position against the page as the server sent it, folded, so a visitor + // reading the README lands short of where they were. The fold's own state + // can't ride along in the entry — Chromium drops a `replaceState` made + // while the page unloads — so the position goes to session storage, keyed + // by the history entry. A convenience: if storage is refused, a reload + // just lands where it did before. const entry = () => `readme-top:${history.state?.position ?? ''}:${route.path}` const remember = () => { try { diff --git a/app/router.options.ts b/app/router.options.ts new file mode 100644 index 0000000..973dfa7 --- /dev/null +++ b/app/router.options.ts @@ -0,0 +1,32 @@ +import type { RouterConfig } from 'nuxt/schema' +// Nuxt's own router options, which this file would otherwise replace. Not an +// export of the package, so it is reached by path: if Nuxt moves it, the +// build fails here rather than the site quietly losing Nuxt's scrolling. +import nuxt from '../node_modules/nuxt/dist/pages/runtime/router.options.js' + +/** + * A restored scroll position is jumped to, not animated to. + * + * The site scrolls smoothly (`scroll-behavior: smooth` in `main.css`), which + * is meant for a reader following a link down the page. vue-router restores + * a saved position with a bare `scrollTo`, so the CSS turned every Back, + * Forward and reload into an animation from wherever the page started — + * traced on a reload of Skidbladnir's about page as `scrollTo({ left: 0, top: + * 6537 })`. On an about page that animation races the README fold's own + * restore (`useReadmeFold`), and on a slow machine it ends off the reader's + * place: main's CI landed 7 and 21 px past it on a reload, and with the CPU + * slowed 6× here, every one of 16 reloads landed 7–34 px past. + * + * Browsers put a reader back where they were in one jump, so this does too. + * Nuxt's `scrollBehavior` — which waits for the page to finish loading and + * handles hashes — is kept and only told to jump. It hands a saved position + * back as the very object it was given, so that is what is matched. (A + * plugin can't do this: Nuxt's router plugin sets `router.options.scrollBehavior` + * back to these options once the first navigation is done.) + */ +export default { + async scrollBehavior(to, from, saved) { + const position = await nuxt.scrollBehavior?.(to, from, saved) + return position && saved && position === saved ? { ...saved, behavior: 'instant' } : position + }, +} diff --git a/scripts/check-navigation.mjs b/scripts/check-navigation.mjs index d690bd9..cf612ce 100644 --- a/scripts/check-navigation.mjs +++ b/scripts/check-navigation.mjs @@ -36,19 +36,8 @@ const BASE = (args.find((a) => !a.startsWith('--')) ?? 'http://127.0.0.1:3000').replace(/\/$/, '') const ENGINE = args.includes('--firefox') ? 'firefox' : 'chromium' -/** - * How far from the saved position a restored one may land, in CSS pixels: - * half a line of body text (14 px × 1.65). A reload can land a couple of - * pixels past where the page was left, after the site's own `scrollTo` has - * put it exactly there: +2 px on about one reload in three here (onyums at - * 390 px, 2026-10-10), +7 on main's CI run after #20, which failed this - * check at 4. No script scrolls in between (every scrolling call was - * hooked), the page's height and the README's offset do not move, and it - * happens with `history.scrollRestoration` manual and `overflow-anchor: - * none` too — something in the browser's own first frames. A reader cannot - * see seven pixels; the bug this guards against was 3,300. - */ -const SLACK = 12 +/** How far from the saved position a restored one may land, in CSS pixels. */ +const SLACK = 4 /** Where a heading scrolled to may sit: its scroll margin clears the masthead. */ const HEADING_BAND = [-2, 200] @@ -58,6 +47,29 @@ const VIEWPORTS = [ { name: 'phone', width: 390, height: 844, deviceScaleFactor: 3, mobile: true }, ] +/** + * A frame-by-frame record of a document's first seconds: every frame where + * the scroll position, the page height, the README's offset or the font + * state changed, plus each `scrollTo` the page made and each layout shift. + * Kept short: it is printed beside a failure, not stored. + */ +const TRACE = `(() => { + const t = []; window.__trace = t + const now = () => Math.round(performance.now()) + const readme = () => { const r = document.querySelector('.readme'); return r ? Math.round(r.getBoundingClientRect().top + scrollY) : null } + const scrollTo = window.scrollTo.bind(window) + window.scrollTo = (...a) => { t.push([now(), 'scrollTo', JSON.stringify(a[0] ?? a), Math.round(scrollY)]); return scrollTo(...a) } + try { new PerformanceObserver((l) => { for (const e of l.getEntries()) t.push([now(), 'shift', +e.value.toFixed(4), (e.sources ?? []).map((x) => x.node?.nodeName + ' ' + Math.round(x.previousRect.y) + '>' + Math.round(x.currentRect.y)).join(';')]) }).observe({ type: 'layout-shift', buffered: true }) } catch {} + let last = '', frames = 0 + const frame = () => { + const row = [Math.round(scrollY), document.documentElement.scrollHeight, readme(), document.fonts.status, document.readyState] + const key = row.join() + if (key !== last) { t.push([now(), 'frame', ...row]); last = key } + if (++frames < 300 && t.length < 60) requestAnimationFrame(frame) + } + requestAnimationFrame(frame) +})()` + const ABOUT = /^\/projects\/([^/]+)\/about$/ const slugs = (await sitePages(BASE)).map((p) => ABOUT.exec(p)?.[1]).filter(Boolean) if (!slugs.length) throw new Error(`${BASE}/sitemap.xml lists no about pages`) @@ -156,10 +168,15 @@ try { s = await state() expect(`Forward and Back again, at y ${left}`, s.open === true && near(s.y), s) + // What the page did on its way back, frame by frame — only read when + // the check fails, so a CI failure says where the pixels went. + const untrace = await page.preload(TRACE) await page.reload() await arrive(about) + await untrace() s = await state() - expect(`a reload reopens it at y ${left}`, s.open === true && near(s.y), s) + const reloaded = s.open === true && near(s.y) + expect(`a reload reopens it at y ${left}`, reloaded, reloaded ? s : { ...s, trace: await page.evaluate('window.__trace ?? null') }) await click(`a[href="${blog}"]`) await arrive(blog)