From 5cdb96a92185698751bffc4dca48c27fa7ac76d0 Mon Sep 17 00:00:00 2001 From: ninan-versioning <269335075+ninan-versioning@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:08:20 +0000 Subject: [PATCH] feat(bandit): Thompson Sampling consumer closing the brand_rules feedback loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New package @shoploop/bandit. Pure TypeScript consumer of the shared BANDIT_QUEUE_PATH: joins PerformanceObservation (Google Ads, PR #18) with RenderProvenance (animated-shorts, PR #19) by (twin_handle, preset), maintains Beta-Bernoulli arms in LibSQL, writes posteriors into the bandit: namespace of brand_rules.json (file-locked, isolation-safe), and emits DRAFT-only BudgetRecommendations to BANDIT_RECOMMENDATIONS_PATH. Distribution paid adapters stay DRAFT — recommendations are visible, never auto-applied. Co-Authored-By: Claude Opus 4.7 --- .gitignore | 6 + packages/bandit/.env.example | 12 + packages/bandit/DESIGN.md | 93 ++ packages/bandit/README.md | 71 + packages/bandit/bin/cli.js | 2 + packages/bandit/package.json | 47 + packages/bandit/src/bandit/beta_arm.ts | 98 ++ packages/bandit/src/bandit/reward.ts | 37 + packages/bandit/src/bandit/thompson.ts | 80 + packages/bandit/src/cli.ts | 105 ++ packages/bandit/src/config.ts | 52 + packages/bandit/src/consumer/loop.ts | 189 +++ packages/bandit/src/consumer/service.ts | 47 + packages/bandit/src/index.ts | 18 + packages/bandit/src/join/joiner.ts | 107 ++ packages/bandit/src/queue/reader.ts | 91 + packages/bandit/src/queue/writer.ts | 29 + packages/bandit/src/rules/writer.ts | 87 + packages/bandit/src/store/arms.ts | 133 ++ packages/bandit/src/store/checkpoints.ts | 46 + packages/bandit/src/types.ts | 138 ++ packages/bandit/test/beta_arm.test.ts | 70 + packages/bandit/test/consumer.test.ts | 99 ++ .../fixtures/brand_rules.after.expected.json | 25 + .../test/fixtures/brand_rules.before.json | 12 + .../bandit/test/fixtures/observations.jsonl | 7 + .../bandit/test/fixtures/provenance.jsonl | 2 + packages/bandit/test/joiner.test.ts | 99 ++ packages/bandit/test/rules_writer.test.ts | 114 ++ packages/bandit/test/thompson.test.ts | 55 + packages/bandit/tsconfig.build.json | 9 + packages/bandit/tsconfig.json | 25 + packages/bandit/vitest.config.ts | 10 + pnpm-lock.yaml | 1460 +++++++++++++++++ pnpm-workspace.yaml | 3 + 35 files changed, 3478 insertions(+) create mode 100644 .gitignore create mode 100644 packages/bandit/.env.example create mode 100644 packages/bandit/DESIGN.md create mode 100644 packages/bandit/README.md create mode 100644 packages/bandit/bin/cli.js create mode 100644 packages/bandit/package.json create mode 100644 packages/bandit/src/bandit/beta_arm.ts create mode 100644 packages/bandit/src/bandit/reward.ts create mode 100644 packages/bandit/src/bandit/thompson.ts create mode 100644 packages/bandit/src/cli.ts create mode 100644 packages/bandit/src/config.ts create mode 100644 packages/bandit/src/consumer/loop.ts create mode 100644 packages/bandit/src/consumer/service.ts create mode 100644 packages/bandit/src/index.ts create mode 100644 packages/bandit/src/join/joiner.ts create mode 100644 packages/bandit/src/queue/reader.ts create mode 100644 packages/bandit/src/queue/writer.ts create mode 100644 packages/bandit/src/rules/writer.ts create mode 100644 packages/bandit/src/store/arms.ts create mode 100644 packages/bandit/src/store/checkpoints.ts create mode 100644 packages/bandit/src/types.ts create mode 100644 packages/bandit/test/beta_arm.test.ts create mode 100644 packages/bandit/test/consumer.test.ts create mode 100644 packages/bandit/test/fixtures/brand_rules.after.expected.json create mode 100644 packages/bandit/test/fixtures/brand_rules.before.json create mode 100644 packages/bandit/test/fixtures/observations.jsonl create mode 100644 packages/bandit/test/fixtures/provenance.jsonl create mode 100644 packages/bandit/test/joiner.test.ts create mode 100644 packages/bandit/test/rules_writer.test.ts create mode 100644 packages/bandit/test/thompson.test.ts create mode 100644 packages/bandit/tsconfig.build.json create mode 100644 packages/bandit/tsconfig.json create mode 100644 packages/bandit/vitest.config.ts create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c19be12 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +dist-demo/ +*.tsbuildinfo +.DS_Store +var/ diff --git a/packages/bandit/.env.example b/packages/bandit/.env.example new file mode 100644 index 0000000..5e71984 --- /dev/null +++ b/packages/bandit/.env.example @@ -0,0 +1,12 @@ +# @shoploop/bandit — Thompson Sampling consumer. All optional; defaults shown. +BANDIT_QUEUE_PATH=./var/bandit/queue.jsonl +BANDIT_RECOMMENDATIONS_PATH=./var/bandit/recommendations.jsonl +BANDIT_STATE_DB=./var/bandit/state.db +BANDIT_CHECKPOINT_DB=./var/bandit/checkpoints.db +BRAND_RULES_PATH=./brand_rules.json +BANDIT_REWARD_MODE=roas +ROAS_CEILING=5 +MIN_IMPRESSIONS_FOR_REWARD=50 +BANDIT_DECAY=0.995 +BANDIT_PROMOTE_MARGIN=0.1 +BANDIT_POLL_INTERVAL_MS=5000 diff --git a/packages/bandit/DESIGN.md b/packages/bandit/DESIGN.md new file mode 100644 index 0000000..1c8dbb3 --- /dev/null +++ b/packages/bandit/DESIGN.md @@ -0,0 +1,93 @@ +# `@shoploop/bandit` — Thompson Sampling feedback consumer + +Closes the architecture loop: `Performance Ingest → Thompson Sampling → brand_rules.json`. +This package is the **consumer** half. It does not render, does not call ad APIs, and does +not (in this PR) move money. It reads the durable bandit queue, learns which scene preset +wins per product twin, writes that posterior back into `brand_rules.json`, and emits +**DRAFT-only** budget recommendations. + +## Why Beta-Bernoulli (and not more) + +The reward is a normalized scalar in `[0,1]` (ROAS-or-CTR). Beta is the conjugate prior for +a Bernoulli success probability, so a fractional reward updates the posterior in closed form +(`α += r; β += 1−r`). It is the simplest *correct* bandit: no gradient steps, no replay +buffer, no context features. Contextual bandits / deep RL are explicitly a later PR — arms +here are flat `(twin_handle, preset)` pairs. + +## The 7-step consumer loop (`consumer/loop.ts`) + +``` + BANDIT_QUEUE_PATH (JSONL, single-writer, shared with producers) + │ + ┌───────────────────┴────────────────────────────────────────────────┐ + │ 1. READ tail new bytes since the LibSQL checkpoint │ + │ 2. BUCKET split by `schema`: performance_observation │ provenance │ + │ 3. JOIN observation × provenance → per-arm reward │ + │ key precedence: external_id ▶ product_gid ▶ campaign_id │ + │ 4. UPDATE decay all arms ×BANDIT_DECAY, then α/β update each arm │ + │ 5. RECOMMEND per twin w/ ≥2 presets: Thompson-sample; if leader │ + │ beats runner-up by ≥BANDIT_PROMOTE_MARGIN → emit DRAFT │ + │ 6. WRITE merge bandit: namespace into brand_rules.json (locked) │ + │ 7. CHECKPOINT advance the byte offset │ + └───────────────────┬─────────────────────────┬──────────────────────┘ + │ │ + brand_rules.json (bandit: ns) BANDIT_RECOMMENDATIONS_PATH (DRAFT) + ← Scene Composer reads this ← distribution PR reads this (later) +``` + +## Producers — the two streams on `BANDIT_QUEUE_PATH` + +Both producers append to the **same** single-writer JSONL queue; the consumer discriminates +on the `schema` field. Neither producer knows the sampler exists. + +| Stream | `schema` | Producer | Carries | +|---|---|---|---| +| Reward | `performance_observation.v1` | Google Ads connector (PR #18) | `ad_id`, `metrics.{roas,ctr,impressions,...}`, `shopify_product_gid` | +| Arm | `render_provenance.v1` | animated-shorts (PR #19) | `twin_handle`, `preset`, `storyboard_id` | + +Types are **mirrored, not imported** (`src/types.ts`) — the consumer lives in a different +deploy and must not couple to producer internals. The contract is the JSON shape. + +## The join seam with the distribution PR + +A `PerformanceObservation` knows the channel `ad_id`; a `RenderProvenance` knows the arm +(`twin_handle × preset`). The missing link is *which ad_id a creative was dispatched as* — +and that is owned by the **distribution paid adapters**, which stay DRAFT in their PR. + +The contract this package proposes: when a paid adapter dispatches a creative, it stamps the +returned channel ad_id onto the provenance record's **`external_id`** field. Then +`observation.ad_id === provenance.external_id` is an exact 1:1 join (`join_kind: "external_id"`). + +Until that lands, the joiner falls back in precedence order: + +1. `external_id` — exact, the intended seam. +2. `shopify_product_gid` — observation and provenance both reference the same product. +3. `campaign_id` — coarsest; the original task fallback. Flagged in `JoinedSample.join_kind` + so we can measure how much reward is attributed loosely. + +`animated-shorts` does not yet emit `external_id` — see the `TODO(distribution-PR)` in +`join/joiner.ts`. Adding it is a one-field, additive change on the producer side. + +## Out of scope (owned by the distribution PR) + +- `request_budget_shift` MCP tool — the thing that would *act* on a recommendation. This + package only writes recommendations to `BANDIT_RECOMMENDATIONS_PATH`; nothing auto-applies. +- `list_recent_dispatches` — the distribution-side inventory of what was dispatched as what. + +The DRAFT posture is deliberate: positionless full-funnel feedback *control* over +`brand_rules` is the moat, but flipping it to live budget moves is a separate, gated step. + +## Persistence + +- **Arm state** — LibSQL `arm_states`, one row per `(twin_handle, preset)`. The Beta + posterior is the entire learned state (≪10 MB for the dogfood). +- **Checkpoints** — LibSQL `checkpoints`, byte offset per source file. Byte (not line) + offset so a partially-written trailing line is re-read whole on the next pass. +- **brand_rules.json** — file-locked (`proper-lockfile`) read-modify-write. Scene Composer + reads this concurrently; only the `bandit` key is replaced, every other top-level key is + preserved exactly. + +## Cost + +Pure CPU, co-locates on the existing `t4g.small`: **$0 incremental**. LibSQL state <$1. +**Total ≈ $0/mo.** diff --git a/packages/bandit/README.md b/packages/bandit/README.md new file mode 100644 index 0000000..ca5cba2 --- /dev/null +++ b/packages/bandit/README.md @@ -0,0 +1,71 @@ +# @shoploop/bandit + +Thompson Sampling consumer that closes the `brand_rules.json` feedback loop. Tails the +shared bandit queue, maintains Beta-Bernoulli arms keyed by `(twin_handle, preset)` in +LibSQL, writes posteriors into the `bandit:` namespace of `brand_rules.json`, and emits +**DRAFT-only** budget recommendations. It is a pure consumer — it never calls a paid ad API. + +See [`DESIGN.md`](./DESIGN.md) for the 7-step loop, the producer contracts, and the join +seam with the distribution PR. + +## CLI + +```bash +shoploop-bandit run # long-running consumer service (polls the queue) +shoploop-bandit once # single pass — for cron / tests +shoploop-bandit inspect [--twin ] # pretty-print arm states + last recommendation +``` + +## Reward + +Normalized to `[0,1]`. `BANDIT_REWARD_MODE=roas` (default) saturates ROAS at `ROAS_CEILING`; +`ctr` clamps click-through to `[0,0.2]` then scales. Observations below +`MIN_IMPRESSIONS_FOR_REWARD` are skipped as noise. + +## `brand_rules.json` — `bandit:` namespace + +Only the `bandit` key is written; all other keys (`voice`, `palette`, `materials`, +`lighting`, …) are preserved exactly. + +```json +{ + "bandit": { + "version": 1, + "updated_at": "2026-06-25T10:06:47.244Z", + "arms": { + "varitea_jasmine_pearl": { + "hero": { "alpha": 4, "beta": 1, "pulls": 3, "last_reward": 1 }, + "lifestyle": { "alpha": 1.1, "beta": 3.9, "pulls": 3, "last_reward": 0.02 } + } + }, + "last_recommendation": { + "twin_handle": "varitea_jasmine_pearl", + "winning_preset": "hero", + "confidence": 0.983 + } + } +} +``` + +## Env + +| Var | Default | Meaning | +|---|---|---| +| `BANDIT_QUEUE_PATH` | `./var/bandit/queue.jsonl` | Input queue (shared with producers) | +| `BANDIT_RECOMMENDATIONS_PATH` | `./var/bandit/recommendations.jsonl` | DRAFT output queue | +| `BANDIT_STATE_DB` | `./var/bandit/state.db` | LibSQL arm posteriors | +| `BANDIT_CHECKPOINT_DB` | `./var/bandit/checkpoints.db` | LibSQL consumer offsets | +| `BRAND_RULES_PATH` | `./brand_rules.json` | File the `bandit:` namespace merges into | +| `BANDIT_REWARD_MODE` | `roas` | `roas` \| `ctr` | +| `ROAS_CEILING` | `5` | ROAS that maps to reward `1.0` | +| `MIN_IMPRESSIONS_FOR_REWARD` | `50` | Noise floor | +| `BANDIT_DECAY` | `0.995` | Per-loop multiplicative forgetting | +| `BANDIT_PROMOTE_MARGIN` | `0.1` | Min sampled gap to emit a recommendation | + +## Develop + +```bash +pnpm install +pnpm typecheck +pnpm test +``` diff --git a/packages/bandit/bin/cli.js b/packages/bandit/bin/cli.js new file mode 100644 index 0000000..8fb1272 --- /dev/null +++ b/packages/bandit/bin/cli.js @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import "../dist/cli.js"; diff --git a/packages/bandit/package.json b/packages/bandit/package.json new file mode 100644 index 0000000..9f240c9 --- /dev/null +++ b/packages/bandit/package.json @@ -0,0 +1,47 @@ +{ + "name": "@shoploop/bandit", + "version": "0.1.0", + "description": "Thompson Sampling consumer that closes the brand_rules feedback loop. Tails the bandit queue (PerformanceObservation × RenderProvenance), maintains Beta-Bernoulli arms keyed by (twin_handle, preset) in LibSQL, writes posteriors into the brand_rules.json bandit: namespace, and emits DRAFT-only BudgetRecommendations. Positionless full-funnel feedback control.", + "license": "UNLICENSED", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "bin": { + "shoploop-bandit": "./bin/cli.js" + }, + "files": [ + "dist", + "bin" + ], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "lint": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "cli": "tsx src/cli.ts" + }, + "dependencies": { + "@libsql/client": "^0.14.0", + "proper-lockfile": "^4.1.2", + "seedrandom": "^3.0.5" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "@types/proper-lockfile": "^4.1.4", + "@types/seedrandom": "^3.0.8", + "tsx": "^4.19.0", + "typescript": "^5.6.0", + "vitest": "^2.1.0" + }, + "engines": { + "node": ">=20" + } +} diff --git a/packages/bandit/src/bandit/beta_arm.ts b/packages/bandit/src/bandit/beta_arm.ts new file mode 100644 index 0000000..d1b57fc --- /dev/null +++ b/packages/bandit/src/bandit/beta_arm.ts @@ -0,0 +1,98 @@ +// Beta-Bernoulli arm. The simplest correct bandit posterior: a Beta(alpha,beta) +// over the arm's success probability. Reward ∈ [0,1] is treated as a fractional +// Bernoulli outcome (alpha += reward; beta += 1-reward), so a continuous normalized +// ROAS/CTR slots straight into conjugate Beta updating. No deep RL, no context. + +export type Rng = () => number; // uniform in [0,1) + +export interface BetaArmSnapshot { + alpha: number; + beta: number; + reward_sum: number; + pulls: number; +} + +export class BetaArm { + alpha: number; + beta: number; + reward_sum: number; + pulls: number; + + constructor(alpha = 1, beta = 1, reward_sum = 0, pulls = 0) { + this.alpha = alpha; + this.beta = beta; + this.reward_sum = reward_sum; + this.pulls = pulls; + } + + // Posterior mean of the success probability. + mean(): number { + return this.alpha / (this.alpha + this.beta); + } + + // Fractional conjugate update. reward is clamped defensively to [0,1]. + update(reward: number): void { + const r = reward < 0 ? 0 : reward > 1 ? 1 : reward; + this.alpha += r; + this.beta += 1 - r; + this.reward_sum += r; + this.pulls += 1; + } + + // Multiplicative decay toward the prior — keeps arms responsive to regime change + // without fully forgetting. Floored at the Beta(1,1) uniform prior so a long-idle + // arm never collapses below the prior and starts sampling pathologically. + decay(factor: number): void { + this.alpha = 1 + (this.alpha - 1) * factor; + this.beta = 1 + (this.beta - 1) * factor; + } + + // A draw from Beta(alpha,beta) via two Gamma draws: B = X/(X+Y), + // X~Gamma(alpha,1), Y~Gamma(beta,1). Deterministic given the rng. + sample(rng: Rng): number { + const x = gamma(this.alpha, rng); + const y = gamma(this.beta, rng); + const s = x + y; + return s > 0 ? x / s : 0.5; + } + + snapshot(): BetaArmSnapshot { + return { alpha: this.alpha, beta: this.beta, reward_sum: this.reward_sum, pulls: this.pulls }; + } + + static from(s: BetaArmSnapshot): BetaArm { + return new BetaArm(s.alpha, s.beta, s.reward_sum, s.pulls); + } +} + +// Standard normal from a uniform rng via Box-Muller. Guards against u1=0 (log(0)). +function normal(rng: Rng): number { + let u1 = rng(); + if (u1 < 1e-12) u1 = 1e-12; + const u2 = rng(); + return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2); +} + +// Marsaglia & Tsang (2000) Gamma(shape, scale=1) sampler. For shape<1 use the +// boosting trick Gamma(a) = Gamma(a+1) * U^(1/a). Accepts any uniform rng so the +// whole sampler is seedable for deterministic convergence tests. +export function gamma(shape: number, rng: Rng): number { + if (shape <= 0) return 0; + if (shape < 1) { + const g = gamma(shape + 1, rng); + const u = Math.max(rng(), 1e-12); + return g * Math.pow(u, 1 / shape); + } + const d = shape - 1 / 3; + const c = 1 / Math.sqrt(9 * d); + for (;;) { + let x = normal(rng); + let v = 1 + c * x; + if (v <= 0) continue; + v = v * v * v; + const u = rng(); + const x2 = x * x; + if (u < 1 - 0.0331 * x2 * x2) return d * v; + if (Math.log(u) < 0.5 * x2 + d * (1 - v + Math.log(v))) return d * v; + } +} diff --git a/packages/bandit/src/bandit/reward.ts b/packages/bandit/src/bandit/reward.ts new file mode 100644 index 0000000..71b8bc2 --- /dev/null +++ b/packages/bandit/src/bandit/reward.ts @@ -0,0 +1,37 @@ +import type { PerformanceObservation } from "../types.js"; +import type { BanditConfig } from "../config.js"; + +// Normalize a PerformanceObservation into a Bernoulli reward ∈ [0,1] the Beta arm +// can ingest. Two modes: +// roas: conversions_value / cost, saturating at ROAS_CEILING → 1.0. +// ctr: clicks / impressions, clamped to [0,0.2] then linearly scaled to [0,1] +// (a 20% CTR is already exceptional for paid, so it maps to a full reward). +// Observations below the impressions noise floor are skipped (null) — a single click +// on 3 impressions is not signal. + +const CTR_CAP = 0.2; + +export interface RewardResult { + reward: number; + skipped: boolean; + reason?: string; +} + +export function computeReward(obs: PerformanceObservation, cfg: BanditConfig): RewardResult { + const m = obs.metrics; + if (m.impressions < cfg.minImpressionsForReward) { + return { reward: 0, skipped: true, reason: "below_min_impressions" }; + } + + if (cfg.rewardMode === "ctr") { + const ctr = m.clicks / Math.max(m.impressions, 1); + const clamped = Math.min(Math.max(ctr, 0), CTR_CAP); + return { reward: clamped / CTR_CAP, skipped: false }; + } + + // roas + const cost = m.cost > 0 ? m.cost : m.cost_micros / 1_000_000; + const roas = m.conversions_value / Math.max(cost, 0.01); + const ceiling = cfg.roasCeiling > 0 ? cfg.roasCeiling : 5; + return { reward: Math.min(1, roas / ceiling), skipped: false }; +} diff --git a/packages/bandit/src/bandit/thompson.ts b/packages/bandit/src/bandit/thompson.ts new file mode 100644 index 0000000..47cda92 --- /dev/null +++ b/packages/bandit/src/bandit/thompson.ts @@ -0,0 +1,80 @@ +import { BetaArm, type Rng } from "./beta_arm.js"; + +// Thompson Sampling over a set of named arms: draw one sample from each arm's +// posterior, pick the argmax. An epsilon floor occasionally forces a uniform-random +// arm so a starved arm still gets explored even after the leader's posterior sharpens. + +export interface ArmDraw { + key: string; + sample: number; + mean: number; +} + +export interface ThompsonResult { + winner: string; + draws: ArmDraw[]; + best: ArmDraw; + runnerUp: ArmDraw | null; + margin: number; // best.sample - runnerUp.sample (0 when single arm) +} + +export interface ThompsonOptions { + epsilon?: number; // probability of a forced uniform-random pick (default 0) +} + +export function sampleArms( + arms: Map, + rng: Rng, + opts: ThompsonOptions = {}, +): ThompsonResult { + const keys = [...arms.keys()]; + if (keys.length === 0) throw new Error("sampleArms: no arms"); + + const draws: ArmDraw[] = keys.map((key) => { + const arm = arms.get(key)!; + return { key, sample: arm.sample(rng), mean: arm.mean() }; + }); + + const epsilon = opts.epsilon ?? 0; + let ranked = [...draws].sort((a, b) => b.sample - a.sample); + + let winner: string; + if (epsilon > 0 && rng() < epsilon) { + winner = keys[Math.floor(rng() * keys.length) % keys.length]; + } else { + winner = ranked[0].key; + } + + const best = ranked[0]; + const runnerUp = ranked.length > 1 ? ranked[1] : null; + const margin = runnerUp ? best.sample - runnerUp.sample : 0; + return { winner, draws, best, runnerUp, margin }; +} + +// Monte-Carlo confidence that `target` is the best arm: fraction of joint draws in +// which target's sample is the max. Used to attach a [0,1] confidence to a +// BudgetRecommendation without committing to a closed-form integral. +export function probabilityBest( + arms: Map, + target: string, + rng: Rng, + iterations = 2000, +): number { + if (!arms.has(target)) return 0; + const keys = [...arms.keys()]; + if (keys.length === 1) return 1; + let wins = 0; + for (let i = 0; i < iterations; i++) { + let bestKey = ""; + let bestVal = -Infinity; + for (const key of keys) { + const s = arms.get(key)!.sample(rng); + if (s > bestVal) { + bestVal = s; + bestKey = key; + } + } + if (bestKey === target) wins++; + } + return wins / iterations; +} diff --git a/packages/bandit/src/cli.ts b/packages/bandit/src/cli.ts new file mode 100644 index 0000000..167087d --- /dev/null +++ b/packages/bandit/src/cli.ts @@ -0,0 +1,105 @@ +import { loadConfig } from "./config.js"; +import { ArmStore } from "./store/arms.js"; +import { CheckpointStore } from "./store/checkpoints.js"; +import { runOnce } from "./consumer/loop.js"; +import { runService } from "./consumer/service.js"; +import { readBanditNamespace } from "./rules/writer.js"; + +// shoploop-bandit run|once|inspect [--twin ] + +function arg(name: string): string | undefined { + const i = process.argv.indexOf(name); + return i >= 0 ? process.argv[i + 1] : undefined; +} + +async function once(): Promise { + const cfg = loadConfig(); + const arms = await ArmStore.open(cfg.stateDb); + const checkpoints = await CheckpointStore.open(cfg.checkpointDb); + try { + const r = await runOnce(cfg, { arms, checkpoints }); + console.log( + `[bandit] once: obs=${r.observations} prov=${r.provenance} joined=${r.joined} ` + + `skipped=${r.skipped} unjoined=${r.unjoined} updated=${r.updatedArms} ` + + `recs=${r.recommendations.length} offset=${r.newOffset}`, + ); + for (const rec of r.recommendations) { + console.log( + ` → ${rec.twin_handle}: promote "${rec.winning_preset}" ` + + `(confidence ${rec.confidence.toFixed(2)}, margin ${rec.margin.toFixed(3)}) [DRAFT]`, + ); + } + } finally { + arms.close(); + checkpoints.close(); + } +} + +async function inspect(): Promise { + const cfg = loadConfig(); + const twin = arg("--twin"); + const arms = await ArmStore.open(cfg.stateDb); + try { + const states = twin ? await arms.listByTwin(twin) : await arms.all(); + if (states.length === 0) { + console.log("[bandit] no arm states yet."); + } else { + console.log(`[bandit] arm states${twin ? ` for ${twin}` : ""}:`); + for (const s of states) { + const mean = s.alpha / (s.alpha + s.beta); + console.log( + ` ${s.twin_handle} / ${s.preset}: Beta(${s.alpha.toFixed(3)}, ${s.beta.toFixed(3)}) ` + + `mean=${mean.toFixed(3)} pulls=${s.pulls} last_reward=${s.last_reward ?? "—"}`, + ); + } + } + const ns = await readBanditNamespace(cfg.brandRulesPath); + if (ns?.last_recommendation) { + const r = ns.last_recommendation; + console.log( + `\n[bandit] last recommendation: ${r.twin_handle} → "${r.winning_preset}" ` + + `(confidence ${r.confidence}) [DRAFT]`, + ); + } + } finally { + arms.close(); + } +} + +async function run(): Promise { + const cfg = loadConfig(); + console.log( + `[bandit] service: polling ${cfg.queuePath} every ${cfg.pollIntervalMs}ms ` + + `(reward=${cfg.rewardMode}, decay=${cfg.decay}, margin=${cfg.promoteMargin})`, + ); + await runService(cfg, (r) => { + if (r.joined > 0 || r.recommendations.length > 0) { + console.log( + `[bandit] tick: joined=${r.joined} updated=${r.updatedArms} recs=${r.recommendations.length}`, + ); + } + }); +} + +async function main(): Promise { + const cmd = process.argv[2]; + switch (cmd) { + case "run": + await run(); + break; + case "once": + await once(); + break; + case "inspect": + await inspect(); + break; + default: + console.log("usage: shoploop-bandit run|once|inspect [--twin ]"); + process.exit(cmd ? 1 : 0); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/packages/bandit/src/config.ts b/packages/bandit/src/config.ts new file mode 100644 index 0000000..8574972 --- /dev/null +++ b/packages/bandit/src/config.ts @@ -0,0 +1,52 @@ +// Centralized env. Lazy so importing the package never throws when env is absent +// (tests, type tooling, inspect). Mirrors the producer connectors' config style. + +export type RewardMode = "roas" | "ctr"; + +export interface BanditConfig { + queuePath: string; // BANDIT_QUEUE_PATH — input (shared with producers) + recommendationsPath: string; // BANDIT_RECOMMENDATIONS_PATH — DRAFT output + stateDb: string; // BANDIT_STATE_DB — LibSQL arm_states + checkpointDb: string; // BANDIT_CHECKPOINT_DB — LibSQL consumer offsets + brandRulesPath: string; // BRAND_RULES_PATH — file the bandit: namespace merges into + rewardMode: RewardMode; // BANDIT_REWARD_MODE + roasCeiling: number; // ROAS_CEILING — roas reward saturates here → 1.0 + minImpressionsForReward: number; // MIN_IMPRESSIONS_FOR_REWARD — noise floor + decay: number; // BANDIT_DECAY — per-loop multiplicative forgetting + promoteMargin: number; // BANDIT_PROMOTE_MARGIN — min sampled gap to recommend + pollIntervalMs: number; // service loop cadence +} + +function optional(key: string, fallback: string): string { + const v = process.env[key]; + return v === undefined || v === "" ? fallback : v; +} + +function num(key: string, fallback: number): number { + const v = process.env[key]; + if (v === undefined || v === "") return fallback; + const n = Number(v); + return Number.isFinite(n) ? n : fallback; +} + +export function loadConfig(overrides: Partial = {}): BanditConfig { + const rewardMode = (overrides.rewardMode ?? + (optional("BANDIT_REWARD_MODE", "roas") as RewardMode)); + return { + queuePath: overrides.queuePath ?? optional("BANDIT_QUEUE_PATH", "./var/bandit/queue.jsonl"), + recommendationsPath: + overrides.recommendationsPath ?? + optional("BANDIT_RECOMMENDATIONS_PATH", "./var/bandit/recommendations.jsonl"), + stateDb: overrides.stateDb ?? optional("BANDIT_STATE_DB", "./var/bandit/state.db"), + checkpointDb: + overrides.checkpointDb ?? optional("BANDIT_CHECKPOINT_DB", "./var/bandit/checkpoints.db"), + brandRulesPath: overrides.brandRulesPath ?? optional("BRAND_RULES_PATH", "./brand_rules.json"), + rewardMode: rewardMode === "ctr" ? "ctr" : "roas", + roasCeiling: overrides.roasCeiling ?? num("ROAS_CEILING", 5), + minImpressionsForReward: + overrides.minImpressionsForReward ?? num("MIN_IMPRESSIONS_FOR_REWARD", 50), + decay: overrides.decay ?? num("BANDIT_DECAY", 0.995), + promoteMargin: overrides.promoteMargin ?? num("BANDIT_PROMOTE_MARGIN", 0.1), + pollIntervalMs: overrides.pollIntervalMs ?? num("BANDIT_POLL_INTERVAL_MS", 5000), + }; +} diff --git a/packages/bandit/src/consumer/loop.ts b/packages/bandit/src/consumer/loop.ts new file mode 100644 index 0000000..89f3de2 --- /dev/null +++ b/packages/bandit/src/consumer/loop.ts @@ -0,0 +1,189 @@ +import seedrandom from "seedrandom"; +import type { BanditConfig } from "../config.js"; +import type { ArmState, BudgetRecommendation, JoinedSample, Preset } from "../types.js"; +import { ArmStore } from "../store/arms.js"; +import { CheckpointStore } from "../store/checkpoints.js"; +import { readSince } from "../queue/reader.js"; +import { appendRecommendation } from "../queue/writer.js"; +import { joinObservations } from "../join/joiner.js"; +import { sampleArms, probabilityBest } from "../bandit/thompson.js"; +import { BetaArm, type Rng } from "../bandit/beta_arm.js"; +import { buildBanditNamespace, writeBanditNamespace } from "../rules/writer.js"; + +// The full feedback loop, one pass: +// 1. read new queue lines since the checkpoint +// 2. bucket into observations vs provenance (done in the reader, by `schema`) +// 3. join → per-arm rewards +// 4. update each (twin_handle, preset) Beta posterior in LibSQL (with per-loop decay) +// 5. per twin with ≥2 presets, Thompson-sample; if the leader beats the runner-up by +// BANDIT_PROMOTE_MARGIN, emit a DRAFT BudgetRecommendation +// 6. merge the bandit: namespace into brand_rules.json (file-locked, isolation-safe) +// 7. advance the checkpoint +// The bandit is a pure CONSUMER: it emits recommendations but never calls a paid adapter. + +export interface LoopReport { + observations: number; + provenance: number; + joined: number; + skipped: number; + unjoined: number; + updatedArms: number; + recommendations: BudgetRecommendation[]; + newOffset: number; + malformed: number; +} + +export interface LoopDeps { + arms: ArmStore; + checkpoints: CheckpointStore; + rng?: Rng; +} + +export async function runOnce(cfg: BanditConfig, deps: LoopDeps): Promise { + const rng: Rng = deps.rng ?? seedrandom(`bandit-${Date.now()}`); + const now = new Date().toISOString(); + + // 1–2. read + bucket + const read = await readSince(cfg.queuePath, deps.checkpoints); + + // 3. join + const join = joinObservations(read.observations, read.provenance, cfg); + + // 4. decay once, then apply this batch's updates + if (join.samples.length > 0) { + await deps.arms.decayAll(cfg.decay, now); + } + const touchedTwins = new Set(); + let updatedArms = 0; + for (const s of join.samples) { + const arm = await deps.arms.loadArm(s.twin_handle, s.preset); + arm.update(s.reward); + await deps.arms.upsert(stateFromArm(s.twin_handle, s.preset, arm, s.reward, now)); + touchedTwins.add(s.twin_handle); + updatedArms++; + } + + // 5. recommend per twin (only twins touched this pass, to avoid churn) + const recommendations: BudgetRecommendation[] = []; + for (const twin of touchedTwins) { + const rec = await recommendForTwin(twin, cfg, deps.arms, rng, now); + if (rec) { + recommendations.push(rec); + await appendRecommendation(cfg.recommendationsPath, rec); + } + } + + // 6. write bandit namespace (full arm table + last recommendation) + const allArms = await deps.arms.all(); + const lastRec = recommendations[recommendations.length - 1] ?? null; + const ns = buildBanditNamespace(allArms, lastRec, now); + await writeBanditNamespace(cfg.brandRulesPath, ns); + + // 7. checkpoint + if (read.consumedBytes > 0) { + await deps.checkpoints.setOffset(cfg.queuePath, read.newOffset, now); + } + + return { + observations: read.observations.length, + provenance: read.provenance.length, + joined: join.samples.length, + skipped: join.skipped.length, + unjoined: join.unjoined.length, + updatedArms, + recommendations, + newOffset: read.newOffset, + malformed: read.malformed, + }; +} + +async function recommendForTwin( + twin: string, + cfg: BanditConfig, + store: ArmStore, + rng: Rng, + now: string, +): Promise { + const states = await store.listByTwin(twin); + if (states.length < 2) return null; + + const arms = new Map(); + for (const s of states) arms.set(s.preset, BetaArm.from(s)); + + const result = sampleArms(arms, rng); + if (result.margin < cfg.promoteMargin) return null; + + const winner = result.best.key; + const confidence = probabilityBest(arms, winner, rng, 2000); + const current = currentSplit(states); + const recommended = shiftSplit(current, winner, confidence); + + return { + schema: "budget_recommendation.v1", + twin_handle: twin, + winning_preset: winner, + current_split: current, + recommended_split: recommended, + confidence, + margin: result.margin, + draft: true, + generated_at: now, + }; +} + +// Current split is inferred from realized pulls (spend proxy): how attention has been +// allocated so far. Equal split when no arm has pulls yet. +function currentSplit(states: ArmState[]): Record { + const total = states.reduce((acc, s) => acc + s.pulls, 0); + const split: Record = {}; + if (total === 0) { + for (const s of states) split[s.preset] = round(1 / states.length); + return split; + } + for (const s of states) split[s.preset] = round(s.pulls / total); + return split; +} + +// Move a confidence-scaled slice of budget (max 30%) from losers to the winner, +// proportionally to each loser's current share. DRAFT only — never applied here. +function shiftSplit( + current: Record, + winner: Preset, + confidence: number, +): Record { + const shift = Math.min(0.3, confidence * 0.3); + const loserMass = Object.entries(current) + .filter(([k]) => k !== winner) + .reduce((acc, [, v]) => acc + v, 0); + const next: Record = {}; + for (const [preset, share] of Object.entries(current)) { + if (preset === winner) next[preset] = round(share + shift); + else next[preset] = round(loserMass > 0 ? share - shift * (share / loserMass) : share); + } + return next; +} + +function stateFromArm( + twin: string, + preset: Preset, + arm: BetaArm, + lastReward: number, + now: string, +): ArmState { + return { + twin_handle: twin, + preset, + alpha: arm.alpha, + beta: arm.beta, + reward_sum: arm.reward_sum, + pulls: arm.pulls, + last_reward: lastReward, + updated_at: now, + }; +} + +function round(n: number): number { + return Math.round(n * 1e6) / 1e6; +} + +export type { JoinedSample }; diff --git a/packages/bandit/src/consumer/service.ts b/packages/bandit/src/consumer/service.ts new file mode 100644 index 0000000..e25dbf4 --- /dev/null +++ b/packages/bandit/src/consumer/service.ts @@ -0,0 +1,47 @@ +import type { BanditConfig } from "../config.js"; +import { ArmStore } from "../store/arms.js"; +import { CheckpointStore } from "../store/checkpoints.js"; +import { runOnce, type LoopReport } from "./loop.js"; + +// Long-running consumer. Opens the stores once and polls the queue on an interval, +// running the loop each tick. Co-locates on the existing t4g.small (pure CPU, $0 +// incremental). Stops cleanly on SIGINT/SIGTERM. + +export interface ServiceHandle { + stop: () => Promise; +} + +export async function runService( + cfg: BanditConfig, + onTick?: (r: LoopReport) => void, +): Promise { + const arms = await ArmStore.open(cfg.stateDb); + const checkpoints = await CheckpointStore.open(cfg.checkpointDb); + let stopped = false; + let timer: NodeJS.Timeout | null = null; + + const tick = async () => { + if (stopped) return; + try { + const report = await runOnce(cfg, { arms, checkpoints }); + if (onTick) onTick(report); + } catch (err) { + console.error("[bandit] loop error:", err); + } finally { + if (!stopped) timer = setTimeout(tick, cfg.pollIntervalMs); + } + }; + + const stop = async () => { + stopped = true; + if (timer) clearTimeout(timer); + arms.close(); + checkpoints.close(); + }; + + process.once("SIGINT", () => void stop()); + process.once("SIGTERM", () => void stop()); + + void tick(); + return { stop }; +} diff --git a/packages/bandit/src/index.ts b/packages/bandit/src/index.ts new file mode 100644 index 0000000..aec5f66 --- /dev/null +++ b/packages/bandit/src/index.ts @@ -0,0 +1,18 @@ +// @shoploop/bandit — Thompson Sampling consumer closing the brand_rules feedback loop. +export * from "./types.js"; +export { loadConfig, type BanditConfig, type RewardMode } from "./config.js"; +export { BetaArm, gamma, type Rng } from "./bandit/beta_arm.js"; +export { sampleArms, probabilityBest, type ThompsonResult } from "./bandit/thompson.js"; +export { computeReward, type RewardResult } from "./bandit/reward.js"; +export { joinObservations, type JoinResult } from "./join/joiner.js"; +export { ArmStore } from "./store/arms.js"; +export { CheckpointStore } from "./store/checkpoints.js"; +export { + buildBanditNamespace, + writeBanditNamespace, + readBanditNamespace, +} from "./rules/writer.js"; +export { readNewRecords, readSince, type ReadResult } from "./queue/reader.js"; +export { appendRecommendation, readRecommendations } from "./queue/writer.js"; +export { runOnce, type LoopReport, type LoopDeps } from "./consumer/loop.js"; +export { runService, type ServiceHandle } from "./consumer/service.js"; diff --git a/packages/bandit/src/join/joiner.ts b/packages/bandit/src/join/joiner.ts new file mode 100644 index 0000000..729ffd6 --- /dev/null +++ b/packages/bandit/src/join/joiner.ts @@ -0,0 +1,107 @@ +import type { + PerformanceObservation, + RenderProvenance, + JoinedSample, +} from "../types.js"; +import type { BanditConfig } from "../config.js"; +import { computeReward } from "../bandit/reward.js"; + +// Joins the two halves of the loop into per-arm rewards. The reward stream +// (PerformanceObservation) knows a channel ad_id but not which creative arm made it; +// the provenance stream (RenderProvenance) knows the arm (twin_handle × preset) but +// not the channel. We bridge them with a precedence of join keys: +// +// 1. external_id — provenance.external_id === observation.ad_id. THE intended seam: +// the distribution adapter stamps the dispatched ad_id onto +// provenance. Exact, 1:1. (Distribution PR; DRAFT today.) +// 2. product_gid — provenance.shopify_product_gid === observation.shopify_product_gid. +// 3. campaign_id — provenance.campaign_id === observation.campaign_id. Coarsest +// fallback per the task contract; flagged so we can see how much +// reward is attributed loosely until the external_id seam lands. +// +// TODO(distribution-PR): once paid adapters always stamp external_id, drop the +// product_gid/campaign_id fallbacks and treat a missing external_id as unjoined. + +export interface JoinResult { + samples: JoinedSample[]; + skipped: { obs_id: string; reason: string }[]; + unjoined: { obs_id: string; reason: string }[]; +} + +function obsId(o: PerformanceObservation): string { + return `${o.date}|${o.campaign_id}|${o.ad_group_id ?? "-"}|${o.ad_id ?? "-"}`; +} + +type Index = { + byExternalId: Map; + byProductGid: Map; + byCampaignId: Map; +}; + +function indexProvenance(provenance: RenderProvenance[]): Index { + const idx: Index = { + byExternalId: new Map(), + byProductGid: new Map(), + byCampaignId: new Map(), + }; + // Last writer wins per key: most-recent provenance for a given ad/product/campaign. + for (const p of provenance) { + if (p.external_id) idx.byExternalId.set(p.external_id, p); + if (p.shopify_product_gid) idx.byProductGid.set(p.shopify_product_gid, p); + if (p.campaign_id) idx.byCampaignId.set(p.campaign_id, p); + } + return idx; +} + +function matchProvenance( + o: PerformanceObservation, + idx: Index, +): { prov: RenderProvenance; kind: JoinedSample["join_kind"] } | null { + if (o.ad_id) { + const p = idx.byExternalId.get(o.ad_id); + if (p) return { prov: p, kind: "external_id" }; + } + if (o.shopify_product_gid) { + const p = idx.byProductGid.get(o.shopify_product_gid); + if (p) return { prov: p, kind: "product_gid" }; + } + const c = idx.byCampaignId.get(o.campaign_id); + if (c) return { prov: c, kind: "campaign_id" }; + return null; +} + +export function joinObservations( + observations: PerformanceObservation[], + provenance: RenderProvenance[], + cfg: BanditConfig, +): JoinResult { + const idx = indexProvenance(provenance); + const samples: JoinedSample[] = []; + const skipped: JoinResult["skipped"] = []; + const unjoined: JoinResult["unjoined"] = []; + + for (const o of observations) { + const id = obsId(o); + const reward = computeReward(o, cfg); + if (reward.skipped) { + skipped.push({ obs_id: id, reason: reward.reason ?? "skipped" }); + continue; + } + const match = matchProvenance(o, idx); + if (!match) { + unjoined.push({ obs_id: id, reason: "no_provenance_match" }); + continue; + } + samples.push({ + twin_handle: match.prov.twin_handle, + preset: match.prov.preset, + reward: reward.reward, + source_obs_id: id, + source_prov_id: match.prov.storyboard_id, + observed_at: o.observed_at, + join_kind: match.kind, + }); + } + + return { samples, skipped, unjoined }; +} diff --git a/packages/bandit/src/queue/reader.ts b/packages/bandit/src/queue/reader.ts new file mode 100644 index 0000000..1822030 --- /dev/null +++ b/packages/bandit/src/queue/reader.ts @@ -0,0 +1,91 @@ +import { open, stat } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import type { CheckpointStore } from "../store/checkpoints.js"; +import type { QueueRecord, PerformanceObservation, RenderProvenance } from "../types.js"; +import { isObservation, isProvenance } from "../types.js"; + +// Tails an append-only JSONL queue from a persisted byte offset. Reads only the bytes +// appended since the last checkpoint, parses whole lines, and buckets records by their +// `schema` discriminator. A trailing partial line (writer mid-append) is NOT consumed: +// we advance the offset only past the last complete newline, so it's re-read next pass. + +export interface ReadResult { + observations: PerformanceObservation[]; + provenance: RenderProvenance[]; + newOffset: number; + consumedBytes: number; + malformed: number; +} + +export async function readNewRecords( + queuePath: string, + fromOffset: number, +): Promise { + const empty: ReadResult = { + observations: [], + provenance: [], + newOffset: fromOffset, + consumedBytes: 0, + malformed: 0, + }; + if (!existsSync(queuePath)) return empty; + + const { size } = await stat(queuePath); + if (size <= fromOffset) { + // File truncated/rotated (size shrank) → reset to start. + return size < fromOffset ? { ...empty, newOffset: 0 } : empty; + } + + const fh = await open(queuePath, "r"); + try { + const length = size - fromOffset; + const buf = Buffer.alloc(length); + await fh.read(buf, 0, length, fromOffset); + const text = buf.toString("utf8"); + + const lastNl = text.lastIndexOf("\n"); + if (lastNl === -1) return empty; // no complete line yet + + const complete = text.slice(0, lastNl); + const consumedBytes = Buffer.byteLength(complete, "utf8") + 1; // include the \n + + const observations: PerformanceObservation[] = []; + const provenance: RenderProvenance[] = []; + let malformed = 0; + + for (const line of complete.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + let rec: QueueRecord; + try { + rec = JSON.parse(trimmed) as QueueRecord; + } catch { + malformed++; + continue; + } + if (isObservation(rec)) observations.push(rec); + else if (isProvenance(rec)) provenance.push(rec); + // records with an unknown schema are ignored (forward-compat) + } + + return { + observations, + provenance, + newOffset: fromOffset + consumedBytes, + consumedBytes, + malformed, + }; + } finally { + await fh.close(); + } +} + +// Convenience used by the loop: read using the checkpoint store and the canonical +// source key (the queue path itself — a single-writer queue per the producer contract). +export async function readSince( + queuePath: string, + checkpoints: CheckpointStore, +): Promise { + const offset = await checkpoints.getOffset(queuePath); + return readNewRecords(queuePath, offset); +} diff --git a/packages/bandit/src/queue/writer.ts b/packages/bandit/src/queue/writer.ts new file mode 100644 index 0000000..9eeb417 --- /dev/null +++ b/packages/bandit/src/queue/writer.ts @@ -0,0 +1,29 @@ +import { appendFile, mkdir, readFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { dirname } from "node:path"; +import type { BudgetRecommendation } from "../types.js"; + +// Single-writer append for the DRAFT recommendations queue. Same JSONL discipline as +// the input queue. These recommendations are VISIBLE but NOT auto-applied — the +// distribution PR's `request_budget_shift` tool (out of scope) is the only consumer +// that would act on them, and it stays DRAFT. + +export async function appendRecommendation( + recommendationsPath: string, + rec: BudgetRecommendation, +): Promise { + await mkdir(dirname(recommendationsPath), { recursive: true }); + await appendFile(recommendationsPath, JSON.stringify(rec) + "\n", "utf8"); +} + +export async function readRecommendations( + recommendationsPath: string, +): Promise { + if (!existsSync(recommendationsPath)) return []; + const raw = await readFile(recommendationsPath, "utf8"); + return raw + .split("\n") + .map((l) => l.trim()) + .filter(Boolean) + .map((l) => JSON.parse(l) as BudgetRecommendation); +} diff --git a/packages/bandit/src/rules/writer.ts b/packages/bandit/src/rules/writer.ts new file mode 100644 index 0000000..f74c17b --- /dev/null +++ b/packages/bandit/src/rules/writer.ts @@ -0,0 +1,87 @@ +import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { dirname } from "node:path"; +import lockfile from "proper-lockfile"; +import type { + ArmState, + BanditNamespace, + BanditArmSnapshot, + BudgetRecommendation, +} from "../types.js"; + +// Merges the bandit: namespace into brand_rules.json WITHOUT touching any other key. +// Scene Composer reads this file concurrently, so we file-lock the write and do a +// read-modify-write of the parsed object: every non-bandit top-level key (voice, +// palette, materials, lighting, ...) is preserved byte-for-byte in value, and only +// the `bandit` key is replaced. + +const BANDIT_KEY = "bandit"; + +export function buildBanditNamespace( + arms: ArmState[], + lastRecommendation: BudgetRecommendation | null, + updatedAt: string, +): BanditNamespace { + const byTwin: Record> = {}; + for (const a of arms) { + (byTwin[a.twin_handle] ??= {})[a.preset] = { + alpha: round(a.alpha), + beta: round(a.beta), + pulls: a.pulls, + last_reward: a.last_reward === null ? null : round(a.last_reward), + }; + } + return { + version: 1, + updated_at: updatedAt, + arms: byTwin, + last_recommendation: lastRecommendation + ? { + twin_handle: lastRecommendation.twin_handle, + winning_preset: lastRecommendation.winning_preset, + confidence: round(lastRecommendation.confidence), + } + : null, + }; +} + +// Lock-protected read-modify-write. Returns the merged object that was written. +export async function writeBanditNamespace( + brandRulesPath: string, + ns: BanditNamespace, +): Promise> { + await mkdir(dirname(brandRulesPath), { recursive: true }); + if (!existsSync(brandRulesPath)) { + await writeFile(brandRulesPath, "{}\n", "utf8"); + } + + // proper-lockfile needs the target to exist; lock a sibling .lock so we never + // race-create the lock against Scene Composer. Retries with backoff on contention. + const release = await lockfile.lock(brandRulesPath, { + retries: { retries: 10, factor: 1.5, minTimeout: 20, maxTimeout: 500 }, + stale: 10_000, + }); + try { + const raw = await readFile(brandRulesPath, "utf8"); + const parsed = (raw.trim() ? JSON.parse(raw) : {}) as Record; + parsed[BANDIT_KEY] = ns; + await writeFile(brandRulesPath, JSON.stringify(parsed, null, 2) + "\n", "utf8"); + return parsed; + } finally { + await release(); + } +} + +export async function readBanditNamespace( + brandRulesPath: string, +): Promise { + if (!existsSync(brandRulesPath)) return null; + const raw = await readFile(brandRulesPath, "utf8"); + if (!raw.trim()) return null; + const parsed = JSON.parse(raw) as Record; + return (parsed[BANDIT_KEY] as BanditNamespace) ?? null; +} + +function round(n: number): number { + return Math.round(n * 1e6) / 1e6; +} diff --git a/packages/bandit/src/store/arms.ts b/packages/bandit/src/store/arms.ts new file mode 100644 index 0000000..b3a024b --- /dev/null +++ b/packages/bandit/src/store/arms.ts @@ -0,0 +1,133 @@ +import { createClient, type Client } from "@libsql/client"; +import { mkdirSync } from "node:fs"; +import { dirname } from "node:path"; +import { BetaArm } from "../bandit/beta_arm.js"; +import type { ArmState, Preset } from "../types.js"; + +// Durable arm posteriors in LibSQL. One row per (twin_handle, preset). The Beta +// posterior is the entire learned state — small (≪10MB for the dogfood), so a single +// table with a composite PK is all we need. No Redis, no migrations framework. + +export function fileUrl(path: string): string { + if (path.startsWith("file:") || path.startsWith("libsql:") || path.startsWith("http")) { + return path; + } + mkdirSync(dirname(path), { recursive: true }); + return `file:${path}`; +} + +const DDL = ` +CREATE TABLE IF NOT EXISTS arm_states ( + twin_handle TEXT NOT NULL, + preset TEXT NOT NULL, + alpha REAL NOT NULL, + beta REAL NOT NULL, + reward_sum REAL NOT NULL, + pulls INTEGER NOT NULL, + last_reward REAL, + updated_at TEXT NOT NULL, + PRIMARY KEY (twin_handle, preset) +); +`; + +export class ArmStore { + private constructor(private readonly db: Client) {} + + static async open(path: string): Promise { + const db = createClient({ url: fileUrl(path) }); + await db.execute(DDL); + return new ArmStore(db); + } + + async get(twin: string, preset: Preset): Promise { + const rs = await this.db.execute({ + sql: "SELECT * FROM arm_states WHERE twin_handle = ? AND preset = ?", + args: [twin, preset], + }); + const row = rs.rows[0]; + return row ? rowToState(row) : null; + } + + // Loads a BetaArm, defaulting to the uniform Beta(1,1) prior for a never-seen arm. + async loadArm(twin: string, preset: Preset): Promise { + const s = await this.get(twin, preset); + return s ? new BetaArm(s.alpha, s.beta, s.reward_sum, s.pulls) : new BetaArm(); + } + + async listByTwin(twin: string): Promise { + const rs = await this.db.execute({ + sql: "SELECT * FROM arm_states WHERE twin_handle = ? ORDER BY preset", + args: [twin], + }); + return rs.rows.map(rowToState); + } + + async all(): Promise { + const rs = await this.db.execute( + "SELECT * FROM arm_states ORDER BY twin_handle, preset", + ); + return rs.rows.map(rowToState); + } + + async twins(): Promise { + const rs = await this.db.execute( + "SELECT DISTINCT twin_handle FROM arm_states ORDER BY twin_handle", + ); + return rs.rows.map((r) => String(r.twin_handle)); + } + + async upsert(state: ArmState): Promise { + await this.db.execute({ + sql: `INSERT INTO arm_states + (twin_handle, preset, alpha, beta, reward_sum, pulls, last_reward, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(twin_handle, preset) DO UPDATE SET + alpha=excluded.alpha, beta=excluded.beta, reward_sum=excluded.reward_sum, + pulls=excluded.pulls, last_reward=excluded.last_reward, + updated_at=excluded.updated_at`, + args: [ + state.twin_handle, + state.preset, + state.alpha, + state.beta, + state.reward_sum, + state.pulls, + state.last_reward, + state.updated_at, + ], + }); + } + + // Multiplicative decay across every arm — applied once per consumer loop so arms + // forget stale regimes without losing all history. + async decayAll(factor: number, at: string): Promise { + const states = await this.all(); + for (const s of states) { + const arm = new BetaArm(s.alpha, s.beta, s.reward_sum, s.pulls); + arm.decay(factor); + await this.upsert({ + ...s, + alpha: arm.alpha, + beta: arm.beta, + updated_at: at, + }); + } + } + + close(): void { + this.db.close(); + } +} + +function rowToState(row: Record): ArmState { + return { + twin_handle: String(row.twin_handle), + preset: String(row.preset), + alpha: Number(row.alpha), + beta: Number(row.beta), + reward_sum: Number(row.reward_sum), + pulls: Number(row.pulls), + last_reward: row.last_reward === null ? null : Number(row.last_reward), + updated_at: String(row.updated_at), + }; +} diff --git a/packages/bandit/src/store/checkpoints.ts b/packages/bandit/src/store/checkpoints.ts new file mode 100644 index 0000000..2a9ba73 --- /dev/null +++ b/packages/bandit/src/store/checkpoints.ts @@ -0,0 +1,46 @@ +import { createClient, type Client } from "@libsql/client"; +import { fileUrl } from "./arms.js"; + +// Per-source-file consumer offset. The queue is append-only JSONL; we record how many +// bytes of each source file we've already consumed so a restart replays only new lines. +// Byte offset (not line count) so a partially-written trailing line is re-read whole. + +const DDL = ` +CREATE TABLE IF NOT EXISTS checkpoints ( + source TEXT PRIMARY KEY, + byte_offset INTEGER NOT NULL, + updated_at TEXT NOT NULL +); +`; + +export class CheckpointStore { + private constructor(private readonly db: Client) {} + + static async open(path: string): Promise { + const db = createClient({ url: fileUrl(path) }); + await db.execute(DDL); + return new CheckpointStore(db); + } + + async getOffset(source: string): Promise { + const rs = await this.db.execute({ + sql: "SELECT byte_offset FROM checkpoints WHERE source = ?", + args: [source], + }); + return rs.rows[0] ? Number(rs.rows[0].byte_offset) : 0; + } + + async setOffset(source: string, offset: number, at: string): Promise { + await this.db.execute({ + sql: `INSERT INTO checkpoints (source, byte_offset, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(source) DO UPDATE SET + byte_offset=excluded.byte_offset, updated_at=excluded.updated_at`, + args: [source, offset, at], + }); + } + + close(): void { + this.db.close(); + } +} diff --git a/packages/bandit/src/types.ts b/packages/bandit/src/types.ts new file mode 100644 index 0000000..b4a3b52 --- /dev/null +++ b/packages/bandit/src/types.ts @@ -0,0 +1,138 @@ +// The bandit consumer's wire + domain types. The two queue record types +// (PerformanceObservation, RenderProvenance) are the CONTRACTS written by the +// Google Ads connector and the animated-shorts renderer respectively onto the +// shared BANDIT_QUEUE_PATH. They are mirrored — not imported — so this package +// has zero coupling to those producers (different workspaces, different deploys). + +// Scene presets an arm can be. Mirrors animated-shorts dsl/storyboard.ts `Preset`, +// kept as a plain string so a new preset upstream never breaks the consumer. +export type Preset = string; + +// --- PerformanceObservation (reward side) --------------------------------- +// CONTRACT: connectors/google-ads/contracts/performance_observation.v1.json +// Discriminated on `schema`. The reward lives in metrics (roas | ctr). + +export interface PerformanceMetrics { + impressions: number; + clicks: number; + conversions: number; + cost_micros: number; + cost: number; + conversions_value: number; + ctr: number; + average_cpc: number; + roas: number; +} + +export interface PerformanceObservation { + schema: "performance_observation.v1"; + source: "google-ads" | "meta" | "tiktok" | "klaviyo"; + customer_id: string; + date: string; + level: "campaign" | "ad_group" | "ad"; + campaign_id: string; + campaign_name?: string; + ad_group_id?: string | null; + ad_group_name?: string | null; + ad_id?: string | null; + metrics: PerformanceMetrics; + shopify_product_gid?: string | null; + observed_at: string; +} + +// --- RenderProvenance (arm side) ------------------------------------------ +// CONTRACT: packages/animated-shorts/src/feedback/observation.ts +// Tells us which arm (twin_handle × preset) produced a creative. +// +// `external_id` is the JOIN SEAM with the distribution PR: when a creative is +// dispatched as an ad, the paid adapter stamps the channel ad_id here so a +// PerformanceObservation.ad_id joins back to the arm that made it. It is OPTIONAL +// today (distribution adapters stay DRAFT) — the joiner falls back to +// shopify_product_gid → twin_handle when it is absent. See join/joiner.ts + DESIGN.md. + +export interface RenderProvenance { + schema: "render_provenance.v1"; + storyboard_id: string; + twin_handle: string; + preset: Preset; + duration_s?: number; + brand_score?: number; + output_uri?: string; + render_mode?: string; + observed_at: string; + // Distribution seam (see above). Not yet emitted by animated-shorts. + external_id?: string | null; + // Fallback join hints, populated by the distribution adapter when known. + campaign_id?: string | null; + shopify_product_gid?: string | null; +} + +export type QueueRecord = PerformanceObservation | RenderProvenance; + +export function isObservation(r: QueueRecord): r is PerformanceObservation { + return r.schema === "performance_observation.v1"; +} + +export function isProvenance(r: QueueRecord): r is RenderProvenance { + return r.schema === "render_provenance.v1"; +} + +// --- Internal domain ------------------------------------------------------ + +// A reward attributed to a specific arm after the join. reward ∈ [0,1]. +export interface JoinedSample { + twin_handle: string; + preset: Preset; + reward: number; + source_obs_id: string; + source_prov_id: string; + observed_at: string; + join_kind: "external_id" | "product_gid" | "campaign_id"; +} + +// Beta(alpha,beta) posterior for one (twin_handle, preset) arm. +export interface ArmState { + twin_handle: string; + preset: Preset; + alpha: number; + beta: number; + reward_sum: number; + pulls: number; + last_reward: number | null; + updated_at: string; +} + +// DRAFT-only output. Visible on BANDIT_RECOMMENDATIONS_PATH but never auto-applied; +// the distribution PR's `request_budget_shift` tool (OUT OF SCOPE here) consumes it. +export interface BudgetRecommendation { + schema: "budget_recommendation.v1"; + twin_handle: string; + winning_preset: Preset; + current_split: Record; + recommended_split: Record; + confidence: number; + margin: number; + draft: true; + generated_at: string; +} + +// The bandit: namespace block merged into brand_rules.json (all other keys preserved). +export interface BanditNamespace { + version: 1; + updated_at: string; + arms: Record>; + last_recommendation?: BanditRecommendationSnapshot | null; +} + +export interface BanditArmSnapshot { + alpha: number; + beta: number; + pulls: number; + last_reward: number | null; +} + +export interface BanditRecommendationSnapshot { + twin_handle: string; + winning_preset: Preset; + confidence: number; +} diff --git a/packages/bandit/test/beta_arm.test.ts b/packages/bandit/test/beta_arm.test.ts new file mode 100644 index 0000000..d1c380a --- /dev/null +++ b/packages/bandit/test/beta_arm.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from "vitest"; +import seedrandom from "seedrandom"; +import { BetaArm } from "../src/bandit/beta_arm.js"; + +describe("BetaArm posterior math", () => { + it("starts at the uniform Beta(1,1) prior with mean 0.5", () => { + const arm = new BetaArm(); + expect(arm.alpha).toBe(1); + expect(arm.beta).toBe(1); + expect(arm.mean()).toBeCloseTo(0.5, 12); + }); + + it("after 8 successes and 2 failures → Beta(9,3), E[θ]=0.75", () => { + const arm = new BetaArm(); + for (let i = 0; i < 8; i++) arm.update(1); + for (let i = 0; i < 2; i++) arm.update(0); + expect(arm.alpha).toBeCloseTo(9, 12); + expect(arm.beta).toBeCloseTo(3, 12); + expect(arm.pulls).toBe(10); + expect(arm.reward_sum).toBeCloseTo(8, 12); + expect(arm.mean()).toBeCloseTo(0.75, 12); // 9 / (9+3) + }); + + it("fractional rewards update conjugately: reward 0.6 → +0.6 alpha, +0.4 beta", () => { + const arm = new BetaArm(); + arm.update(0.6); + expect(arm.alpha).toBeCloseTo(1.6, 12); + expect(arm.beta).toBeCloseTo(1.4, 12); + expect(arm.mean()).toBeCloseTo(1.6 / 3.0, 12); + }); + + it("clamps out-of-range rewards into [0,1]", () => { + const arm = new BetaArm(); + arm.update(5); + arm.update(-3); + expect(arm.alpha).toBeCloseTo(2, 12); // +1 then +0 + expect(arm.beta).toBeCloseTo(2, 12); // +0 then +1 + }); + + it("decay pulls toward the prior but never below Beta(1,1)", () => { + const arm = new BetaArm(9, 3); + arm.decay(0.5); + expect(arm.alpha).toBeCloseTo(1 + 8 * 0.5, 12); // 5 + expect(arm.beta).toBeCloseTo(1 + 2 * 0.5, 12); // 2 + const prior = new BetaArm(1, 1); + prior.decay(0.5); + expect(prior.alpha).toBeCloseTo(1, 12); + expect(prior.beta).toBeCloseTo(1, 12); + }); + + it("sample() empirical mean of Beta(9,3) ≈ 0.75 over many seeded draws", () => { + const arm = new BetaArm(9, 3); + const rng = seedrandom("beta-9-3"); + const N = 20000; + let sum = 0; + let sumsq = 0; + for (let i = 0; i < N; i++) { + const x = arm.sample(rng); + expect(x).toBeGreaterThanOrEqual(0); + expect(x).toBeLessThanOrEqual(1); + sum += x; + sumsq += x * x; + } + const mean = sum / N; + // Var of Beta(9,3) = ab / ((a+b)^2 (a+b+1)) = 27 / (144*13) ≈ 0.01442 + const variance = sumsq / N - mean * mean; + expect(mean).toBeCloseTo(0.75, 2); + expect(variance).toBeCloseTo(0.0144, 2); + }); +}); diff --git a/packages/bandit/test/consumer.test.ts b/packages/bandit/test/consumer.test.ts new file mode 100644 index 0000000..a572ea3 --- /dev/null +++ b/packages/bandit/test/consumer.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { mkdtempSync, rmSync, readFileSync, writeFileSync, copyFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import seedrandom from "seedrandom"; +import { loadConfig } from "../src/config.js"; +import { ArmStore } from "../src/store/arms.js"; +import { CheckpointStore } from "../src/store/checkpoints.js"; +import { runOnce } from "../src/consumer/loop.js"; +import { readRecommendations } from "../src/queue/writer.js"; + +const FIX = join(dirname(fileURLToPath(import.meta.url)), "fixtures"); + +const dirs: string[] = []; +function tmp(): string { + const d = mkdtempSync(join(tmpdir(), "bandit-e2e-")); + dirs.push(d); + return d; +} +afterEach(() => { + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); +}); + +// Concatenate the two producer streams (provenance + observations) into one queue, +// exactly as the single-writer BANDIT_QUEUE_PATH would contain them in production. +function buildQueue(path: string): void { + const prov = readFileSync(join(FIX, "provenance.jsonl"), "utf8"); + const obs = readFileSync(join(FIX, "observations.jsonl"), "utf8"); + writeFileSync(path, prov + obs, "utf8"); +} + +function stripVolatile(ns: Record): Record { + const clone = JSON.parse(JSON.stringify(ns)); + delete clone.updated_at; + if (clone.last_recommendation) delete clone.last_recommendation.confidence; + return clone; +} + +describe("consumer end-to-end", () => { + it("ingests the fixture queue, updates brand_rules bandit namespace, emits one recommendation", async () => { + const d = tmp(); + const queuePath = join(d, "queue.jsonl"); + const recPath = join(d, "recommendations.jsonl"); + const brandRules = join(d, "brand_rules.json"); + buildQueue(queuePath); + copyFileSync(join(FIX, "brand_rules.before.json"), brandRules); + + const cfg = loadConfig({ + queuePath, + recommendationsPath: recPath, + stateDb: join(d, "state.db"), + checkpointDb: join(d, "checkpoints.db"), + brandRulesPath: brandRules, + rewardMode: "roas", + roasCeiling: 5, + minImpressionsForReward: 50, + promoteMargin: 0.1, + }); + + const arms = await ArmStore.open(cfg.stateDb); + const checkpoints = await CheckpointStore.open(cfg.checkpointDb); + const report = await runOnce(cfg, { arms, checkpoints, rng: seedrandom("e2e-seed") }); + + // 6 valid observations (1 below impressions floor is skipped), all joined via external_id. + expect(report.observations).toBe(7); + expect(report.provenance).toBe(2); + expect(report.skipped).toBe(1); // AD_NOISE, 12 impressions + expect(report.joined).toBe(6); + expect(report.updatedArms).toBe(6); + + // Exactly one recommendation, promoting the high-ROAS hero arm. + expect(report.recommendations).toHaveLength(1); + const onDisk = await readRecommendations(recPath); + expect(onDisk).toHaveLength(1); + expect(onDisk[0].twin_handle).toBe("varitea_jasmine_pearl"); + expect(onDisk[0].winning_preset).toBe("hero"); + expect(onDisk[0].draft).toBe(true); + expect(onDisk[0].confidence).toBeGreaterThan(0.5); + + // brand_rules.json: bandit namespace matches expected (modulo timestamps + confidence); + // every non-bandit field is preserved byte-identical to the before fixture. + const after = JSON.parse(readFileSync(brandRules, "utf8")); + const expected = JSON.parse(readFileSync(join(FIX, "brand_rules.after.expected.json"), "utf8")); + expect(stripVolatile(after.bandit)).toEqual(stripVolatile(expected.bandit)); + + const before = JSON.parse(readFileSync(join(FIX, "brand_rules.before.json"), "utf8")); + const { bandit: _b, ...afterRest } = after; + expect(afterRest).toEqual(before); + + // Idempotent checkpointing: a second pass with no new lines is a no-op. + const second = await runOnce(cfg, { arms, checkpoints, rng: seedrandom("e2e-seed-2") }); + expect(second.joined).toBe(0); + expect(second.updatedArms).toBe(0); + + arms.close(); + checkpoints.close(); + }); +}); diff --git a/packages/bandit/test/fixtures/brand_rules.after.expected.json b/packages/bandit/test/fixtures/brand_rules.after.expected.json new file mode 100644 index 0000000..3847f2d --- /dev/null +++ b/packages/bandit/test/fixtures/brand_rules.after.expected.json @@ -0,0 +1,25 @@ +{ + "brand": "Drink Varitea", + "note": "Kinfolk test as code. Warm kraft + linen, ink type, sage accent. Matte/satin only.", + "palette": ["#c9a27a", "#f3ece2", "#2b2620", "#8a9a5b", "#d8c3a5"], + "palette_tolerance": 0.32, + "forbidden_finishes": ["gloss"], + "allowed_camera": ["orbit", "dolly", "macro", "still"], + "materials": { "body": "matte", "cap": "satin", "label": "matte" }, + "lighting": { "key": "soft-north", "ratio": 2, "warmth_k": 4200 }, + "voice": { "tone": "calm", "person": "second", "banned_words": ["hustle", "grind"] }, + "min_score": 0.6, + "bandit": { + "version": 1, + "arms": { + "varitea_jasmine_pearl": { + "hero": { "alpha": 4, "beta": 1, "pulls": 3, "last_reward": 1 }, + "lifestyle": { "alpha": 1.1, "beta": 3.9, "pulls": 3, "last_reward": 0.02 } + } + }, + "last_recommendation": { + "twin_handle": "varitea_jasmine_pearl", + "winning_preset": "hero" + } + } +} diff --git a/packages/bandit/test/fixtures/brand_rules.before.json b/packages/bandit/test/fixtures/brand_rules.before.json new file mode 100644 index 0000000..234235b --- /dev/null +++ b/packages/bandit/test/fixtures/brand_rules.before.json @@ -0,0 +1,12 @@ +{ + "brand": "Drink Varitea", + "note": "Kinfolk test as code. Warm kraft + linen, ink type, sage accent. Matte/satin only.", + "palette": ["#c9a27a", "#f3ece2", "#2b2620", "#8a9a5b", "#d8c3a5"], + "palette_tolerance": 0.32, + "forbidden_finishes": ["gloss"], + "allowed_camera": ["orbit", "dolly", "macro", "still"], + "materials": { "body": "matte", "cap": "satin", "label": "matte" }, + "lighting": { "key": "soft-north", "ratio": 2, "warmth_k": 4200 }, + "voice": { "tone": "calm", "person": "second", "banned_words": ["hustle", "grind"] }, + "min_score": 0.6 +} diff --git a/packages/bandit/test/fixtures/observations.jsonl b/packages/bandit/test/fixtures/observations.jsonl new file mode 100644 index 0000000..9abfcb2 --- /dev/null +++ b/packages/bandit/test/fixtures/observations.jsonl @@ -0,0 +1,7 @@ +{"schema":"performance_observation.v1","source":"google-ads","customer_id":"1234567890","date":"2026-06-19","level":"ad","campaign_id":"C_VARITEA","campaign_name":"Varitea PMax","ad_group_id":"AG1","ad_group_name":"Jasmine","ad_id":"AD_HERO","metrics":{"impressions":1200,"clicks":84,"conversions":12,"cost_micros":1000000,"cost":1,"conversions_value":5,"ctr":0.07,"average_cpc":0.0119,"roas":5},"shopify_product_gid":"gid://shopify/Product/111","observed_at":"2026-06-20T00:00:00.000Z"} +{"schema":"performance_observation.v1","source":"google-ads","customer_id":"1234567890","date":"2026-06-20","level":"ad","campaign_id":"C_VARITEA","campaign_name":"Varitea PMax","ad_group_id":"AG1","ad_group_name":"Jasmine","ad_id":"AD_HERO","metrics":{"impressions":1500,"clicks":105,"conversions":15,"cost_micros":1000000,"cost":1,"conversions_value":6,"ctr":0.07,"average_cpc":0.0095,"roas":6},"shopify_product_gid":"gid://shopify/Product/111","observed_at":"2026-06-21T00:00:00.000Z"} +{"schema":"performance_observation.v1","source":"google-ads","customer_id":"1234567890","date":"2026-06-21","level":"ad","campaign_id":"C_VARITEA","campaign_name":"Varitea PMax","ad_group_id":"AG1","ad_group_name":"Jasmine","ad_id":"AD_HERO","metrics":{"impressions":1100,"clicks":77,"conversions":10,"cost_micros":1000000,"cost":1,"conversions_value":5,"ctr":0.07,"average_cpc":0.013,"roas":5},"shopify_product_gid":"gid://shopify/Product/111","observed_at":"2026-06-22T00:00:00.000Z"} +{"schema":"performance_observation.v1","source":"google-ads","customer_id":"1234567890","date":"2026-06-19","level":"ad","campaign_id":"C_VARITEA","campaign_name":"Varitea PMax","ad_group_id":"AG2","ad_group_name":"Lifestyle","ad_id":"AD_LIFE","metrics":{"impressions":1300,"clicks":26,"conversions":1,"cost_micros":5000000,"cost":5,"conversions_value":1,"ctr":0.02,"average_cpc":0.192,"roas":0.2},"shopify_product_gid":"gid://shopify/Product/111","observed_at":"2026-06-20T00:00:00.000Z"} +{"schema":"performance_observation.v1","source":"google-ads","customer_id":"1234567890","date":"2026-06-20","level":"ad","campaign_id":"C_VARITEA","campaign_name":"Varitea PMax","ad_group_id":"AG2","ad_group_name":"Lifestyle","ad_id":"AD_LIFE","metrics":{"impressions":1400,"clicks":28,"conversions":1,"cost_micros":5000000,"cost":5,"conversions_value":1,"ctr":0.02,"average_cpc":0.178,"roas":0.2},"shopify_product_gid":"gid://shopify/Product/111","observed_at":"2026-06-21T00:00:00.000Z"} +{"schema":"performance_observation.v1","source":"google-ads","customer_id":"1234567890","date":"2026-06-21","level":"ad","campaign_id":"C_VARITEA","campaign_name":"Varitea PMax","ad_group_id":"AG2","ad_group_name":"Lifestyle","ad_id":"AD_LIFE","metrics":{"impressions":1250,"clicks":20,"conversions":0,"cost_micros":5000000,"cost":5,"conversions_value":0.5,"ctr":0.016,"average_cpc":0.25,"roas":0.1},"shopify_product_gid":"gid://shopify/Product/111","observed_at":"2026-06-22T00:00:00.000Z"} +{"schema":"performance_observation.v1","source":"google-ads","customer_id":"1234567890","date":"2026-06-21","level":"ad","campaign_id":"C_VARITEA","campaign_name":"Varitea PMax","ad_group_id":"AG3","ad_group_name":"Noise","ad_id":"AD_NOISE","metrics":{"impressions":12,"clicks":1,"conversions":0,"cost_micros":40000,"cost":0.04,"conversions_value":0,"ctr":0.083,"average_cpc":0.04,"roas":0},"shopify_product_gid":"gid://shopify/Product/111","observed_at":"2026-06-22T00:00:00.000Z"} diff --git a/packages/bandit/test/fixtures/provenance.jsonl b/packages/bandit/test/fixtures/provenance.jsonl new file mode 100644 index 0000000..1e423f8 --- /dev/null +++ b/packages/bandit/test/fixtures/provenance.jsonl @@ -0,0 +1,2 @@ +{"schema":"render_provenance.v1","storyboard_id":"sb-jasmine-hero-6s","twin_handle":"varitea_jasmine_pearl","preset":"hero","duration_s":6,"brand_score":0.82,"output_uri":"s3://shoploop/varitea/hero.mp4","render_mode":"flux_pro","observed_at":"2026-06-18T10:00:00.000Z","external_id":"AD_HERO"} +{"schema":"render_provenance.v1","storyboard_id":"sb-jasmine-life-15s","twin_handle":"varitea_jasmine_pearl","preset":"lifestyle","duration_s":15,"brand_score":0.79,"output_uri":"s3://shoploop/varitea/lifestyle.mp4","render_mode":"flux_pro","observed_at":"2026-06-18T10:05:00.000Z","external_id":"AD_LIFE"} diff --git a/packages/bandit/test/joiner.test.ts b/packages/bandit/test/joiner.test.ts new file mode 100644 index 0000000..08f88c5 --- /dev/null +++ b/packages/bandit/test/joiner.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from "vitest"; +import { joinObservations } from "../src/join/joiner.js"; +import { loadConfig } from "../src/config.js"; +import type { PerformanceObservation, RenderProvenance } from "../src/types.js"; + +const cfg = loadConfig({ rewardMode: "roas", roasCeiling: 5, minImpressionsForReward: 50 }); + +function obs(over: Partial = {}): PerformanceObservation { + return { + schema: "performance_observation.v1", + source: "google-ads", + customer_id: "1234567890", + date: "2026-06-20", + level: "ad", + campaign_id: "C1", + ad_id: "AD1", + metrics: { + impressions: 1000, + clicks: 50, + conversions: 5, + cost_micros: 2_000_000, + cost: 2, + conversions_value: 5, + ctr: 0.05, + average_cpc: 0.04, + roas: 2.5, + }, + shopify_product_gid: "gid://shopify/Product/111", + observed_at: "2026-06-21T00:00:00.000Z", + ...over, + }; +} + +function prov(over: Partial = {}): RenderProvenance { + return { + schema: "render_provenance.v1", + storyboard_id: "sb-hero-1", + twin_handle: "varitea_jasmine_pearl", + preset: "hero", + observed_at: "2026-06-19T00:00:00.000Z", + ...over, + }; +} + +describe("joiner", () => { + it("joins on external_id (the distribution seam) with highest precedence", () => { + const res = joinObservations( + [obs({ ad_id: "AD1", campaign_id: "C1", shopify_product_gid: "gid://shopify/Product/111" })], + [ + prov({ storyboard_id: "viaproduct", preset: "lifestyle", campaign_id: "C1" }), + prov({ storyboard_id: "viaexternal", preset: "hero", external_id: "AD1" }), + ], + cfg, + ); + expect(res.samples).toHaveLength(1); + expect(res.samples[0].join_kind).toBe("external_id"); + expect(res.samples[0].preset).toBe("hero"); + // roas reward: conversions_value 5 / cost 2 = 2.5; /ceiling 5 = 0.5 + expect(res.samples[0].reward).toBeCloseTo(0.5, 12); + }); + + it("falls back to product_gid, then campaign_id", () => { + const viaProduct = joinObservations( + [obs({ ad_id: "NO_MATCH" })], + [prov({ shopify_product_gid: "gid://shopify/Product/111", preset: "pdp" })], + cfg, + ); + expect(viaProduct.samples[0].join_kind).toBe("product_gid"); + expect(viaProduct.samples[0].preset).toBe("pdp"); + + const viaCampaign = joinObservations( + [obs({ ad_id: "NO_MATCH", shopify_product_gid: null })], + [prov({ campaign_id: "C1", preset: "ugc" })], + cfg, + ); + expect(viaCampaign.samples[0].join_kind).toBe("campaign_id"); + expect(viaCampaign.samples[0].preset).toBe("ugc"); + }); + + it("skips observations below the impressions floor", () => { + const res = joinObservations( + [obs({ metrics: { ...obs().metrics, impressions: 10 } })], + [prov({ external_id: "AD1" })], + cfg, + ); + expect(res.samples).toHaveLength(0); + expect(res.skipped[0].reason).toBe("below_min_impressions"); + }); + + it("reports unjoined observations with no matching provenance", () => { + const res = joinObservations( + [obs({ ad_id: "X", campaign_id: "ZZ", shopify_product_gid: null })], + [prov({ external_id: "AD1" })], + cfg, + ); + expect(res.samples).toHaveLength(0); + expect(res.unjoined[0].reason).toBe("no_provenance_match"); + }); +}); diff --git a/packages/bandit/test/rules_writer.test.ts b/packages/bandit/test/rules_writer.test.ts new file mode 100644 index 0000000..3bdd448 --- /dev/null +++ b/packages/bandit/test/rules_writer.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + buildBanditNamespace, + writeBanditNamespace, + readBanditNamespace, +} from "../src/rules/writer.js"; +import type { ArmState, BudgetRecommendation } from "../src/types.js"; + +const dirs: string[] = []; +function tmp(): string { + const d = mkdtempSync(join(tmpdir(), "bandit-rules-")); + dirs.push(d); + return d; +} +afterEach(() => { + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); +}); + +const NON_BANDIT = { + brand: "Drink Varitea", + note: "Kinfolk test as code.", + palette: ["#c9a27a", "#f3ece2", "#2b2620"], + palette_tolerance: 0.32, + forbidden_finishes: ["gloss"], + allowed_camera: ["orbit", "dolly", "macro", "still"], + min_score: 0.6, + materials: { body: "matte", cap: "satin" }, + lighting: { key: "soft", ratio: 2 }, +}; + +const arms: ArmState[] = [ + { + twin_handle: "varitea_jasmine_pearl", + preset: "hero", + alpha: 1.4, + beta: 1.2, + reward_sum: 0.4, + pulls: 32, + last_reward: 0.61, + updated_at: "2026-06-20T00:00:00.000Z", + }, +]; + +const rec: BudgetRecommendation = { + schema: "budget_recommendation.v1", + twin_handle: "varitea_jasmine_pearl", + winning_preset: "hero", + current_split: { hero: 0.5, lifestyle: 0.5 }, + recommended_split: { hero: 0.7, lifestyle: 0.3 }, + confidence: 0.83, + margin: 0.2, + draft: true, + generated_at: "2026-06-20T00:00:00.000Z", +}; + +describe("rules writer — namespace isolation", () => { + it("preserves every non-bandit field's value exactly and writes a correct bandit block", async () => { + const d = tmp(); + const path = join(d, "brand_rules.json"); + writeFileSync(path, JSON.stringify(NON_BANDIT, null, 2), "utf8"); + + const ns = buildBanditNamespace(arms, rec, "2026-06-20T00:00:00.000Z"); + const merged = await writeBanditNamespace(path, ns); + + // Every original key survives with an identical value. + for (const [k, v] of Object.entries(NON_BANDIT)) { + expect(merged[k]).toEqual(v); + } + + // bandit block is correct. + const after = JSON.parse(readFileSync(path, "utf8")); + expect(after.bandit.version).toBe(1); + expect(after.bandit.arms.varitea_jasmine_pearl.hero).toEqual({ + alpha: 1.4, + beta: 1.2, + pulls: 32, + last_reward: 0.61, + }); + expect(after.bandit.last_recommendation).toEqual({ + twin_handle: "varitea_jasmine_pearl", + winning_preset: "hero", + confidence: 0.83, + }); + // Non-bandit keys remain byte-identical when re-serialized. + const { bandit: _omit, ...rest } = after; + expect(rest).toEqual(NON_BANDIT); + }); + + it("re-writing only swaps the bandit key, leaving prior non-bandit edits intact", async () => { + const d = tmp(); + const path = join(d, "brand_rules.json"); + writeFileSync(path, JSON.stringify({ ...NON_BANDIT, bandit: { version: 1, stale: true } }, null, 2)); + + const ns = buildBanditNamespace(arms, null, "2026-06-21T00:00:00.000Z"); + await writeBanditNamespace(path, ns); + + const after = JSON.parse(readFileSync(path, "utf8")); + expect(after.bandit.last_recommendation).toBeNull(); + expect((after.bandit as Record).stale).toBeUndefined(); + expect(after.palette).toEqual(NON_BANDIT.palette); + }); + + it("creates the file from scratch when absent", async () => { + const d = tmp(); + const path = join(d, "nested", "brand_rules.json"); + const ns = buildBanditNamespace(arms, null, "2026-06-20T00:00:00.000Z"); + await writeBanditNamespace(path, ns); + const back = await readBanditNamespace(path); + expect(back?.arms.varitea_jasmine_pearl.hero.pulls).toBe(32); + }); +}); diff --git a/packages/bandit/test/thompson.test.ts b/packages/bandit/test/thompson.test.ts new file mode 100644 index 0000000..893109e --- /dev/null +++ b/packages/bandit/test/thompson.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from "vitest"; +import seedrandom from "seedrandom"; +import { BetaArm } from "../src/bandit/beta_arm.js"; +import { sampleArms, probabilityBest } from "../src/bandit/thompson.js"; + +// Convergence: 3 arms with true Bernoulli means [0.2, 0.5, 0.8]. Run 2000 rounds of +// Thompson Sampling (sample → pick → pull → Bernoulli reward → update). The best arm +// (mean 0.8) must dominate the tail of the run. +describe("Thompson Sampling convergence", () => { + it("best arm is pulled >70% of the last 500 pulls", () => { + const trueMeans: Record = { a: 0.2, b: 0.5, c: 0.8 }; + const rng = seedrandom("thompson-converge"); + const arms = new Map([ + ["a", new BetaArm()], + ["b", new BetaArm()], + ["c", new BetaArm()], + ]); + + const N = 2000; + const picks: string[] = []; + for (let i = 0; i < N; i++) { + const { winner } = sampleArms(arms, rng); + picks.push(winner); + const reward = rng() < trueMeans[winner] ? 1 : 0; + arms.get(winner)!.update(reward); + } + + const last500 = picks.slice(-500); + const cPulls = last500.filter((p) => p === "c").length; + expect(cPulls / 500).toBeGreaterThan(0.7); + + // Posterior means recover the ordering a < b < c. + expect(arms.get("a")!.mean()).toBeLessThan(arms.get("b")!.mean()); + expect(arms.get("b")!.mean()).toBeLessThan(arms.get("c")!.mean()); + }); + + it("probabilityBest concentrates on the strongest arm", () => { + const rng = seedrandom("prob-best"); + const arms = new Map([ + ["weak", new BetaArm(2, 8)], // mean 0.2 + ["strong", new BetaArm(8, 2)], // mean 0.8 + ]); + const pStrong = probabilityBest(arms, "strong", rng, 3000); + expect(pStrong).toBeGreaterThan(0.95); + }); + + it("single arm yields zero margin", () => { + const rng = seedrandom("single"); + const arms = new Map([["only", new BetaArm(3, 2)]]); + const res = sampleArms(arms, rng); + expect(res.winner).toBe("only"); + expect(res.runnerUp).toBeNull(); + expect(res.margin).toBe(0); + }); +}); diff --git a/packages/bandit/tsconfig.build.json b/packages/bandit/tsconfig.build.json new file mode 100644 index 0000000..376ad7f --- /dev/null +++ b/packages/bandit/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "noEmit": false + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules", "test"] +} diff --git a/packages/bandit/tsconfig.json b/packages/bandit/tsconfig.json new file mode 100644 index 0000000..ffdfb2d --- /dev/null +++ b/packages/bandit/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "./dist", + "rootDir": ".", + "declaration": true, + "sourceMap": true, + "strict": true, + "noImplicitOverride": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "verbatimModuleSyntax": false, + "types": ["node", "vitest/globals"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/bandit/vitest.config.ts b/packages/bandit/vitest.config.ts new file mode 100644 index 0000000..b74705b --- /dev/null +++ b/packages/bandit/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["test/**/*.test.ts"], + globals: true, + testTimeout: 30_000, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..25c2ec6 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1460 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + packages/bandit: + dependencies: + '@libsql/client': + specifier: ^0.14.0 + version: 0.14.0 + proper-lockfile: + specifier: ^4.1.2 + version: 4.1.2 + seedrandom: + specifier: ^3.0.5 + version: 3.0.5 + devDependencies: + '@types/node': + specifier: ^20.14.0 + version: 20.19.43 + '@types/proper-lockfile': + specifier: ^4.1.4 + version: 4.1.4 + '@types/seedrandom': + specifier: ^3.0.8 + version: 3.0.8 + tsx: + specifier: ^4.19.0 + version: 4.22.4 + typescript: + specifier: ^5.6.0 + version: 5.9.3 + vitest: + specifier: ^2.1.0 + version: 2.1.9(@types/node@20.19.43) + +packages: + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@libsql/client@0.14.0': + resolution: {integrity: sha512-/9HEKfn6fwXB5aTEEoMeFh4CtG0ZzbncBb1e++OCdVpgKZ/xyMsIVYXm0w7Pv4RUel803vE6LwniB3PqD72R0Q==} + + '@libsql/core@0.14.0': + resolution: {integrity: sha512-nhbuXf7GP3PSZgdCY2Ecj8vz187ptHlZQ0VRc751oB2C1W8jQUXKKklvt7t1LJiUTQBVJuadF628eUk+3cRi4Q==} + + '@libsql/darwin-arm64@0.4.7': + resolution: {integrity: sha512-yOL742IfWUlUevnI5PdnIT4fryY3LYTdLm56bnY0wXBw7dhFcnjuA7jrH3oSVz2mjZTHujxoITgAE7V6Z+eAbg==} + cpu: [arm64] + os: [darwin] + + '@libsql/darwin-x64@0.4.7': + resolution: {integrity: sha512-ezc7V75+eoyyH07BO9tIyJdqXXcRfZMbKcLCeF8+qWK5nP8wWuMcfOVywecsXGRbT99zc5eNra4NEx6z5PkSsA==} + cpu: [x64] + os: [darwin] + + '@libsql/hrana-client@0.7.0': + resolution: {integrity: sha512-OF8fFQSkbL7vJY9rfuegK1R7sPgQ6kFMkDamiEccNUvieQ+3urzfDFI616oPl8V7T9zRmnTkSjMOImYCAVRVuw==} + + '@libsql/isomorphic-fetch@0.3.1': + resolution: {integrity: sha512-6kK3SUK5Uu56zPq/Las620n5aS9xJq+jMBcNSOmjhNf/MUvdyji4vrMTqD7ptY7/4/CAVEAYDeotUz60LNQHtw==} + engines: {node: '>=18.0.0'} + + '@libsql/isomorphic-ws@0.1.5': + resolution: {integrity: sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==} + + '@libsql/linux-arm64-gnu@0.4.7': + resolution: {integrity: sha512-WlX2VYB5diM4kFfNaYcyhw5y+UJAI3xcMkEUJZPtRDEIu85SsSFrQ+gvoKfcVh76B//ztSeEX2wl9yrjF7BBCA==} + cpu: [arm64] + os: [linux] + + '@libsql/linux-arm64-musl@0.4.7': + resolution: {integrity: sha512-6kK9xAArVRlTCpWeqnNMCoXW1pe7WITI378n4NpvU5EJ0Ok3aNTIC2nRPRjhro90QcnmLL1jPcrVwO4WD1U0xw==} + cpu: [arm64] + os: [linux] + + '@libsql/linux-x64-gnu@0.4.7': + resolution: {integrity: sha512-CMnNRCmlWQqqzlTw6NeaZXzLWI8bydaXDke63JTUCvu8R+fj/ENsLrVBtPDlxQ0wGsYdXGlrUCH8Qi9gJep0yQ==} + cpu: [x64] + os: [linux] + + '@libsql/linux-x64-musl@0.4.7': + resolution: {integrity: sha512-nI6tpS1t6WzGAt1Kx1n1HsvtBbZ+jHn0m7ogNNT6pQHZQj7AFFTIMeDQw/i/Nt5H38np1GVRNsFe99eSIMs9XA==} + cpu: [x64] + os: [linux] + + '@libsql/win32-x64-msvc@0.4.7': + resolution: {integrity: sha512-7pJzOWzPm6oJUxml+PCDRzYQ4A1hTMHAciTAHfFK4fkbDZX33nWPVG7Y3vqdKtslcwAzwmrNDc6sXy2nwWnbiw==} + cpu: [x64] + os: [win32] + + '@neon-rs/load@0.0.4': + resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} + + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@20.19.43': + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + + '@types/proper-lockfile@4.1.4': + resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==} + + '@types/retry@0.12.5': + resolution: {integrity: sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==} + + '@types/seedrandom@3.0.8': + resolution: {integrity: sha512-TY1eezMU2zH2ozQoAFAQFOPpvP15g+ZgSfTZt31AUUH/Rxtnz3H+A/Sv1Snw2/amp//omibc+AEkTaA8KUeOLQ==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + detect-libc@2.0.2: + resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==} + engines: {node: '>=8'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + js-base64@3.7.8: + resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} + + libsql@0.4.7: + resolution: {integrity: sha512-T9eIRCs6b0J1SHKYIvD8+KCJMcWZ900iZyxdnSCdqxN12Z1ijzT+jY5nrk72Jw4B0HGzms2NgpryArlJqvc3Lw==} + cpu: [x64, arm64, wasm32] + os: [darwin, linux, win32] + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + promise-limit@2.7.0: + resolution: {integrity: sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==} + + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + seedrandom@3.0.5: + resolution: {integrity: sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + tsx@4.22.4: + resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + +snapshots: + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@libsql/client@0.14.0': + dependencies: + '@libsql/core': 0.14.0 + '@libsql/hrana-client': 0.7.0 + js-base64: 3.7.8 + libsql: 0.4.7 + promise-limit: 2.7.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@libsql/core@0.14.0': + dependencies: + js-base64: 3.7.8 + + '@libsql/darwin-arm64@0.4.7': + optional: true + + '@libsql/darwin-x64@0.4.7': + optional: true + + '@libsql/hrana-client@0.7.0': + dependencies: + '@libsql/isomorphic-fetch': 0.3.1 + '@libsql/isomorphic-ws': 0.1.5 + js-base64: 3.7.8 + node-fetch: 3.3.2 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@libsql/isomorphic-fetch@0.3.1': {} + + '@libsql/isomorphic-ws@0.1.5': + dependencies: + '@types/ws': 8.18.1 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@libsql/linux-arm64-gnu@0.4.7': + optional: true + + '@libsql/linux-arm64-musl@0.4.7': + optional: true + + '@libsql/linux-x64-gnu@0.4.7': + optional: true + + '@libsql/linux-x64-musl@0.4.7': + optional: true + + '@libsql/win32-x64-msvc@0.4.7': + optional: true + + '@neon-rs/load@0.0.4': {} + + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + + '@types/estree@1.0.9': {} + + '@types/node@20.19.43': + dependencies: + undici-types: 6.21.0 + + '@types/proper-lockfile@4.1.4': + dependencies: + '@types/retry': 0.12.5 + + '@types/retry@0.12.5': {} + + '@types/seedrandom@3.0.8': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 20.19.43 + + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@20.19.43))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@20.19.43) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + + assertion-error@2.0.1: {} + + cac@6.7.14: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + data-uri-to-buffer@4.0.1: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + detect-libc@2.0.2: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.3.0: {} + + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + + fsevents@2.3.3: + optional: true + + graceful-fs@4.2.11: {} + + js-base64@3.7.8: {} + + libsql@0.4.7: + dependencies: + '@neon-rs/load': 0.0.4 + detect-libc: 2.0.2 + optionalDependencies: + '@libsql/darwin-arm64': 0.4.7 + '@libsql/darwin-x64': 0.4.7 + '@libsql/linux-arm64-gnu': 0.4.7 + '@libsql/linux-arm64-musl': 0.4.7 + '@libsql/linux-x64-gnu': 0.4.7 + '@libsql/linux-x64-musl': 0.4.7 + '@libsql/win32-x64-msvc': 0.4.7 + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + ms@2.1.3: {} + + nanoid@3.3.15: {} + + node-domexception@1.0.0: {} + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + + pathe@1.1.2: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + promise-limit@2.7.0: {} + + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + + retry@0.12.0: {} + + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + + seedrandom@3.0.5: {} + + siginfo@2.0.0: {} + + signal-exit@3.0.7: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + + tsx@4.22.4: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + vite-node@2.1.9(@types/node@20.19.43): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@20.19.43) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@20.19.43): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.15 + rollup: 4.62.2 + optionalDependencies: + '@types/node': 20.19.43 + fsevents: 2.3.3 + + vitest@2.1.9(@types/node@20.19.43): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@20.19.43)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@20.19.43) + vite-node: 2.1.9(@types/node@20.19.43) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.43 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + web-streams-polyfill@3.3.3: {} + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + ws@8.21.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..15add63 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "connectors/*" + - "packages/*"