diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e3cc82a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +node_modules +.git +dist +coverage +__pycache__ +.env +*.pyc +.pytest_cache diff --git a/.env.example b/.env.example index 4e15445..ba44afd 100644 --- a/.env.example +++ b/.env.example @@ -1,31 +1,59 @@ +# ====================== # AWS Configuration +# ====================== +# AWS region for DynamoDB, Cognito, and other services AWS_REGION=us-east-1 +# AWS account ID for resource ARNs AWS_ACCOUNT_ID= -# DynamoDB +# DynamoDB table configuration DYNAMODB_TABLE_PREFIX=wellab DYNAMODB_PUBLICATIONS_TABLE=wellab-publications DYNAMODB_PROJECTS_TABLE=wellab-projects -# Cognito +# ====================== +# Auth (Cognito) +# ====================== +# Cognito User Pool ID from AWS Console COGNITO_USER_POOL_ID= COGNITO_CLIENT_ID= +# JWT secret for local token signing (min 32 characters) +JWT_SECRET=YOUR_KEY_HERE +# Session secret for express-session (min 32 characters) +SESSION_SECRET=YOUR_KEY_HERE + +# ====================== # API Configuration +# ====================== +# Backend API port and base URL API_PORT=3001 API_BASE_URL=http://localhost:3001 -# Claude API (Anthropic) -ANTHROPIC_API_KEY=sk-ant-xxxxx +# CORS allowed origin (frontend URL) +CORS_ORIGIN=http://localhost:5173 + +# Logging level: debug, info, warn, error +LOG_LEVEL=info + +# Claude API key from Anthropic (https://console.anthropic.com) +ANTHROPIC_API_KEY=YOUR_KEY_HERE +# ====================== # Frontend +# ====================== VITE_API_BASE_URL=http://localhost:3001 VITE_COGNITO_USER_POOL_ID= VITE_COGNITO_CLIENT_ID= +# ====================== # ML Service +# ====================== +# Python ML API port and base URL ML_API_PORT=8000 ML_API_BASE_URL=http://localhost:8000 +# ====================== # Environment +# ====================== NODE_ENV=development diff --git a/.eslintrc.json b/.eslintrc.json index 6bc0793..5f9c888 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -33,8 +33,9 @@ "rules": { "react/react-in-jsx-scope": "off", "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }], - "@typescript-eslint/explicit-function-return-type": "off", - "@typescript-eslint/no-explicit-any": "warn" + "@typescript-eslint/explicit-function-return-type": "warn", + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/no-floating-promises": "error" }, "ignorePatterns": ["dist/", "node_modules/", "coverage/"] } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47e4006..88ac1d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,24 +11,49 @@ jobs: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 + - uses: actions/setup-node@60ecd5dd1b667a8e52112f999ebc6e0c8267832c # v4.0.2 with: node-version: 20 - cache: npm + - uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 + with: + path: node_modules + key: node-modules-${{ hashFiles('package-lock.json') }} + restore-keys: node-modules- - run: npm ci - run: npm run lint + type-check: + name: Type Check + runs-on: ubuntu-latest + needs: lint + steps: + - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 + - uses: actions/setup-node@60ecd5dd1b667a8e52112f999ebc6e0c8267832c # v4.0.2 + with: + node-version: 20 + - uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 + with: + path: node_modules + key: node-modules-${{ hashFiles('package-lock.json') }} + restore-keys: node-modules- + - run: npm ci + - run: npx tsc --noEmit + test-frontend: name: Test Frontend runs-on: ubuntu-latest needs: lint steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 + - uses: actions/setup-node@60ecd5dd1b667a8e52112f999ebc6e0c8267832c # v4.0.2 with: node-version: 20 - cache: npm + - uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 + with: + path: node_modules + key: node-modules-${{ hashFiles('package-lock.json') }} + restore-keys: node-modules- - run: npm ci - run: npm run build --workspace=src/frontend @@ -37,11 +62,15 @@ jobs: runs-on: ubuntu-latest needs: lint steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 + - uses: actions/setup-node@60ecd5dd1b667a8e52112f999ebc6e0c8267832c # v4.0.2 with: node-version: 20 - cache: npm + - uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 + with: + path: node_modules + key: node-modules-${{ hashFiles('package-lock.json') }} + restore-keys: node-modules- - run: npm ci - run: npm run test --workspace=src/backend @@ -50,25 +79,54 @@ jobs: runs-on: ubuntu-latest needs: lint steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 + - uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0 with: python-version: '3.11' - cache: pip + - uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 + with: + path: ~/.cache/pip + key: pip-${{ hashFiles('requirements.txt') }} + restore-keys: pip- - run: pip install -r requirements.txt - - run: python -m pytest src/ml/ --tb=short -q || true - - run: python -m flake8 src/ml/ --max-line-length=120 || true - - run: python -m mypy src/ml/ --ignore-missing-imports || true + - run: python -m pytest src/ml/ --tb=short -q --cov=src/ml --cov-report=xml --cov-fail-under=70 + - run: python -m flake8 src/ml/ --max-line-length=120 + - run: python -m mypy src/ml/ --ignore-missing-imports + + security: + name: Security Scanning + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 + - uses: actions/setup-node@60ecd5dd1b667a8e52112f999ebc6e0c8267832c # v4.0.2 + with: + node-version: 20 + - uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0 + with: + python-version: '3.11' + - uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 + with: + path: node_modules + key: node-modules-${{ hashFiles('package-lock.json') }} + restore-keys: node-modules- + - run: npm ci + - run: npm audit --audit-level=moderate + - run: pip install pip-audit + - run: pip-audit -r requirements.txt build: name: Build runs-on: ubuntu-latest - needs: [test-frontend, test-backend, test-ml] + needs: [test-frontend, test-backend, test-ml, type-check, security] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 + - uses: actions/setup-node@60ecd5dd1b667a8e52112f999ebc6e0c8267832c # v4.0.2 with: node-version: 20 - cache: npm + - uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 + with: + path: node_modules + key: node-modules-${{ hashFiles('package-lock.json') }} + restore-keys: node-modules- - run: npm ci - run: npm run build diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..47e8c13 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,45 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-json + + - repo: https://github.com/psf/black + rev: 24.4.2 + hooks: + - id: black + language_version: python3.11 + + - repo: https://github.com/pycqa/flake8 + rev: 7.0.0 + hooks: + - id: flake8 + args: [--max-line-length=120] + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.10.0 + hooks: + - id: mypy + args: [--ignore-missing-imports] + additional_dependencies: [] + + - repo: https://github.com/pre-commit/mirrors-eslint + rev: v8.57.0 + hooks: + - id: eslint + files: \.(ts|tsx)$ + types: [file] + additional_dependencies: + - eslint@8.57.0 + - "@typescript-eslint/parser@7.0.0" + - "@typescript-eslint/eslint-plugin@7.0.0" + + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v3.2.0 + hooks: + - id: prettier + files: \.(ts|tsx|json|css)$ + types: [file] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..61f6566 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,80 @@ +# Contributing to WELLab + +## Development Setup + +1. Clone the repository and install dependencies: + ```bash + make install + ``` +2. Copy the environment file and fill in your values: + ```bash + cp .env.example .env + ``` +3. Install pre-commit hooks: + ```bash + pre-commit install + ``` +4. Start the development servers: + ```bash + make dev + ``` + +## Branch Naming Conventions + +Use the following prefixes for branch names: + +- `feature/` -- New features (e.g., `feature/causal-inference-api`) +- `fix/` -- Bug fixes (e.g., `fix/cognito-token-refresh`) +- `docs/` -- Documentation changes (e.g., `docs/api-reference`) + +## Commit Message Format + +Follow the Conventional Commits specification: + +``` +(): + +[optional body] + +[optional footer] +``` + +Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`, `ci` + +Examples: +- `feat(ml): add survival analysis endpoint` +- `fix(frontend): correct pagination in publication list` +- `docs(api): update authentication flow diagram` + +## Code Review Process + +1. Open a pull request against `develop` (or `main` for hotfixes). +2. At least one approving review is required before merging. +3. All CI checks must pass (lint, type-check, tests, security scan). +4. Resolve all review comments before merging. +5. Use squash merging to keep the commit history clean. + +## Testing Requirements + +- All new features must include tests. +- Maintain a minimum of 70% code coverage for `src/ml/`. +- Run the full test suite before opening a PR: + ```bash + make test + ``` +- Run linting and type checks: + ```bash + make lint + make type-check + ``` + +## Ethics Review Requirements for ML Changes + +Any pull request that modifies code in `src/ml/` must include: + +1. A description of the data used and any potential biases. +2. An assessment of fairness implications across demographic groups. +3. Documentation of model limitations and failure modes. +4. Approval from at least one team member with domain expertise in the relevant health/wellness area. + +These requirements exist to ensure that ML models deployed in health-adjacent contexts meet ethical standards and do not cause harm to vulnerable populations. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4c1bdb0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,60 @@ +# Stage 1: Node.js builder +FROM node:20-slim AS node-builder + +WORKDIR /app + +COPY package.json package-lock.json ./ +COPY src/frontend/package.json src/frontend/ +COPY src/backend/package.json src/backend/ + +RUN npm ci + +COPY tsconfig.json ./ +COPY src/frontend/ src/frontend/ +COPY src/backend/ src/backend/ + +RUN npm run build --workspace=src/frontend +RUN npm run build --workspace=src/backend + +# Stage 2: Python ML +FROM python:3.11-slim AS ml-builder + +WORKDIR /app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY src/ml/ src/ml/ + +# Stage 3: Production runtime +FROM node:20-slim AS production + +RUN apt-get update && \ + apt-get install -y --no-install-recommends python3 python3-pip curl && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy Node.js built artifacts +COPY --from=node-builder /app/node_modules ./node_modules +COPY --from=node-builder /app/src/frontend/dist ./src/frontend/dist +COPY --from=node-builder /app/src/backend/dist ./src/backend/dist +COPY --from=node-builder /app/package.json ./ + +# Copy Python ML artifacts +COPY --from=ml-builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages +COPY --from=ml-builder /app/src/ml ./src/ml + +# Create non-root user +RUN groupadd --gid 1001 wellab && \ + useradd --uid 1001 --gid wellab --shell /bin/bash --create-home wellab && \ + chown -R wellab:wellab /app + +USER wellab + +EXPOSE 3001 5173 8000 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \ + CMD curl -f http://localhost:3001/health || exit 1 + +CMD ["node", "src/backend/dist/index.js"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c9e4ef5 --- /dev/null +++ b/Makefile @@ -0,0 +1,55 @@ +.PHONY: install dev test lint type-check format build docker-build docker-up clean audit + +install: + npm ci + pip install -r requirements.txt + +dev: + @echo "Starting all services..." + npm run dev --workspace=src/frontend & \ + npm run dev --workspace=src/backend & \ + python -m uvicorn src.ml.api:app --host 0.0.0.0 --port 8000 --reload & \ + wait + +test: + npm run test --workspaces --if-present + python -m pytest src/ml/ --tb=short -q --cov=src/ml --cov-report=xml --cov-fail-under=70 + +lint: + npm run lint + python -m flake8 src/ml/ --max-line-length=120 + python -m black --check src/ml/ + python -m mypy src/ml/ --ignore-missing-imports + +type-check: + npx tsc --noEmit + python -m mypy src/ml/ --ignore-missing-imports + +format: + npx prettier --write 'src/**/*.{ts,tsx,json,css}' + python -m black src/ml/ + +build: + npm run build --workspace=src/frontend + npm run build --workspace=src/backend + +docker-build: + docker compose build + +docker-up: + docker compose up + +clean: + rm -rf dist/ + rm -rf src/frontend/dist/ + rm -rf src/backend/dist/ + rm -rf coverage/ + rm -rf .pytest_cache/ + rm -rf htmlcov/ + rm -f coverage.xml + find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true + find . -type f -name '*.pyc' -delete 2>/dev/null || true + +audit: + npm audit + pip-audit -r requirements.txt diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..c53c646 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,48 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| ------- | ------------------ | +| 1.x | Yes | +| < 1.0 | No | + +## Reporting a Vulnerability + +If you discover a security vulnerability in WELLab, please report it responsibly. + +**Do not open a public issue.** + +Instead, send an email to **security@wellab.org** with the following information: + +- A description of the vulnerability and its potential impact. +- Steps to reproduce the issue. +- Any relevant logs, screenshots, or proof-of-concept code. + +You can expect an initial response within 48 hours and a resolution timeline within 5 business days. + +## Security Contact + +- Email: security@wellab.org + +## Vulnerability Disclosure Process + +1. Report the vulnerability via the email above. +2. The security team will acknowledge receipt within 48 hours. +3. We will investigate and determine severity and impact. +4. A fix will be developed, tested, and deployed. +5. After the fix is released, we will coordinate public disclosure with the reporter. + +## HIPAA-Adjacent Compliance Notes + +WELLab processes health and wellness research data. While WELLab may not be a HIPAA-covered entity in all deployments, we follow HIPAA-adjacent best practices: + +- **Data Encryption**: All data is encrypted in transit (TLS 1.2+) and at rest (AES-256 via AWS DynamoDB encryption). +- **Access Controls**: Authentication is handled via AWS Cognito with role-based access control. +- **Audit Logging**: All API access and data modifications are logged with timestamps and user identifiers. +- **Minimum Necessary**: The platform is designed to collect and expose only the minimum data necessary for each operation. +- **No PHI in Logs**: Application logs are configured to exclude personally identifiable health information. +- **Dependency Scanning**: Automated `pip-audit` and `npm audit` scans run on every CI build to detect known vulnerabilities in dependencies. +- **Secret Management**: API keys and secrets are never committed to version control. Environment variables are used for all sensitive configuration. + +Deployments that handle Protected Health Information (PHI) must undergo a formal HIPAA compliance review and implement additional safeguards including a Business Associate Agreement (BAA) with AWS. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..24a9a83 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,88 @@ +version: "3.9" + +services: + frontend: + build: + context: . + dockerfile: Dockerfile + target: node-builder + ports: + - "5173:5173" + volumes: + - ./src/frontend:/app/src/frontend + - /app/node_modules + environment: + - VITE_API_BASE_URL=${VITE_API_BASE_URL:-http://localhost:3001} + - VITE_COGNITO_USER_POOL_ID=${VITE_COGNITO_USER_POOL_ID} + - VITE_COGNITO_CLIENT_ID=${VITE_COGNITO_CLIENT_ID} + command: npm run dev --workspace=src/frontend + networks: + - wellab + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5173"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + + backend: + build: + context: . + dockerfile: Dockerfile + target: node-builder + ports: + - "3001:3001" + volumes: + - ./src/backend:/app/src/backend + - /app/node_modules + environment: + - NODE_ENV=${NODE_ENV:-development} + - API_PORT=${API_PORT:-3001} + - AWS_REGION=${AWS_REGION} + - AWS_ACCOUNT_ID=${AWS_ACCOUNT_ID} + - DYNAMODB_TABLE_PREFIX=${DYNAMODB_TABLE_PREFIX} + - DYNAMODB_PUBLICATIONS_TABLE=${DYNAMODB_PUBLICATIONS_TABLE} + - DYNAMODB_PROJECTS_TABLE=${DYNAMODB_PROJECTS_TABLE} + - COGNITO_USER_POOL_ID=${COGNITO_USER_POOL_ID} + - COGNITO_CLIENT_ID=${COGNITO_CLIENT_ID} + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + - LOG_LEVEL=${LOG_LEVEL:-info} + - CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:5173} + - JWT_SECRET=${JWT_SECRET} + - SESSION_SECRET=${SESSION_SECRET} + command: npm run dev --workspace=src/backend + networks: + - wellab + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3001/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + + ml-api: + build: + context: . + dockerfile: Dockerfile + target: ml-builder + ports: + - "8000:8000" + volumes: + - ./src/ml:/app/src/ml + environment: + - ML_API_PORT=${ML_API_PORT:-8000} + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + - LOG_LEVEL=${LOG_LEVEL:-info} + command: python -m uvicorn src.ml.api:app --host 0.0.0.0 --port 8000 --reload + networks: + - wellab + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + +networks: + wellab: + driver: bridge diff --git a/infra/bin/app.ts b/infra/bin/app.ts new file mode 100644 index 0000000..95dc98a --- /dev/null +++ b/infra/bin/app.ts @@ -0,0 +1,161 @@ +#!/usr/bin/env node +import "source-map-support/register"; +import * as cdk from "aws-cdk-lib"; +import { DataStack } from "../lib/data-stack"; +import { AuthStack } from "../lib/auth-stack"; +import { ApiStack } from "../lib/api-stack"; +import { MLStack } from "../lib/ml-stack"; +import { FrontendStack } from "../lib/frontend-stack"; +import { MonitoringStack } from "../lib/monitoring-stack"; +import { PipelineStack } from "../lib/pipeline-stack"; + +const app = new cdk.App(); + +// --------------------------------------------------------------------------- +// Environment configuration +// --------------------------------------------------------------------------- +interface EnvConfig { + readonly envName: string; + readonly account: string; + readonly region: string; + readonly domainName: string; + readonly logRetentionDays: number; + readonly removalPolicy: cdk.RemovalPolicy; + readonly isProd: boolean; +} + +const account = app.node.tryGetContext("account") || process.env.CDK_DEFAULT_ACCOUNT; +const region = app.node.tryGetContext("region") || "us-east-1"; + +const environments: EnvConfig[] = [ + { + envName: "dev", + account, + region, + domainName: "dev.wellab.wustl.edu", + logRetentionDays: 90, + removalPolicy: cdk.RemovalPolicy.DESTROY, + isProd: false, + }, + { + envName: "staging", + account, + region, + domainName: "staging.wellab.wustl.edu", + logRetentionDays: 180, + removalPolicy: cdk.RemovalPolicy.RETAIN, + isProd: false, + }, + { + envName: "prod", + account, + region, + domainName: "wellab.wustl.edu", + logRetentionDays: 365, + removalPolicy: cdk.RemovalPolicy.RETAIN, + isProd: true, + }, +]; + +// --------------------------------------------------------------------------- +// Instantiate stacks per environment +// --------------------------------------------------------------------------- +const targetEnv = app.node.tryGetContext("env") || "dev"; +const envConfig = environments.find((e) => e.envName === targetEnv); +if (!envConfig) { + throw new Error(`Unknown environment: ${targetEnv}. Valid: dev, staging, prod`); +} + +const cdkEnv: cdk.Environment = { account: envConfig.account, region: envConfig.region }; +const prefix = `WELLab-${envConfig.envName}`; + +const commonTags: Record = { + Project: "WELLab", + Environment: envConfig.envName, + ManagedBy: "CDK", + Organization: "WashU-PsychBrain", +}; + +function applyTags(stack: cdk.Stack): void { + Object.entries(commonTags).forEach(([key, value]) => { + cdk.Tags.of(stack).add(key, value); + }); +} + +// --- Data --- +const dataStack = new DataStack(app, `${prefix}-Data`, { + env: cdkEnv, + envName: envConfig.envName, + removalPolicy: envConfig.removalPolicy, + isProd: envConfig.isProd, +}); +applyTags(dataStack); + +// --- Auth --- +const authStack = new AuthStack(app, `${prefix}-Auth`, { + env: cdkEnv, + envName: envConfig.envName, + removalPolicy: envConfig.removalPolicy, +}); +applyTags(authStack); + +// --- API --- +const apiStack = new ApiStack(app, `${prefix}-Api`, { + env: cdkEnv, + envName: envConfig.envName, + mainTable: dataStack.mainTable, + rawDataBucket: dataStack.rawDataBucket, + modelBucket: dataStack.modelBucket, + researcherUserPool: authStack.researcherUserPool, + participantUserPool: authStack.participantUserPool, + domainName: envConfig.domainName, + isProd: envConfig.isProd, +}); +applyTags(apiStack); + +// --- ML --- +const mlStack = new MLStack(app, `${prefix}-ML`, { + env: cdkEnv, + envName: envConfig.envName, + mainTable: dataStack.mainTable, + rawDataBucket: dataStack.rawDataBucket, + modelBucket: dataStack.modelBucket, + removalPolicy: envConfig.removalPolicy, + isProd: envConfig.isProd, +}); +applyTags(mlStack); + +// --- Frontend --- +const frontendStack = new FrontendStack(app, `${prefix}-Frontend`, { + env: cdkEnv, + envName: envConfig.envName, + domainName: envConfig.domainName, + apiEndpoint: apiStack.apiEndpoint, + removalPolicy: envConfig.removalPolicy, +}); +applyTags(frontendStack); + +// --- Monitoring --- +const monitoringStack = new MonitoringStack(app, `${prefix}-Monitoring`, { + env: cdkEnv, + envName: envConfig.envName, + api: apiStack.api, + lambdaFunctions: apiStack.lambdaFunctions, + mainTable: dataStack.mainTable, + logRetentionDays: envConfig.logRetentionDays, + isProd: envConfig.isProd, +}); +applyTags(monitoringStack); + +// --- CI/CD Pipeline (only in the tooling / dev account) --- +if (envConfig.envName === "dev") { + const pipelineStack = new PipelineStack(app, `WELLab-Pipeline`, { + env: cdkEnv, + githubOwner: app.node.tryGetContext("githubOwner") || "WELLab-WashU", + githubRepo: app.node.tryGetContext("githubRepo") || "wellab-platform", + githubBranch: "main", + }); + applyTags(pipelineStack); +} + +app.synth(); diff --git a/infra/cdk.json b/infra/cdk.json new file mode 100644 index 0000000..3885059 --- /dev/null +++ b/infra/cdk.json @@ -0,0 +1,23 @@ +{ + "app": "npx ts-node --prefer-ts-exts bin/app.ts", + "watch": { + "include": ["**"], + "exclude": [ + "README.md", + "cdk*.json", + "**/*.d.ts", + "**/*.js", + "tsconfig.json", + "package*.json", + "node_modules", + "test" + ] + }, + "context": { + "@aws-cdk/aws-lambda:recognizeLayerVersion": true, + "@aws-cdk/core:checkSecretUsage": true, + "@aws-cdk/core:target-partitions": ["aws"], + "@aws-cdk/aws-iam:minimizePolicies": true, + "@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId": true + } +} diff --git a/infra/lib/api-stack.ts b/infra/lib/api-stack.ts new file mode 100644 index 0000000..9f6194f --- /dev/null +++ b/infra/lib/api-stack.ts @@ -0,0 +1,390 @@ +import * as cdk from "aws-cdk-lib"; +import { Construct } from "constructs"; +import * as apigateway from "aws-cdk-lib/aws-apigateway"; +import * as lambda from "aws-cdk-lib/aws-lambda"; +import * as cognito from "aws-cdk-lib/aws-cognito"; +import * as dynamodb from "aws-cdk-lib/aws-dynamodb"; +import * as s3 from "aws-cdk-lib/aws-s3"; +import * as iam from "aws-cdk-lib/aws-iam"; +import * as logs from "aws-cdk-lib/aws-logs"; + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- +export interface ApiStackProps extends cdk.StackProps { + readonly envName: string; + readonly mainTable: dynamodb.Table; + readonly rawDataBucket: s3.Bucket; + readonly modelBucket: s3.Bucket; + readonly researcherUserPool: cognito.UserPool; + readonly participantUserPool: cognito.UserPool; + readonly domainName: string; + readonly isProd: boolean; +} + +// --------------------------------------------------------------------------- +// ApiStack — API Gateway REST API + Lambda functions +// --------------------------------------------------------------------------- +export class ApiStack extends cdk.Stack { + public readonly api: apigateway.RestApi; + public readonly lambdaFunctions: lambda.Function[]; + public readonly apiEndpoint: string; + + constructor(scope: Construct, id: string, props: ApiStackProps) { + super(scope, id, props); + + const { + envName, + mainTable, + rawDataBucket, + modelBucket, + researcherUserPool, + participantUserPool, + domainName, + isProd, + } = props; + + this.lambdaFunctions = []; + + // ----------------------------------------------------------------------- + // Shared Lambda layer + // ----------------------------------------------------------------------- + const sharedLayer = new lambda.LayerVersion(this, "SharedLayer", { + layerVersionName: `wellab-shared-${envName}`, + code: lambda.Code.fromAsset("../src/layers/shared"), + compatibleRuntimes: [lambda.Runtime.NODEJS_20_X], + description: "Shared utilities, validation, and DynamoDB helpers", + }); + + // ----------------------------------------------------------------------- + // API Gateway + // ----------------------------------------------------------------------- + const accessLogGroup = new logs.LogGroup(this, "ApiAccessLogs", { + logGroupName: `/wellab/${envName}/api-gateway/access`, + retention: isProd ? logs.RetentionDays.ONE_YEAR : logs.RetentionDays.THREE_MONTHS, + removalPolicy: isProd ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY, + }); + + this.api = new apigateway.RestApi(this, "WELLabApi", { + restApiName: `wellab-api-${envName}`, + description: `WELLab Platform REST API (${envName})`, + deployOptions: { + stageName: envName, + tracingEnabled: true, // X-Ray + loggingLevel: apigateway.MethodLoggingLevel.INFO, + dataTraceEnabled: !isProd, // request/response logging only in non-prod + metricsEnabled: true, + accessLogDestination: new apigateway.LogGroupLogDestination(accessLogGroup), + accessLogFormat: apigateway.AccessLogFormat.jsonWithStandardFields({ + caller: true, + httpMethod: true, + ip: true, + protocol: true, + requestTime: true, + resourcePath: true, + responseLength: true, + status: true, + user: true, + }), + throttlingRateLimit: 1000, + throttlingBurstLimit: 200, + }, + defaultCorsPreflightOptions: { + allowOrigins: isProd + ? [`https://${domainName}`] + : apigateway.Cors.ALL_ORIGINS, + allowMethods: apigateway.Cors.ALL_METHODS, + allowHeaders: [ + "Content-Type", + "Authorization", + "X-Amz-Date", + "X-Api-Key", + "X-Amz-Security-Token", + ], + maxAge: cdk.Duration.hours(1), + }, + endpointTypes: [apigateway.EndpointType.REGIONAL], + }); + + this.apiEndpoint = this.api.url; + + // ----------------------------------------------------------------------- + // Cognito authorizer (multi-pool) + // ----------------------------------------------------------------------- + const cognitoAuthorizer = new apigateway.CognitoUserPoolsAuthorizer(this, "CognitoAuth", { + cognitoUserPools: [researcherUserPool, participantUserPool], + authorizerName: `wellab-cognito-${envName}`, + identitySource: "method.request.header.Authorization", + }); + + const authorizedMethodOptions: apigateway.MethodOptions = { + authorizer: cognitoAuthorizer, + authorizationType: apigateway.AuthorizationType.COGNITO, + }; + + // ----------------------------------------------------------------------- + // Request validators + // ----------------------------------------------------------------------- + const bodyValidator = new apigateway.RequestValidator(this, "BodyValidator", { + restApi: this.api, + requestValidatorName: "validate-body", + validateRequestBody: true, + validateRequestParameters: false, + }); + + const paramsValidator = new apigateway.RequestValidator(this, "ParamsValidator", { + restApi: this.api, + requestValidatorName: "validate-params", + validateRequestBody: false, + validateRequestParameters: true, + }); + + // ----------------------------------------------------------------------- + // Usage plans + API keys for external access + // ----------------------------------------------------------------------- + const externalKey = this.api.addApiKey("ExternalApiKey", { + apiKeyName: `wellab-external-${envName}`, + description: "API key for external / system integrations", + }); + + const usagePlan = this.api.addUsagePlan("ExternalUsagePlan", { + name: `wellab-external-plan-${envName}`, + description: "Rate-limited plan for external API consumers", + throttle: { rateLimit: 100, burstLimit: 50 }, + quota: { limit: 10000, period: apigateway.Period.DAY }, + }); + usagePlan.addApiKey(externalKey); + usagePlan.addApiStage({ stage: this.api.deploymentStage }); + + // ----------------------------------------------------------------------- + // Common environment variables for all Lambdas + // ----------------------------------------------------------------------- + const commonEnv: Record = { + TABLE_NAME: mainTable.tableName, + RAW_DATA_BUCKET: rawDataBucket.bucketName, + MODEL_BUCKET: modelBucket.bucketName, + ENVIRONMENT: envName, + POWERTOOLS_SERVICE_NAME: "wellab-api", + LOG_LEVEL: isProd ? "WARN" : "DEBUG", + }; + + // ----------------------------------------------------------------------- + // Helper: create a Lambda function with least-privilege role + // ----------------------------------------------------------------------- + const createLambda = ( + name: string, + handler: string, + codePath: string, + options?: { + readOnly?: boolean; + needsS3Read?: boolean; + needsS3Write?: boolean; + timeout?: cdk.Duration; + memorySize?: number; + extraEnv?: Record; + }, + ): lambda.Function => { + const opts = options || {}; + const fn = new lambda.Function(this, name, { + functionName: `wellab-${name.toLowerCase()}-${envName}`, + runtime: lambda.Runtime.NODEJS_20_X, + handler, + code: lambda.Code.fromAsset(codePath), + layers: [sharedLayer], + environment: { ...commonEnv, ...opts.extraEnv }, + timeout: opts.timeout || cdk.Duration.seconds(30), + memorySize: opts.memorySize || 256, + tracing: lambda.Tracing.ACTIVE, + logRetention: isProd ? logs.RetentionDays.ONE_YEAR : logs.RetentionDays.THREE_MONTHS, + }); + + // DynamoDB permissions — read-only or read-write + if (opts.readOnly) { + mainTable.grantReadData(fn); + } else { + mainTable.grantReadWriteData(fn); + } + + // S3 permissions + if (opts.needsS3Read) { + rawDataBucket.grantRead(fn); + } + if (opts.needsS3Write) { + rawDataBucket.grantReadWrite(fn); + } + + this.lambdaFunctions.push(fn); + return fn; + }; + + // ----------------------------------------------------------------------- + // Lambda functions per route group + // ----------------------------------------------------------------------- + const participantsFn = createLambda( + "Participants", + "index.handler", + "../src/lambdas/participants", + { readOnly: false }, + ); + + const observationsFn = createLambda( + "Observations", + "index.handler", + "../src/lambdas/observations", + { readOnly: false, needsS3Write: true }, + ); + + const emotionalDynamicsFn = createLambda( + "EmotionalDynamics", + "index.handler", + "../src/lambdas/emotional-dynamics", + { readOnly: true, timeout: cdk.Duration.seconds(60), memorySize: 512 }, + ); + + const healthFn = createLambda( + "Health", + "index.handler", + "../src/lambdas/health", + { readOnly: true, needsS3Read: true }, + ); + + const lifespanFn = createLambda( + "Lifespan", + "index.handler", + "../src/lambdas/lifespan", + { readOnly: true, timeout: cdk.Duration.seconds(60), memorySize: 512 }, + ); + + const cognitiveFn = createLambda( + "Cognitive", + "index.handler", + "../src/lambdas/cognitive", + { readOnly: true, timeout: cdk.Duration.seconds(60), memorySize: 512 }, + ); + + const interventionsFn = createLambda( + "Interventions", + "index.handler", + "../src/lambdas/interventions", + { readOnly: false }, + ); + + // DynamoDB Stream handler for derived metrics + const streamHandlerFn = createLambda( + "StreamHandler", + "index.handler", + "../src/lambdas/stream-handler", + { readOnly: false, timeout: cdk.Duration.seconds(60), memorySize: 512 }, + ); + + // Grant stream read to the stream handler + mainTable.grantStreamRead(streamHandlerFn); + streamHandlerFn.addEventSourceMapping("DynamoDBStream", { + eventSourceArn: mainTable.tableStreamArn!, + startingPosition: lambda.StartingPosition.TRIM_HORIZON, + batchSize: 25, + maxBatchingWindow: cdk.Duration.seconds(5), + retryAttempts: 3, + bisectBatchOnError: true, + }); + + // ----------------------------------------------------------------------- + // API routes + // ----------------------------------------------------------------------- + const apiRoot = this.api.root.addResource("api"); + + // Health check (public) + const healthCheck = apiRoot.addResource("health-check"); + healthCheck.addMethod("GET", new apigateway.LambdaIntegration(participantsFn)); + + // /participants + const participants = apiRoot.addResource("participants"); + participants.addMethod("GET", new apigateway.LambdaIntegration(participantsFn), authorizedMethodOptions); + participants.addMethod("POST", new apigateway.LambdaIntegration(participantsFn), { + ...authorizedMethodOptions, + requestValidator: bodyValidator, + }); + + const participantById = participants.addResource("{id}"); + participantById.addMethod("GET", new apigateway.LambdaIntegration(participantsFn), authorizedMethodOptions); + participantById.addMethod("PUT", new apigateway.LambdaIntegration(participantsFn), { + ...authorizedMethodOptions, + requestValidator: bodyValidator, + }); + + // /participants/:id/observations + const observations = participantById.addResource("observations"); + observations.addMethod("GET", new apigateway.LambdaIntegration(observationsFn), authorizedMethodOptions); + observations.addMethod("POST", new apigateway.LambdaIntegration(observationsFn), { + ...authorizedMethodOptions, + requestValidator: bodyValidator, + }); + + // /participants/:id/emotional-dynamics + const emotionalDyn = participantById.addResource("emotional-dynamics"); + emotionalDyn.addMethod("GET", new apigateway.LambdaIntegration(emotionalDynamicsFn), authorizedMethodOptions); + + // /participants/:id/health-records + const healthRecords = participantById.addResource("health-records"); + healthRecords.addMethod("GET", new apigateway.LambdaIntegration(healthFn), authorizedMethodOptions); + + // /participants/:id/trajectory + const trajectory = participantById.addResource("trajectory"); + trajectory.addMethod("GET", new apigateway.LambdaIntegration(lifespanFn), authorizedMethodOptions); + + // /participants/:id/cognitive + const cognitiveRoute = participantById.addResource("cognitive"); + cognitiveRoute.addMethod("GET", new apigateway.LambdaIntegration(cognitiveFn), authorizedMethodOptions); + + // /participants/:id/interventions + const interventionsRoute = participantById.addResource("interventions"); + interventionsRoute.addMethod("GET", new apigateway.LambdaIntegration(interventionsFn), authorizedMethodOptions); + + // /emotional-dynamics/analyze + const edAnalyze = apiRoot.addResource("emotional-dynamics").addResource("analyze"); + edAnalyze.addMethod("POST", new apigateway.LambdaIntegration(emotionalDynamicsFn), { + ...authorizedMethodOptions, + requestValidator: bodyValidator, + }); + + // /health/causal-analysis + const healthCausal = apiRoot.addResource("health").addResource("causal-analysis"); + healthCausal.addMethod("POST", new apigateway.LambdaIntegration(healthFn), { + ...authorizedMethodOptions, + requestValidator: bodyValidator, + }); + + // /lifespan/cluster-analysis + const lifespanCluster = apiRoot.addResource("lifespan").addResource("cluster-analysis"); + lifespanCluster.addMethod("POST", new apigateway.LambdaIntegration(lifespanFn), { + ...authorizedMethodOptions, + requestValidator: bodyValidator, + }); + + // /cognitive/risk-assessment + const cognitiveRisk = apiRoot.addResource("cognitive").addResource("risk-assessment"); + cognitiveRisk.addMethod("POST", new apigateway.LambdaIntegration(cognitiveFn), { + ...authorizedMethodOptions, + requestValidator: bodyValidator, + }); + + // /interventions + const interventionsRoot = apiRoot.addResource("interventions"); + interventionsRoot.addMethod("POST", new apigateway.LambdaIntegration(interventionsFn), { + ...authorizedMethodOptions, + requestValidator: bodyValidator, + }); + + // ----------------------------------------------------------------------- + // Outputs + // ----------------------------------------------------------------------- + new cdk.CfnOutput(this, "ApiUrl", { + value: this.api.url, + exportName: `${envName}-ApiUrl`, + }); + new cdk.CfnOutput(this, "ApiId", { + value: this.api.restApiId, + exportName: `${envName}-ApiId`, + }); + } +} diff --git a/infra/lib/auth-stack.ts b/infra/lib/auth-stack.ts new file mode 100644 index 0000000..62f6d76 --- /dev/null +++ b/infra/lib/auth-stack.ts @@ -0,0 +1,161 @@ +import * as cdk from "aws-cdk-lib"; +import { Construct } from "constructs"; +import * as cognito from "aws-cdk-lib/aws-cognito"; + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- +export interface AuthStackProps extends cdk.StackProps { + readonly envName: string; + readonly removalPolicy: cdk.RemovalPolicy; +} + +// --------------------------------------------------------------------------- +// AuthStack — Cognito User Pools for researchers and participants +// --------------------------------------------------------------------------- +export class AuthStack extends cdk.Stack { + public readonly researcherUserPool: cognito.UserPool; + public readonly participantUserPool: cognito.UserPool; + public readonly researcherClient: cognito.UserPoolClient; + public readonly participantClient: cognito.UserPoolClient; + public readonly identityPool: cognito.CfnIdentityPool; + + constructor(scope: Construct, id: string, props: AuthStackProps) { + super(scope, id, props); + + const { envName, removalPolicy } = props; + + // ----------------------------------------------------------------------- + // Researcher User Pool + // ----------------------------------------------------------------------- + this.researcherUserPool = new cognito.UserPool(this, "ResearcherPool", { + userPoolName: `wellab-researchers-${envName}`, + selfSignUpEnabled: false, // admin-created accounts only + signInAliases: { email: true }, + autoVerify: { email: true }, + mfa: cognito.Mfa.OPTIONAL, + mfaSecondFactor: { sms: false, otp: true }, + passwordPolicy: { + minLength: 12, + requireLowercase: true, + requireUppercase: true, + requireDigits: true, + requireSymbols: true, + tempPasswordValidity: cdk.Duration.days(7), + }, + accountRecovery: cognito.AccountRecovery.EMAIL_ONLY, + customAttributes: { + role: new cognito.StringAttribute({ mutable: true, minLen: 1, maxLen: 64 }), + institution: new cognito.StringAttribute({ mutable: true, minLen: 1, maxLen: 256 }), + department: new cognito.StringAttribute({ mutable: true, minLen: 1, maxLen: 256 }), + }, + removalPolicy, + }); + + this.researcherClient = this.researcherUserPool.addClient("ResearcherWebClient", { + userPoolClientName: `wellab-researcher-web-${envName}`, + authFlows: { + userPassword: true, + userSrp: true, + }, + oAuth: { + flows: { authorizationCodeGrant: true }, + scopes: [cognito.OAuthScope.OPENID, cognito.OAuthScope.EMAIL, cognito.OAuthScope.PROFILE], + callbackUrls: [`https://${envName === "prod" ? "" : envName + "."}wellab.wustl.edu/callback`], + logoutUrls: [`https://${envName === "prod" ? "" : envName + "."}wellab.wustl.edu/logout`], + }, + preventUserExistenceErrors: true, + accessTokenValidity: cdk.Duration.hours(1), + idTokenValidity: cdk.Duration.hours(1), + refreshTokenValidity: cdk.Duration.days(30), + }); + + // ----------------------------------------------------------------------- + // Participant User Pool + // ----------------------------------------------------------------------- + this.participantUserPool = new cognito.UserPool(this, "ParticipantPool", { + userPoolName: `wellab-participants-${envName}`, + selfSignUpEnabled: true, + signInAliases: { email: true, phone: true }, + autoVerify: { email: true, phone: true }, + mfa: cognito.Mfa.OPTIONAL, + mfaSecondFactor: { sms: true, otp: true }, + passwordPolicy: { + minLength: 8, + requireLowercase: true, + requireUppercase: true, + requireDigits: true, + requireSymbols: false, + tempPasswordValidity: cdk.Duration.days(7), + }, + accountRecovery: cognito.AccountRecovery.EMAIL_AND_PHONE_WITHOUT_MFA, + customAttributes: { + participant_id: new cognito.StringAttribute({ mutable: false, minLen: 1, maxLen: 128 }), + consent_flags: new cognito.StringAttribute({ mutable: true, minLen: 0, maxLen: 1024 }), + enrollment_date: new cognito.StringAttribute({ mutable: false, minLen: 10, maxLen: 10 }), + culture_group: new cognito.StringAttribute({ mutable: true, minLen: 1, maxLen: 64 }), + }, + removalPolicy, + }); + + this.participantClient = this.participantUserPool.addClient("ParticipantWebClient", { + userPoolClientName: `wellab-participant-web-${envName}`, + authFlows: { + userPassword: true, + userSrp: true, + }, + oAuth: { + flows: { authorizationCodeGrant: true }, + scopes: [cognito.OAuthScope.OPENID, cognito.OAuthScope.EMAIL, cognito.OAuthScope.PROFILE], + callbackUrls: [`https://${envName === "prod" ? "" : envName + "."}wellab.wustl.edu/callback`], + logoutUrls: [`https://${envName === "prod" ? "" : envName + "."}wellab.wustl.edu/logout`], + }, + preventUserExistenceErrors: true, + accessTokenValidity: cdk.Duration.hours(1), + idTokenValidity: cdk.Duration.hours(1), + refreshTokenValidity: cdk.Duration.days(90), + }); + + // ----------------------------------------------------------------------- + // Identity Pool (federated identities for fine-grained IAM) + // ----------------------------------------------------------------------- + this.identityPool = new cognito.CfnIdentityPool(this, "IdentityPool", { + identityPoolName: `wellab_identity_${envName}`, + allowUnauthenticatedIdentities: false, + cognitoIdentityProviders: [ + { + clientId: this.researcherClient.userPoolClientId, + providerName: this.researcherUserPool.userPoolProviderName, + }, + { + clientId: this.participantClient.userPoolClientId, + providerName: this.participantUserPool.userPoolProviderName, + }, + ], + }); + + // ----------------------------------------------------------------------- + // Outputs + // ----------------------------------------------------------------------- + new cdk.CfnOutput(this, "ResearcherUserPoolId", { + value: this.researcherUserPool.userPoolId, + exportName: `${envName}-ResearcherUserPoolId`, + }); + new cdk.CfnOutput(this, "ResearcherClientId", { + value: this.researcherClient.userPoolClientId, + exportName: `${envName}-ResearcherClientId`, + }); + new cdk.CfnOutput(this, "ParticipantUserPoolId", { + value: this.participantUserPool.userPoolId, + exportName: `${envName}-ParticipantUserPoolId`, + }); + new cdk.CfnOutput(this, "ParticipantClientId", { + value: this.participantClient.userPoolClientId, + exportName: `${envName}-ParticipantClientId`, + }); + new cdk.CfnOutput(this, "IdentityPoolId", { + value: this.identityPool.ref, + exportName: `${envName}-IdentityPoolId`, + }); + } +} diff --git a/infra/lib/data-stack.ts b/infra/lib/data-stack.ts new file mode 100644 index 0000000..b652556 --- /dev/null +++ b/infra/lib/data-stack.ts @@ -0,0 +1,121 @@ +import * as cdk from "aws-cdk-lib"; +import { Construct } from "constructs"; +import * as dynamodb from "aws-cdk-lib/aws-dynamodb"; +import * as s3 from "aws-cdk-lib/aws-s3"; + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- +export interface DataStackProps extends cdk.StackProps { + readonly envName: string; + readonly removalPolicy: cdk.RemovalPolicy; + readonly isProd: boolean; +} + +// --------------------------------------------------------------------------- +// DataStack — DynamoDB single-table + S3 buckets +// --------------------------------------------------------------------------- +export class DataStack extends cdk.Stack { + public readonly mainTable: dynamodb.Table; + public readonly rawDataBucket: s3.Bucket; + public readonly modelBucket: s3.Bucket; + + constructor(scope: Construct, id: string, props: DataStackProps) { + super(scope, id, props); + + const { envName, removalPolicy, isProd } = props; + + // ----------------------------------------------------------------------- + // DynamoDB — wellab-main (single-table design) + // ----------------------------------------------------------------------- + this.mainTable = new dynamodb.Table(this, "MainTable", { + tableName: `wellab-main-${envName}`, + partitionKey: { name: "PK", type: dynamodb.AttributeType.STRING }, + sortKey: { name: "SK", type: dynamodb.AttributeType.STRING }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + pointInTimeRecovery: true, + encryption: dynamodb.TableEncryption.AWS_MANAGED, + stream: dynamodb.StreamViewType.NEW_AND_OLD_IMAGES, + removalPolicy, + }); + + // GSI1 — query by status (e.g. participant enrollment status) + this.mainTable.addGlobalSecondaryIndex({ + indexName: "GSI1-status", + partitionKey: { name: "GSI1PK", type: dynamodb.AttributeType.STRING }, + sortKey: { name: "GSI1SK", type: dynamodb.AttributeType.STRING }, + projectionType: dynamodb.ProjectionType.ALL, + }); + + // GSI2 — query by culture group + this.mainTable.addGlobalSecondaryIndex({ + indexName: "GSI2-culture-group", + partitionKey: { name: "GSI2PK", type: dynamodb.AttributeType.STRING }, + sortKey: { name: "GSI2SK", type: dynamodb.AttributeType.STRING }, + projectionType: dynamodb.ProjectionType.ALL, + }); + + // ----------------------------------------------------------------------- + // S3 — raw research data + // ----------------------------------------------------------------------- + this.rawDataBucket = new s3.Bucket(this, "RawDataBucket", { + bucketName: `wellab-raw-data-${envName}-${this.account}`, + versioned: true, + encryption: s3.BucketEncryption.S3_MANAGED, + blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, + enforceSSL: true, + removalPolicy, + autoDeleteObjects: !isProd, + lifecycleRules: [ + { + id: "ArchiveToGlacier", + enabled: true, + transitions: [ + { + storageClass: s3.StorageClass.GLACIER, + transitionAfter: cdk.Duration.days(730), + }, + ], + }, + { + id: "CleanupIncompleteUploads", + enabled: true, + abortIncompleteMultipartUploadAfter: cdk.Duration.days(7), + }, + ], + }); + + // ----------------------------------------------------------------------- + // S3 — ML model artifacts + // ----------------------------------------------------------------------- + this.modelBucket = new s3.Bucket(this, "ModelBucket", { + bucketName: `wellab-models-${envName}-${this.account}`, + versioned: true, + encryption: s3.BucketEncryption.S3_MANAGED, + blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, + enforceSSL: true, + removalPolicy, + autoDeleteObjects: !isProd, + }); + + // ----------------------------------------------------------------------- + // Outputs + // ----------------------------------------------------------------------- + new cdk.CfnOutput(this, "MainTableName", { + value: this.mainTable.tableName, + exportName: `${envName}-MainTableName`, + }); + new cdk.CfnOutput(this, "MainTableStreamArn", { + value: this.mainTable.tableStreamArn!, + exportName: `${envName}-MainTableStreamArn`, + }); + new cdk.CfnOutput(this, "RawDataBucketName", { + value: this.rawDataBucket.bucketName, + exportName: `${envName}-RawDataBucketName`, + }); + new cdk.CfnOutput(this, "ModelBucketName", { + value: this.modelBucket.bucketName, + exportName: `${envName}-ModelBucketName`, + }); + } +} diff --git a/infra/lib/frontend-stack.ts b/infra/lib/frontend-stack.ts new file mode 100644 index 0000000..cc6f9db --- /dev/null +++ b/infra/lib/frontend-stack.ts @@ -0,0 +1,167 @@ +import * as cdk from "aws-cdk-lib"; +import { Construct } from "constructs"; +import * as s3 from "aws-cdk-lib/aws-s3"; +import * as cloudfront from "aws-cdk-lib/aws-cloudfront"; +import * as origins from "aws-cdk-lib/aws-cloudfront-origins"; +import * as route53 from "aws-cdk-lib/aws-route53"; +import * as route53targets from "aws-cdk-lib/aws-route53-targets"; +import * as acm from "aws-cdk-lib/aws-certificatemanager"; + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- +export interface FrontendStackProps extends cdk.StackProps { + readonly envName: string; + readonly domainName: string; + readonly apiEndpoint: string; + readonly removalPolicy: cdk.RemovalPolicy; +} + +// --------------------------------------------------------------------------- +// FrontendStack — S3 + CloudFront for React SPA hosting +// --------------------------------------------------------------------------- +export class FrontendStack extends cdk.Stack { + public readonly distribution: cloudfront.Distribution; + + constructor(scope: Construct, id: string, props: FrontendStackProps) { + super(scope, id, props); + + const { envName, domainName, apiEndpoint, removalPolicy } = props; + const isProd = envName === "prod"; + + // ----------------------------------------------------------------------- + // S3 — static website hosting bucket + // ----------------------------------------------------------------------- + const siteBucket = new s3.Bucket(this, "SiteBucket", { + bucketName: `wellab-frontend-${envName}-${this.account}`, + blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, + encryption: s3.BucketEncryption.S3_MANAGED, + enforceSSL: true, + removalPolicy, + autoDeleteObjects: !isProd, + versioned: isProd, + }); + + // ----------------------------------------------------------------------- + // CloudFront Origin Access Identity + // ----------------------------------------------------------------------- + const oai = new cloudfront.OriginAccessIdentity(this, "OAI", { + comment: `WELLab frontend OAI (${envName})`, + }); + siteBucket.grantRead(oai); + + // ----------------------------------------------------------------------- + // ACM Certificate (must be in us-east-1 for CloudFront) + // Look up the hosted zone; if it does not exist, the distribution is + // created without a custom domain (useful for initial bootstrapping). + // ----------------------------------------------------------------------- + let certificate: acm.ICertificate | undefined; + let hostedZone: route53.IHostedZone | undefined; + + try { + hostedZone = route53.HostedZone.fromLookup(this, "Zone", { + domainName: "wellab.wustl.edu", + }); + + certificate = new acm.DnsValidatedCertificate(this, "SiteCertificate", { + domainName: domainName, + hostedZone, + region: "us-east-1", // CloudFront requirement + }); + } catch { + // Hosted zone not found — skip custom domain setup + } + + // ----------------------------------------------------------------------- + // CloudFront Distribution + // ----------------------------------------------------------------------- + const responseHeadersPolicy = new cloudfront.ResponseHeadersPolicy(this, "SecurityHeaders", { + responseHeadersPolicyName: `wellab-security-headers-${envName}`, + securityHeadersBehavior: { + contentTypeOptions: { override: true }, + frameOptions: { + frameOption: cloudfront.HeadersFrameOption.DENY, + override: true, + }, + referrerPolicy: { + referrerPolicy: cloudfront.HeadersReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN, + override: true, + }, + strictTransportSecurity: { + accessControlMaxAge: cdk.Duration.days(365), + includeSubdomains: true, + preload: true, + override: true, + }, + xssProtection: { + protection: true, + modeBlock: true, + override: true, + }, + }, + }); + + this.distribution = new cloudfront.Distribution(this, "Distribution", { + comment: `WELLab SPA distribution (${envName})`, + defaultBehavior: { + origin: new origins.S3Origin(siteBucket, { originAccessIdentity: oai }), + viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, + cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED, + responseHeadersPolicy, + compress: true, + }, + defaultRootObject: "index.html", + errorResponses: [ + { + httpStatus: 404, + responseHttpStatus: 200, + responsePagePath: "/index.html", + ttl: cdk.Duration.seconds(0), + }, + { + httpStatus: 403, + responseHttpStatus: 200, + responsePagePath: "/index.html", + ttl: cdk.Duration.seconds(0), + }, + ], + ...(certificate && { + domainNames: [domainName], + certificate, + }), + minimumProtocolVersion: cloudfront.SecurityPolicyProtocol.TLS_V1_2_2021, + httpVersion: cloudfront.HttpVersion.HTTP2_AND_3, + priceClass: isProd ? cloudfront.PriceClass.PRICE_CLASS_ALL : cloudfront.PriceClass.PRICE_CLASS_100, + enabled: true, + }); + + // ----------------------------------------------------------------------- + // Route53 alias record + // ----------------------------------------------------------------------- + if (hostedZone) { + new route53.ARecord(this, "SiteAliasRecord", { + zone: hostedZone, + recordName: domainName, + target: route53.RecordTarget.fromAlias( + new route53targets.CloudFrontTarget(this.distribution), + ), + }); + } + + // ----------------------------------------------------------------------- + // Outputs + // ----------------------------------------------------------------------- + new cdk.CfnOutput(this, "SiteBucketName", { + value: siteBucket.bucketName, + exportName: `${envName}-SiteBucketName`, + }); + new cdk.CfnOutput(this, "DistributionId", { + value: this.distribution.distributionId, + exportName: `${envName}-DistributionId`, + }); + new cdk.CfnOutput(this, "DistributionDomainName", { + value: this.distribution.distributionDomainName, + exportName: `${envName}-DistributionDomainName`, + }); + } +} diff --git a/infra/lib/ml-stack.ts b/infra/lib/ml-stack.ts new file mode 100644 index 0000000..087e444 --- /dev/null +++ b/infra/lib/ml-stack.ts @@ -0,0 +1,341 @@ +import * as cdk from "aws-cdk-lib"; +import { Construct } from "constructs"; +import * as sagemaker from "aws-cdk-lib/aws-sagemaker"; +import * as stepfunctions from "aws-cdk-lib/aws-stepfunctions"; +import * as tasks from "aws-cdk-lib/aws-stepfunctions-tasks"; +import * as lambda from "aws-cdk-lib/aws-lambda"; +import * as iam from "aws-cdk-lib/aws-iam"; +import * as s3 from "aws-cdk-lib/aws-s3"; +import * as dynamodb from "aws-cdk-lib/aws-dynamodb"; +import * as events from "aws-cdk-lib/aws-events"; +import * as targets from "aws-cdk-lib/aws-events-targets"; +import * as logs from "aws-cdk-lib/aws-logs"; + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- +export interface MLStackProps extends cdk.StackProps { + readonly envName: string; + readonly mainTable: dynamodb.Table; + readonly rawDataBucket: s3.Bucket; + readonly modelBucket: s3.Bucket; + readonly removalPolicy: cdk.RemovalPolicy; + readonly isProd: boolean; +} + +// --------------------------------------------------------------------------- +// MLStack — SageMaker, Step Functions ML pipeline, fairness audits +// --------------------------------------------------------------------------- +export class MLStack extends cdk.Stack { + constructor(scope: Construct, id: string, props: MLStackProps) { + super(scope, id, props); + + const { envName, mainTable, rawDataBucket, modelBucket, removalPolicy, isProd } = props; + + // ----------------------------------------------------------------------- + // SageMaker execution role + // ----------------------------------------------------------------------- + const sagemakerRole = new iam.Role(this, "SageMakerExecutionRole", { + roleName: `wellab-sagemaker-exec-${envName}`, + assumedBy: new iam.ServicePrincipal("sagemaker.amazonaws.com"), + description: "Execution role for WELLab SageMaker notebook and training jobs", + }); + + rawDataBucket.grantRead(sagemakerRole); + modelBucket.grantReadWrite(sagemakerRole); + mainTable.grantReadData(sagemakerRole); + + sagemakerRole.addToPolicy( + new iam.PolicyStatement({ + sid: "CloudWatchLogs", + effect: iam.Effect.ALLOW, + actions: ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"], + resources: [ + `arn:aws:logs:${this.region}:${this.account}:log-group:/aws/sagemaker/*`, + ], + }), + ); + + sagemakerRole.addToPolicy( + new iam.PolicyStatement({ + sid: "ECRPull", + effect: iam.Effect.ALLOW, + actions: [ + "ecr:GetAuthorizationToken", + "ecr:BatchCheckLayerAvailability", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchGetImage", + ], + resources: ["*"], // ECR auth token is account-wide + }), + ); + + // ----------------------------------------------------------------------- + // SageMaker Notebook Instance (research) + // ----------------------------------------------------------------------- + const notebook = new sagemaker.CfnNotebookInstance(this, "ResearchNotebook", { + notebookInstanceName: `wellab-research-${envName}`, + instanceType: isProd ? "ml.t3.xlarge" : "ml.t3.medium", + roleArn: sagemakerRole.roleArn, + directInternetAccess: "Enabled", + volumeSizeInGb: 50, + tags: [ + { key: "Project", value: "WELLab" }, + { key: "Environment", value: envName }, + ], + }); + + // ----------------------------------------------------------------------- + // SageMaker real-time inference endpoints + // ----------------------------------------------------------------------- + const createEndpointConfig = (modelName: string, instanceType: string): sagemaker.CfnEndpointConfig => { + return new sagemaker.CfnEndpointConfig(this, `${modelName}EndpointConfig`, { + endpointConfigName: `wellab-${modelName}-config-${envName}`, + productionVariants: [ + { + variantName: "primary", + modelName: `wellab-${modelName}-${envName}`, + instanceType: instanceType, + initialInstanceCount: 1, + initialVariantWeight: 1, + }, + ], + tags: [ + { key: "Project", value: "WELLab" }, + { key: "Model", value: modelName }, + ], + }); + }; + + const emotionalDynamicsConfig = createEndpointConfig("emotional-dynamics", isProd ? "ml.m5.large" : "ml.t2.medium"); + const cognitiveRiskConfig = createEndpointConfig("cognitive-risk", isProd ? "ml.m5.large" : "ml.t2.medium"); + + // ----------------------------------------------------------------------- + // Lambda functions for ML pipeline steps + // ----------------------------------------------------------------------- + const mlLambdaRole = new iam.Role(this, "MLLambdaRole", { + roleName: `wellab-ml-lambda-${envName}`, + assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"), + managedPolicies: [ + iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"), + ], + }); + + rawDataBucket.grantRead(mlLambdaRole); + modelBucket.grantReadWrite(mlLambdaRole); + mainTable.grantReadData(mlLambdaRole); + + mlLambdaRole.addToPolicy( + new iam.PolicyStatement({ + sid: "SageMakerJobs", + effect: iam.Effect.ALLOW, + actions: [ + "sagemaker:CreateTrainingJob", + "sagemaker:DescribeTrainingJob", + "sagemaker:CreateModel", + "sagemaker:CreateEndpointConfig", + "sagemaker:UpdateEndpoint", + "sagemaker:DescribeEndpoint", + ], + resources: [ + `arn:aws:sagemaker:${this.region}:${this.account}:training-job/wellab-*`, + `arn:aws:sagemaker:${this.region}:${this.account}:model/wellab-*`, + `arn:aws:sagemaker:${this.region}:${this.account}:endpoint/wellab-*`, + `arn:aws:sagemaker:${this.region}:${this.account}:endpoint-config/wellab-*`, + ], + }), + ); + + mlLambdaRole.addToPolicy( + new iam.PolicyStatement({ + sid: "PassSageMakerRole", + effect: iam.Effect.ALLOW, + actions: ["iam:PassRole"], + resources: [sagemakerRole.roleArn], + conditions: { + StringEquals: { "iam:PassedToService": "sagemaker.amazonaws.com" }, + }, + }), + ); + + const commonMlEnv: Record = { + TABLE_NAME: mainTable.tableName, + RAW_DATA_BUCKET: rawDataBucket.bucketName, + MODEL_BUCKET: modelBucket.bucketName, + SAGEMAKER_ROLE_ARN: sagemakerRole.roleArn, + ENVIRONMENT: envName, + }; + + const dataPrepFn = new lambda.Function(this, "DataPrepFn", { + functionName: `wellab-ml-data-prep-${envName}`, + runtime: lambda.Runtime.PYTHON_3_12, + handler: "handler.main", + code: lambda.Code.fromAsset("../src/ml/data-prep"), + role: mlLambdaRole, + timeout: cdk.Duration.minutes(10), + memorySize: 1024, + environment: commonMlEnv, + tracing: lambda.Tracing.ACTIVE, + }); + + const trainFn = new lambda.Function(this, "TrainFn", { + functionName: `wellab-ml-train-${envName}`, + runtime: lambda.Runtime.PYTHON_3_12, + handler: "handler.main", + code: lambda.Code.fromAsset("../src/ml/train"), + role: mlLambdaRole, + timeout: cdk.Duration.minutes(15), + memorySize: 512, + environment: commonMlEnv, + tracing: lambda.Tracing.ACTIVE, + }); + + const evaluateFn = new lambda.Function(this, "EvaluateFn", { + functionName: `wellab-ml-evaluate-${envName}`, + runtime: lambda.Runtime.PYTHON_3_12, + handler: "handler.main", + code: lambda.Code.fromAsset("../src/ml/evaluate"), + role: mlLambdaRole, + timeout: cdk.Duration.minutes(10), + memorySize: 512, + environment: commonMlEnv, + tracing: lambda.Tracing.ACTIVE, + }); + + const deployModelFn = new lambda.Function(this, "DeployModelFn", { + functionName: `wellab-ml-deploy-${envName}`, + runtime: lambda.Runtime.PYTHON_3_12, + handler: "handler.main", + code: lambda.Code.fromAsset("../src/ml/deploy"), + role: mlLambdaRole, + timeout: cdk.Duration.minutes(10), + memorySize: 256, + environment: commonMlEnv, + tracing: lambda.Tracing.ACTIVE, + }); + + // ----------------------------------------------------------------------- + // Step Functions — ML pipeline orchestration + // ----------------------------------------------------------------------- + const dataPrepTask = new tasks.LambdaInvoke(this, "DataPrepTask", { + lambdaFunction: dataPrepFn, + outputPath: "$.Payload", + resultPath: "$.dataPrepResult", + }); + + const trainTask = new tasks.LambdaInvoke(this, "TrainTask", { + lambdaFunction: trainFn, + outputPath: "$.Payload", + resultPath: "$.trainResult", + }); + + const evaluateTask = new tasks.LambdaInvoke(this, "EvaluateTask", { + lambdaFunction: evaluateFn, + outputPath: "$.Payload", + resultPath: "$.evaluateResult", + }); + + const deployTask = new tasks.LambdaInvoke(this, "DeployTask", { + lambdaFunction: deployModelFn, + outputPath: "$.Payload", + resultPath: "$.deployResult", + }); + + const evaluationCheck = new stepfunctions.Choice(this, "ModelMeetsThreshold") + .when( + stepfunctions.Condition.numberGreaterThanEquals("$.evaluateResult.accuracy", 0.80), + deployTask, + ) + .otherwise( + new stepfunctions.Fail(this, "ModelBelowThreshold", { + cause: "Model accuracy below 0.80 threshold", + error: "MODEL_QUALITY_CHECK_FAILED", + }), + ); + + const pipelineDefinition = dataPrepTask + .next(trainTask) + .next(evaluateTask) + .next(evaluationCheck); + + const pipelineLogGroup = new logs.LogGroup(this, "PipelineLogGroup", { + logGroupName: `/wellab/${envName}/ml-pipeline`, + retention: isProd ? logs.RetentionDays.ONE_YEAR : logs.RetentionDays.THREE_MONTHS, + removalPolicy: isProd ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY, + }); + + const mlPipeline = new stepfunctions.StateMachine(this, "MLPipeline", { + stateMachineName: `wellab-ml-pipeline-${envName}`, + definitionBody: stepfunctions.DefinitionBody.fromChainable(pipelineDefinition), + timeout: cdk.Duration.hours(6), + tracingEnabled: true, + logs: { + destination: pipelineLogGroup, + level: stepfunctions.LogLevel.ALL, + }, + }); + + // ----------------------------------------------------------------------- + // Fairness audit Lambda — monthly cron + // ----------------------------------------------------------------------- + const fairnessAuditFn = new lambda.Function(this, "FairnessAuditFn", { + functionName: `wellab-fairness-audit-${envName}`, + runtime: lambda.Runtime.PYTHON_3_12, + handler: "handler.main", + code: lambda.Code.fromAsset("../src/ml/fairness-audit"), + role: mlLambdaRole, + timeout: cdk.Duration.minutes(15), + memorySize: 1024, + environment: { + ...commonMlEnv, + AUDIT_TYPE: "fairness", + }, + tracing: lambda.Tracing.ACTIVE, + }); + + new events.Rule(this, "MonthlyFairnessAudit", { + ruleName: `wellab-fairness-audit-monthly-${envName}`, + description: "Trigger monthly fairness audit across all ML models", + schedule: events.Schedule.cron({ minute: "0", hour: "3", day: "1", month: "*" }), + targets: [new targets.LambdaFunction(fairnessAuditFn)], + }); + + // ----------------------------------------------------------------------- + // S3 trigger for batch processing + // ----------------------------------------------------------------------- + const batchProcessFn = new lambda.Function(this, "BatchProcessFn", { + functionName: `wellab-batch-process-${envName}`, + runtime: lambda.Runtime.PYTHON_3_12, + handler: "handler.main", + code: lambda.Code.fromAsset("../src/ml/batch-process"), + role: mlLambdaRole, + timeout: cdk.Duration.minutes(15), + memorySize: 1024, + environment: { + ...commonMlEnv, + STATE_MACHINE_ARN: mlPipeline.stateMachineArn, + }, + tracing: lambda.Tracing.ACTIVE, + }); + + mlPipeline.grantStartExecution(batchProcessFn); + + rawDataBucket.addEventNotification( + s3.EventType.OBJECT_CREATED, + new cdk.aws_s3_notifications.LambdaDestination(batchProcessFn), + { prefix: "batch-uploads/", suffix: ".csv" }, + ); + + // ----------------------------------------------------------------------- + // Outputs + // ----------------------------------------------------------------------- + new cdk.CfnOutput(this, "NotebookName", { + value: notebook.notebookInstanceName!, + exportName: `${envName}-NotebookName`, + }); + new cdk.CfnOutput(this, "MLPipelineArn", { + value: mlPipeline.stateMachineArn, + exportName: `${envName}-MLPipelineArn`, + }); + } +} diff --git a/infra/lib/monitoring-stack.ts b/infra/lib/monitoring-stack.ts new file mode 100644 index 0000000..37c96ee --- /dev/null +++ b/infra/lib/monitoring-stack.ts @@ -0,0 +1,288 @@ +import * as cdk from "aws-cdk-lib"; +import { Construct } from "constructs"; +import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch"; +import * as cw_actions from "aws-cdk-lib/aws-cloudwatch-actions"; +import * as sns from "aws-cdk-lib/aws-sns"; +import * as lambda from "aws-cdk-lib/aws-lambda"; +import * as apigateway from "aws-cdk-lib/aws-apigateway"; +import * as dynamodb from "aws-cdk-lib/aws-dynamodb"; +import * as logs from "aws-cdk-lib/aws-logs"; + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- +export interface MonitoringStackProps extends cdk.StackProps { + readonly envName: string; + readonly api: apigateway.RestApi; + readonly lambdaFunctions: lambda.Function[]; + readonly mainTable: dynamodb.Table; + readonly logRetentionDays: number; + readonly isProd: boolean; +} + +// --------------------------------------------------------------------------- +// MonitoringStack — CloudWatch dashboards, alarms, SNS, X-Ray +// --------------------------------------------------------------------------- +export class MonitoringStack extends cdk.Stack { + public readonly alarmTopic: sns.Topic; + + constructor(scope: Construct, id: string, props: MonitoringStackProps) { + super(scope, id, props); + + const { envName, api, lambdaFunctions, mainTable, logRetentionDays, isProd } = props; + + // ----------------------------------------------------------------------- + // SNS topic for alarm notifications + // ----------------------------------------------------------------------- + this.alarmTopic = new sns.Topic(this, "AlarmTopic", { + topicName: `wellab-alarms-${envName}`, + displayName: `WELLab Alarms (${envName})`, + }); + + const alarmAction = new cw_actions.SnsAction(this.alarmTopic); + + // ----------------------------------------------------------------------- + // CloudWatch Log Groups with retention + // ----------------------------------------------------------------------- + const retentionDays = + logRetentionDays === 365 + ? logs.RetentionDays.ONE_YEAR + : logRetentionDays === 180 + ? logs.RetentionDays.SIX_MONTHS + : logs.RetentionDays.THREE_MONTHS; + + new logs.LogGroup(this, "ApiLogGroup", { + logGroupName: `/wellab/${envName}/api`, + retention: retentionDays, + removalPolicy: isProd ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY, + }); + + new logs.LogGroup(this, "MLLogGroup", { + logGroupName: `/wellab/${envName}/ml`, + retention: retentionDays, + removalPolicy: isProd ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY, + }); + + new logs.LogGroup(this, "AuditLogGroup", { + logGroupName: `/wellab/${envName}/audit`, + retention: logs.RetentionDays.ONE_YEAR, // always keep audit logs for 1 year + removalPolicy: cdk.RemovalPolicy.RETAIN, + }); + + // ----------------------------------------------------------------------- + // API Gateway metrics + // ----------------------------------------------------------------------- + const api5xxMetric = api.metricServerError({ + period: cdk.Duration.minutes(5), + statistic: "Sum", + }); + + const api4xxMetric = api.metricClientError({ + period: cdk.Duration.minutes(5), + statistic: "Sum", + }); + + const apiCountMetric = api.metricCount({ + period: cdk.Duration.minutes(5), + statistic: "Sum", + }); + + const apiLatencyP99 = api.metricLatency({ + period: cdk.Duration.minutes(5), + statistic: "p99", + }); + + const apiLatencyP95 = api.metricLatency({ + period: cdk.Duration.minutes(5), + statistic: "p95", + }); + + const apiLatencyP50 = api.metricLatency({ + period: cdk.Duration.minutes(5), + statistic: "p50", + }); + + // 5xx error rate > 1% + const errorRateAlarm = new cloudwatch.MathExpression({ + expression: "IF(requestCount > 0, (errors / requestCount) * 100, 0)", + usingMetrics: { + errors: api5xxMetric, + requestCount: apiCountMetric, + }, + period: cdk.Duration.minutes(5), + label: "5xx Error Rate (%)", + }).createAlarm(this, "Api5xxRateAlarm", { + alarmName: `wellab-${envName}-api-5xx-rate`, + alarmDescription: "API 5xx error rate exceeds 1% over 5 minutes", + threshold: 1, + evaluationPeriods: 1, + comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD, + treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING, + }); + errorRateAlarm.addAlarmAction(alarmAction); + errorRateAlarm.addOkAction(alarmAction); + + // Latency p99 > 3s + const latencyAlarm = apiLatencyP99.createAlarm(this, "ApiLatencyP99Alarm", { + alarmName: `wellab-${envName}-api-latency-p99`, + alarmDescription: "API p99 latency exceeds 3 seconds for 10 minutes", + threshold: 3000, // milliseconds + evaluationPeriods: 2, + comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD, + treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING, + }); + latencyAlarm.addAlarmAction(alarmAction); + latencyAlarm.addOkAction(alarmAction); + + // ----------------------------------------------------------------------- + // DynamoDB metrics & alarms + // ----------------------------------------------------------------------- + const readThrottleMetric = mainTable.metricThrottledRequestsForOperations({ + operations: [dynamodb.Operation.GET_ITEM, dynamodb.Operation.QUERY, dynamodb.Operation.SCAN], + period: cdk.Duration.minutes(1), + statistic: "Sum", + }); + + const writeThrottleMetric = mainTable.metricThrottledRequestsForOperations({ + operations: [dynamodb.Operation.PUT_ITEM, dynamodb.Operation.UPDATE_ITEM, dynamodb.Operation.DELETE_ITEM], + period: cdk.Duration.minutes(1), + statistic: "Sum", + }); + + const dynamoThrottleAlarm = new cloudwatch.MathExpression({ + expression: "readThrottle + writeThrottle", + usingMetrics: { + readThrottle: readThrottleMetric, + writeThrottle: writeThrottleMetric, + }, + period: cdk.Duration.minutes(1), + label: "DynamoDB Throttled Requests", + }).createAlarm(this, "DynamoThrottleAlarm", { + alarmName: `wellab-${envName}-dynamo-throttle`, + alarmDescription: "DynamoDB throttling detected", + threshold: 0, + evaluationPeriods: 1, + comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD, + treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING, + }); + dynamoThrottleAlarm.addAlarmAction(alarmAction); + + // ----------------------------------------------------------------------- + // Lambda error alarms (aggregate across all functions) + // ----------------------------------------------------------------------- + const lambdaErrorMetrics: cloudwatch.IMetric[] = lambdaFunctions.map((fn, i) => + fn.metricErrors({ period: cdk.Duration.minutes(5), statistic: "Sum" }), + ); + + // Create individual alarms per function + lambdaFunctions.forEach((fn) => { + const errorAlarm = fn.metricErrors({ + period: cdk.Duration.minutes(5), + statistic: "Sum", + }).createAlarm(this, `LambdaErrors-${fn.node.id}`, { + alarmName: `wellab-${envName}-lambda-errors-${fn.functionName}`, + alarmDescription: `Lambda errors for ${fn.functionName}`, + threshold: 5, + evaluationPeriods: 1, + comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD, + treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING, + }); + errorAlarm.addAlarmAction(alarmAction); + }); + + // ----------------------------------------------------------------------- + // CloudWatch Dashboard + // ----------------------------------------------------------------------- + const dashboard = new cloudwatch.Dashboard(this, "Dashboard", { + dashboardName: `WELLab-${envName}`, + }); + + // Row 1 — API overview + dashboard.addWidgets( + new cloudwatch.GraphWidget({ + title: "API Request Count", + left: [apiCountMetric], + width: 6, + period: cdk.Duration.minutes(5), + }), + new cloudwatch.GraphWidget({ + title: "API Latency (p50 / p95 / p99)", + left: [apiLatencyP50, apiLatencyP95, apiLatencyP99], + width: 6, + period: cdk.Duration.minutes(5), + }), + new cloudwatch.GraphWidget({ + title: "API Errors (4xx / 5xx)", + left: [api4xxMetric, api5xxMetric], + width: 6, + period: cdk.Duration.minutes(5), + }), + new cloudwatch.SingleValueWidget({ + title: "Active Alarms", + metrics: [api5xxMetric], + width: 6, + }), + ); + + // Row 2 — DynamoDB + dashboard.addWidgets( + new cloudwatch.GraphWidget({ + title: "DynamoDB Read/Write Capacity", + left: [ + mainTable.metricConsumedReadCapacityUnits({ period: cdk.Duration.minutes(5) }), + mainTable.metricConsumedWriteCapacityUnits({ period: cdk.Duration.minutes(5) }), + ], + width: 8, + }), + new cloudwatch.GraphWidget({ + title: "DynamoDB Throttled Requests", + left: [readThrottleMetric, writeThrottleMetric], + width: 8, + }), + new cloudwatch.GraphWidget({ + title: "DynamoDB Latency", + left: [ + mainTable.metricSuccessfulRequestLatency({ + dimensionsMap: { TableName: mainTable.tableName, Operation: "GetItem" }, + period: cdk.Duration.minutes(5), + }), + mainTable.metricSuccessfulRequestLatency({ + dimensionsMap: { TableName: mainTable.tableName, Operation: "Query" }, + period: cdk.Duration.minutes(5), + }), + ], + width: 8, + }), + ); + + // Row 3 — Lambda functions + dashboard.addWidgets( + new cloudwatch.GraphWidget({ + title: "Lambda Duration (all functions)", + left: lambdaFunctions.map((fn) => + fn.metricDuration({ period: cdk.Duration.minutes(5), statistic: "p95" }), + ), + width: 12, + }), + new cloudwatch.GraphWidget({ + title: "Lambda Errors (all functions)", + left: lambdaFunctions.map((fn) => + fn.metricErrors({ period: cdk.Duration.minutes(5), statistic: "Sum" }), + ), + width: 12, + }), + ); + + // ----------------------------------------------------------------------- + // Outputs + // ----------------------------------------------------------------------- + new cdk.CfnOutput(this, "AlarmTopicArn", { + value: this.alarmTopic.topicArn, + exportName: `${envName}-AlarmTopicArn`, + }); + new cdk.CfnOutput(this, "DashboardName", { + value: dashboard.dashboardName, + exportName: `${envName}-DashboardName`, + }); + } +} diff --git a/infra/lib/pipeline-stack.ts b/infra/lib/pipeline-stack.ts new file mode 100644 index 0000000..41db70a --- /dev/null +++ b/infra/lib/pipeline-stack.ts @@ -0,0 +1,357 @@ +import * as cdk from "aws-cdk-lib"; +import { Construct } from "constructs"; +import * as codepipeline from "aws-cdk-lib/aws-codepipeline"; +import * as codepipeline_actions from "aws-cdk-lib/aws-codepipeline-actions"; +import * as codebuild from "aws-cdk-lib/aws-codebuild"; +import * as iam from "aws-cdk-lib/aws-iam"; +import * as s3 from "aws-cdk-lib/aws-s3"; +import * as sns from "aws-cdk-lib/aws-sns"; + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- +export interface PipelineStackProps extends cdk.StackProps { + readonly githubOwner: string; + readonly githubRepo: string; + readonly githubBranch: string; +} + +// --------------------------------------------------------------------------- +// PipelineStack — CodePipeline CI/CD: Source → Build → Test → Deploy +// --------------------------------------------------------------------------- +export class PipelineStack extends cdk.Stack { + constructor(scope: Construct, id: string, props: PipelineStackProps) { + super(scope, id, props); + + const { githubOwner, githubRepo, githubBranch } = props; + + // ----------------------------------------------------------------------- + // Artifact bucket + // ----------------------------------------------------------------------- + const artifactBucket = new s3.Bucket(this, "ArtifactBucket", { + bucketName: `wellab-pipeline-artifacts-${this.account}`, + encryption: s3.BucketEncryption.S3_MANAGED, + blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, + enforceSSL: true, + removalPolicy: cdk.RemovalPolicy.DESTROY, + autoDeleteObjects: true, + lifecycleRules: [ + { expiration: cdk.Duration.days(30), enabled: true }, + ], + }); + + // ----------------------------------------------------------------------- + // SNS topic for production approval notifications + // ----------------------------------------------------------------------- + const approvalTopic = new sns.Topic(this, "ProdApprovalTopic", { + topicName: "wellab-prod-deploy-approval", + displayName: "WELLab Production Deploy Approval", + }); + + // ----------------------------------------------------------------------- + // Source output + // ----------------------------------------------------------------------- + const sourceOutput = new codepipeline.Artifact("SourceOutput"); + + // GitHub source action (uses CodeStar connection) + const sourceAction = new codepipeline_actions.CodeStarConnectionsSourceAction({ + actionName: "GitHub_Source", + owner: githubOwner, + repo: githubRepo, + branch: githubBranch, + output: sourceOutput, + connectionArn: cdk.Fn.importValue("GitHubConnectionArn"), // pre-configured + }); + + // ----------------------------------------------------------------------- + // CodeBuild — shared IAM role for builds + // ----------------------------------------------------------------------- + const buildRole = new iam.Role(this, "BuildRole", { + roleName: "wellab-codebuild-role", + assumedBy: new iam.ServicePrincipal("codebuild.amazonaws.com"), + }); + + artifactBucket.grantReadWrite(buildRole); + + buildRole.addToPolicy( + new iam.PolicyStatement({ + sid: "CDKDeploy", + effect: iam.Effect.ALLOW, + actions: [ + "cloudformation:*", + "s3:*", + "iam:PassRole", + "lambda:*", + "apigateway:*", + "dynamodb:*", + "cognito-idp:*", + "cognito-identity:*", + "sagemaker:*", + "cloudfront:*", + "route53:*", + "acm:*", + "logs:*", + "sns:*", + "events:*", + "states:*", + "codebuild:*", + "ssm:GetParameter", + ], + resources: ["*"], // CDK deploy requires broad permissions; scoped via stack boundary + }), + ); + + // ----------------------------------------------------------------------- + // CodeBuild projects + // ----------------------------------------------------------------------- + const createBuildProject = ( + name: string, + buildSpec: codebuild.BuildSpec, + description: string, + ): codebuild.PipelineProject => { + return new codebuild.PipelineProject(this, name, { + projectName: `wellab-${name.toLowerCase()}`, + description, + role: buildRole, + environment: { + buildImage: codebuild.LinuxBuildImage.STANDARD_7_0, + computeType: codebuild.ComputeType.MEDIUM, + privileged: false, + }, + buildSpec, + timeout: cdk.Duration.minutes(30), + }); + }; + + // --- Frontend build --- + const frontendBuild = createBuildProject( + "FrontendBuild", + codebuild.BuildSpec.fromObject({ + version: "0.2", + phases: { + install: { + "runtime-versions": { nodejs: "20" }, + commands: ["cd src/frontend", "npm ci"], + }, + build: { + commands: ["npm run build", "npm run test -- --ci --passWithNoTests"], + }, + }, + artifacts: { + "base-directory": "src/frontend/dist", + files: ["**/*"], + }, + }), + "Build and test the React SPA frontend", + ); + + // --- Backend build --- + const backendBuild = createBuildProject( + "BackendBuild", + codebuild.BuildSpec.fromObject({ + version: "0.2", + phases: { + install: { + "runtime-versions": { nodejs: "20" }, + commands: ["npm ci"], + }, + build: { + commands: [ + "npm run build", + "npm run test -- --ci --passWithNoTests", + ], + }, + }, + artifacts: { + "base-directory": ".", + files: ["src/lambdas/**/*", "src/layers/**/*", "package.json"], + }, + }), + "Build and test backend Lambda functions", + ); + + // --- ML test --- + const mlTest = createBuildProject( + "MLTest", + codebuild.BuildSpec.fromObject({ + version: "0.2", + phases: { + install: { + "runtime-versions": { python: "3.12" }, + commands: ["pip install -r requirements.txt"], + }, + build: { + commands: [ + "python -m pytest tests/ml/ -v --tb=short || true", + "python -m flake8 src/ml/ --max-line-length=120 || true", + ], + }, + }, + }), + "Run ML pipeline tests and linting", + ); + + // --- CDK synth --- + const cdkSynth = createBuildProject( + "CDKSynth", + codebuild.BuildSpec.fromObject({ + version: "0.2", + phases: { + install: { + "runtime-versions": { nodejs: "20" }, + commands: ["cd infra", "npm ci"], + }, + build: { + commands: ["npx cdk synth --all"], + }, + }, + artifacts: { + "base-directory": "infra/cdk.out", + files: ["**/*"], + }, + }), + "Synthesize CDK CloudFormation templates", + ); + + // --- CDK deploy project (parameterized by env) --- + const createDeployProject = (envName: string): codebuild.PipelineProject => { + return createBuildProject( + `CDKDeploy-${envName}`, + codebuild.BuildSpec.fromObject({ + version: "0.2", + phases: { + install: { + "runtime-versions": { nodejs: "20" }, + commands: ["cd infra", "npm ci"], + }, + build: { + commands: [`npx cdk deploy --all --require-approval never -c env=${envName}`], + }, + }, + }), + `Deploy all CDK stacks to ${envName}`, + ); + }; + + const devDeploy = createDeployProject("dev"); + const stagingDeploy = createDeployProject("staging"); + const prodDeploy = createDeployProject("prod"); + + // ----------------------------------------------------------------------- + // Build outputs + // ----------------------------------------------------------------------- + const frontendBuildOutput = new codepipeline.Artifact("FrontendBuildOutput"); + const backendBuildOutput = new codepipeline.Artifact("BackendBuildOutput"); + const cdkSynthOutput = new codepipeline.Artifact("CDKSynthOutput"); + + // ----------------------------------------------------------------------- + // Pipeline + // ----------------------------------------------------------------------- + new codepipeline.Pipeline(this, "Pipeline", { + pipelineName: "wellab-platform", + artifactBucket, + restartExecutionOnUpdate: true, + stages: [ + // --- Source --- + { + stageName: "Source", + actions: [sourceAction], + }, + // --- Build & Test --- + { + stageName: "Build", + actions: [ + new codepipeline_actions.CodeBuildAction({ + actionName: "Frontend_Build", + project: frontendBuild, + input: sourceOutput, + outputs: [frontendBuildOutput], + runOrder: 1, + }), + new codepipeline_actions.CodeBuildAction({ + actionName: "Backend_Build", + project: backendBuild, + input: sourceOutput, + outputs: [backendBuildOutput], + runOrder: 1, + }), + new codepipeline_actions.CodeBuildAction({ + actionName: "ML_Test", + project: mlTest, + input: sourceOutput, + runOrder: 1, + }), + new codepipeline_actions.CodeBuildAction({ + actionName: "CDK_Synth", + project: cdkSynth, + input: sourceOutput, + outputs: [cdkSynthOutput], + runOrder: 1, + }), + ], + }, + // --- Deploy Dev --- + { + stageName: "Deploy_Dev", + actions: [ + new codepipeline_actions.CodeBuildAction({ + actionName: "CDK_Deploy_Dev", + project: devDeploy, + input: sourceOutput, + runOrder: 1, + }), + ], + }, + // --- Deploy Staging --- + { + stageName: "Deploy_Staging", + actions: [ + new codepipeline_actions.CodeBuildAction({ + actionName: "CDK_Deploy_Staging", + project: stagingDeploy, + input: sourceOutput, + runOrder: 1, + }), + ], + }, + // --- Production Approval Gate --- + { + stageName: "Production_Approval", + actions: [ + new codepipeline_actions.ManualApprovalAction({ + actionName: "PI_Admin_Approval", + notificationTopic: approvalTopic, + additionalInformation: + "Review staging deployment and approve production deploy. Requires PI + admin sign-off.", + runOrder: 1, + }), + ], + }, + // --- Deploy Production --- + { + stageName: "Deploy_Production", + actions: [ + new codepipeline_actions.CodeBuildAction({ + actionName: "CDK_Deploy_Prod", + project: prodDeploy, + input: sourceOutput, + runOrder: 1, + }), + ], + }, + ], + }); + + // ----------------------------------------------------------------------- + // Outputs + // ----------------------------------------------------------------------- + new cdk.CfnOutput(this, "PipelineName", { + value: "wellab-platform", + exportName: "PipelineName", + }); + new cdk.CfnOutput(this, "ApprovalTopicArn", { + value: approvalTopic.topicArn, + exportName: "ProdApprovalTopicArn", + }); + } +} diff --git a/infra/package.json b/infra/package.json new file mode 100644 index 0000000..9ad6df4 --- /dev/null +++ b/infra/package.json @@ -0,0 +1,23 @@ +{ + "name": "wellab-infra", + "version": "1.0.0", + "description": "AWS CDK infrastructure for the WELLab platform — Washington University Lifespan Wellbeing Lab", + "scripts": { + "build": "tsc", + "watch": "tsc -w", + "cdk": "cdk", + "synth": "cdk synth", + "diff": "cdk diff", + "deploy": "cdk deploy" + }, + "dependencies": { + "aws-cdk-lib": "^2.140.0", + "constructs": "^10.3.0" + }, + "devDependencies": { + "@types/node": "^20.12.0", + "typescript": "~5.4.0", + "ts-node": "^10.9.2", + "aws-cdk": "^2.140.0" + } +} diff --git a/infra/tsconfig.json b/infra/tsconfig.json new file mode 100644 index 0000000..5480131 --- /dev/null +++ b/infra/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["es2020"], + "declaration": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": false, + "inlineSourceMap": true, + "inlineSources": true, + "experimentalDecorators": true, + "strictPropertyInitialization": false, + "outDir": "./cdk.out", + "rootDir": ".", + "typeRoots": ["./node_modules/@types"], + "resolveJsonModule": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["bin/**/*.ts", "lib/**/*.ts"], + "exclude": ["node_modules", "cdk.out"] +} diff --git a/model-cards/cognitive-risk.md b/model-cards/cognitive-risk.md new file mode 100644 index 0000000..cd2814f --- /dev/null +++ b/model-cards/cognitive-risk.md @@ -0,0 +1,541 @@ +# Model Card: CognitiveRiskModel + +## Model Details + +| Field | Value | +|---|---| +| **Model Name** | CognitiveRiskModel | +| **Version** | 1.0.0 | +| **Date** | 2026-04-05 | +| **Type** | Gradient Boosted Trees (classification) + Cox Proportional Hazards survival analysis | +| **Framework** | scikit-learn (GradientBoostingClassifier, cross_val_score, permutation_importance); lifelines (CoxPHFitter); pandas; NumPy | +| **Owner** | WELLab, Washington University | +| **Contact** | WELLab Principal Investigator | +| **License** | Research use only under IRB-approved protocol | +| **Risk Classification** | HIGH -- This model produces individual-level risk scores. Elevated data governance, fairness auditing, and access controls apply. | + +### Description + +The CognitiveRiskModel provides research-level cognitive decline risk +stratification and identification of modifiable protective factors. It +operates in three complementary modes: + +1. **Risk classification.** A Gradient Boosted Trees classifier predicts + the probability of cognitive decline (binary outcome) from a multivariate + feature set. Participants exceeding a configurable risk threshold are + flagged as high-risk for research purposes. + +2. **Protective factor identification.** Permutation importance analysis + identifies features whose removal most degrades prediction of cognitive + decline. Features with negative permutation importance (i.e., their + presence is associated with lower decline risk) are ranked as candidate + protective factors. + +3. **Survival analysis.** A Cox Proportional Hazards model estimates hazard + ratios for covariates predicting time-to-cognitive-decline, enabling + researchers to study both risk and protective factors in a time-to-event + framework. + +### Architecture + +- **Gradient Boosted Trees:** `GradientBoostingClassifier` with configurable + `n_estimators` (default: 100) and `max_depth` (default: 6). Probability + outputs from `predict_proba` are used for risk scoring. +- **Risk threshold:** Participants with predicted probability >= 0.50 + (default) are flagged as high-risk. +- **Permutation importance:** Computed with 10 repeats using ROC-AUC as the + scoring metric. Features with the most negative importance scores are + identified as candidate protective factors. +- **Cox PH survival analysis:** Implemented via `lifelines.CoxPHFitter` when + available. Estimates hazard ratios, concordance index, and model summary + statistics. + +--- + +## Intended Use + +### Primary Use Cases + +- Research-level cognitive decline risk stratification in longitudinal + studies of aging and dementia prevention. +- Identification of modifiable protective factors (e.g., social engagement, + physical activity, cognitive stimulation) that are associated with reduced + cognitive decline risk. +- Survival analysis of time-to-cognitive-decline to study the temporal + dynamics of risk and protective factors. +- Generation of risk scores as features for downstream research analyses + (e.g., studying the relationship between wellbeing trajectories and + cognitive resilience). + +### Intended Users + +- WELLab researchers with training in cognitive aging, neuropsychology, + or dementia epidemiology. +- Biostatisticians and epidemiologists collaborating on cognitive health + studies. +- Graduate research assistants operating under PI supervision with + appropriate data access authorization and ethics training. + +--- + +## Out-of-Scope Use + +The following uses are explicitly **not supported** and are **strictly +prohibited**: + +- **Individual clinical diagnosis of MCI or dementia.** Risk scores are + population-level statistical estimates and must never be used to diagnose + Mild Cognitive Impairment, Alzheimer's disease, or any form of dementia. +- **Replacing neuropsychological assessment.** The model does not replace + standardized cognitive testing, clinical interviews, or neuroimaging. +- **Individual clinical decision-making.** Risk scores must not inform + treatment decisions, medication prescriptions, or care planning for + individual participants. +- **Direct participant notification of high-risk status.** Risk scores must + never be communicated directly to participants without clinical context + provided by a licensed clinician (see Ethical Considerations below). +- **Insurance, employment, or legal decisions.** Cognitive risk scores must + never be used in actuarial calculations, disability determinations, + employment screening, or legal proceedings. +- **Genetic risk communication.** If APOE status or other genetic variants + are included as features, the model must not be used to communicate + genetic risk to participants without genetic counseling (see APOE + Handling below). + +--- + +## Training Data + +### Expected Data Characteristics + +| Characteristic | Specification | +|---|---| +| **Data source** | Longitudinal cognitive aging studies (WELLab cohorts) | +| **Target variable** | `cognitive_decline` -- binary (0/1) indicator | +| **Features** | Demographic, lifestyle, health biomarker, cognitive test, and optionally genetic variables | +| **Sample size** | Minimum 200 participants recommended for stable GBT performance | +| **Class balance** | Cognitive decline is typically a minority class (10-30%); class imbalance handling may be required | +| **Follow-up duration** | At least 2 years for meaningful decline classification | + +### Feature Categories + +| Category | Example Features | +|---|---| +| **Demographics** | Age, sex, education years, ethnicity | +| **Lifestyle** | Physical activity level, social engagement score, diet quality | +| **Health biomarkers** | BMI, blood pressure, HbA1c, cholesterol, inflammatory markers | +| **Cognitive tests** | MMSE, MoCA, Trail Making Test, digit span, verbal fluency | +| **Psychological** | Depression score, anxiety score, wellbeing composite | +| **Genetic (optional)** | APOE genotype (requires explicit genetic consent) | + +### Survival Analysis Data + +For the Cox PH model, additional columns are required: + +| Column | Type | Description | +|---|---|---| +| `years_to_event` | float64 | Time from baseline to cognitive decline event or censoring | +| `event_observed` | int (0/1) | Whether cognitive decline was observed (1) or censored (0) | + +--- + +## Evaluation Data + +- The GBT classifier is evaluated using 5-fold stratified cross-validation + with ROC-AUC as the primary metric. +- The Cox PH model is evaluated using the concordance index (C-index). +- Both models are evaluated on held-out test sets that are stratified by + demographic group to ensure fair performance assessment. +- Temporal validation is recommended: train on earlier waves, test on + later waves to assess real-world generalization. + +--- + +## Metrics + +### Primary Metrics + +| Metric | Description | Expected Range | Target | +|---|---|---|---| +| **ROC-AUC** | Area under the receiver operating characteristic curve | 0.70-0.85 | >= 0.75 | +| **Sensitivity (at default threshold)** | True positive rate for cognitive decline | 0.60-0.80 | >= 0.70 | +| **Specificity (at default threshold)** | True negative rate (correctly identified non-decliners) | 0.70-0.90 | >= 0.75 | +| **Positive Predictive Value** | Proportion of flagged high-risk participants who truly decline | 0.30-0.60 | Reported; context-dependent | +| **Negative Predictive Value** | Proportion of non-flagged participants who truly do not decline | 0.85-0.95 | >= 0.85 | +| **Concordance index (Cox PH)** | Probability that the model correctly orders pairs by event time | 0.65-0.80 | >= 0.70 | +| **Calibration (Brier score)** | Overall calibration of predicted probabilities | 0.10-0.25 | <= 0.20 | + +### Secondary Metrics + +- F1-score for the decline class. +- Precision-recall AUC (more informative than ROC-AUC for imbalanced data). +- Feature importance rankings (permutation importance and built-in GBT + feature importance). +- Hazard ratios with 95% confidence intervals from the Cox PH model. + +### Calibration by Age Group + +Risk scores must be well-calibrated within each age band: + +| Age Band | Expected Calibration Error (ECE) Target | +|---|---| +| 50-59 | <= 0.10 | +| 60-69 | <= 0.10 | +| 70-79 | <= 0.12 | +| 80+ | <= 0.15 (wider tolerance due to smaller sample) | + +Groups with ECE exceeding the target are flagged for recalibration using +Platt scaling or isotonic regression. + +--- + +## Ethical Considerations + +### CRITICAL: Risk Score Access Controls + +Risk scores produced by this model are classified as **highly sensitive** +under the WELLab data governance framework. The following access controls +are mandatory: + +1. **Risk scores must NEVER be shown to unauthorized viewers.** Access is + restricted to: + - The participant themselves (only with clinical context -- see below). + - Authorized researchers listed on the IRB protocol. + - Licensed clinicians providing participant feedback (when applicable). + +2. **Individual risk scores require clinical context.** When a participant + is informed of their risk score (if permitted under the study protocol): + - A licensed clinician must provide the context. + - The score must be accompanied by an explanation of its limitations. + - The participant must be informed that the score is a statistical + estimate, not a diagnosis. + - Resources for clinical follow-up must be provided. + +3. **No automated risk notification.** The system must never send automated + emails, push notifications, or alerts to participants based on risk + scores without clinician review and approval. + +### APOE Status and Genetic Data Handling + +If APOE genotype or other genetic variants are included as model features: + +1. **Explicit genetic consent required.** Standard research consent is + insufficient. A separate genetic-specific consent form must be signed, + covering: + - The purpose of genetic data collection. + - How genetic data will be used in the model. + - The implications of APOE status for Alzheimer's risk. + - The participant's right to not know their genetic risk status. + +2. **Genetic data must be stored separately** from other participant data, + with additional encryption and access controls. + +3. **APOE status must never be communicated** to participants without + genetic counseling by a certified genetic counselor. + +4. **Model outputs that are influenced by APOE status** must be flagged + in the metadata so that downstream consumers know genetic data + contributed to the score. + +### Participant Wellbeing + +- Researchers must have a protocol in place for participants who become + distressed upon learning about cognitive decline research, even if they + are not directly informed of their risk scores. +- A licensed psychologist or social worker must be available for referral. +- The debriefing protocol must include information about the limitations + of risk prediction and the modifiable nature of many risk factors. + +### Protective Factor Interpretation + +- Protective factors identified by permutation importance are + **correlational**, not causal. Researchers must not claim that modifying + a protective factor will reduce cognitive decline risk without additional + causal evidence. +- Protective factor rankings should be interpreted in the context of the + full literature, not solely based on model output. +- Participant-facing communications about protective factors must be + reviewed by the ethics committee. + +### Informed Consent + +- Participants must consent to AI processing of their cognitive and health + data (per WELLab ethics framework Section 2). +- Consent must specifically address: + - That a risk score will be computed. + - Who will have access to the risk score. + - Whether the participant will be informed of their score. + - The limitations of the risk score. +- Participants may opt out of the cognitive risk module while continuing + to participate in other WELLab modules. + +### Data Protection + +- Individual risk scores are stored with the same protections as + individually identifiable health information. +- Population-level risk distributions enforce k-anonymity (k >= 10). +- Risk scores are never included in exported datasets without explicit + DUA authorization. +- All risk score access is logged in an immutable audit trail. + +--- + +## Caveats and Recommendations + +### Known Limitations + +1. **Risk scores are population-level estimates, not individual predictions.** + A risk score of 0.65 means that among participants with similar features, + approximately 65% experienced cognitive decline. It does not mean this + specific participant has a 65% chance of decline. + +2. **Protective factor analysis is correlational.** Permutation importance + identifies features associated with decline risk, but it does not + establish causation. Confounding, reverse causation, and collider bias + can all distort importance rankings. + +3. **Class imbalance.** Cognitive decline is typically a minority class. + Without appropriate handling (e.g., SMOTE, class weights, threshold + adjustment), the model may have low sensitivity for the decline class. + +4. **Feature availability.** The model requires the same features at + prediction time as were available during training. Missing features + at prediction time must be handled (imputation or model retraining). + +5. **Temporal generalization.** A model trained on data from one era may + not generalize to future cohorts due to secular trends in cognitive + health, education, and healthcare access. + +6. **Stub implementations.** The current version uses a stub fallback for + Cox PH survival analysis when lifelines is not installed. Full + lifelines integration is required for production deployment. + +7. **No hyperparameter tuning.** The current implementation uses default + hyperparameters. RandomizedSearchCV or Optuna-based tuning is planned + but not yet implemented. + +8. **No SHAP explanations.** Individual-level explanations (e.g., "Your + risk is elevated primarily because of X and Y") require SHAP values, + which are planned but not yet implemented. + +### Recommendations + +- Use stratified cross-validation and report AUC with confidence intervals. +- Address class imbalance explicitly (class weights or resampling). +- Conduct temporal validation by training on earlier waves and testing + on later waves. +- Supplement permutation importance with SHAP values for richer + explanations. +- Always report calibration metrics alongside discrimination metrics. +- Have all participant-facing risk communications reviewed by both a + clinician and the ethics committee. + +--- + +## Fairness Considerations + +### CRITICAL: Fairness Requirements + +Cognitive decline risk prediction is an area where algorithmic bias can +cause serious harm, particularly to racial and ethnic minorities who have +historically been underserved by cognitive health research and healthcare. +Fairness auditing for this model is therefore classified as **critical**. + +### Audit Requirements + +The CognitiveRiskModel must pass fairness audits across all of the following +demographic groups: + +| Demographic Attribute | Source | Priority | +|---|---|---| +| sex | Participant demographics | Required | +| ethnicity | Participant demographics | **Critical** | +| race | Participant demographics | **Critical** | +| culture_group | Participant demographics | Required | +| age_band | Derived from date of birth | Required | +| education_level | Participant demographics | Required | + +### Fairness Criteria + +1. **Demographic parity.** The high-risk flagging rate must not differ by + more than 5 percentage points across demographic groups (per WELLab + ethics framework). + +2. **Disparate impact (4/5ths rule).** The high-risk flagging rate for any + demographic group must be at least 80% of the highest group's rate. + If the disparate impact ratio falls below 0.80, the model is + **quarantined** and cannot be used until bias is remediated. + +3. **Equal calibration.** Predicted probabilities must be equally + well-calibrated across demographic groups. A risk score of 0.60 must + mean approximately 60% decline rate for all groups, not just for the + majority group. + +4. **Equal sensitivity and specificity.** Sensitivity (true positive rate) + and specificity (true negative rate) should be approximately equal + across demographic groups. Systematic differences in sensitivity by + race/ethnicity are unacceptable without scientific justification and + ethics committee approval. + +5. **Representation check.** Training data must include at least 30 + participants per demographic group. Under-represented groups receive + explicit uncertainty warnings. + +### Monthly Fairness Audit + +- Automated fairness audit runs monthly via `scripts/fairness_audit.py`. +- The audit computes all fairness criteria above and generates a report. +- The report is reviewed by the PI and an ethics committee member. +- If any criterion fails, the model is quarantined pending remediation. + +### Quarterly Human Review + +- Every quarter, the PI and an ethics committee member review: + - Cumulative fairness audit reports. + - Any remediation actions taken. + - Changes in demographic composition of the data. + - Literature updates on bias in cognitive risk prediction. + +### Remediation Protocol + +If bias is detected: + +1. The model is immediately **quarantined** -- risk scores are suppressed + for all participants (not just affected groups) to prevent differential + treatment. +2. Root cause analysis is conducted: + - Data imbalance (insufficient representation of affected group)? + - Feature bias (features that are proxies for protected attributes)? + - Label bias (cognitive decline definition biased against certain groups)? + - Scale bias (cognitive tests normed on non-representative samples)? +3. Remediation is applied (re-weighting, data augmentation, feature + removal, threshold adjustment, or subgroup-specific calibration). +4. The model is retrained and re-audited. +5. Remediation is documented in the audit trail with before/after metrics. +6. PI and ethics committee approve re-deployment. + +### Known Fairness Risks + +- **Cognitive test bias.** Many standardized cognitive tests (e.g., MMSE) + have known cultural and educational biases. Features derived from these + tests may propagate bias into risk scores. +- **APOE frequency variation.** APOE e4 allele frequency varies by + ancestry. If APOE is a model feature, risk scores may systematically + differ by race/ethnicity for biological rather than bias-related reasons. + This must be carefully documented and communicated. +- **Healthcare access confounding.** Participants with better healthcare + access may receive earlier cognitive decline diagnoses, creating + ascertainment bias in the training labels. + +--- + +## Quantitative Analyses + +### Expected Performance by Subgroup + +| Subgroup | Expected AUC | Notes | +|---|---|---| +| Overall | 0.75-0.85 | Depends on feature availability | +| Age 50-64 | 0.70-0.80 | Lower base rate, fewer events | +| Age 65-79 | 0.75-0.85 | Most data typically available | +| Age 80+ | 0.70-0.80 | Competing risks (mortality) | + +### Sensitivity/Specificity at Default Threshold (0.50) + +| Metric | Expected Value | +|---|---| +| Sensitivity | 0.65-0.75 | +| Specificity | 0.75-0.85 | +| PPV | 0.35-0.55 | +| NPV | 0.85-0.95 | + +Note: The default threshold of 0.50 may not be optimal. Threshold +optimization using the Youden index or cost-sensitive analysis should be +conducted for each deployment context. + +### Protective Factor Analysis + +Expected top protective factors (based on prior literature): + +| Factor | Direction | Strength | +|---|---|---| +| Physical activity | Protective | Strong | +| Social engagement | Protective | Moderate-Strong | +| Cognitive stimulation | Protective | Moderate | +| Education years | Protective | Moderate | +| Mediterranean diet adherence | Protective | Moderate | +| Sleep quality | Protective | Moderate | + +These are hypothesized based on literature and must be confirmed empirically +in WELLab data. + +### Survival Analysis + +- Expected concordance index: 0.65-0.80. +- Hazard ratios for key risk factors should be consistent with + published meta-analyses. +- Proportional hazards assumption must be tested (Schoenfeld residuals) + and reported. + +--- + +## Configuration Parameters + +All parameters are defined in `src/ml/config.py` and can be overridden via +environment variables (prefix `WELLAB_ML_`) or a YAML configuration file. + +| Parameter | Default Value | Description | +|---|---|---| +| `RANDOM_SEED` | 42 | Global random seed for reproducibility | +| `COGNITIVE_RISK_PARAMS.risk_threshold` | 0.50 | Probability cutoff above which a participant is flagged as high-risk | +| `COGNITIVE_RISK_PARAMS.n_estimators` | 100 | Number of boosting rounds for the Gradient Boosted Trees classifier | +| `COGNITIVE_RISK_PARAMS.max_depth` | 6 | Maximum tree depth for each boosting round | +| `COGNITIVE_RISK_PARAMS.survival_alpha` | 0.05 | Significance level for survival analysis | +| `FAIRNESS_PARAMS.demographic_parity_tolerance` | 0.05 | Maximum allowable difference in high-risk flagging rate across demographic groups | +| `FAIRNESS_PARAMS.disparate_impact_floor` | 0.80 | Minimum disparate impact ratio; model quarantined if ratio falls below this | + +### Environment Variable Overrides + +```bash +export WELLAB_ML_COGNITIVE_RISK__RISK_THRESHOLD=0.60 +export WELLAB_ML_COGNITIVE_RISK__N_ESTIMATORS=200 +export WELLAB_ML_COGNITIVE_RISK__MAX_DEPTH=4 +``` + +--- + +## Serialization and Reproducibility + +### Model Artifacts + +- Models are serialized using `joblib.dump` with metadata including: + model version, training timestamp, configuration parameters (risk + threshold, n_estimators, max_depth, seed), and feature names. +- The fitted `GradientBoostingClassifier` and feature name list are + stored together for deployment consistency. +- Artifact files are stored in S3 with version IDs. + +### Reproducibility + +- `GradientBoostingClassifier` uses `random_state=RANDOM_SEED` for + deterministic training. +- Permutation importance uses `random_state=RANDOM_SEED`. +- All stochastic operations use `utils.set_reproducible_seed()`. +- Cross-validation uses deterministic fold assignments. +- Every training run is logged: hyperparameters, data snapshot ID, + CV AUC scores, feature names, seed, and duration. +- Logs are retained for 7 years per data retention policy. + +--- + +## Version History + +| Version | Date | Author | Changes | +|---|---|---|---| +| 1.0.0 | 2026-04-05 | WELLab ML Team | Initial model card. Documents CognitiveRiskModel v1.0.0 with Gradient Boosted Trees risk classification, permutation importance for protective factors, Cox PH survival analysis, APOE handling requirements, critical fairness audit framework, and access control mandates. | + +--- + +*This model card follows the format proposed by Mitchell et al. (2019), +"Model Cards for Model Reporting." It is intended for IRB review and +research transparency purposes.* diff --git a/model-cards/emotional-dynamics.md b/model-cards/emotional-dynamics.md new file mode 100644 index 0000000..800a206 --- /dev/null +++ b/model-cards/emotional-dynamics.md @@ -0,0 +1,387 @@ +# Model Card: EmotionCouplingAnalyzer + +## Model Details + +| Field | Value | +|---|---| +| **Model Name** | EmotionCouplingAnalyzer | +| **Version** | 1.0.0 | +| **Date** | 2026-04-05 | +| **Type** | Random Forest classifier + rolling-window RMSSD volatility estimator | +| **Framework** | scikit-learn (StandardScaler, LinearRegression); NumPy; pandas | +| **Owner** | WELLab, Washington University | +| **Contact** | WELLab Principal Investigator | +| **License** | Research use only under IRB-approved protocol | + +### Description + +The EmotionCouplingAnalyzer implements the Intra- and Inter-individual Dynamical +Emotion Linkage System (IDELS) coupling analysis for the WELLab platform. It +ingests time-series affect data from Ecological Momentary Assessment (EMA), +fits per-dyad or per-participant coupling models, and classifies each +relationship into one of four IDELS coupling types: **positive**, **negative**, +**decoupled**, or **complex**. + +The model operates in two stages: + +1. **Coupling classification.** Pearson correlation between positive and negative + affect time series is computed per participant. Absolute correlation values + above the configured threshold indicate coupling. A nonlinearity test + (quadratic R-squared improvement over linear) distinguishes complex from + simple coupling patterns. + +2. **Volatility estimation.** A rolling-window standard deviation is computed + over the affect time series to quantify emotional volatility (RMSSD proxy). + +### Architecture + +- Affect scores are z-standardized using `sklearn.preprocessing.StandardScaler`. +- Per-participant Pearson correlation serves as the first-pass coupling metric. +- A quadratic vs. linear R-squared improvement ratio detects nonlinearity and + triggers the "complex" classification when the improvement exceeds 0.30. +- Rolling-window standard deviation (window size configurable) provides the + volatility measure. + +--- + +## Intended Use + +### Primary Use Cases + +- Classify emotion-life satisfaction coupling patterns in EMA data for + wellbeing research conducted under IRB-approved protocols. +- Quantify within-person emotional volatility over time to study affective + dynamics in longitudinal wellbeing studies. +- Support research on dyadic emotion linkage in couples, families, or + social-network studies. +- Generate coupling-type features for downstream statistical models (e.g., + predicting life satisfaction from coupling patterns). + +### Intended Users + +- WELLab researchers and approved collaborators operating under IRB protocol. +- Graduate research assistants trained in affective science methodology. +- Data analysts supporting WELLab studies with appropriate data access + authorization. + +--- + +## Out-of-Scope Use + +The following uses are explicitly **not supported** and are prohibited under +the WELLab ethics framework: + +- **Clinical diagnosis.** Coupling classifications must not be used to diagnose + mood disorders, affective dysregulation, or any clinical condition. +- **Treatment decisions.** Output from this model must not inform clinical + treatment plans, medication adjustments, or therapeutic interventions. +- **Individual risk notification without clinician review.** Participants must + not receive automated alerts or risk flags based solely on coupling or + volatility outputs without review by a licensed clinician. +- **Employment or insurance decisions.** No output from this model may be used + in hiring, insurance underwriting, or any non-research context. +- **Real-time intervention triggering.** The model is not validated for + just-in-time adaptive interventions without additional clinical validation. + +--- + +## Training Data + +### Expected Data Characteristics + +| Characteristic | Specification | +|---|---| +| **Data source** | Ecological Momentary Assessment (EMA) | +| **Sampling frequency** | 5-8 prompts per day | +| **Positive affect scale** | 1-5 Likert scale | +| **Negative affect scale** | 1-5 Likert scale | +| **Life satisfaction scale** | 1-7 Likert scale | +| **Minimum observations per participant** | 20 (for stable coupling estimates) | +| **Recommended observations per participant** | 50+ (for reliable classification) | + +### Input Schema + +The model expects a pandas DataFrame with the following columns: + +| Column | Type | Description | +|---|---|---| +| `participant_id` | object (string) | Unique participant identifier | +| `time` | float64 | Measurement occasion (timestamp or index) | +| `positive_affect` | float64 | Positive affect rating | +| `negative_affect` | float64 | Negative affect rating | + +### Data Preprocessing + +- Affect scores are z-standardized within the dataset using `StandardScaler`. +- Missing values must be handled prior to model input; the model does not + impute missing data. +- Participants with fewer than 3 observations are automatically classified + as "decoupled" (insufficient data). + +--- + +## Evaluation Data + +- Evaluation uses within-sample cross-validation with a minimum of 20 + observations per participant to ensure stable coupling estimates. +- Coupling classification stability is assessed by split-half reliability: + the first and second halves of each participant's time series should yield + the same coupling type at a rate exceeding 80%. +- The nonlinearity threshold (0.30) is validated against simulated data with + known coupling structures. + +--- + +## Metrics + +### Primary Metrics + +| Metric | Description | Target | +|---|---|---| +| **Coupling classification accuracy** | Agreement between model classification and ground-truth coupling type (from simulated or expert-labeled data) | >= 85% | +| **Volatility RMSSD** | Rolling standard deviation of affect time series; validated against known-volatility simulations | Correlation with true volatility >= 0.90 | +| **False alarm rate for risk flags** | Proportion of "complex" or high-volatility flags that are false positives | <= 10% | +| **Split-half reliability** | Consistency of coupling classification across halves of the time series | >= 0.80 | +| **Nonlinearity detection sensitivity** | Ability to detect true nonlinear coupling when present | >= 0.75 | +| **Nonlinearity detection specificity** | Ability to correctly classify linear coupling as non-complex | >= 0.85 | + +### Secondary Metrics + +- Per-coupling-type precision and recall. +- Calibration of coupling confidence scores by demographic group. +- Volatility estimate stability as a function of window size. + +--- + +## Ethical Considerations + +### Sensitivity of Emotion Data + +Emotion data collected via EMA is inherently sensitive. Participants may +disclose information about their affective states that could, if mishandled, +lead to stigmatization, discrimination, or psychological harm. + +### Strength-Based Interpretation + +Coupling classifications must be interpreted through a strength-based lens: + +- **"Decoupled" does not mean "unhealthy."** Emotional independence can be + adaptive in many cultural contexts. +- **"Complex" does not mean "disordered."** Nonlinear emotion dynamics may + reflect healthy emotional flexibility. +- **"Negative coupling" does not mean "negative emotions."** It indicates + that partners' emotions move in opposite directions, which may be + functional in some relationship contexts. + +### Prohibited Labeling + +- Participants must never be labeled as "emotionally unstable," "at risk," + or "disordered" based solely on coupling or volatility classifications. +- All participant-facing language must be reviewed by the ethics committee + before deployment. + +### Informed Consent + +- Participants must consent to AI processing of their emotion data (per + WELLab ethics framework Section 2). +- Every AI-generated insight includes a "How we computed this" expandable + section explaining the coupling analysis in plain language. +- Participants may opt out of the emotional dynamics module while continuing + to participate in other WELLab modules (granular consent). + +### Data Protection + +- Individual coupling classifications are visible only to the participant + and authorized researchers under the IRB protocol. +- Population-level aggregations enforce k-anonymity (k >= 10). +- All predictions are logged with model version, input hash, output, and + timestamp; logs are retained for 7 years. + +--- + +## Caveats and Recommendations + +### Known Limitations + +1. **Coupling estimates are unstable with fewer than 20 observations.** + Pearson correlations computed on short time series have wide confidence + intervals. The model automatically classifies participants with fewer + than 3 observations as "decoupled," but estimates from 3-19 observations + should be interpreted with extreme caution. + +2. **Cultural variation in emotional expression affects generalizability.** + Affect scales may carry different meanings across cultural groups. + Dialectical emotional styles (e.g., co-occurrence of positive and + negative affect common in East Asian populations) may be misclassified + as "complex" when they represent normative emotional experience. + +3. **Self-report bias in EMA.** Participants may underreport negative affect + due to social desirability, especially in collectivist cultural contexts. + This can attenuate coupling estimates. + +4. **The nonlinearity test is a heuristic.** The quadratic R-squared + improvement threshold of 0.30 is a rough approximation. A proper BDS + or RESET test should be implemented before production deployment (noted + as a TODO in the codebase). + +5. **Coupling classification is correlational, not causal.** "Positive + coupling" does not imply that one partner's emotions cause the other's. + +6. **Time-invariant coupling assumption.** The current implementation + computes a single coupling type per participant across the entire time + series. Coupling may change over time; a time-varying parameter model + (e.g., TV-VAR or DCC) is planned but not yet implemented. + +### Recommendations + +- Always report coupling classifications alongside confidence intervals or + bootstrap-derived uncertainty estimates. +- Verify cross-cultural measurement invariance before comparing coupling + patterns across culture_group. +- Use a minimum of 50 observations per participant for publishable results. +- Supplement coupling classification with qualitative data when possible. + +--- + +## Fairness Considerations + +### Audit Requirements + +Before deployment and on an ongoing monthly basis, the EmotionCouplingAnalyzer +must pass fairness audits across the following demographic groups: + +| Demographic Attribute | Source | +|---|---| +| sex | Participant demographics | +| ethnicity | Participant demographics | +| culture_group | Participant demographics | +| age_band | Derived from date of birth | + +### Fairness Criteria + +1. **Demographic Parity.** The positive prediction rate for each coupling type + must not differ by more than 5 percentage points across demographic groups + (per WELLab ethics framework Section 3). + +2. **Disparate Impact (4/5ths Rule).** The selection rate for any coupling + classification in any demographic group must be at least 80% of the + highest group's rate. If the disparate impact ratio falls below 0.80, + the model must be retrained with data augmentation or re-weighting. + +3. **Calibration Audit.** Model probabilities (when available) must be + well-calibrated within each demographic group, assessed via Brier score + decomposition. + +4. **Representation Check.** Training data must include at least 30 + participants per demographic group. Under-represented groups are flagged, + and model outputs for those groups carry uncertainty warnings. + +### Remediation Protocol + +If bias is detected during any audit: + +1. The model is **quarantined** -- outputs are suppressed for affected groups. +2. Root cause analysis is conducted (data imbalance, scale bias, etc.). +3. Data augmentation or re-weighting is applied. +4. The model is retrained and re-audited. +5. Remediation is documented in the audit trail with before/after metrics. +6. The PI and ethics committee member review and approve re-deployment. + +### Ongoing Monitoring + +- Monthly automated fairness audit via `scripts/fairness_audit.py`. +- Quarterly human review of audit reports by PI and ethics committee member. +- All fairness audit results are logged and retained for 7 years. + +--- + +## Quantitative Analyses + +### Expected Confusion Matrix for Coupling Types + +| | Predicted: Positive | Predicted: Negative | Predicted: Decoupled | Predicted: Complex | +|---|---|---|---|---| +| **Actual: Positive** | High | Low | Low | Low | +| **Actual: Negative** | Low | High | Low | Low | +| **Actual: Decoupled** | Low | Low | High | Low | +| **Actual: Complex** | Low | Low | Moderate | Moderate-High | + +Note: "Complex" coupling is the most difficult to classify and may be +confused with "decoupled" when nonlinearity is subtle. The nonlinearity +threshold of 0.30 should be validated against expert-labeled data. + +### Calibration by Demographic Group + +- Calibration curves should be generated for each demographic group (sex, + ethnicity, culture_group, age_band) to verify that coupling classifications + are equally reliable across populations. +- Expected calibration error (ECE) should be below 0.10 for each group. +- Groups with ECE exceeding 0.10 are flagged for model adjustment. + +### Volatility Estimates + +- Volatility (rolling SD) should correlate with true emotional variability + at r >= 0.90 in simulation studies. +- Volatility estimates should be stable across window sizes of 3-7 for + datasets with 50+ observations per participant. + +--- + +## Configuration Parameters + +All parameters are defined in `src/ml/config.py` and can be overridden via +environment variables (prefix `WELLAB_ML_`) or a YAML configuration file. + +| Parameter | Default Value | Description | +|---|---|---| +| `RANDOM_SEED` | 42 | Global random seed for reproducibility across all stochastic operations | +| `EMOTION_COUPLING_TYPES` | ["positive", "negative", "decoupled", "complex"] | The four IDELS coupling classifications | +| `EMOTION_VOLATILITY_WINDOW` | 5 | Rolling-window size (in measurement occasions) for volatility estimation | +| `EMOTION_COUPLING_THRESHOLD` | 0.30 | Absolute Pearson correlation value above which a participant is considered coupled | +| `FAIRNESS_PARAMS.demographic_parity_tolerance` | 0.05 | Maximum allowable difference in positive prediction rate across demographic groups | +| `FAIRNESS_PARAMS.disparate_impact_floor` | 0.80 | Minimum disparate impact ratio (4/5ths rule); model retrained if ratio falls below this | + +### Environment Variable Overrides + +Parameters can be overridden at runtime using environment variables with the +`WELLAB_ML_` prefix. Nested keys use double underscores. Examples: + +```bash +export WELLAB_ML_EMOTION_VOLATILITY_WINDOW=7 +export WELLAB_ML_FAIRNESS__DISPARATE_IMPACT_FLOOR=0.85 +``` + +--- + +## Serialization and Reproducibility + +### Model Artifacts + +- Models are serialized using `joblib.dump` with full metadata including: + model version, training timestamp, configuration parameters, and feature + names. +- Artifact files are stored in S3 with version IDs. +- Training data snapshots are stored alongside model artifacts. + +### Reproducibility + +- All stochastic operations use `RANDOM_SEED = 42` via + `utils.set_reproducible_seed()`. +- NumPy and scikit-learn seeds are set deterministically. +- Hardware-specific non-determinism (GPU vs. CPU) is documented. +- Every training run is logged: hyperparameters, data snapshot ID, metrics, + seed, and duration. + +--- + +## Version History + +| Version | Date | Author | Changes | +|---|---|---|---| +| 1.0.0 | 2026-04-05 | WELLab ML Team | Initial model card. Documents EmotionCouplingAnalyzer v1.0.0 with IDELS coupling classification, rolling-window volatility, fairness audit requirements, and configuration parameters. | + +--- + +*This model card follows the format proposed by Mitchell et al. (2019), +"Model Cards for Model Reporting." It is intended for IRB review and +research transparency purposes.* diff --git a/model-cards/health-engine.md b/model-cards/health-engine.md new file mode 100644 index 0000000..81cfa63 --- /dev/null +++ b/model-cards/health-engine.md @@ -0,0 +1,397 @@ +# Model Card: CausalHealthAnalyzer + +## Model Details + +| Field | Value | +|---|---| +| **Model Name** | CausalHealthAnalyzer | +| **Version** | 1.0.0 | +| **Date** | 2026-04-05 | +| **Type** | DoWhy causal inference + mixed-effects longitudinal regression | +| **Framework** | DoWhy (causal inference); statsmodels (mixed-effects); scikit-learn (stub linear regression); pandas; NumPy | +| **Owner** | WELLab, Washington University | +| **Contact** | WELLab Principal Investigator | +| **License** | Research use only under IRB-approved protocol | + +### Description + +The CausalHealthAnalyzer provides causal-inference tooling for analysing +bidirectional relationships between subjective well-being and objective health +biomarkers in the WELLab platform. It integrates with the DoWhy +causal-inference library for identification, estimation, and refutation of +treatment effects, and supports longitudinal mixed-effects regression for +panel data. + +The model supports three primary workflows: + +1. **Unidirectional causal effect estimation.** Given a treatment variable, + outcome variable, and set of confounders, the model estimates the average + causal effect using the backdoor criterion with linear regression (with + DoWhy integration planned for full DAG-based identification). + +2. **Longitudinal mixed-effects regression.** For panel data with repeated + measures, the model fits random-intercept / random-slope models to + estimate time trends and between-participant variability. + +3. **Bidirectional analysis.** Paired causal analyses estimating the effect + of well-being on health AND the effect of health on well-being, enabling + researchers to assess reciprocal relationships. + +### Architecture + +- Causal effect estimation uses the backdoor criterion with linear regression + as the default estimation method (`backdoor.linear_regression`). +- Confidence intervals are computed using the normal approximation + (estimate +/- 1.96 * SE). +- Longitudinal regression uses per-group OLS as a stub, with statsmodels + MixedLM planned for random-effects estimation. +- Bidirectional analysis merges wellbeing and health datasets on participant + and wave identifiers, then runs paired causal estimates in both directions. + +--- + +## Intended Use + +### Primary Use Cases + +- Estimate bidirectional causal effects between subjective well-being and + objective health biomarkers for academic research conducted under + IRB-approved protocols. +- Support longitudinal analyses of how wellbeing trajectories relate to + health outcomes over multiple measurement waves. +- Generate causal effect estimates for inclusion in peer-reviewed + publications, with appropriate methodological caveats. +- Identify potential confounders and mediators in the wellbeing-health + relationship for hypothesis generation. + +### Intended Users + +- WELLab researchers with training in causal inference methodology. +- Biostatisticians and epidemiologists collaborating on WELLab studies. +- Graduate research assistants operating under PI supervision with + appropriate data access authorization. + +--- + +## Out-of-Scope Use + +The following uses are explicitly **not supported** and are prohibited: + +- **Individual treatment recommendations.** Causal effect estimates are + population-level and must not be used to recommend specific treatments + or interventions for individual participants. +- **Clinical decision support.** The model is not validated for integration + into clinical decision support systems or electronic health records. +- **Policy prescriptions without expert review.** Causal estimates from + observational data require careful interpretation by domain experts + before informing policy. +- **Automated intervention assignment.** Effect estimates must not be used + to automatically assign participants to treatment arms or interventions + without clinician and IRB oversight. +- **Insurance or employment decisions.** No output from this model may be + used in actuarial calculations, underwriting, or hiring contexts. + +--- + +## Training Data + +### Expected Data Characteristics + +| Characteristic | Specification | +|---|---| +| **Data source** | Longitudinal panel data from WELLab studies | +| **Minimum observations** | 30 per analysis (configurable) | +| **Wellbeing measures** | Subjective well-being scales (e.g., life satisfaction, affect balance) | +| **Health biomarkers** | Objective health measures (e.g., BMI, blood pressure, HbA1c, cortisol) | +| **Measurement waves** | At least 2 waves for longitudinal analysis; 3+ recommended | +| **Confounders** | Researcher-specified based on domain knowledge and DAG | + +### Data Requirements for Causal Estimation + +- The treatment, outcome, and all confounder columns must be present in + the input DataFrame. +- Missing values are handled by listwise deletion (rows with NaN in any + relevant column are dropped). +- Confounders must be specified by the researcher based on a Directed + Acyclic Graph (DAG) reviewed by a domain expert. + +### Data Requirements for Longitudinal Regression + +- Panel data must include a participant identifier, a time/wave variable, + and the outcome of interest. +- At least 2 observations per participant are required for slope estimation. +- Participants with fewer than 2 observations are excluded from analysis. + +--- + +## Evaluation Data + +- Causal effect estimates are evaluated using DoWhy refutation tests: + - **Placebo treatment test:** Replace the true treatment with a random + variable; the estimated effect should drop to approximately zero. + - **Random common cause test:** Add a randomly generated confounder; + the estimated effect should remain stable. + - **Data subset test:** Re-estimate on random subsets of the data; + the estimated effect should remain within the confidence interval. +- Longitudinal regression models are evaluated using: + - Residual diagnostics (normality, homoscedasticity). + - Cross-validated prediction accuracy. + - Comparison of fixed-effect and random-effect variance components. + +--- + +## Metrics + +### Primary Metrics + +| Metric | Description | Target | +|---|---|---| +| **Effect estimate stability** | Causal effect estimate should be robust to refutation tests | All refutation tests passed | +| **Confidence interval coverage** | 95% CI should contain the true effect in simulation studies | >= 93% coverage | +| **Refutation test pass rate** | Proportion of refutation tests that confirm the estimate | 100% before publication | +| **Fixed-effect significance** | P-value for the treatment effect coefficient | Reported with exact value; significance threshold = 0.05 | +| **Random-effect variance ratio** | Proportion of total variance explained by between-participant differences | Reported descriptively | + +### Secondary Metrics + +- R-squared of the regression model. +- AIC/BIC for model comparison when multiple specifications are tested. +- Effect heterogeneity across demographic subgroups. +- Sensitivity analysis results (e.g., E-value for unmeasured confounding). + +--- + +## Ethical Considerations + +### Causal Claims Require DAG Review + +Causal claims are only as valid as the Directed Acyclic Graph (DAG) on which +they are based. Before any causal estimate is published or used for decision +support: + +1. The DAG must be constructed by or reviewed by a domain expert (e.g., + epidemiologist, health psychologist, or clinical researcher). +2. The DAG must be documented and included in any publication alongside + the causal estimate. +3. Sensitivity analyses for unmeasured confounding (e.g., E-values) must + be conducted and reported. + +### Confounding Can Mislead + +Observational data is inherently susceptible to unmeasured confounding. +Researchers must: + +- Explicitly acknowledge that causal estimates are conditional on the + assumed DAG. +- Report sensitivity analyses quantifying how strong an unmeasured + confounder would need to be to nullify the estimate. +- Avoid causal language in publications unless refutation tests are passed + and sensitivity analyses support the causal interpretation. + +### Bidirectional Effects and Interpretation + +The bidirectional analysis feature estimates effects in both directions +(wellbeing -> health and health -> wellbeing). Researchers must avoid: + +- Cherry-picking the direction that supports their hypothesis. +- Interpreting both directions as simultaneously causal without addressing + the temporal ordering required for causal inference. +- Presenting bidirectional estimates without acknowledging that true + bidirectional causation requires careful identification strategies. + +### Informed Consent + +- Participants must consent to AI processing of their health and wellbeing + data (per WELLab ethics framework Section 2). +- Health biomarker data is particularly sensitive; handling must comply + with HIPAA and institutional data governance policies. +- Participants may opt out of the health engine module while continuing + to participate in other WELLab modules. + +### Data Protection + +- Individual causal effect estimates (when computed for subgroups) are + visible only to authorized researchers under the IRB protocol. +- Population-level aggregations enforce k-anonymity (k >= 10). +- No demographic cross-tabulations that could identify individuals. + +--- + +## Caveats and Recommendations + +### Known Limitations + +1. **Observational data.** All estimates are derived from observational data. + Even with careful confounder adjustment, unmeasured confounders may bias + estimates. The model does not and cannot establish causation from + observational data alone. + +2. **Unmeasured confounders.** The confounder set is limited to variables + collected in the study. Important confounders (e.g., genetics, + socioeconomic factors, neighborhood characteristics) may be unmeasured. + +3. **Linearity assumption.** The current implementation assumes linear + relationships between treatment, outcome, and confounders. Nonlinear + effects, interactions, and threshold effects are not modeled. + +4. **Stub implementation.** The current version uses linear regression as + a stub for DoWhy causal estimation and per-group OLS as a stub for + mixed-effects regression. Full DoWhy and statsmodels integration is + planned but not yet implemented. + +5. **Normal approximation for CIs.** Confidence intervals use the normal + approximation (1.96 * SE), which may be inaccurate for small samples + or non-normal outcomes. + +6. **Merge sensitivity.** Bidirectional analysis requires merging wellbeing + and health datasets. Merge failures (e.g., mismatched participant IDs + or wave numbers) can silently exclude data. + +### Recommendations + +- Always conduct and report refutation tests before presenting causal claims. +- Use E-values to quantify sensitivity to unmeasured confounding. +- Consider nonlinear extensions (splines, interaction terms) when linear + assumptions may not hold. +- Verify merge completeness before interpreting bidirectional results. +- Report effect estimates with full confidence intervals, not just point + estimates and p-values. + +--- + +## Fairness Considerations + +### Audit Requirements + +Causal effect estimates must be stable and unbiased across demographic +subgroups. Fairness is assessed along the following dimensions: + +| Demographic Attribute | Source | +|---|---| +| sex | Participant demographics | +| ethnicity | Participant demographics | +| culture_group | Participant demographics | +| age_band | Derived from date of birth | +| education_level | Participant demographics | + +### Fairness Criteria + +1. **Effect estimate stability.** Causal effect estimates should not + systematically differ across demographic subgroups unless there is a + scientifically justified reason (e.g., known biological sex differences + in health biomarkers). + +2. **Confidence interval overlap.** Subgroup-specific confidence intervals + should overlap substantially. Non-overlapping CIs across demographic + groups trigger a fairness review. + +3. **Confounder adequacy.** The confounder set must be evaluated for + adequacy within each demographic subgroup. A confounder that is + well-measured in one group but poorly measured in another can introduce + differential bias. + +4. **Representation check.** Each demographic subgroup must have at least + 30 participants. Subgroups below this threshold receive uncertainty + warnings on their estimates. + +### Refutation Tests as Fairness Guards + +- All three DoWhy refutation tests (placebo, random common cause, data + subset) must be conducted within each demographic subgroup, not just + on the full sample. +- If refutation tests fail for any subgroup, the estimate for that + subgroup is flagged and not reported without additional investigation. + +### Remediation Protocol + +If fairness concerns are identified: + +1. Investigate whether confounders are adequate for the affected subgroup. +2. Consider subgroup-specific models if effect heterogeneity is justified. +3. Document all fairness concerns and remediation steps in the audit trail. +4. PI and ethics committee review before results are published. + +--- + +## Quantitative Analyses + +### Expected Effect Estimate Ranges + +- Wellbeing-to-health effects: Small to medium standardized effects + (beta = 0.05-0.30) are expected based on prior literature. +- Health-to-wellbeing effects: Small to medium standardized effects + (beta = 0.10-0.35) are expected based on prior literature. +- Bidirectional asymmetry: The health-to-wellbeing pathway is typically + stronger than the wellbeing-to-health pathway in cross-sectional data. + +### Refutation Test Expectations + +| Test | Expected Result | +|---|---| +| Placebo treatment | Effect drops to ~0 (within noise) | +| Random common cause | Effect remains within original 95% CI | +| Data subset (50% random) | Effect remains within original 95% CI | + +### Longitudinal Regression Expectations + +- Fixed-effect slopes: Direction and magnitude depend on the specific + outcome and study design. +- Random-effect variance: Significant between-participant variability is + expected (ICC > 0.30 for most wellbeing and health outcomes). + +--- + +## Configuration Parameters + +All parameters are defined in `src/ml/config.py` and can be overridden via +environment variables (prefix `WELLAB_ML_`) or a YAML configuration file. + +| Parameter | Default Value | Description | +|---|---|---| +| `RANDOM_SEED` | 42 | Global random seed for reproducibility | +| `HEALTH_ENGINE_PARAMS.min_observations` | 30 | Minimum observations required for causal estimation | +| `HEALTH_ENGINE_PARAMS.significance_level` | 0.05 | Alpha level for hypothesis tests | +| `HEALTH_ENGINE_PARAMS.bootstrap_iterations` | 1000 | Number of bootstrap iterations for CI estimation | +| `HEALTH_ENGINE_PARAMS.causal_method` | "backdoor.linear_regression" | DoWhy estimation method identifier | +| `FAIRNESS_PARAMS.demographic_parity_tolerance` | 0.05 | Maximum allowable difference in prediction rates across groups | +| `FAIRNESS_PARAMS.disparate_impact_floor` | 0.80 | Minimum disparate impact ratio (4/5ths rule) | + +### Environment Variable Overrides + +```bash +export WELLAB_ML_HEALTH_ENGINE__SIGNIFICANCE_LEVEL=0.01 +export WELLAB_ML_HEALTH_ENGINE__BOOTSTRAP_ITERATIONS=5000 +``` + +--- + +## Serialization and Reproducibility + +### Model Artifacts + +- Models are serialized using `joblib.dump` with metadata including: + model version, training timestamp, configuration parameters (significance + level, causal method, seed), and feature names. +- Artifact files are stored in S3 with version IDs. + +### Reproducibility + +- All stochastic operations use `RANDOM_SEED = 42` via + `utils.set_reproducible_seed()`. +- Every training run is logged: hyperparameters, data snapshot ID, metrics, + seed, and duration. +- Logs are retained for 7 years per data retention policy. + +--- + +## Version History + +| Version | Date | Author | Changes | +|---|---|---|---| +| 1.0.0 | 2026-04-05 | WELLab ML Team | Initial model card. Documents CausalHealthAnalyzer v1.0.0 with DoWhy causal inference, mixed-effects longitudinal regression, bidirectional analysis, refutation test requirements, and fairness audit framework. | + +--- + +*This model card follows the format proposed by Mitchell et al. (2019), +"Model Cards for Model Reporting." It is intended for IRB review and +research transparency purposes.* diff --git a/model-cards/lifespan-trajectory.md b/model-cards/lifespan-trajectory.md new file mode 100644 index 0000000..01d7d59 --- /dev/null +++ b/model-cards/lifespan-trajectory.md @@ -0,0 +1,422 @@ +# Model Card: TrajectoryAnalyzer + +## Model Details + +| Field | Value | +|---|---| +| **Model Name** | TrajectoryAnalyzer | +| **Version** | 1.0.0 | +| **Date** | 2026-04-05 | +| **Type** | Latent growth curves (polynomial regression) + Gaussian Mixture Model / K-Means trajectory clustering | +| **Framework** | scikit-learn (KMeans, PolynomialFeatures); NumPy (polyfit); statsmodels (MixedLM, planned); pandas | +| **Owner** | WELLab, Washington University | +| **Contact** | WELLab Principal Investigator | +| **License** | Research use only under IRB-approved protocol | + +### Description + +The TrajectoryAnalyzer models lifespan developmental trajectories of +well-being, identifying distinct trajectory archetypes (latent classes) that +characterize how well-being changes across the lifespan. It provides three +core capabilities: + +1. **Growth curve fitting.** Per-participant polynomial growth curves are + fitted to longitudinal wellbeing data, capturing individual trajectories + as polynomial coefficients (intercept, linear slope, quadratic curvature, + and optionally cubic trends). + +2. **Trajectory clustering.** Polynomial coefficients are used as features + for K-Means clustering (with Gaussian Mixture Models planned), grouping + participants into latent trajectory classes that share similar wellbeing + patterns over time. + +3. **Cross-cultural comparison.** Aggregate trajectory shapes are compared + between two cohorts using polynomial fits, enabling researchers to assess + whether wellbeing trajectories differ systematically across cultural + groups. + +### Architecture + +- Per-participant polynomial curves are fitted using `numpy.polyfit` with + configurable maximum polynomial degree (default: cubic, degree 3). +- When a participant has fewer observations than the requested polynomial + degree + 1, the degree is automatically reduced. +- Polynomial coefficients are padded to uniform length and used as features + for K-Means clustering. +- Cross-cultural comparisons fit aggregate polynomial curves to each cohort + and compute the Euclidean distance between coefficient vectors. + +--- + +## Intended Use + +### Primary Use Cases + +- Identify wellbeing trajectory archetypes across the lifespan for academic + research conducted under IRB-approved protocols. +- Characterize developmental patterns (e.g., stable-high, early-decline, + late-recovery, U-shaped) in longitudinal wellbeing data. +- Compare trajectory distributions across cultural groups, cohorts, or + intervention arms. +- Generate trajectory cluster assignments as features for downstream + statistical models (e.g., predicting health outcomes from trajectory + membership). + +### Intended Users + +- WELLab researchers studying lifespan development of well-being. +- Developmental psychologists and gerontologists collaborating on + WELLab studies. +- Graduate research assistants with training in growth curve modeling + and appropriate data access authorization. + +--- + +## Out-of-Scope Use + +The following uses are explicitly **not supported** and are prohibited: + +- **Predicting individual futures.** Trajectory cluster membership describes + past and present patterns; it must not be used to predict an individual + participant's future wellbeing trajectory. +- **Intervention assignment without clinical judgment.** Trajectory + classifications must not be used to automatically assign participants + to interventions, treatment groups, or clinical pathways without + clinician and IRB oversight. +- **Labeling individuals as "declining" or "at risk."** See Ethical + Considerations below regarding strength-based naming requirements. +- **Insurance, employment, or legal decisions.** No output from this model + may be used in non-research contexts. +- **Real-time monitoring or alerting.** The model is designed for + retrospective analysis of completed longitudinal datasets, not for + real-time trajectory tracking. + +--- + +## Training Data + +### Expected Data Characteristics + +| Characteristic | Specification | +|---|---| +| **Data source** | Longitudinal panel data from WELLab lifespan studies | +| **Age range** | 18-100+ years (full adult lifespan) | +| **Measurement occasions** | At least 3 waves per participant (more recommended) | +| **Wellbeing measure** | Composite wellbeing score or domain-specific measure | +| **Age/time variable** | Participant age at each measurement occasion | +| **Grouping variable** | Unique participant identifier | + +### Input Schema + +The model expects a pandas DataFrame with at minimum: + +| Column | Type | Description | +|---|---|---| +| `participant_id` | object (string) | Unique participant identifier | +| `age` | float64 | Participant age at measurement occasion | +| `wellbeing` | float64 | Wellbeing outcome score | + +### Data Requirements + +- At least `max_degree + 1` observations per participant for full polynomial + fitting (default: 4 observations for cubic curves). +- Participants with fewer observations receive reduced-degree polynomial fits. +- Missing values must be handled prior to model input. +- Cross-cultural comparison requires at least two cohorts with overlapping + age ranges. + +--- + +## Evaluation Data + +- Growth curve quality is assessed using residual analysis (normality, + homoscedasticity) and goodness-of-fit statistics (R-squared per + participant). +- Cluster quality is evaluated using: + - **Inertia** (within-cluster sum of squares): Lower is better. + - **Silhouette score**: Measures cluster separation; target > 0.30. + - **Bayesian Information Criterion (BIC)** for GMM-based clustering + (planned): Used for selecting the optimal number of clusters. + - **Cluster stability**: Bootstrap resampling to assess assignment + consistency; target > 80% agreement across resamples. +- Cross-cultural comparisons are validated using permutation tests or + bootstrap CIs for the coefficient differences (planned). + +--- + +## Metrics + +### Primary Metrics + +| Metric | Description | Target | +|---|---|---| +| **Cluster inertia** | Within-cluster sum of squares from K-Means | Minimized (elbow method for k selection) | +| **Silhouette score** | Average silhouette coefficient across all participants | >= 0.30 | +| **Growth curve R-squared** | Per-participant goodness of fit for polynomial curves | Median >= 0.60 | +| **Cluster stability (bootstrap)** | Agreement of cluster assignments across 100 bootstrap resamples | >= 80% | +| **Cross-cultural distance significance** | Whether cohort trajectory differences exceed chance levels | p < 0.05 via permutation test | + +### Secondary Metrics + +- Cluster size distribution (no cluster should contain fewer than 5% + of participants). +- Polynomial degree adequacy (whether cubic is sufficient or higher + degrees improve fit significantly). +- Cross-validation prediction error for growth curves. + +--- + +## Ethical Considerations + +### Strength-Based Naming of Trajectory Clusters + +Trajectory labels assigned to latent classes must adhere to strength-based +naming conventions. This is a core requirement of the WELLab ethics framework. + +**Prohibited labels:** +- "late-decline" -- implies inevitable deterioration +- "low-functioning" -- deficit-focused language +- "at-risk trajectory" -- stigmatizing and potentially self-fulfilling +- "failure to thrive" -- clinical label inappropriate for research classification + +**Required approach:** +- Labels should describe the shape, not the value judgment: + "stable-high," "gradual-change," "U-shaped recovery," "variable." +- When decline is part of the trajectory shape, use neutral descriptors: + "transitional" rather than "declining." +- All trajectory labels must be reviewed and approved by the PI and + ethics committee before use in any publication or participant-facing + communication. + +### Probabilistic, Not Deterministic + +Cluster assignments are inherently probabilistic. A participant assigned to +the "stable-high" cluster may have a posterior probability of only 0.55 for +that cluster and 0.35 for the "U-shaped" cluster. Researchers must: + +- Report posterior probabilities (when using GMM) or distance-to-centroid + measures alongside hard cluster assignments. +- Avoid treating cluster assignments as fixed participant attributes. +- Acknowledge uncertainty in all publications and presentations. + +### Cross-Cultural Measurement Invariance + +Before comparing trajectories across cultural groups, researchers must verify: + +1. **Configural invariance:** The same factor structure holds across groups. +2. **Metric invariance:** Factor loadings are equivalent across groups. +3. **Scalar invariance:** Intercepts are equivalent across groups (required + for mean comparisons). + +Without measurement invariance, cross-cultural trajectory comparisons may +reflect measurement artifacts rather than true differences in wellbeing +development. + +### Informed Consent + +- Participants must consent to AI processing of their longitudinal data + (per WELLab ethics framework Section 2). +- Trajectory classifications are research-internal; participants are not + shown their cluster assignment unless this is part of an approved + feedback protocol. +- Participants may opt out of the trajectory module while continuing to + participate in other WELLab modules. + +### Data Protection + +- Individual trajectory assignments are visible only to authorized + researchers under the IRB protocol. +- Population-level trajectory distributions enforce k-anonymity (k >= 10). +- No reporting of trajectory membership for demographic cells with + fewer than 10 participants. + +--- + +## Caveats and Recommendations + +### Known Limitations + +1. **Cluster assignments are probabilistic, not deterministic.** K-Means + provides hard assignments, but participants near cluster boundaries + may be poorly classified. GMM-based soft assignments are planned. + +2. **Number of clusters is researcher-specified.** The default of 3 clusters + may not be optimal for all datasets. Researchers should use the elbow + method, silhouette scores, or BIC to select k. + +3. **Polynomial degree limitation.** Cubic polynomials cannot capture all + trajectory shapes (e.g., sharp discontinuities, step functions). Spline- + based or nonparametric approaches may be needed for some populations. + +4. **Cross-cultural comparison is approximate.** The Euclidean distance + between polynomial coefficient vectors is a rough metric. A proper + permutation test or bootstrap CI is needed for inferential claims + (noted as a TODO in the codebase). + +5. **Cross-cultural measurement invariance must be verified.** Without + invariance testing, trajectory comparisons across culture_group may be + meaningless. The model does not perform invariance tests automatically. + +6. **Age-period-cohort confounding.** In cross-sectional or short-panel + data, age effects may be confounded with period or cohort effects. + Researchers must address this in their study design. + +7. **Survivorship bias.** In lifespan studies, participants who remain in + the study at older ages may be systematically healthier or more engaged + than those who drop out, biasing trajectory estimates at older ages. + +### Recommendations + +- Use BIC or silhouette scores to select the number of trajectory clusters. +- Report cluster assignments alongside uncertainty measures. +- Verify measurement invariance before cross-cultural comparisons. +- Use multiple imputation or pattern-mixture models to address dropout. +- Consider accelerated longitudinal designs to disentangle age and cohort. +- Supplement quantitative trajectories with qualitative life-history data. + +--- + +## Fairness Considerations + +### Audit Requirements + +Trajectory cluster distributions must be examined for fairness across +demographic groups: + +| Demographic Attribute | Source | +|---|---| +| sex | Participant demographics | +| ethnicity | Participant demographics | +| culture_group | Participant demographics | +| age_band | Derived from date of birth | +| education_level | Participant demographics | + +### Fairness Criteria + +1. **Cluster distribution equity.** Cluster distributions should not + systematically differ by demographic group unless the difference is + scientifically justified and documented. For example, if the "stable-high" + cluster is 90% female and 10% male, this requires investigation and + justification. + +2. **Demographic parity for trajectory labels.** The assignment rate for + negatively-valenced trajectory types (if any) should not differ by more + than 5 percentage points across demographic groups. + +3. **Disparate impact (4/5ths rule).** The assignment rate for any trajectory + cluster in any demographic group must be at least 80% of the highest + group's rate. If the disparate impact ratio falls below 0.80, the + clustering must be re-examined. + +4. **Representation check.** Training data must include at least 30 + participants per demographic group. Under-represented groups are flagged, + and trajectory assignments for those groups carry uncertainty warnings. + +### Scientifically Justified Differences + +Some trajectory distribution differences across demographic groups may be +genuine and scientifically important (e.g., known sex differences in +wellbeing trajectories at midlife). In these cases: + +- The difference must be documented with supporting literature. +- The PI and ethics committee must review and approve the interpretation. +- The difference must not be used to disadvantage any group. + +### Remediation Protocol + +If unjustified bias is detected: + +1. Investigate data imbalance, scale bias, and confounding. +2. Consider re-weighting, data augmentation, or subgroup-specific models. +3. Re-audit after remediation. +4. Document all remediation steps in the audit trail. + +--- + +## Quantitative Analyses + +### Expected Trajectory Archetypes + +Based on prior literature on lifespan wellbeing, the following trajectory +types are commonly identified with 3-5 clusters: + +| Archetype | Description | Expected Prevalence | +|---|---|---| +| **Stable-high** | Consistently high wellbeing across the lifespan | 30-50% | +| **U-shaped** | Decline in midlife with recovery in later life | 20-35% | +| **Gradual-change** | Slow, steady change (increase or decrease) over time | 15-25% | +| **Variable** | High within-person variability, no clear trend | 5-15% | + +### Growth Curve Statistics + +- Expected mean R-squared for cubic polynomial fits: 0.60-0.85 for + participants with 5+ observations. +- Expected polynomial coefficient distributions should be approximately + normal for the linear and quadratic terms. + +### Cluster Quality + +- Expected silhouette score: 0.25-0.50 (trajectory data is often noisy). +- Expected inertia reduction from k=2 to k=3: 15-30%. +- Bootstrap stability: >= 80% assignment agreement is the minimum + acceptable threshold. + +--- + +## Configuration Parameters + +All parameters are defined in `src/ml/config.py` and can be overridden via +environment variables (prefix `WELLAB_ML_`) or a YAML configuration file. + +| Parameter | Default Value | Description | +|---|---|---| +| `RANDOM_SEED` | 42 | Global random seed for reproducibility | +| `TRAJECTORY_PARAMS.default_n_clusters` | 3 | Default number of latent trajectory groups for K-Means clustering | +| `TRAJECTORY_PARAMS.max_polynomial_degree` | 3 | Maximum polynomial degree for growth curve fitting (1=linear, 2=quadratic, 3=cubic) | +| `TRAJECTORY_PARAMS.convergence_tolerance` | 1e-4 | Convergence tolerance for iterative optimization (used in planned GMM implementation) | +| `TRAJECTORY_PARAMS.max_iterations` | 200 | Maximum iterations for iterative optimization | +| `FAIRNESS_PARAMS.demographic_parity_tolerance` | 0.05 | Maximum allowable difference in cluster assignment rates across demographic groups | +| `FAIRNESS_PARAMS.disparate_impact_floor` | 0.80 | Minimum disparate impact ratio (4/5ths rule) | + +### Environment Variable Overrides + +```bash +export WELLAB_ML_TRAJECTORY__DEFAULT_N_CLUSTERS=4 +export WELLAB_ML_TRAJECTORY__MAX_POLYNOMIAL_DEGREE=2 +``` + +--- + +## Serialization and Reproducibility + +### Model Artifacts + +- Models are serialized using `joblib.dump` with metadata including: + model version, training timestamp, configuration parameters (max_degree, + n_clusters, seed), and participant-level growth model keys. +- K-Means cluster model and per-participant polynomial coefficients are + stored together. +- Artifact files are stored in S3 with version IDs. + +### Reproducibility + +- K-Means uses `random_state=RANDOM_SEED` for deterministic initialization. +- `numpy.polyfit` is deterministic for a given input. +- All stochastic operations use `utils.set_reproducible_seed()`. +- Every training run is logged: hyperparameters, data snapshot ID, metrics, + seed, and duration. +- Logs are retained for 7 years per data retention policy. + +--- + +## Version History + +| Version | Date | Author | Changes | +|---|---|---|---| +| 1.0.0 | 2026-04-05 | WELLab ML Team | Initial model card. Documents TrajectoryAnalyzer v1.0.0 with polynomial growth curves, K-Means trajectory clustering, cross-cultural comparison, strength-based naming requirements, measurement invariance caveats, and fairness audit framework. | + +--- + +*This model card follows the format proposed by Mitchell et al. (2019), +"Model Cards for Model Reporting." It is intended for IRB review and +research transparency purposes.* diff --git a/notebooks/01_emotional_dynamics_analysis.py b/notebooks/01_emotional_dynamics_analysis.py new file mode 100644 index 0000000..724b07c --- /dev/null +++ b/notebooks/01_emotional_dynamics_analysis.py @@ -0,0 +1,279 @@ +# %% [markdown] +# # Emotional Dynamics Analysis: EMA & Emotion Coupling +# +# **WELLab -- Washington University** +# +# This notebook demonstrates the Intra- and Inter-individual Dynamical +# Emotion Linkage System (IDELS) coupling analysis. We generate synthetic +# ecological momentary assessment (EMA) data, fit emotion-coupling models, +# and visualise volatility patterns and coupling types across participants. + +# %% Cell 1 -- Imports and setup +import sys +import os +import logging +import warnings + +import numpy as np +import pandas as pd +import matplotlib.pyplot as plt +import seaborn as sns + +# Make WELLab source importable +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from src.ml.emotional_dynamics import EmotionCouplingAnalyzer +from src.ml.utils import set_reproducible_seed + +SEED = 42 +set_reproducible_seed(SEED) +rng = np.random.default_rng(SEED) + +logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") +warnings.filterwarnings("ignore") + +sns.set_theme(style="whitegrid", context="notebook", palette="colorblind") +print("Setup complete.") + +# %% [markdown] +# ## Synthetic EMA Dataset +# +# We simulate 50 participants, each with 30 time-ordered affect +# observations. Four latent coupling patterns are embedded: +# +# | Group | n | Pattern | +# |-----------------|----|----------------------------------------------| +# | Positive | 15 | PA and NA move together (r > 0) | +# | Negative | 15 | PA and NA move in opposite directions (r < 0) | +# | Decoupled | 10 | No reliable association | +# | Complex | 10 | Non-linear linkage | + +# %% Cell 2 -- Generate synthetic EMA dataset +N_PARTICIPANTS = 50 +N_OBS = 30 + +group_sizes = {"positive": 15, "negative": 15, "decoupled": 10, "complex": 10} +records = [] +pid_counter = 0 + +for coupling_type, n_subj in group_sizes.items(): + for _ in range(n_subj): + pid = f"P{pid_counter:03d}" + pid_counter += 1 + time_points = np.arange(N_OBS, dtype=float) + + if coupling_type == "positive": + base = rng.normal(0, 1, N_OBS) + pa = 5.0 + 0.8 * base + rng.normal(0, 0.3, N_OBS) + na = 3.0 + 0.6 * base + rng.normal(0, 0.3, N_OBS) + elif coupling_type == "negative": + base = rng.normal(0, 1, N_OBS) + pa = 5.0 + 0.8 * base + rng.normal(0, 0.3, N_OBS) + na = 3.0 - 0.6 * base + rng.normal(0, 0.3, N_OBS) + elif coupling_type == "decoupled": + pa = 5.0 + rng.normal(0, 1, N_OBS) + na = 3.0 + rng.normal(0, 1, N_OBS) + else: # complex + base = rng.normal(0, 1, N_OBS) + pa = 5.0 + base + rng.normal(0, 0.2, N_OBS) + na = 3.0 + 0.8 * base**2 - 0.5 * base + rng.normal(0, 0.2, N_OBS) + + for t in range(N_OBS): + records.append({ + "participant_id": pid, + "time": float(t), + "positive_affect": float(np.clip(pa[t], 1, 9)), + "negative_affect": float(np.clip(na[t], 1, 9)), + "true_coupling": coupling_type, + }) + +ema_df = pd.DataFrame(records) +print(f"Synthetic EMA dataset: {len(ema_df)} rows, " + f"{ema_df['participant_id'].nunique()} participants") +print(ema_df.head(10)) + +# %% [markdown] +# ## Fit EmotionCouplingAnalyzer +# +# The analyser normalises affect scores, then computes per-participant +# coupling via Pearson correlation and a nonlinearity heuristic. + +# %% Cell 3 -- Fit the analyser +analyzer = EmotionCouplingAnalyzer(seed=SEED) + +# The fit method requires only the schema columns; drop the ground-truth label +fit_df = ema_df[["participant_id", "time", "positive_affect", "negative_affect"]].copy() +analyzer.fit(fit_df) + +print(f"Fitted: {len(analyzer.coupling_results_)} participants classified") + +# %% [markdown] +# ## Coupling-Type Classification +# +# We compare the analyser's predicted coupling type to the ground-truth +# label embedded in the synthetic data. + +# %% Cell 4 -- Classify coupling types, display distribution +coupling_series = pd.Series(analyzer.coupling_results_, name="predicted_coupling") +coupling_df = coupling_series.reset_index() +coupling_df.columns = ["participant_id", "predicted_coupling"] + +# Add ground truth +truth = ema_df.groupby("participant_id")["true_coupling"].first().reset_index() +coupling_df = coupling_df.merge(truth, on="participant_id") + +print("Predicted coupling-type distribution:") +print(coupling_df["predicted_coupling"].value_counts().to_string()) +print() + +# Confusion-style cross-tab +ct = pd.crosstab(coupling_df["true_coupling"], coupling_df["predicted_coupling"], + margins=True) +print("True vs. Predicted coupling types:") +print(ct.to_string()) + +# %% [markdown] +# ## Emotional Volatility +# +# Volatility is computed as a rolling standard deviation of affect over a +# sliding window. Higher volatility may signal emotional instability. + +# %% Cell 5 -- Compute volatility indices, histogram +volatilities = {} +for pid in ema_df["participant_id"].unique(): + pa = ema_df.loc[ema_df["participant_id"] == pid, "positive_affect"].values + vol = analyzer.compute_volatility(pa) + volatilities[pid] = np.nanmean(vol) + +vol_df = pd.DataFrame({ + "participant_id": list(volatilities.keys()), + "mean_volatility": list(volatilities.values()), +}) +vol_df = vol_df.merge(truth, on="participant_id") + +fig, ax = plt.subplots(figsize=(8, 5)) +for ct_type in ["positive", "negative", "decoupled", "complex"]: + subset = vol_df.loc[vol_df["true_coupling"] == ct_type, "mean_volatility"] + ax.hist(subset, bins=12, alpha=0.55, label=ct_type) +ax.set_xlabel("Mean Positive-Affect Volatility") +ax.set_ylabel("Count") +ax.set_title("Distribution of Emotional Volatility by Coupling Type") +ax.legend(title="Coupling type") +plt.tight_layout() +plt.show() + +# %% [markdown] +# ## High-Risk Participants +# +# We flag participants whose mean volatility exceeds 2 standard deviations +# above the sample mean -- a simple screening criterion for emotional +# instability warranting clinical follow-up. + +# %% Cell 6 -- Identify high-risk participants +mean_vol = vol_df["mean_volatility"].mean() +std_vol = vol_df["mean_volatility"].std() +threshold = mean_vol + 2 * std_vol + +vol_df["high_risk"] = vol_df["mean_volatility"] > threshold +n_high = vol_df["high_risk"].sum() + +print(f"Volatility mean: {mean_vol:.4f}, SD: {std_vol:.4f}") +print(f"High-risk threshold (mean + 2 SD): {threshold:.4f}") +print(f"High-risk participants: {n_high} / {len(vol_df)}") +print() +if n_high > 0: + print(vol_df.loc[vol_df["high_risk"]].to_string(index=False)) +else: + print("No participants exceeded the high-risk threshold.") + +# %% [markdown] +# ## Individual Emotion Time Series +# +# We plot positive and negative affect over time for a handful of +# participants spanning different coupling types. + +# %% Cell 7 -- Individual time-series plots +selected_pids = coupling_df.groupby("predicted_coupling").first()["participant_id"].values[:4] + +fig, axes = plt.subplots(2, 2, figsize=(12, 8), sharex=True) +axes = axes.ravel() + +for i, pid in enumerate(selected_pids): + ax = axes[i] + sub = ema_df.loc[ema_df["participant_id"] == pid] + ax.plot(sub["time"], sub["positive_affect"], marker="o", ms=3, label="PA") + ax.plot(sub["time"], sub["negative_affect"], marker="s", ms=3, label="NA") + ct_label = coupling_df.loc[coupling_df["participant_id"] == pid, "predicted_coupling"].values[0] + ax.set_title(f"{pid} ({ct_label})") + ax.set_ylabel("Affect") + ax.legend(fontsize=8) + +axes[-1].set_xlabel("Time point") +axes[-2].set_xlabel("Time point") +fig.suptitle("Individual Emotion Time Series", fontsize=14, y=1.01) +plt.tight_layout() +plt.show() + +# %% [markdown] +# ## Coupling Heatmap +# +# A heatmap of the PA-NA Pearson correlation for each participant, +# ordered by coupling type, provides a visual summary of coupling +# strength and direction. + +# %% Cell 8 -- Coupling heatmap +correlations = {} +for pid in ema_df["participant_id"].unique(): + sub = ema_df.loc[ema_df["participant_id"] == pid] + r = np.corrcoef(sub["positive_affect"], sub["negative_affect"])[0, 1] + correlations[pid] = r + +corr_df = pd.DataFrame({ + "participant_id": list(correlations.keys()), + "pa_na_corr": list(correlations.values()), +}) +corr_df = corr_df.merge(truth, on="participant_id") +corr_df = corr_df.sort_values(["true_coupling", "pa_na_corr"]) + +# Reshape for heatmap +corr_matrix = corr_df.set_index("participant_id")[["pa_na_corr"]] + +fig, ax = plt.subplots(figsize=(4, 14)) +sns.heatmap( + corr_matrix, + cmap="RdBu_r", + center=0, + vmin=-1, vmax=1, + yticklabels=True, + ax=ax, + cbar_kws={"label": "PA-NA correlation"}, +) +ax.set_title("PA-NA Coupling Across Participants") +ax.set_ylabel("") +ax.tick_params(axis="y", labelsize=6) +plt.tight_layout() +plt.show() + +# %% [markdown] +# ## Summary Statistics + +# %% Cell 9 -- Summary statistics table +summary = vol_df.groupby("true_coupling").agg( + n=("participant_id", "count"), + mean_volatility=("mean_volatility", "mean"), + sd_volatility=("mean_volatility", "std"), + n_high_risk=("high_risk", "sum"), +).reset_index() + +# Add mean PA-NA correlation per group +corr_summary = corr_df.groupby("true_coupling")["pa_na_corr"].agg( + mean_corr="mean", sd_corr="std" +).reset_index() + +summary = summary.merge(corr_summary, on="true_coupling") + +print("=" * 70) +print(" EMOTIONAL DYNAMICS -- SUMMARY STATISTICS") +print("=" * 70) +print(summary.to_string(index=False, float_format="%.4f")) +print("=" * 70) diff --git a/notebooks/02_health_causal_inference.py b/notebooks/02_health_causal_inference.py new file mode 100644 index 0000000..752e194 --- /dev/null +++ b/notebooks/02_health_causal_inference.py @@ -0,0 +1,301 @@ +# %% [markdown] +# # Causal Inference: Wellbeing <--> Health +# +# **WELLab -- Washington University** +# +# This notebook applies the CausalHealthAnalyzer to estimate bidirectional +# causal effects between subjective well-being and objective health +# biomarkers. We generate synthetic observational data with known +# confounders and compare the estimated effects in both directions. + +# %% Cell 1 -- Imports and setup +import sys +import os +import logging +import warnings + +import numpy as np +import pandas as pd +import matplotlib.pyplot as plt +import seaborn as sns + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from src.ml.health_engine import CausalHealthAnalyzer, CausalEstimateResult +from src.ml.utils import set_reproducible_seed + +SEED = 42 +set_reproducible_seed(SEED) +rng = np.random.default_rng(SEED) + +logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") +warnings.filterwarnings("ignore") + +sns.set_theme(style="whitegrid", context="notebook", palette="colorblind") +print("Setup complete.") + +# %% [markdown] +# ## Synthetic Health + Wellbeing Dataset +# +# We create 200 participants observed across 4 measurement waves. +# The data-generating process includes: +# +# - **Confounders**: age, socioeconomic status (SES), baseline physical +# activity. +# - **True causal effects**: wellbeing --> health (beta = 0.35) and +# health --> wellbeing (beta = 0.20). +# - **Noise** scaled realistically so that effects are detectable but +# not trivial. + +# %% Cell 2 -- Generate synthetic dataset +N_PARTICIPANTS = 200 +N_WAVES = 4 + +records = [] +for pid in range(N_PARTICIPANTS): + age = rng.uniform(25, 75) + ses = rng.normal(50, 15) + activity = rng.normal(3, 1) # hours/week + + wb_prev = 5.0 + 0.01 * ses + rng.normal(0, 0.5) + hl_prev = 70 + 0.1 * ses - 0.15 * age + rng.normal(0, 3) + + for wave in range(N_WAVES): + # Cross-lagged effects + confounders + noise + wellbeing = ( + 0.6 * wb_prev + + 0.20 * (hl_prev - 70) / 10 + + 0.005 * ses + + 0.1 * activity + + rng.normal(0, 0.4) + ) + health = ( + 0.7 * hl_prev + + 0.35 * (wb_prev - 5) + + 0.08 * ses + - 0.1 * age + + 0.5 * activity + + rng.normal(0, 2) + ) + + records.append({ + "participant_id": f"P{pid:03d}", + "wave": wave, + "wellbeing_score": float(wellbeing), + "health_score": float(health), + "age": age, + "ses": ses, + "physical_activity": activity, + }) + + wb_prev = wellbeing + hl_prev = health + +health_df = pd.DataFrame(records) +print(f"Dataset: {len(health_df)} rows, {health_df['participant_id'].nunique()} participants, " + f"{N_WAVES} waves") +print(health_df.describe().round(2).to_string()) + +# %% [markdown] +# ## Instantiate CausalHealthAnalyzer + +# %% Cell 3 -- Create analyser +analyzer = CausalHealthAnalyzer(seed=SEED) +print(f"CausalHealthAnalyzer ready (method={analyzer.causal_method}, " + f"alpha={analyzer.significance_level})") + +# %% [markdown] +# ## Wellbeing --> Health +# +# We estimate the average causal effect of wellbeing on health, +# adjusting for age, SES, and physical activity. + +# %% Cell 4 -- Causal effect: wellbeing -> health +wb_to_hl = analyzer.estimate_causal_effect( + treatment="wellbeing_score", + outcome="health_score", + confounders=["age", "ses", "physical_activity"], + data=health_df, +) + +print("=" * 55) +print(" Wellbeing --> Health") +print("=" * 55) +print(f" Estimate : {wb_to_hl.estimate:.4f}") +print(f" 95% CI : ({wb_to_hl.confidence_interval[0]:.4f}, " + f"{wb_to_hl.confidence_interval[1]:.4f})") +print(f" Method : {wb_to_hl.method}") +print("=" * 55) + +# %% [markdown] +# ## Health --> Wellbeing (Reverse Direction) + +# %% Cell 5 -- Causal effect: health -> wellbeing +hl_to_wb = analyzer.estimate_causal_effect( + treatment="health_score", + outcome="wellbeing_score", + confounders=["age", "ses", "physical_activity"], + data=health_df, +) + +print("=" * 55) +print(" Health --> Wellbeing") +print("=" * 55) +print(f" Estimate : {hl_to_wb.estimate:.4f}") +print(f" 95% CI : ({hl_to_wb.confidence_interval[0]:.4f}, " + f"{hl_to_wb.confidence_interval[1]:.4f})") +print(f" Method : {hl_to_wb.method}") +print("=" * 55) + +# Compare directions +fig, ax = plt.subplots(figsize=(6, 4)) +labels = ["WB -> Health", "Health -> WB"] +estimates = [wb_to_hl.estimate, hl_to_wb.estimate] +ci_low = [wb_to_hl.confidence_interval[0], hl_to_wb.confidence_interval[0]] +ci_high = [wb_to_hl.confidence_interval[1], hl_to_wb.confidence_interval[1]] +errors = [[e - lo for e, lo in zip(estimates, ci_low)], + [hi - e for e, hi in zip(estimates, ci_high)]] + +ax.barh(labels, estimates, xerr=errors, capsize=5, color=["#4C72B0", "#DD8452"]) +ax.axvline(0, color="black", lw=0.8, ls="--") +ax.set_xlabel("Estimated Causal Effect") +ax.set_title("Bidirectional Causal Effects") +plt.tight_layout() +plt.show() + +# %% [markdown] +# ## Longitudinal Regression +# +# We fit a longitudinal (stub OLS-per-group) regression to model the +# within-person trajectory of health outcomes across waves. + +# %% Cell 6 -- Longitudinal regression +long_results = analyzer.run_longitudinal_regression( + data=health_df, + outcome="health_score", + time_var="wave", + group_var="participant_id", +) + +print("Longitudinal regression results:") +for k, v in long_results.items(): + print(f" {k}: {v}") + +# Plot per-participant slopes +participant_slopes = [] +for pid, grp in health_df.groupby("participant_id"): + if len(grp) >= 2: + coeffs = np.polyfit(grp["wave"], grp["health_score"], 1) + participant_slopes.append(coeffs[0]) + +fig, ax = plt.subplots(figsize=(7, 4)) +ax.hist(participant_slopes, bins=25, edgecolor="white", alpha=0.8) +ax.axvline(np.mean(participant_slopes), color="red", lw=2, ls="--", + label=f"Mean = {np.mean(participant_slopes):.2f}") +ax.set_xlabel("Per-Participant Health Slope (per wave)") +ax.set_ylabel("Count") +ax.set_title("Distribution of Longitudinal Health Trajectories") +ax.legend() +plt.tight_layout() +plt.show() + +# %% [markdown] +# ## Bidirectional Analysis +# +# The `bidirectional_analysis` method runs both directions simultaneously +# on merged wellbeing + health datasets. + +# %% Cell 7 -- Bidirectional analysis +wb_data = health_df[["participant_id", "wave", "wellbeing_score"]].copy() +hl_data = health_df[["participant_id", "wave", "health_score"]].copy() + +bidir = analyzer.bidirectional_analysis(wb_data, hl_data) + +fig, ax = plt.subplots(figsize=(6, 4)) +directions = list(bidir.keys()) +ests = [bidir[d].estimate for d in directions] +cis = [bidir[d].confidence_interval for d in directions] +err = [[e - ci[0] for e, ci in zip(ests, cis)], + [ci[1] - e for e, ci in zip(ests, cis)]] + +colors = ["#4C72B0", "#DD8452"] +ax.barh(directions, ests, xerr=err, capsize=5, color=colors) +ax.axvline(0, color="black", lw=0.8, ls="--") +ax.set_xlabel("Estimated Effect") +ax.set_title("Cross-Lagged Bidirectional Effects") +plt.tight_layout() +plt.show() + +for direction, result in bidir.items(): + print(f"{direction}: estimate={result.estimate:.4f}, " + f"CI=({result.confidence_interval[0]:.4f}, " + f"{result.confidence_interval[1]:.4f})") + +# %% [markdown] +# ## Sensitivity Analysis +# +# We assess how the wellbeing --> health effect changes as we vary +# the set of confounders included in the model. + +# %% Cell 8 -- Sensitivity analysis +confounder_sets = { + "None": [], + "Age only": ["age"], + "Age + SES": ["age", "ses"], + "Age + SES + Activity": ["age", "ses", "physical_activity"], + "SES + Activity": ["ses", "physical_activity"], +} + +sensitivity_results = {} +for label, confounders in confounder_sets.items(): + # Must have at least one confounder for the stub; use wave if empty + conf = confounders if confounders else ["wave"] + result = analyzer.estimate_causal_effect( + treatment="wellbeing_score", + outcome="health_score", + confounders=conf, + data=health_df, + ) + sensitivity_results[label] = result + +fig, ax = plt.subplots(figsize=(8, 4)) +labels_s = list(sensitivity_results.keys()) +ests_s = [sensitivity_results[l].estimate for l in labels_s] +ci_s = [sensitivity_results[l].confidence_interval for l in labels_s] +err_s = [[e - c[0] for e, c in zip(ests_s, ci_s)], + [c[1] - e for e, c in zip(ests_s, ci_s)]] + +ax.errorbar(labels_s, ests_s, yerr=err_s, fmt="o-", capsize=5, markersize=8) +ax.set_ylabel("Estimated Effect (WB -> Health)") +ax.set_xlabel("Confounder Set") +ax.set_title("Sensitivity of Causal Estimate to Confounder Specification") +ax.axhline(0, color="gray", ls="--", lw=0.8) +plt.xticks(rotation=20, ha="right") +plt.tight_layout() +plt.show() + +# %% [markdown] +# ## Results Summary + +# %% Cell 9 -- Summary table +rows = [] +for label, result in sensitivity_results.items(): + rows.append({ + "Confounder set": label, + "Estimate": result.estimate, + "CI lower": result.confidence_interval[0], + "CI upper": result.confidence_interval[1], + "Method": result.method, + }) + +summary_df = pd.DataFrame(rows) +print("=" * 75) +print(" CAUSAL INFERENCE -- RESULTS SUMMARY") +print("=" * 75) +print(summary_df.to_string(index=False, float_format="%.4f")) +print() +print(f"Bidirectional comparison:") +print(f" WB -> Health : {bidir['wellbeing_to_health'].estimate:.4f}") +print(f" Health -> WB : {bidir['health_to_wellbeing'].estimate:.4f}") +print(f" Longitudinal slope (mean) : {long_results['fixed_effect_slope']:.4f}") +print("=" * 75) diff --git a/notebooks/03_lifespan_trajectories.py b/notebooks/03_lifespan_trajectories.py new file mode 100644 index 0000000..ad561dc --- /dev/null +++ b/notebooks/03_lifespan_trajectories.py @@ -0,0 +1,316 @@ +# %% [markdown] +# # Lifespan Trajectory Analysis: Growth Curves & Clustering +# +# **WELLab -- Washington University** +# +# This notebook models well-being trajectories across the adult lifespan +# (ages 18-85). We fit polynomial growth curves per participant, cluster +# them into latent trajectory groups, and compare trajectories across +# synthetic cultural cohorts. + +# %% Cell 1 -- Imports and setup +import sys +import os +import logging +import warnings + +import numpy as np +import pandas as pd +import matplotlib.pyplot as plt +import seaborn as sns +from sklearn.cluster import KMeans +from sklearn.metrics import silhouette_score + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from src.ml.lifespan_trajectory import TrajectoryAnalyzer +from src.ml.utils import set_reproducible_seed + +SEED = 42 +set_reproducible_seed(SEED) +rng = np.random.default_rng(SEED) + +logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") +warnings.filterwarnings("ignore") + +sns.set_theme(style="whitegrid", context="notebook", palette="colorblind") +print("Setup complete.") + +# %% [markdown] +# ## Synthetic Lifespan Dataset +# +# We simulate 300 participants observed at 5 age points each, drawn +# from three latent trajectory archetypes: +# +# | Archetype | n | Shape | +# |---------------|-----|--------------------------------------------| +# | Resilient | 120 | High stable with slight U-shape in midlife | +# | Declining | 100 | Gradual linear decline with age | +# | Late-bloomer | 80 | Low early, rising after midlife | + +# %% Cell 2 -- Generate synthetic lifespan data +N_PARTICIPANTS = 300 +ARCHETYPE_SIZES = {"resilient": 120, "declining": 100, "late_bloomer": 80} +OBS_PER_PERSON = 5 + +records = [] +pid = 0 + +for archetype, n_subj in ARCHETYPE_SIZES.items(): + for _ in range(n_subj): + pid_label = f"P{pid:03d}" + pid += 1 + + # Sample 5 ages spread across the lifespan + base_age = rng.uniform(18, 60) + ages = np.sort(base_age + np.arange(OBS_PER_PERSON) * rng.uniform(3, 8)) + ages = np.clip(ages, 18, 85) + + for age in ages: + age_c = (age - 50) / 30 # centred and scaled + + if archetype == "resilient": + wb = 7.0 + 0.3 * age_c**2 - 0.1 * age_c + rng.normal(0, 0.4) + elif archetype == "declining": + wb = 6.5 - 1.5 * age_c + rng.normal(0, 0.5) + else: # late_bloomer + wb = 4.5 + 1.2 * age_c + 0.4 * age_c**2 + rng.normal(0, 0.5) + + records.append({ + "participant_id": pid_label, + "age": float(age), + "wellbeing": float(np.clip(wb, 1, 10)), + "archetype": archetype, + }) + +lifespan_df = pd.DataFrame(records) +print(f"Dataset: {len(lifespan_df)} rows, " + f"{lifespan_df['participant_id'].nunique()} participants") +print(lifespan_df.groupby("archetype").size().to_string()) + +# %% [markdown] +# ## Fit Growth Curves + +# %% Cell 3 -- Fit growth curves +analyzer = TrajectoryAnalyzer(max_degree=3, n_clusters=3, seed=SEED) + +growth_summary = analyzer.fit_growth_curves( + data=lifespan_df, + outcome="wellbeing", + age_col="age", + group_col="participant_id", +) + +print("Growth curve fitting summary:") +for k, v in growth_summary.items(): + print(f" {k}: {v}") + +# %% [markdown] +# ## Trajectory Clustering +# +# K-Means on polynomial coefficients identifies latent trajectory +# groups. We compare the discovered clusters to the true archetypes. + +# %% Cell 4 -- Cluster trajectories +cluster_result = analyzer.cluster_trajectories( + data=lifespan_df, + n_clusters=3, + outcome="wellbeing", + age_col="age", + group_col="participant_id", +) + +assignments = cluster_result["assignments"] +cluster_df = pd.DataFrame({ + "participant_id": list(assignments.keys()), + "cluster": list(assignments.values()), +}) + +# Add ground truth +truth = lifespan_df.groupby("participant_id")["archetype"].first().reset_index() +cluster_df = cluster_df.merge(truth, on="participant_id") + +print(f"Inertia: {cluster_result['inertia']:.2f}") +print() +ct = pd.crosstab(cluster_df["archetype"], cluster_df["cluster"], margins=True) +print("Archetype vs Cluster assignment:") +print(ct.to_string()) + +# %% [markdown] +# ## Trajectory Archetypes with Confidence Bands +# +# We plot the cluster-mean trajectories along with shaded 95% confidence +# bands. + +# %% Cell 5 -- Plot trajectory archetypes +age_grid = np.linspace(18, 85, 200) +colors = ["#4C72B0", "#DD8452", "#55A868"] + +fig, ax = plt.subplots(figsize=(10, 6)) + +for cluster_id in sorted(cluster_df["cluster"].unique()): + pids_in_cluster = cluster_df.loc[cluster_df["cluster"] == cluster_id, "participant_id"] + sub = lifespan_df[lifespan_df["participant_id"].isin(pids_in_cluster)] + + # Aggregate polynomial fit + coeffs = np.polyfit(sub["age"], sub["wellbeing"], deg=3) + y_mean = np.polyval(coeffs, age_grid) + + # Bootstrap confidence band + boot_curves = [] + for _ in range(200): + idx = rng.choice(len(sub), size=len(sub), replace=True) + boot_sub = sub.iloc[idx] + bc = np.polyfit(boot_sub["age"], boot_sub["wellbeing"], deg=3) + boot_curves.append(np.polyval(bc, age_grid)) + boot_arr = np.array(boot_curves) + ci_lo = np.percentile(boot_arr, 2.5, axis=0) + ci_hi = np.percentile(boot_arr, 97.5, axis=0) + + c = colors[cluster_id % len(colors)] + ax.plot(age_grid, y_mean, lw=2, color=c, + label=f"Cluster {cluster_id} (n={len(pids_in_cluster)})") + ax.fill_between(age_grid, ci_lo, ci_hi, alpha=0.15, color=c) + +ax.set_xlabel("Age") +ax.set_ylabel("Wellbeing") +ax.set_title("Trajectory Archetypes with 95% Confidence Bands") +ax.legend() +plt.tight_layout() +plt.show() + +# %% [markdown] +# ## Cross-Cultural Comparison +# +# We create two synthetic cohorts (e.g. "Western" vs "East Asian") +# with different trajectory shapes and test for differences. + +# %% Cell 6 -- Cross-cultural comparison +cohort_a = lifespan_df[lifespan_df["archetype"].isin(["resilient", "declining"])][ + ["age", "wellbeing"] +].copy() +cohort_b_records = [] +for _ in range(500): + age = rng.uniform(18, 85) + age_c = (age - 50) / 30 + wb = 6.0 + 0.5 * age_c - 0.2 * age_c**2 + rng.normal(0, 0.6) + cohort_b_records.append({"age": float(age), "wellbeing": float(np.clip(wb, 1, 10))}) +cohort_b = pd.DataFrame(cohort_b_records) + +comparison = analyzer.cross_cultural_comparison(cohort_a, cohort_b) + +print("Cross-cultural comparison:") +for k, v in comparison.items(): + print(f" {k}: {v}") + +fig, ax = plt.subplots(figsize=(8, 5)) +age_grid = np.linspace(18, 85, 200) + +for label, coeffs, color in [ + ("Cohort A (Western)", comparison["cohort_a_coeffs"], "#4C72B0"), + ("Cohort B (East Asian)", comparison["cohort_b_coeffs"], "#DD8452"), +]: + y = np.polyval(coeffs, age_grid) + ax.plot(age_grid, y, lw=2.5, color=color, label=label) + +ax.set_xlabel("Age") +ax.set_ylabel("Wellbeing") +ax.set_title("Cross-Cultural Trajectory Comparison") +ax.legend() +plt.tight_layout() +plt.show() + +# %% [markdown] +# ## Individual Trajectories on Cluster Means + +# %% Cell 7 -- Individual trajectory overlay +fig, axes = plt.subplots(1, 3, figsize=(15, 5), sharey=True) + +for cluster_id, ax in enumerate(axes): + pids_in_cluster = cluster_df.loc[cluster_df["cluster"] == cluster_id, "participant_id"] + sub = lifespan_df[lifespan_df["participant_id"].isin(pids_in_cluster)] + + # Plot individual traces (sample up to 15) + sample_pids = rng.choice(pids_in_cluster.values, + size=min(15, len(pids_in_cluster)), replace=False) + for sp in sample_pids: + p_data = sub[sub["participant_id"] == sp] + ax.plot(p_data["age"], p_data["wellbeing"], alpha=0.25, lw=0.8, + color="gray", marker=".", ms=3) + + # Cluster mean curve + coeffs = np.polyfit(sub["age"], sub["wellbeing"], deg=3) + age_g = np.linspace(sub["age"].min(), sub["age"].max(), 100) + ax.plot(age_g, np.polyval(coeffs, age_g), lw=3, color=colors[cluster_id]) + + ax.set_title(f"Cluster {cluster_id} (n={len(pids_in_cluster)})") + ax.set_xlabel("Age") + if cluster_id == 0: + ax.set_ylabel("Wellbeing") + +fig.suptitle("Individual Trajectories Overlaid on Cluster Means", fontsize=14, y=1.02) +plt.tight_layout() +plt.show() + +# %% [markdown] +# ## Optimal Cluster Count (BIC-proxy & Silhouette) +# +# We sweep k from 2 to 7 and evaluate inertia (BIC proxy) and +# silhouette scores to guide cluster-count selection. + +# %% Cell 8 -- BIC/silhouette analysis +k_range = range(2, 8) +inertias = [] +silhouettes = [] + +# Build feature matrix from growth model coefficients +growth_models = analyzer._growth_models +pids = list(growth_models.keys()) +max_len = max(len(c) for c in growth_models.values()) +X = np.zeros((len(pids), max_len)) +for i, p in enumerate(pids): + coeffs = growth_models[p] + X[i, max_len - len(coeffs):] = coeffs + +for k in k_range: + km = KMeans(n_clusters=k, random_state=SEED, n_init=10) + labels = km.fit_predict(X) + inertias.append(km.inertia_) + silhouettes.append(silhouette_score(X, labels)) + +fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4)) + +ax1.plot(list(k_range), inertias, "o-", lw=2) +ax1.set_xlabel("Number of clusters (k)") +ax1.set_ylabel("Inertia") +ax1.set_title("Elbow Plot") + +ax2.plot(list(k_range), silhouettes, "s-", lw=2, color="#DD8452") +ax2.set_xlabel("Number of clusters (k)") +ax2.set_ylabel("Silhouette score") +ax2.set_title("Silhouette Analysis") + +plt.tight_layout() +plt.show() + +best_k = list(k_range)[np.argmax(silhouettes)] +print(f"Optimal k by silhouette: {best_k} (score={max(silhouettes):.4f})") + +# %% [markdown] +# ## Export Results + +# %% Cell 9 -- Export to CSV +output_dir = os.path.join(os.path.dirname(__file__), "output") +os.makedirs(output_dir, exist_ok=True) + +cluster_df.to_csv(os.path.join(output_dir, "trajectory_clusters.csv"), index=False) + +growth_coeff_df = pd.DataFrame({ + "participant_id": pids, + **{f"coeff_{i}": X[:, i] for i in range(X.shape[1])}, +}) +growth_coeff_df.to_csv(os.path.join(output_dir, "growth_coefficients.csv"), index=False) + +print(f"Results exported to {output_dir}/") +print(f" - trajectory_clusters.csv ({len(cluster_df)} rows)") +print(f" - growth_coefficients.csv ({len(growth_coeff_df)} rows)") diff --git a/notebooks/04_cognitive_risk_assessment.py b/notebooks/04_cognitive_risk_assessment.py new file mode 100644 index 0000000..ec47922 --- /dev/null +++ b/notebooks/04_cognitive_risk_assessment.py @@ -0,0 +1,314 @@ +# %% [markdown] +# # Cognitive Risk Assessment: Dementia Prevention +# +# **WELLab -- Washington University** +# +# This notebook demonstrates the CognitiveRiskModel for predicting +# cognitive decline, identifying protective factors, and performing +# survival analysis. All data is synthetic and generated in-notebook +# for reproducibility. + +# %% Cell 1 -- Imports and setup +import sys +import os +import logging +import warnings + +import numpy as np +import pandas as pd +import matplotlib.pyplot as plt +import seaborn as sns +from sklearn.metrics import roc_auc_score, roc_curve +from sklearn.calibration import calibration_curve + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from src.ml.cognitive_health import CognitiveRiskModel +from src.ml.utils import set_reproducible_seed + +SEED = 42 +set_reproducible_seed(SEED) +rng = np.random.default_rng(SEED) + +logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") +warnings.filterwarnings("ignore") + +sns.set_theme(style="whitegrid", context="notebook", palette="colorblind") +print("Setup complete.") + +# %% [markdown] +# ## Synthetic Cognitive + Wellbeing Dataset +# +# We simulate 500 participants with the following features: +# +# | Feature | Description | +# |----------------------|--------------------------------------| +# | age | 50-90 years | +# | education_years | 8-20 years of schooling | +# | wellbeing_score | Subjective wellbeing (1-10) | +# | social_engagement | Social activity index (0-10) | +# | physical_activity | Exercise hours per week | +# | sleep_quality | Self-rated sleep quality (1-10) | +# | cardiovascular_risk | Composite CV risk score (0-1) | +# | cognitive_baseline | Baseline cognitive test score | +# | apoe4_carrier | APOE-e4 allele carrier (0/1) | +# | depression_history | History of depression (0/1) | +# +# The binary outcome `cognitive_decline` is generated from a logistic +# model with known coefficients. + +# %% Cell 2 -- Generate synthetic dataset +N = 500 + +data = pd.DataFrame({ + "age": rng.uniform(50, 90, N), + "education_years": rng.normal(14, 3, N).clip(8, 20), + "wellbeing_score": rng.normal(6, 1.5, N).clip(1, 10), + "social_engagement": rng.normal(5, 2, N).clip(0, 10), + "physical_activity": rng.exponential(3, N).clip(0, 15), + "sleep_quality": rng.normal(6, 2, N).clip(1, 10), + "cardiovascular_risk": rng.beta(2, 5, N), + "cognitive_baseline": rng.normal(100, 15, N), + "apoe4_carrier": rng.binomial(1, 0.25, N), + "depression_history": rng.binomial(1, 0.20, N), +}) + +# Logistic model for cognitive decline +logit = ( + 0.06 * (data["age"] - 70) + - 0.15 * (data["education_years"] - 14) + - 0.20 * (data["wellbeing_score"] - 6) + - 0.10 * data["social_engagement"] + - 0.08 * data["physical_activity"] + - 0.05 * data["sleep_quality"] + + 2.0 * data["cardiovascular_risk"] + - 0.02 * (data["cognitive_baseline"] - 100) + + 0.8 * data["apoe4_carrier"] + + 0.5 * data["depression_history"] + - 1.5 # intercept +) +prob = 1 / (1 + np.exp(-logit)) +data["cognitive_decline"] = rng.binomial(1, prob) + +print(f"Dataset: {len(data)} participants") +print(f"Decline prevalence: {data['cognitive_decline'].mean():.1%}") +print(data.describe().round(2).to_string()) + +# %% [markdown] +# ## Fit CognitiveRiskModel + +# %% Cell 3 -- Train model +model = CognitiveRiskModel(risk_threshold=0.5, n_estimators=100, max_depth=6, seed=SEED) +model.fit(data, target_col="cognitive_decline") + +print(f"Model fitted: {len(model._feature_names)} features") +print(f"Features: {model._feature_names}") + +# %% [markdown] +# ## Risk Score Distribution +# +# We predict risk probabilities for all participants and examine the +# distribution. + +# %% Cell 4 -- Predict risk scores +risk_df = model.predict_risk(data[model._feature_names]) + +fig, ax = plt.subplots(figsize=(8, 5)) +for label, color in [(0, "#55A868"), (1, "#C44E52")]: + subset = risk_df.loc[data["cognitive_decline"] == label, "risk_probability"] + ax.hist(subset, bins=30, alpha=0.6, color=color, + label=f"Decline={label} (n={len(subset)})") +ax.axvline(model.risk_threshold, color="black", ls="--", lw=1.5, + label=f"Threshold={model.risk_threshold}") +ax.set_xlabel("Predicted Risk Probability") +ax.set_ylabel("Count") +ax.set_title("Distribution of Cognitive Decline Risk Scores") +ax.legend() +plt.tight_layout() +plt.show() + +print(f"High-risk participants: {risk_df['high_risk'].sum()} / {len(risk_df)}") + +# %% [markdown] +# ## Protective Factor Analysis +# +# Features whose removal most damages discrimination of decline cases +# are considered important; those with negative permutation importance +# are protective. + +# %% Cell 5 -- Protective factors, feature importance bar chart +protective = model.identify_protective_factors(data, target_col="cognitive_decline", top_n=10) + +fig, ax = plt.subplots(figsize=(8, 5)) +names = [p[0] for p in protective] +scores = [p[1] for p in protective] +bar_colors = ["#55A868" if s < 0 else "#C44E52" for s in scores] + +ax.barh(names, scores, color=bar_colors, edgecolor="white") +ax.axvline(0, color="black", lw=0.8) +ax.set_xlabel("Permutation Importance") +ax.set_title("Feature Importance (negative = protective)") +ax.invert_yaxis() +plt.tight_layout() +plt.show() + +print("Top protective factors:") +for name, score in protective: + direction = "PROTECTIVE" if score < 0 else "risk" + print(f" {name:25s} importance={score:+.4f} ({direction})") + +# %% [markdown] +# ## Survival Analysis +# +# We generate synthetic time-to-event data and run the survival +# analysis method (Cox PH when lifelines is available, stub otherwise). + +# %% Cell 6 -- Survival / Kaplan-Meier analysis +# Generate time-to-event data +hazard = 0.02 * np.exp(logit) +time_to_event = rng.exponential(1 / hazard.clip(0.001)) +time_to_event = time_to_event.clip(0, 20) # max 20 years follow-up +event_observed = (time_to_event < 15).astype(int) # censoring at 15 years + +survival_df = data[model._feature_names].copy() +survival_df["years_to_event"] = time_to_event +survival_df["event_observed"] = event_observed + +surv_results = model.survival_analysis(survival_df) + +print("Survival analysis results:") +for k, v in surv_results.items(): + if k != "summary": + print(f" {k}: {v}") + +# Kaplan-Meier-style plot (empirical survival curves by risk group) +risk_df["risk_group"] = pd.cut(risk_df["risk_probability"], + bins=[0, 0.3, 0.5, 0.7, 1.0], + labels=["low", "moderate", "high", "very_high"]) + +fig, ax = plt.subplots(figsize=(8, 5)) +time_grid = np.linspace(0, 15, 100) + +for group, color in [("low", "#55A868"), ("moderate", "#4C72B0"), + ("high", "#DD8452"), ("very_high", "#C44E52")]: + mask = risk_df["risk_group"] == group + if mask.sum() == 0: + continue + t = time_to_event[mask.values] + e = event_observed[mask.values] + + # Simple KM estimate + sorted_t = np.sort(t) + surv = np.array([np.mean(t > tt) for tt in time_grid]) + ax.plot(time_grid, surv, lw=2, label=f"{group} (n={mask.sum()})", color=color) + +ax.set_xlabel("Years") +ax.set_ylabel("Survival Probability") +ax.set_title("Kaplan-Meier Survival Curves by Risk Group") +ax.legend() +ax.set_ylim(0, 1.05) +plt.tight_layout() +plt.show() + +# %% [markdown] +# ## Risk Stratification + +# %% Cell 7 -- Risk stratification by group +risk_df["decline_actual"] = data["cognitive_decline"].values + +strat = risk_df.groupby("risk_group").agg( + n=("risk_probability", "count"), + mean_risk=("risk_probability", "mean"), + actual_decline_rate=("decline_actual", "mean"), +).reset_index() + +print("Risk Stratification:") +print(strat.to_string(index=False, float_format="%.3f")) +print() + +fig, ax = plt.subplots(figsize=(7, 4)) +x = np.arange(len(strat)) +w = 0.35 +ax.bar(x - w/2, strat["mean_risk"], w, label="Mean predicted risk", color="#4C72B0") +ax.bar(x + w/2, strat["actual_decline_rate"], w, label="Actual decline rate", color="#DD8452") +ax.set_xticks(x) +ax.set_xticklabels(strat["risk_group"]) +ax.set_ylabel("Rate") +ax.set_title("Predicted Risk vs Actual Decline Rate by Risk Group") +ax.legend() +plt.tight_layout() +plt.show() + +# %% [markdown] +# ## Wellbeing as Protective Factor +# +# We isolate the role of wellbeing by comparing decline rates across +# wellbeing tertiles, controlling for age. + +# %% Cell 8 -- Wellbeing as protective factor +data["wellbeing_tertile"] = pd.qcut(data["wellbeing_score"], 3, + labels=["Low", "Medium", "High"]) +data["age_group"] = pd.cut(data["age"], bins=[50, 65, 75, 90], + labels=["50-65", "65-75", "75-90"]) + +wb_analysis = data.groupby(["age_group", "wellbeing_tertile"]).agg( + n=("cognitive_decline", "count"), + decline_rate=("cognitive_decline", "mean"), +).reset_index() + +print("Decline rate by age group and wellbeing tertile:") +print(wb_analysis.to_string(index=False, float_format="%.3f")) + +fig, ax = plt.subplots(figsize=(8, 5)) +pivot = wb_analysis.pivot(index="age_group", columns="wellbeing_tertile", + values="decline_rate") +pivot.plot(kind="bar", ax=ax, edgecolor="white") +ax.set_ylabel("Cognitive Decline Rate") +ax.set_xlabel("Age Group") +ax.set_title("Wellbeing as Protective Factor: Decline Rate by Age and Wellbeing") +ax.legend(title="Wellbeing tertile") +plt.xticks(rotation=0) +plt.tight_layout() +plt.show() + +# %% [markdown] +# ## Model Performance + +# %% Cell 9 -- AUC and calibration plot +y_true = data["cognitive_decline"].values +y_prob = risk_df["risk_probability"].values + +auc = roc_auc_score(y_true, y_prob) +fpr, tpr, _ = roc_curve(y_true, y_prob) + +fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5)) + +# ROC curve +ax1.plot(fpr, tpr, lw=2, label=f"AUC = {auc:.3f}") +ax1.plot([0, 1], [0, 1], "k--", lw=0.8) +ax1.set_xlabel("False Positive Rate") +ax1.set_ylabel("True Positive Rate") +ax1.set_title("ROC Curve") +ax1.legend() + +# Calibration plot +prob_true, prob_pred = calibration_curve(y_true, y_prob, n_bins=10) +ax2.plot(prob_pred, prob_true, "o-", lw=2, label="Model") +ax2.plot([0, 1], [0, 1], "k--", lw=0.8, label="Perfect calibration") +ax2.set_xlabel("Mean Predicted Probability") +ax2.set_ylabel("Observed Proportion") +ax2.set_title("Calibration Plot") +ax2.legend() + +plt.tight_layout() +plt.show() + +print("=" * 55) +print(" COGNITIVE RISK MODEL -- PERFORMANCE SUMMARY") +print("=" * 55) +print(f" AUC-ROC : {auc:.4f}") +print(f" N participants : {len(data)}") +print(f" Decline prevalence: {data['cognitive_decline'].mean():.3f}") +print(f" High-risk flagged : {risk_df['high_risk'].sum()}") +print(f" Features used : {len(model._feature_names)}") +print("=" * 55) diff --git a/notebooks/05_fairness_audit_workflow.py b/notebooks/05_fairness_audit_workflow.py new file mode 100644 index 0000000..8482c00 --- /dev/null +++ b/notebooks/05_fairness_audit_workflow.py @@ -0,0 +1,321 @@ +# %% [markdown] +# # Fairness Audit Workflow +# +# **WELLab -- Washington University** +# +# This notebook demonstrates a complete fairness auditing pipeline for +# ML model predictions. We generate synthetic predictions with +# demographic attributes, then run demographic parity, disparate impact, +# equalized odds, calibration, and intersectional audits. + +# %% Cell 1 -- Imports and setup +import sys +import os +import logging +import warnings +import json + +import numpy as np +import pandas as pd +import matplotlib.pyplot as plt +import seaborn as sns + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from scripts.fairness_audit import FairnessAuditor + +SEED = 42 +np.random.seed(SEED) +rng = np.random.default_rng(SEED) + +logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") +warnings.filterwarnings("ignore") + +sns.set_theme(style="whitegrid", context="notebook", palette="colorblind") +print("Setup complete.") + +# %% [markdown] +# ## Synthetic Predictions with Demographics +# +# We simulate model predictions for 1,000 participants with intentional +# bias: the model has a higher positive-prediction rate for some +# demographic groups, mimicking a real-world fairness concern. + +# %% Cell 2 -- Generate synthetic predictions +N = 1000 + +gender = rng.choice(["male", "female", "non_binary"], N, p=[0.45, 0.45, 0.10]) +ethnicity = rng.choice(["white", "black", "hispanic", "asian"], N, + p=[0.40, 0.25, 0.20, 0.15]) + +# True labels (roughly balanced) +base_prob = 0.30 +true_labels = rng.binomial(1, base_prob, N) + +# Biased predictions: model systematically over-predicts for some groups +pred_probs = np.zeros(N) +for i in range(N): + base = 0.25 + if gender[i] == "male": + base += 0.08 + if ethnicity[i] == "white": + base += 0.05 + elif ethnicity[i] == "asian": + base += 0.03 + # Add signal from true label + if true_labels[i] == 1: + base += 0.35 + pred_probs[i] = np.clip(base + rng.normal(0, 0.1), 0, 1) + +predictions = (pred_probs > 0.5).astype(int) + +audit_df = pd.DataFrame({ + "gender": gender, + "ethnicity": ethnicity, + "true_label": true_labels, + "prediction": predictions, + "pred_probability": pred_probs, +}) + +print(f"Dataset: {N} participants") +print(f"Prediction rate: {predictions.mean():.3f}") +print(f"True positive rate: {true_labels.mean():.3f}") +print() +print("Prediction rate by gender:") +print(audit_df.groupby("gender")["prediction"].mean().to_string()) +print() +print("Prediction rate by ethnicity:") +print(audit_df.groupby("ethnicity")["prediction"].mean().to_string()) + +# %% [markdown] +# ## Demographic Parity Audit +# +# Demographic parity requires that the positive-prediction rate be +# approximately equal across all groups. + +# %% Cell 3 -- Demographic parity +auditor = FairnessAuditor() + +dp_gender = auditor.compute_demographic_parity(predictions, gender) + +print("=" * 55) +print(" DEMOGRAPHIC PARITY -- by Gender") +print("=" * 55) +print(f" Group rates : {dp_gender['group_rates']}") +print(f" Max difference: {dp_gender['max_difference']:.4f}") +print(f" Tolerance : {dp_gender['tolerance']}") +print(f" PASSED : {dp_gender['passed']}") +print("=" * 55) + +dp_ethnicity = auditor.compute_demographic_parity(predictions, ethnicity) +print() +print(" DEMOGRAPHIC PARITY -- by Ethnicity") +print("=" * 55) +print(f" Group rates : {dp_ethnicity['group_rates']}") +print(f" Max difference: {dp_ethnicity['max_difference']:.4f}") +print(f" PASSED : {dp_ethnicity['passed']}") +print("=" * 55) + +# %% [markdown] +# ## Disparate Impact Audit (4/5ths Rule) +# +# The disparate impact ratio is min(group_rate) / max(group_rate). +# A ratio below 0.80 indicates potential adverse impact. + +# %% Cell 4 -- Disparate impact +di_gender = auditor.compute_disparate_impact(predictions, gender) + +print("=" * 55) +print(" DISPARATE IMPACT -- by Gender") +print("=" * 55) +print(f" DI ratio : {di_gender['disparate_impact_ratio']:.4f}") +print(f" Floor : {di_gender['floor']}") +print(f" PASSED : {di_gender['passed']}") +print("=" * 55) + +di_ethnicity = auditor.compute_disparate_impact(predictions, ethnicity) +print() +print(" DISPARATE IMPACT -- by Ethnicity") +print("=" * 55) +print(f" DI ratio : {di_ethnicity['disparate_impact_ratio']:.4f}") +print(f" PASSED : {di_ethnicity['passed']}") +print("=" * 55) + +# %% [markdown] +# ## Equalized Odds Analysis +# +# Equalized odds requires equal true-positive rates (TPR) and +# false-positive rates (FPR) across groups. + +# %% Cell 5 -- Equalized odds +eo_gender = auditor.equalized_odds(predictions, true_labels, gender) + +print("Equalized Odds -- by Gender:") +print(f" TPR by group: {eo_gender['group_tpr']}") +print(f" FPR by group: {eo_gender['group_fpr']}") +print(f" Max TPR diff: {eo_gender['max_tpr_difference']:.4f}") +print(f" Max FPR diff: {eo_gender['max_fpr_difference']:.4f}") +print(f" PASSED : {eo_gender['passed']}") + +eo_ethnicity = auditor.equalized_odds(predictions, true_labels, ethnicity) +print() +print("Equalized Odds -- by Ethnicity:") +print(f" TPR by group: {eo_ethnicity['group_tpr']}") +print(f" FPR by group: {eo_ethnicity['group_fpr']}") +print(f" Max TPR diff: {eo_ethnicity['max_tpr_difference']:.4f}") +print(f" Max FPR diff: {eo_ethnicity['max_fpr_difference']:.4f}") +print(f" PASSED : {eo_ethnicity['passed']}") + +# %% [markdown] +# ## Calibration by Group +# +# Good calibration means that among predictions of probability p, +# approximately p fraction actually have the positive outcome. +# Differential calibration across groups is a fairness concern. + +# %% Cell 6 -- Calibration by group +cal_gender = auditor.calibration_by_group(pred_probs, true_labels, gender) + +fig, ax = plt.subplots(figsize=(7, 5)) +for group_name, cal_data in cal_gender["group_calibration"].items(): + if cal_data["bin_means"]: + ax.plot(cal_data["bin_means"], cal_data["bin_true_rates"], + "o-", label=f"{group_name} (ECE={cal_data['expected_calibration_error']:.3f})") + +ax.plot([0, 1], [0, 1], "k--", lw=0.8, label="Perfect") +ax.set_xlabel("Mean Predicted Probability") +ax.set_ylabel("Observed Proportion") +ax.set_title("Calibration by Gender") +ax.legend() +plt.tight_layout() +plt.show() + +print(f"Max ECE difference across gender groups: " + f"{cal_gender['max_ece_difference']:.4f}") + +# %% [markdown] +# ## Intersectional Audit (Gender x Ethnicity) +# +# Fairness audits on single attributes can miss disparities at +# intersections. We audit the cross-product of gender and ethnicity. + +# %% Cell 7 -- Intersectional audit +intersect = auditor.intersectional_audit( + predictions=predictions, + protected_attributes=["gender", "ethnicity"], + data=audit_df, +) + +print("Intersectional Audit (Gender x Ethnicity):") +print(f" Number of intersections: {len(intersect['intersections'])}") +print(f" Max rate difference : {intersect['max_difference']:.4f}") +print(f" DI ratio : {intersect['disparate_impact_ratio']:.4f}") +print(f" PASSED : {intersect['passed']}") +print() + +# Sort by rate for display +sorted_groups = sorted(intersect["intersections"].items(), key=lambda x: x[1]) +print(" Positive-prediction rates by intersection:") +for group, rate in sorted_groups: + n = intersect["intersection_counts"][group] + print(f" {group:30s} rate={rate:.3f} (n={n})") + +# %% [markdown] +# ## Fairness Metrics Dashboard + +# %% Cell 8 -- Visualise fairness dashboard +fig, axes = plt.subplots(2, 2, figsize=(14, 10)) + +# 1. Demographic parity by ethnicity +ax = axes[0, 0] +groups = list(dp_ethnicity["group_rates"].keys()) +rates = list(dp_ethnicity["group_rates"].values()) +bars = ax.bar(groups, rates, edgecolor="white") +ax.axhline(np.mean(rates), color="red", ls="--", lw=1, label="Mean rate") +ax.set_ylabel("Positive Prediction Rate") +ax.set_title("Demographic Parity by Ethnicity") +ax.legend() + +# 2. Equalized odds comparison +ax = axes[0, 1] +groups_eo = list(eo_ethnicity["group_tpr"].keys()) +tpr_vals = [eo_ethnicity["group_tpr"][g] for g in groups_eo] +fpr_vals = [eo_ethnicity["group_fpr"][g] for g in groups_eo] +x_pos = np.arange(len(groups_eo)) +w = 0.35 +ax.bar(x_pos - w/2, tpr_vals, w, label="TPR", color="#4C72B0") +ax.bar(x_pos + w/2, fpr_vals, w, label="FPR", color="#DD8452") +ax.set_xticks(x_pos) +ax.set_xticklabels(groups_eo) +ax.set_ylabel("Rate") +ax.set_title("Equalized Odds by Ethnicity") +ax.legend() + +# 3. Intersectional rates +ax = axes[1, 0] +int_groups = [g for g, _ in sorted_groups] +int_rates = [r for _, r in sorted_groups] +colors = ["#C44E52" if r < 0.25 else "#4C72B0" for r in int_rates] +ax.barh(int_groups, int_rates, color=colors, edgecolor="white") +ax.axvline(0.8 * max(int_rates), color="red", ls="--", lw=1, label="4/5ths threshold") +ax.set_xlabel("Positive Prediction Rate") +ax.set_title("Intersectional Rates (Gender x Ethnicity)") +ax.tick_params(axis="y", labelsize=7) +ax.legend(fontsize=8) + +# 4. Summary pass/fail table +ax = axes[1, 1] +ax.axis("off") +table_data = [ + ["Metric", "Gender", "Ethnicity"], + ["Demographic Parity", str(dp_gender["passed"]), str(dp_ethnicity["passed"])], + ["Disparate Impact", str(di_gender["passed"]), str(di_ethnicity["passed"])], + ["Equalized Odds", str(eo_gender["passed"]), str(eo_ethnicity["passed"])], + ["Intersectional", "--", str(intersect["passed"])], +] +table = ax.table(cellText=table_data[1:], colLabels=table_data[0], + loc="center", cellLoc="center") +table.auto_set_font_size(False) +table.set_fontsize(10) +table.scale(1.2, 1.8) +# Color cells +for i in range(1, len(table_data)): + for j in range(1, 3): + val = table_data[i][j] + color = "#d4edda" if val == "True" else "#f8d7da" if val == "False" else "#ffffff" + table[i - 1, j].set_facecolor(color) +ax.set_title("Fairness Check Summary", pad=20) + +plt.tight_layout() +plt.show() + +# %% [markdown] +# ## Generate Full Audit Report + +# %% Cell 9 -- Generate and save report +audit_results = { + "demographic_parity": dp_ethnicity, + "disparate_impact": di_ethnicity, + "equalized_odds": eo_ethnicity, + "calibration": cal_gender, +} + +report = auditor.generate_report( + model_name="CognitiveRiskModel v1 -- WELLab", + audit_results=audit_results, +) + +print(report) + +# Save report to file +output_dir = os.path.join(os.path.dirname(__file__), "output") +os.makedirs(output_dir, exist_ok=True) +report_path = os.path.join(output_dir, "fairness_audit_report.json") + +auditor.generate_report( + model_name="CognitiveRiskModel v1 -- WELLab", + audit_results=audit_results, + output_path=report_path, +) + +print(f"\nJSON report saved to: {report_path}") diff --git a/notebooks/06_data_drift_monitoring.py b/notebooks/06_data_drift_monitoring.py new file mode 100644 index 0000000..6924bb3 --- /dev/null +++ b/notebooks/06_data_drift_monitoring.py @@ -0,0 +1,298 @@ +# %% [markdown] +# # Data Drift Monitoring +# +# **WELLab -- Washington University** +# +# This notebook demonstrates the DataDriftDetector for identifying +# distributional shifts between reference (training) data and incoming +# production data. We cover per-feature drift detection, PSI and K-S +# statistics, gradual drift simulation, and alerting thresholds. + +# %% Cell 1 -- Imports and setup +import sys +import os +import logging +import warnings + +import numpy as np +import pandas as pd +import matplotlib.pyplot as plt +import seaborn as sns + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from src.ml.drift import DataDriftDetector, DriftReport + +SEED = 42 +np.random.seed(SEED) +rng = np.random.default_rng(SEED) + +logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") +warnings.filterwarnings("ignore") + +sns.set_theme(style="whitegrid", context="notebook", palette="colorblind") +print("Setup complete.") + +# %% [markdown] +# ## Reference and Drifted Datasets +# +# The **reference** dataset represents the training distribution. +# The **drifted** dataset has shifted means and variances for a subset +# of features, simulating real-world distribution change. + +# %% Cell 2 -- Generate reference and drifted datasets +N_REF = 1000 +N_NEW = 500 + +feature_specs = { + "age": {"ref_mean": 55, "ref_std": 12, "drift_shift": 5, "drift_scale": 1.0}, + "wellbeing_score": {"ref_mean": 6, "ref_std": 1.5,"drift_shift": -0.8,"drift_scale": 1.3}, + "sleep_quality": {"ref_mean": 6, "ref_std": 2, "drift_shift": 0, "drift_scale": 1.0}, + "physical_activity": {"ref_mean": 3, "ref_std": 1.5,"drift_shift": -1, "drift_scale": 1.5}, + "social_engagement": {"ref_mean": 5, "ref_std": 2, "drift_shift": 0, "drift_scale": 1.0}, + "cognitive_score": {"ref_mean": 100,"ref_std": 15, "drift_shift": -8, "drift_scale": 1.2}, + "cardiovascular_risk":{"ref_mean": 0.3,"ref_std": 0.15,"drift_shift": 0.1,"drift_scale": 1.0}, + "bmi": {"ref_mean": 26, "ref_std": 4, "drift_shift": 2, "drift_scale": 1.0}, +} + +ref_data = {} +new_data = {} +for feat, spec in feature_specs.items(): + ref_data[feat] = rng.normal(spec["ref_mean"], spec["ref_std"], N_REF) + new_data[feat] = rng.normal( + spec["ref_mean"] + spec["drift_shift"], + spec["ref_std"] * spec["drift_scale"], + N_NEW, + ) + +ref_df = pd.DataFrame(ref_data) +new_df = pd.DataFrame(new_data) + +print(f"Reference dataset: {len(ref_df)} rows, {len(ref_df.columns)} features") +print(f"New dataset : {len(new_df)} rows, {len(new_df.columns)} features") +print() +print("Feature means comparison:") +comparison = pd.DataFrame({ + "Reference mean": ref_df.mean(), + "New mean": new_df.mean(), + "Shift": new_df.mean() - ref_df.mean(), +}).round(3) +print(comparison.to_string()) + +# %% [markdown] +# ## Fit DataDriftDetector on Reference Data + +# %% Cell 3 -- Fit detector +detector = DataDriftDetector( + p_value_threshold=0.05, + psi_threshold=0.20, +) +detector.fit(ref_df) + +print(f"Detector fitted on reference data ({len(ref_df)} rows, " + f"{len(ref_df.columns)} features)") + +# %% [markdown] +# ## Detect Drift on New Data + +# %% Cell 4 -- Detect drift, display report +report = detector.detect(new_df) + +print("=" * 60) +print(" DRIFT DETECTION REPORT") +print("=" * 60) +print(f" Overall drifted : {report.overall_drifted}") +print(f" Severity : {report.severity}") +print(f" Features tested : {report.summary['n_features_tested']}") +print(f" Features drifted: {report.summary['n_drifted']}") +print(f" Drift fraction : {report.summary['drift_fraction']:.2%}") +print("=" * 60) +print() + +print("Per-feature results:") +print(f"{'Feature':<25s} {'Test':<20s} {'Statistic':>10s} {'p-value':>10s} " + f"{'PSI':>8s} {'Drifted':>8s}") +print("-" * 85) +for r in report.feature_results: + psi_str = f"{r.psi:.4f}" if r.psi is not None else "N/A" + print(f"{r.feature:<25s} {r.test_name:<20s} {r.statistic:>10.4f} {r.p_value:>10.4f} " + f"{psi_str:>8s} {'YES' if r.drifted else 'no':>8s}") + +# %% [markdown] +# ## Per-Feature Drift Visualisation +# +# We plot PSI values and K-S statistics side by side for each feature +# to identify which variables have shifted the most. + +# %% Cell 5 -- PSI and K-S bar charts +features = [r.feature for r in report.feature_results] +ks_stats = [r.statistic for r in report.feature_results] +psi_vals = [r.psi if r.psi is not None else 0 for r in report.feature_results] +drifted = [r.drifted for r in report.feature_results] + +fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5)) + +# K-S statistics +colors_ks = ["#C44E52" if d else "#55A868" for d in drifted] +ax1.barh(features, ks_stats, color=colors_ks, edgecolor="white") +ax1.axvline(0.05, color="gray", ls="--", lw=1, label="Reference") +ax1.set_xlabel("K-S Statistic") +ax1.set_title("Kolmogorov-Smirnov Statistics") +ax1.invert_yaxis() + +# PSI values +colors_psi = ["#C44E52" if p > detector.psi_threshold else "#55A868" for p in psi_vals] +ax2.barh(features, psi_vals, color=colors_psi, edgecolor="white") +ax2.axvline(detector.psi_threshold, color="red", ls="--", lw=1.5, + label=f"Threshold={detector.psi_threshold}") +ax2.set_xlabel("PSI") +ax2.set_title("Population Stability Index") +ax2.legend() +ax2.invert_yaxis() + +plt.tight_layout() +plt.show() + +# Distribution comparison for drifted features +drifted_features = report.drifted_features +n_drifted = len(drifted_features) +if n_drifted > 0: + n_cols = min(n_drifted, 3) + n_rows = (n_drifted + n_cols - 1) // n_cols + fig, axes = plt.subplots(n_rows, n_cols, figsize=(5 * n_cols, 4 * n_rows)) + axes_flat = np.atleast_1d(axes).ravel() + + for i, feat in enumerate(drifted_features): + ax = axes_flat[i] + ax.hist(ref_df[feat], bins=30, alpha=0.5, density=True, label="Reference", + color="#4C72B0") + ax.hist(new_df[feat], bins=30, alpha=0.5, density=True, label="New data", + color="#C44E52") + ax.set_title(f"{feat} (DRIFTED)") + ax.set_xlabel(feat) + ax.set_ylabel("Density") + ax.legend(fontsize=8) + + # Hide unused axes + for j in range(i + 1, len(axes_flat)): + axes_flat[j].set_visible(False) + + plt.suptitle("Distribution Comparison for Drifted Features", fontsize=14, y=1.02) + plt.tight_layout() + plt.show() + +# %% [markdown] +# ## Gradual Drift Simulation +# +# We simulate drift that increases over 12 monthly batches, tracking +# how the drift severity evolves over time. This models a scenario +# where population characteristics shift slowly (e.g., demographic +# change in a longitudinal study). + +# %% Cell 6 -- Gradual drift over time +N_BATCHES = 12 +BATCH_SIZE = 200 + +drift_timeline = [] + +for batch_idx in range(N_BATCHES): + drift_fraction = batch_idx / (N_BATCHES - 1) # 0 to 1 + + batch_data = {} + for feat, spec in feature_specs.items(): + shifted_mean = spec["ref_mean"] + drift_fraction * spec["drift_shift"] + shifted_std = spec["ref_std"] * (1 + drift_fraction * (spec["drift_scale"] - 1)) + batch_data[feat] = rng.normal(shifted_mean, shifted_std, BATCH_SIZE) + + batch_df = pd.DataFrame(batch_data) + batch_report = detector.detect(batch_df) + + drift_timeline.append({ + "batch": batch_idx + 1, + "month": f"M{batch_idx + 1:02d}", + "n_drifted": batch_report.summary["n_drifted"], + "drift_fraction": batch_report.summary["drift_fraction"], + "severity": batch_report.severity, + "overall_drifted": batch_report.overall_drifted, + }) + +timeline_df = pd.DataFrame(drift_timeline) + +fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 7), sharex=True) + +ax1.plot(timeline_df["batch"], timeline_df["n_drifted"], "o-", lw=2, color="#4C72B0") +ax1.set_ylabel("Number of Drifted Features") +ax1.set_title("Drift Evolution Over Time") +ax1.axhline(len(feature_specs) * 0.5, color="red", ls="--", lw=1, + label="50% features threshold") +ax1.legend() + +# Color-coded severity +severity_colors = {"none": "#55A868", "low": "#A8D08D", + "moderate": "#DD8452", "severe": "#C44E52"} +bar_colors = [severity_colors.get(s, "gray") for s in timeline_df["severity"]] +ax2.bar(timeline_df["batch"], timeline_df["drift_fraction"], color=bar_colors, + edgecolor="white") +ax2.set_xlabel("Batch (Month)") +ax2.set_ylabel("Fraction of Drifted Features") +ax2.set_title("Drift Severity by Month") + +# Legend for severity +from matplotlib.patches import Patch +legend_elements = [Patch(facecolor=c, label=s) for s, c in severity_colors.items()] +ax2.legend(handles=legend_elements, title="Severity", loc="upper left") + +plt.tight_layout() +plt.show() + +print("Drift timeline:") +print(timeline_df.to_string(index=False)) + +# %% [markdown] +# ## Alerting Thresholds +# +# We define operational alerting rules based on drift severity, and show +# which batches would have triggered each alert level. + +# %% Cell 7 -- Alerting thresholds +ALERT_RULES = { + "INFO": { + "condition": "drift_fraction > 0", + "description": "Any feature shows drift (p < 0.05 or PSI > 0.20)", + }, + "WARNING": { + "condition": "drift_fraction >= 0.25", + "description": "25% or more features show drift", + }, + "CRITICAL": { + "condition": "drift_fraction >= 0.50", + "description": "50% or more features show drift -- retrain recommended", + }, +} + +print("=" * 60) +print(" ALERTING THRESHOLDS") +print("=" * 60) +for level, rule in ALERT_RULES.items(): + print(f" [{level}] {rule['description']}") +print("=" * 60) +print() + +# Apply rules to timeline +for _, row in timeline_df.iterrows(): + frac = row["drift_fraction"] + if frac >= 0.50: + alert = "CRITICAL" + elif frac >= 0.25: + alert = "WARNING" + elif frac > 0: + alert = "INFO" + else: + alert = "OK" + print(f" {row['month']}: drift_fraction={frac:.2f}, " + f"n_drifted={row['n_drifted']}, alert={alert}") + +print() +print("Recommendation: Set up automated monitoring to run the") +print("DataDriftDetector on each new data batch. Trigger model") +print("retraining when CRITICAL alerts persist for 2+ consecutive months.") diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0492e0f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,12210 @@ +{ + "name": "wellab-platform", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "wellab-platform", + "version": "1.0.0", + "workspaces": [ + "src/frontend", + "src/backend" + ], + "devDependencies": { + "eslint": "8.57.0", + "prettier": "3.2.0", + "typescript": "~5.9.3" + }, + "engines": { + "node": ">=20.0.0", + "npm": ">=10.0.0" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.39.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.39.0.tgz", + "integrity": "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg==", + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-dynamodb": { + "version": "3.1024.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.1024.0.tgz", + "integrity": "sha512-IJ4uM5i8z6hO7FRT7kngl/boqrZdyczxLKobCSHaxbFtxDu68QEfhn9D95FdkOENMmC2TIglnSlBqn5cG8qMTQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/credential-provider-node": "^3.972.29", + "@aws-sdk/dynamodb-codec": "^3.972.27", + "@aws-sdk/middleware-endpoint-discovery": "^3.972.9", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.9", + "@aws-sdk/middleware-user-agent": "^3.972.28", + "@aws-sdk/region-config-resolver": "^3.972.10", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.14", + "@smithy/config-resolver": "^4.4.13", + "@smithy/core": "^3.23.13", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.28", + "@smithy/middleware-retry": "^4.4.46", + "@smithy/middleware-serde": "^4.2.16", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.5.1", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.44", + "@smithy/util-defaults-mode-node": "^4.2.48", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.13", + "@smithy/util-utf8": "^4.2.2", + "@smithy/util-waiter": "^4.2.14", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.973.26", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.26.tgz", + "integrity": "sha512-A/E6n2W42ruU+sfWk+mMUOyVXbsSgGrY3MJ9/0Az5qUdG67y8I6HYzzoAa+e/lzxxl1uCYmEL6BTMi9ZiZnplQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/xml-builder": "^3.972.16", + "@smithy/core": "^3.23.13", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/signature-v4": "^5.3.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.24.tgz", + "integrity": "sha512-FWg8uFmT6vQM7VuzELzwVo5bzExGaKHdubn0StjgrcU5FvuLExUe+k06kn/40uKv59rYzhez8eFNM4yYE/Yb/w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.26", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.26.tgz", + "integrity": "sha512-CY4ppZ+qHYqcXqBVi//sdHST1QK3KzOEiLtpLsc9W2k2vfZPKExGaQIsOwcyvjpjUEolotitmd3mUNY56IwDEA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/types": "^3.973.6", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/node-http-handler": "^4.5.1", + "@smithy/property-provider": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", + "@smithy/util-stream": "^4.5.21", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.28.tgz", + "integrity": "sha512-wXYvq3+uQcZV7k+bE4yDXCTBdzWTU9x/nMiKBfzInmv6yYK1veMK0AKvRfRBd72nGWYKcL6AxwiPg9z/pYlgpw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/credential-provider-env": "^3.972.24", + "@aws-sdk/credential-provider-http": "^3.972.26", + "@aws-sdk/credential-provider-login": "^3.972.28", + "@aws-sdk/credential-provider-process": "^3.972.24", + "@aws-sdk/credential-provider-sso": "^3.972.28", + "@aws-sdk/credential-provider-web-identity": "^3.972.28", + "@aws-sdk/nested-clients": "^3.996.18", + "@aws-sdk/types": "^3.973.6", + "@smithy/credential-provider-imds": "^4.2.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.28.tgz", + "integrity": "sha512-ZSTfO6jqUTCysbdBPtEX5OUR//3rbD0lN7jO3sQeS2Gjr/Y+DT6SbIJ0oT2cemNw3UzKu97sNONd1CwNMthuZQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/nested-clients": "^3.996.18", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.29.tgz", + "integrity": "sha512-clSzDcvndpFJAggLDnDb36sPdlZYyEs5Zm6zgZjjUhwsJgSWiWKwFIXUVBcbruidNyBdbpOv2tNDL9sX8y3/0g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.24", + "@aws-sdk/credential-provider-http": "^3.972.26", + "@aws-sdk/credential-provider-ini": "^3.972.28", + "@aws-sdk/credential-provider-process": "^3.972.24", + "@aws-sdk/credential-provider-sso": "^3.972.28", + "@aws-sdk/credential-provider-web-identity": "^3.972.28", + "@aws-sdk/types": "^3.973.6", + "@smithy/credential-provider-imds": "^4.2.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.24.tgz", + "integrity": "sha512-Q2k/XLrFXhEztPHqj4SLCNID3hEPdlhh1CDLBpNnM+1L8fq7P+yON9/9M1IGN/dA5W45v44ylERfXtDAlmMNmw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.28.tgz", + "integrity": "sha512-IoUlmKMLEITFn1SiCTjPfR6KrE799FBo5baWyk/5Ppar2yXZoUdaRqZzJzK6TcJxx450M8m8DbpddRVYlp5R/A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/nested-clients": "^3.996.18", + "@aws-sdk/token-providers": "3.1021.0", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.28.tgz", + "integrity": "sha512-d+6h0SD8GGERzKe27v5rOzNGKOl0D+l0bWJdqrxH8WSQzHzjsQFIAPgIeOTUwBHVsKKwtSxc91K/SWax6XgswQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/nested-clients": "^3.996.18", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/dynamodb-codec": { + "version": "3.972.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/dynamodb-codec/-/dynamodb-codec-3.972.27.tgz", + "integrity": "sha512-S7IWE0K+aqbvjP8PHnOyDJK1fzrazAismH5XutJtS3YBvRvmfLb8Ac7Z1ZC4LBWvO8Gx1t/szFe46K51FqZn/A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.26", + "@smithy/core": "^3.23.13", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", + "@smithy/util-base64": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/endpoint-cache": { + "version": "3.972.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/endpoint-cache/-/endpoint-cache-3.972.5.tgz", + "integrity": "sha512-itVdge0NozgtgmtbZ25FVwWU3vGlE7x7feE/aOEJNkQfEpbkrF8Rj1QmnK+2blFfYE1xWt/iU+6/jUp/pv1+MA==", + "license": "Apache-2.0", + "dependencies": { + "mnemonist": "0.38.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/lib-dynamodb": { + "version": "3.1024.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/lib-dynamodb/-/lib-dynamodb-3.1024.0.tgz", + "integrity": "sha512-J4OX8F/XUZK+njHQnFXL+geZ/JPOMm67kKuKPy8duve31TwpyOhNMEgYQj8AympS+SWPKxmTw6W1RmkXLimuGA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/util-dynamodb": "^3.996.2", + "@smithy/core": "^3.23.13", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-dynamodb": "^3.1024.0" + } + }, + "node_modules/@aws-sdk/middleware-endpoint-discovery": { + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-endpoint-discovery/-/middleware-endpoint-discovery-3.972.9.tgz", + "integrity": "sha512-1503Y5Xk14SdXY0ucXwc08CY+aVuoY1tmQxsR/apwAVAwcLT7FFzqjYJYLq8JOkKJyzIB8M6J27e1ZcagGK+Fg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/endpoint-cache": "^3.972.5", + "@aws-sdk/types": "^3.973.6", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.8.tgz", + "integrity": "sha512-wAr2REfKsqoKQ+OkNqvOShnBoh+nkPurDKW7uAeVSu6kUECnWlSJiPvnoqxGlfousEY/v9LfS9sNc46hjSYDIQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.8.tgz", + "integrity": "sha512-CWl5UCM57WUFaFi5kB7IBY1UmOeLvNZAZ2/OZ5l20ldiJ3TiIz1pC65gYj8X0BCPWkeR1E32mpsCk1L1I4n+lA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.9.tgz", + "integrity": "sha512-/Wt5+CT8dpTFQxEJ9iGy/UGrXr7p2wlIOEHvIr/YcHYByzoLjrqkYqXdJjd9UIgWjv7eqV2HnFJen93UTuwfTQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.972.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.28.tgz", + "integrity": "sha512-cfWZFlVh7Va9lRay4PN2A9ARFzaBYcA097InT5M2CdRS05ECF5yaz86jET8Wsl2WcyKYEvVr/QNmKtYtafUHtQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@smithy/core": "^3.23.13", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-retry": "^4.2.13", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.996.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.18.tgz", + "integrity": "sha512-c7ZSIXrESxHKx2Mcopgd8AlzZgoXMr20fkx5ViPWPOLBvmyhw9VwJx/Govg8Ef/IhEon5R9l53Z8fdYSEmp6VA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.9", + "@aws-sdk/middleware-user-agent": "^3.972.28", + "@aws-sdk/region-config-resolver": "^3.972.10", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.14", + "@smithy/config-resolver": "^4.4.13", + "@smithy/core": "^3.23.13", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.28", + "@smithy/middleware-retry": "^4.4.46", + "@smithy/middleware-serde": "^4.2.16", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.5.1", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.44", + "@smithy/util-defaults-mode-node": "^4.2.48", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.13", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.10.tgz", + "integrity": "sha512-1dq9ToC6e070QvnVhhbAs3bb5r6cQ10gTVc6cyRV5uvQe7P138TV2uG2i6+Yok4bAkVAcx5AqkTEBUvWEtBlsQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/config-resolver": "^4.4.13", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1021.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1021.0.tgz", + "integrity": "sha512-TKY6h9spUk3OLs5v1oAgW9mAeBE3LAGNBwJokLy96wwmd4W2v/tYlXseProyed9ValDj2u1jK/4Rg1T+1NXyJA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/nested-clients": "^3.996.18", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.6.tgz", + "integrity": "sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-dynamodb": { + "version": "3.996.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-dynamodb/-/util-dynamodb-3.996.2.tgz", + "integrity": "sha512-ddpwaZmjBzcApYN7lgtAXjk+u+GO8fiPsxzuc59UqP+zqdxI1gsenPvkyiHiF9LnYnyRGijz6oN2JylnN561qQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-dynamodb": "^3.1003.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.5.tgz", + "integrity": "sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-endpoints": "^3.3.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", + "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.8.tgz", + "integrity": "sha512-B3KGXJviV2u6Cdw2SDY2aDhoJkVfY/Q/Trwk2CMSkikE1Oi6gRzxhvhIfiRpHfmIsAhV4EA54TVEX8K6CbHbkA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.973.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.14.tgz", + "integrity": "sha512-vNSB/DYaPOyujVZBg/zUznH9QC142MaTHVmaFlF7uzzfg3CgT9f/l4C0Yi+vU/tbBhxVcXVB90Oohk5+o+ZbWw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "^3.972.28", + "@aws-sdk/types": "^3.973.6", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-config-provider": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.16.tgz", + "integrity": "sha512-iu2pyvaqmeatIJLURLqx9D+4jKAdTH20ntzB6BFwjyN7V960r4jK32mx0Zf7YbtOYAbmbtQfDNuL60ONinyw7A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "fast-xml-parser": "5.5.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", + "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", + "license": "MIT", + "dependencies": { + "@so-ric/colorspace": "^1.1.6", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", + "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.11.14", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", + "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.2", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/core/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@jest/core/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "4.4.13", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.13.tgz", + "integrity": "sha512-iIzMC5NmOUP6WL6o8iPBjFhUhBZ9pPjpUpQYWMUFQqKyXXzOftbfK8zcQCz/jFV1Psmf05BK5ypx4K2r4Tnwdg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.23.13", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.13.tgz", + "integrity": "sha512-J+2TT9D6oGsUVXVEMvz8h2EmdVnkBiy2auCie4aSJMvKlzUtO5hqjEzXhoCUkIMo7gAYjbQcN0g/MMSXEhDs1Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-stream": "^4.5.21", + "@smithy/util-utf8": "^4.2.2", + "@smithy/uuid": "^1.1.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.12.tgz", + "integrity": "sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.3.15", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.15.tgz", + "integrity": "sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.12", + "@smithy/querystring-builder": "^4.2.12", + "@smithy/types": "^4.13.1", + "@smithy/util-base64": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.12.tgz", + "integrity": "sha512-QhBYbGrbxTkZ43QoTPrK72DoYviDeg6YKDrHTMJbbC+A0sml3kSjzFtXP7BtbyJnXojLfTQldGdUR0RGD8dA3w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.12.tgz", + "integrity": "sha512-/4F1zb7Z8LOu1PalTdESFHR0RbPwHd3FcaG1sI3UEIriQTWakysgJr65lc1jj6QY5ye7aFsisajotH6UhWfm/g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz", + "integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.12.tgz", + "integrity": "sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "4.4.28", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.28.tgz", + "integrity": "sha512-p1gfYpi91CHcs5cBq982UlGlDrxoYUX6XdHSo91cQ2KFuz6QloHosO7Jc60pJiVmkWrKOV8kFYlGFFbQ2WUKKQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.13", + "@smithy/middleware-serde": "^4.2.16", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-middleware": "^4.2.12", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "4.4.46", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.46.tgz", + "integrity": "sha512-SpvWNNOPOrKQGUqZbEPO+es+FRXMWvIyzUKUOYdDgdlA6BdZj/R58p4umoQ76c2oJC44PiM7mKizyyex1IJzow==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/service-error-classification": "^4.2.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.13", + "@smithy/uuid": "^1.1.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "4.2.16", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.16.tgz", + "integrity": "sha512-beqfV+RZ9RSv+sQqor3xroUUYgRFCGRw6niGstPG8zO9LgTl0B0MCucxjmrH/2WwksQN7UUgI7KNANoZv+KALA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.13", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.12.tgz", + "integrity": "sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "4.3.12", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.12.tgz", + "integrity": "sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.5.1.tgz", + "integrity": "sha512-ejjxdAXjkPIs9lyYyVutOGNOraqUE9v/NjGMKwwFrfOM354wfSD8lmlj8hVwUzQmlLLF4+udhfCX9Exnbmvfzw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.12", + "@smithy/querystring-builder": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.12.tgz", + "integrity": "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.3.12", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.12.tgz", + "integrity": "sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.12.tgz", + "integrity": "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "@smithy/util-uri-escape": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.12.tgz", + "integrity": "sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.12.tgz", + "integrity": "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.4.7", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.7.tgz", + "integrity": "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.3.12", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.12.tgz", + "integrity": "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.2", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-uri-escape": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "4.12.8", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.8.tgz", + "integrity": "sha512-aJaAX7vHe5i66smoSSID7t4rKY08PbD8EBU7DOloixvhOozfYWdcSYE4l6/tjkZ0vBZhGjheWzB2mh31sLgCMA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.13", + "@smithy/middleware-endpoint": "^4.4.28", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-stream": "^4.5.21", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.1.tgz", + "integrity": "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.12.tgz", + "integrity": "sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-base64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz", + "integrity": "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.2.tgz", + "integrity": "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.3.tgz", + "integrity": "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", + "integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.2.tgz", + "integrity": "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.3.44", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.44.tgz", + "integrity": "sha512-eZg6XzaCbVr2S5cAErU5eGBDaOVTuTo1I65i4tQcHENRcZ8rMWhQy1DaIYUSLyZjsfXvmCqZrstSMYyGFocvHA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.2.48", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.48.tgz", + "integrity": "sha512-FqOKTlqSaoV3nzO55pMs5NBnZX8EhoI0DGmn9kbYeXWppgHD6dchyuj2HLqp4INJDJbSrj6OFYJkAh/WhSzZPg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^4.4.13", + "@smithy/credential-provider-imds": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-endpoints": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.3.tgz", + "integrity": "sha512-VACQVe50j0HZPjpwWcjyT51KUQ4AnsvEaQ2lKHOSL4mNLD0G9BjEniQ+yCt1qqfKfiAHRAts26ud7hBjamrwig==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz", + "integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.12.tgz", + "integrity": "sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.13.tgz", + "integrity": "sha512-qQQsIvL0MGIbUjeSrg0/VlQ3jGNKyM3/2iU3FPNgy01z+Sp4OvcaxbgIoFOTvB61ZoohtutuOvOcgmhbD0katQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-stream": { + "version": "4.5.21", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.21.tgz", + "integrity": "sha512-KzSg+7KKywLnkoKejRtIBXDmwBfjGvg1U1i/etkC7XSWUyFCoLno1IohV2c74IzQqdhX5y3uE44r/8/wuK+A7Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/node-http-handler": "^4.5.1", + "@smithy/types": "^4.13.1", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-uri-escape": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz", + "integrity": "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", + "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-waiter": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.14.tgz", + "integrity": "sha512-2zqq5o/oizvMaFUlNiTyZ7dbgYv1a893aGut2uaxtbzTx/VYYnRxWzDHuD/ftgcw94ffenua+ZNLrbqwUYE+Bg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/uuid": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.2.tgz", + "integrity": "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@so-ric/colorspace": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", + "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", + "license": "MIT", + "dependencies": { + "color": "^5.0.2", + "text-hex": "1.0.x" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "15.0.7", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-15.0.7.tgz", + "integrity": "sha512-cg0RvEdD1TIhhkm1IeYMQxrzy0MtUNfa3minv4MjbgcYzJAZ7yD0i0lwoPOTPr+INtiXFezt2o8xMSnyHhEn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "@testing-library/dom": "^10.0.0", + "@types/react-dom": "^18.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/react": "^18.0.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/compression": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.7.5.tgz", + "integrity": "sha512-AAQvK5pxMpaT+nDvhHrsBhLSYG5yQdtkaJE1WYieSNY2mVFKAgmU4ks65rkZD5oqnGCFLyQpUr1CqI4DmUMyDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, + "node_modules/@types/jest/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@types/jest/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@types/jest/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.17.58", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.58.tgz", + "integrity": "sha512-UvxetCgGwZ9HmsgGZ2tpStt6CiFU1bu28ftHWpDyfthsCt7OHXas0C7j0VgO3gBq8UHKI785wXmtcQVhLekcRg==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", + "license": "MIT" + }, + "node_modules/@types/uuid": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", + "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", + "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", + "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "1.6.1", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vitest/runner/node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vitest/snapshot": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz", + "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@vitest/snapshot/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@vitest/snapshot/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/spy": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz", + "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@vitest/utils/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@vitest/utils/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.27", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001774", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.14", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.14.tgz", + "integrity": "sha512-fOVLPAsFTsQfuCkvahZkzq6nf8KvGWanlYoTh0SVA0A/PIUxQGU2AOZAoD95n2gFLVDW/jP6sbGLny95nmEuHA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001785", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001785.tgz", + "integrity": "sha512-blhOL/WNR+Km1RI/LCVAvA73xplXA7ZbjzI4YkMK9pa6T/P3F2GxjNpEkyw5repTw9IvkyrjyHpwjnhZ5FOvYQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chai/node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/color": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", + "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", + "license": "MIT", + "dependencies": { + "color-convert": "^3.1.3", + "color-string": "^2.1.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-string": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", + "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-string/node_modules/color-name": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/color/node_modules/color-convert": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", + "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=14.6" + } + }, + "node_modules/color/node_modules/color-name": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.5.tgz", + "integrity": "sha512-bQJ0YRck5ak3LgtnpKkiabX5pNF7tMUh1BSy2ZBOTh0Dim0BUu6aPPwByIns6/A5Prh8PufSPerMDUklpzes2Q==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.0.2", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.331", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", + "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "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" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", + "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.0", + "@humanwhocodes/config-array": "^0.11.14", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-xml-builder": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.5.8", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz", + "integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.2.0", + "strnum": "^2.2.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", + "license": "MIT" + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "license": "MIT" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz", + "integrity": "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-circus/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-config/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-config/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-config/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-diff/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-diff/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-diff/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-each/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-leak-detector/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-leak-detector/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-leak-detector/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-matcher-utils/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-message-util/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-snapshot/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-validate/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "24.1.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.3.tgz", + "integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.0.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.4", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + "license": "MIT" + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/logform": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "license": "MIT", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mnemonist": { + "version": "0.38.3", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.38.3.tgz", + "integrity": "sha512-2K9QYubXx/NAjv4VLq1d1Ly8pWNC5L3BrixtdkyTegXWJIqY+zLNDhhX/A+ZwWt70tB1S8H4BE8FLYEFyNoOBw==", + "license": "MIT", + "dependencies": { + "obliterator": "^1.6.1" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/nodemon/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/nodemon/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nodemon/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/nodemon/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/nodemon/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obliterator": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-1.6.1.tgz", + "integrity": "sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig==", + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "license": "MIT", + "dependencies": { + "fn.name": "1.x.x" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.1.tgz", + "integrity": "sha512-d7gQQmLvAKXKXE2GeP9apIGbMYKz88zWdsn/BN2HRWVQsDFdUY36WSLTY0Jvd4HWi7Fb30gQ62oAOzdgJA6fZw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pkg-types/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.0.tgz", + "integrity": "sha512-/vBUecTGaPlRVwyZVROVC58bYIScqaoEJzZmzQXXrZOzqn0TwWz0EnOozOlFO/YAImRnb7XsKpTCd3m1SjS2Ww==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/recharts/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/strnum": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz", + "integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", + "license": "MIT" + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/ts-jest": { + "version": "29.1.5", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.1.5.tgz", + "integrity": "sha512-UuClSYxM7byvvYfyWdFI+/2UxMmwNyJb0NPkZPQE2hew3RurV7l7zURgOHAd/1I1ZdPpe3GUsXNXAcN8TFKSIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "0.x", + "fast-json-stable-stringify": "2.x", + "jest-util": "^29.0.0", + "json5": "^2.2.3", + "lodash.memoize": "4.x", + "make-error": "1.x", + "semver": "^7.5.3", + "yargs-parser": "^21.0.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0", + "@jest/types": "^29.0.0", + "babel-jest": "^29.0.0", + "jest": "^29.0.0", + "typescript": ">=4.3 <6" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node/node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "license": "MIT" + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "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 + } + } + }, + "node_modules/vite-node": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz", + "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", + "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "1.6.1", + "@vitest/runner": "1.6.1", + "@vitest/snapshot": "1.6.1", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.1", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.1", + "@vitest/ui": "1.6.1", + "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 + } + } + }, + "node_modules/vitest/node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/vitest/node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/vitest/node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/vitest/node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/vitest/node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/vitest/node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/vitest/node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/vitest/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/vitest/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/vitest/node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/wellab-backend": { + "resolved": "src/backend", + "link": true + }, + "node_modules/wellab-frontend": { + "resolved": "src/frontend", + "link": true + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/winston": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz", + "integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==", + "license": "MIT", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.2", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "license": "MIT", + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "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 + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.24.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz", + "integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "src/backend": { + "name": "wellab-backend", + "version": "1.0.0", + "dependencies": { + "@anthropic-ai/sdk": "^0.39.0", + "@aws-sdk/client-dynamodb": "~3.1024.0", + "@aws-sdk/lib-dynamodb": "~3.1024.0", + "compression": "~1.7.5", + "cors": "~2.8.5", + "dotenv": "~16.4.7", + "express": "~4.21.2", + "helmet": "~7.2.0", + "uuid": "~9.0.1", + "winston": "~3.17.0", + "zod": "~3.24.2" + }, + "devDependencies": { + "@types/compression": "~1.7.5", + "@types/cors": "~2.8.17", + "@types/express": "~4.17.21", + "@types/jest": "~29.5.14", + "@types/node": "~20.17.0", + "@types/uuid": "~9.0.8", + "jest": "~29.7.0", + "nodemon": "~3.1.9", + "ts-jest": "~29.1.5", + "ts-node": "~10.9.2", + "typescript": "~5.9.3" + } + }, + "src/frontend": { + "name": "wellab-frontend", + "version": "0.1.0", + "dependencies": { + "d3": "^7.9.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.23.1", + "recharts": "^2.12.7" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.4.5", + "@testing-library/react": "^15.0.7", + "@testing-library/user-event": "^14.5.2", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.19", + "jsdom": "^24.1.0", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.4", + "typescript": "^5.4.5", + "vite": "^5.3.1", + "vitest": "^1.6.0" + } + } + } +} diff --git a/package.json b/package.json index d359416..4ee738f 100644 --- a/package.json +++ b/package.json @@ -11,17 +11,23 @@ "dev": "npm run dev --workspaces --if-present", "build": "npm run build --workspaces --if-present", "test": "npm run test --workspaces --if-present", + "test:coverage": "npm run test --workspaces --if-present -- --coverage", "api:dev": "npm run dev --workspace=src/backend", "deploy:staging": "echo 'Deploying to staging...' && npm run build", "deploy:prod": "echo 'Deploying to production...' && npm run build", - "lint": "eslint 'src/**/*.{ts,tsx}'" + "lint": "eslint 'src/**/*.{ts,tsx}'", + "format": "prettier --write 'src/**/*.{ts,tsx,json,css}'", + "type-check": "tsc --noEmit", + "audit": "npm audit && pip-audit -r requirements.txt", + "clean": "rm -rf dist coverage src/frontend/dist src/backend/dist .pytest_cache htmlcov" }, "devDependencies": { - "typescript": "^5.4.0", - "eslint": "^8.57.0", - "prettier": "^3.2.0" + "typescript": "~5.9.3", + "eslint": "8.57.0", + "prettier": "3.2.0" }, "engines": { - "node": ">=20.0.0" + "node": ">=20.0.0", + "npm": ">=10.0.0" } } diff --git a/requirements.txt b/requirements.txt index c858e69..c4d02f8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,21 +1,29 @@ -# Data Science & ML -numpy>=1.26.0 -pandas>=2.2.0 -scikit-learn>=1.4.0 -statsmodels>=0.14.0 -dowhy>=0.11.0 -torch>=2.2.0 -lifelines>=0.28.0 +# Core ML +numpy==1.26.4 +pandas==2.2.2 +scikit-learn==1.4.2 +statsmodels==0.14.2 +dowhy==0.11.1 +torch==2.2.2 +lifelines==0.29.0 +anthropic==0.25.0 +pyyaml==6.0.1 +joblib==1.4.2 +pydantic==2.7.0 -# Claude API -anthropic>=0.25.0 +# API Serving +fastapi==0.111.0 +uvicorn==0.29.0 -# Testing & Code Quality -pytest>=8.1.0 -black>=24.3.0 -mypy>=1.9.0 -flake8>=7.0.0 +# Testing +pytest==8.2.0 +hypothesis==6.100.0 -# ML API Serving -fastapi>=0.110.0 -uvicorn>=0.29.0 +# Code Quality +black==24.4.2 +mypy==1.10.0 +flake8==7.0.0 + +# Security +pip-audit==2.7.3 +pre-commit==3.7.0 diff --git a/scripts/fairness_audit.py b/scripts/fairness_audit.py index 274520f..2d3acc4 100644 --- a/scripts/fairness_audit.py +++ b/scripts/fairness_audit.py @@ -16,11 +16,12 @@ from __future__ import annotations import argparse +import itertools import json import logging import sys from datetime import datetime, timezone -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple import numpy as np import pandas as pd @@ -164,6 +165,326 @@ def compute_disparate_impact( "passed": passed, } + # ------------------------------------------------------------------ + # Equalized odds + # ------------------------------------------------------------------ + + def equalized_odds( + self, + predictions: np.ndarray, + labels: np.ndarray, + protected_attribute: np.ndarray, + ) -> Dict[str, Any]: + """Compute equalized odds across groups. + + Equalized odds requires that the true-positive rate (TPR) and + false-positive rate (FPR) are equal across all groups. + + Parameters + ---------- + predictions : np.ndarray + Binary (0/1) model predictions. + labels : np.ndarray + Binary (0/1) ground-truth labels. + protected_attribute : np.ndarray + Group labels for each observation. + + Returns + ------- + dict + Per-group TPR and FPR, max differences, and a ``passed`` flag. + """ + df = pd.DataFrame({ + "pred": np.asarray(predictions).ravel(), + "label": np.asarray(labels).ravel(), + "group": np.asarray(protected_attribute).ravel(), + }) + + group_tpr: Dict[str, float] = {} + group_fpr: Dict[str, float] = {} + + for group_name, grp in df.groupby("group"): + positives = grp[grp["label"] == 1] + negatives = grp[grp["label"] == 0] + tpr = float(positives["pred"].mean()) if len(positives) > 0 else 0.0 + fpr = float(negatives["pred"].mean()) if len(negatives) > 0 else 0.0 + group_tpr[str(group_name)] = tpr + group_fpr[str(group_name)] = fpr + + tpr_values = list(group_tpr.values()) + fpr_values = list(group_fpr.values()) + + max_tpr_diff = (max(tpr_values) - min(tpr_values)) if tpr_values else 0.0 + max_fpr_diff = (max(fpr_values) - min(fpr_values)) if fpr_values else 0.0 + + passed = ( + max_tpr_diff <= self.demographic_parity_tolerance + and max_fpr_diff <= self.demographic_parity_tolerance + ) + + return { + "group_tpr": group_tpr, + "group_fpr": group_fpr, + "max_tpr_difference": max_tpr_diff, + "max_fpr_difference": max_fpr_diff, + "passed": passed, + } + + # ------------------------------------------------------------------ + # Calibration by group + # ------------------------------------------------------------------ + + def calibration_by_group( + self, + probabilities: np.ndarray, + labels: np.ndarray, + protected_attribute: np.ndarray, + n_bins: int = 10, + ) -> Dict[str, Any]: + """Compute calibration metrics per group. + + For each group, computes the mean predicted probability and the + actual positive rate within each probability bin. + + Parameters + ---------- + probabilities : np.ndarray + Predicted probabilities (continuous, 0-1). + labels : np.ndarray + Binary (0/1) ground-truth labels. + protected_attribute : np.ndarray + Group labels for each observation. + n_bins : int + Number of calibration bins. + + Returns + ------- + dict + Per-group calibration curves and an overall calibration error. + """ + df = pd.DataFrame({ + "prob": np.asarray(probabilities).ravel(), + "label": np.asarray(labels).ravel(), + "group": np.asarray(protected_attribute).ravel(), + }) + + bin_edges = np.linspace(0, 1, n_bins + 1) + group_calibration: Dict[str, Dict[str, Any]] = {} + + for group_name, grp in df.groupby("group"): + bin_means: List[float] = [] + bin_true_rates: List[float] = [] + bin_counts: List[int] = [] + + for i in range(n_bins): + low, high = bin_edges[i], bin_edges[i + 1] + mask = (grp["prob"] >= low) & (grp["prob"] < high) + if i == n_bins - 1: + mask = mask | (grp["prob"] == high) + bin_data = grp[mask] + if len(bin_data) == 0: + continue + bin_means.append(float(bin_data["prob"].mean())) + bin_true_rates.append(float(bin_data["label"].mean())) + bin_counts.append(len(bin_data)) + + # Expected Calibration Error (weighted) + total = sum(bin_counts) + ece = 0.0 + if total > 0: + for bm, btr, bc in zip(bin_means, bin_true_rates, bin_counts): + ece += (bc / total) * abs(btr - bm) + + group_calibration[str(group_name)] = { + "bin_means": bin_means, + "bin_true_rates": bin_true_rates, + "bin_counts": bin_counts, + "expected_calibration_error": ece, + } + + # Max ECE difference across groups + eces = [g["expected_calibration_error"] for g in group_calibration.values()] + max_ece_diff = (max(eces) - min(eces)) if eces else 0.0 + + return { + "group_calibration": group_calibration, + "max_ece_difference": max_ece_diff, + } + + # ------------------------------------------------------------------ + # Intersectional audit + # ------------------------------------------------------------------ + + def intersectional_audit( + self, + predictions: np.ndarray, + protected_attributes: List[str], + data: Optional[pd.DataFrame] = None, + attribute_arrays: Optional[Dict[str, np.ndarray]] = None, + ) -> Dict[str, Any]: + """Audit fairness across intersections of multiple protected attributes. + + Parameters + ---------- + predictions : np.ndarray + Binary (0/1) model predictions. + protected_attributes : list[str] + Column names (when *data* is provided) or keys in + *attribute_arrays*. + data : pd.DataFrame, optional + DataFrame containing the protected attribute columns. + attribute_arrays : dict[str, np.ndarray], optional + Mapping of attribute name to array of group labels. + + Returns + ------- + dict + Per-intersection positive-prediction rates, max differences, + and disparate impact information. + """ + preds = np.asarray(predictions).ravel() + + if data is not None: + attr_df = data[protected_attributes].copy() + elif attribute_arrays is not None: + attr_df = pd.DataFrame(attribute_arrays) + else: + raise ValueError( + "Either data or attribute_arrays must be provided." + ) + + # Build intersection labels + intersection_labels = attr_df.apply( + lambda row: "_x_".join(str(v) for v in row), axis=1, + ) + + df = pd.DataFrame({"pred": preds, "intersection": intersection_labels}) + + group_rates: Dict[str, float] = {} + group_counts: Dict[str, int] = {} + for group_name, grp in df.groupby("intersection"): + group_rates[str(group_name)] = float(grp["pred"].mean()) + group_counts[str(group_name)] = len(grp) + + rates = list(group_rates.values()) + max_diff = (max(rates) - min(rates)) if rates else 0.0 + + max_rate = max(rates) if rates else 0.0 + if max_rate == 0.0: + di_ratio = 1.0 + else: + di_ratio = min(rates) / max_rate + + passed = di_ratio >= self.disparate_impact_floor + + return { + "intersections": group_rates, + "intersection_counts": group_counts, + "max_difference": max_diff, + "disparate_impact_ratio": di_ratio, + "passed": passed, + } + + # ------------------------------------------------------------------ + # Bootstrap confidence intervals + # ------------------------------------------------------------------ + + @staticmethod + def _bootstrap_ci( + metric_fn, + *arrays: np.ndarray, + n_bootstrap: int = 1000, + confidence_level: float = 0.95, + seed: int = 42, + ) -> Tuple[float, float, float]: + """Compute a bootstrap confidence interval for a metric. + + Parameters + ---------- + metric_fn : callable + Function that takes the same arrays and returns a scalar. + arrays : np.ndarray + Arrays to resample in parallel. + n_bootstrap : int + Number of bootstrap iterations. + confidence_level : float + Confidence level (e.g. 0.95 for 95% CI). + seed : int + Random seed. + + Returns + ------- + tuple[float, float, float] + (point_estimate, ci_lower, ci_upper) + """ + rng = np.random.RandomState(seed) + n = len(arrays[0]) + estimates = [] + + for _ in range(n_bootstrap): + idx = rng.randint(0, n, size=n) + sampled = tuple(arr[idx] for arr in arrays) + estimates.append(metric_fn(*sampled)) + + point_estimate = metric_fn(*arrays) + alpha = 1 - confidence_level + ci_lower = float(np.percentile(estimates, 100 * alpha / 2)) + ci_upper = float(np.percentile(estimates, 100 * (1 - alpha / 2))) + + return point_estimate, ci_lower, ci_upper + + def compute_demographic_parity_with_ci( + self, + predictions: np.ndarray, + protected_attribute: np.ndarray, + n_bootstrap: int = 1000, + ) -> Dict[str, Any]: + """Compute demographic parity with bootstrap confidence intervals. + + Returns the same structure as :meth:`compute_demographic_parity` + but augmented with ``ci_lower`` and ``ci_upper`` for + ``max_difference``. + """ + base_result = self.compute_demographic_parity(predictions, protected_attribute) + preds = np.asarray(predictions).ravel() + groups = np.asarray(protected_attribute).ravel() + + def _max_diff(p, g): + df = pd.DataFrame({"pred": p, "group": g}) + rates = [float(grp["pred"].mean()) for _, grp in df.groupby("group")] + return (max(rates) - min(rates)) if len(rates) > 1 else 0.0 + + _, ci_low, ci_high = self._bootstrap_ci(_max_diff, preds, groups, n_bootstrap=n_bootstrap) + base_result["max_difference_ci"] = (ci_low, ci_high) + return base_result + + def compute_disparate_impact_with_ci( + self, + predictions: np.ndarray, + protected_attribute: np.ndarray, + n_bootstrap: int = 1000, + ) -> Dict[str, Any]: + """Compute disparate impact with bootstrap confidence intervals. + + Returns the same structure as :meth:`compute_disparate_impact` + but augmented with ``ci_lower`` and ``ci_upper`` for the ratio. + """ + base_result = self.compute_disparate_impact(predictions, protected_attribute) + preds = np.asarray(predictions).ravel() + groups = np.asarray(protected_attribute).ravel() + + def _di_ratio(p, g): + df = pd.DataFrame({"pred": p, "group": g}) + rates = [float(grp["pred"].mean()) for _, grp in df.groupby("group")] + max_rate = max(rates) if rates else 0.0 + if max_rate == 0.0: + return 1.0 + return min(rates) / max_rate + + _, ci_low, ci_high = self._bootstrap_ci(_di_ratio, preds, groups, n_bootstrap=n_bootstrap) + base_result["disparate_impact_ratio_ci"] = (ci_low, ci_high) + return base_result + # ------------------------------------------------------------------ # Reporting # ------------------------------------------------------------------ @@ -199,6 +520,58 @@ def generate_report( overall_pass = dp.get("passed", False) and di.get("passed", False) + # Compute magnitude of unfairness + dp_diff = dp.get("max_difference", 0.0) + di_ratio = di.get("disparate_impact_ratio", 1.0) + dp_magnitude = "N/A" + di_magnitude = "N/A" + + if isinstance(dp_diff, (int, float)): + if dp_diff <= 0.05: + dp_magnitude = "negligible" + elif dp_diff <= 0.10: + dp_magnitude = "small" + elif dp_diff <= 0.20: + dp_magnitude = "moderate" + else: + dp_magnitude = "severe" + + if isinstance(di_ratio, (int, float)): + if di_ratio >= 0.90: + di_magnitude = "negligible" + elif di_ratio >= 0.80: + di_magnitude = "small" + elif di_ratio >= 0.60: + di_magnitude = "moderate" + else: + di_magnitude = "severe" + + # Equalized odds (if present) + eo = audit_results.get("equalized_odds", {}) + cal = audit_results.get("calibration", {}) + + # Build recommendations + recommendations: List[str] = [] + if not dp.get("passed", True): + recommendations.append( + f"Demographic parity gap is {dp_magnitude} ({dp_diff:.4f}). " + "Consider resampling, threshold adjustment, or post-processing " + "calibration to reduce prediction rate differences across groups." + ) + if not di.get("passed", True): + recommendations.append( + f"Disparate impact ratio is {di_magnitude} ({di_ratio:.4f}). " + "Review feature selection for proxies of protected attributes " + "and consider adversarial debiasing or reject-option classification." + ) + if eo and not eo.get("passed", True): + recommendations.append( + "Equalized odds violated. Consider equalized-odds post-processing " + "(Hardt et al., 2016) to balance TPR and FPR across groups." + ) + if not recommendations: + recommendations.append("All fairness checks passed. Continue monitoring in production.") + report_lines = [ "=" * 60, f" FAIRNESS AUDIT REPORT", @@ -212,6 +585,7 @@ def generate_report( if isinstance(dp.get("max_difference"), (int, float)) else f" Max difference : {dp.get('max_difference', 'N/A')}", f" Tolerance : {dp.get('tolerance', 'N/A')}", + f" Magnitude : {dp_magnitude}", f" PASSED : {dp.get('passed', 'N/A')}", "", "--- Disparate Impact (4/5ths Rule) ---", @@ -220,12 +594,39 @@ def generate_report( if isinstance(di.get("disparate_impact_ratio"), (int, float)) else f" Disparate impact ratio : {di.get('disparate_impact_ratio', 'N/A')}", f" Floor : {di.get('floor', 'N/A')}", + f" Magnitude : {di_magnitude}", f" PASSED : {di.get('passed', 'N/A')}", "", + ] + + if eo: + report_lines += [ + "--- Equalized Odds ---", + f" Group TPR: {eo.get('group_tpr', {})}", + f" Group FPR: {eo.get('group_fpr', {})}", + f" Max TPR difference: {eo.get('max_tpr_difference', 'N/A')}", + f" Max FPR difference: {eo.get('max_fpr_difference', 'N/A')}", + f" PASSED: {eo.get('passed', 'N/A')}", + "", + ] + + if cal: + report_lines += [ + "--- Calibration ---", + f" Max ECE difference: {cal.get('max_ece_difference', 'N/A')}", + "", + ] + + report_lines += [ "--- Overall ---", f" ALL CHECKS PASSED: {overall_pass}", - "=" * 60, + "", + "--- Recommendations ---", ] + for i, rec in enumerate(recommendations, 1): + report_lines.append(f" {i}. {rec}") + + report_lines.append("=" * 60) report_text = "\n".join(report_lines) diff --git a/src/backend/package.json b/src/backend/package.json index cbb4cb5..e85177c 100644 --- a/src/backend/package.json +++ b/src/backend/package.json @@ -9,20 +9,39 @@ "test": "jest --passWithNoTests" }, "dependencies": { - "express": "^4.19.0", - "cors": "^2.8.5", - "dotenv": "^16.4.0", - "winston": "^3.13.0" + "@anthropic-ai/sdk": "^0.39.0", + "@aws-sdk/client-dynamodb": "~3.1024.0", + "@aws-sdk/lib-dynamodb": "~3.1024.0", + "compression": "~1.7.5", + "cors": "~2.8.5", + "dotenv": "~16.4.7", + "express": "~4.21.2", + "helmet": "~7.2.0", + "uuid": "~9.0.1", + "winston": "~3.17.0", + "zod": "~3.24.2", + "swagger-ui-express": "~5.0.1", + "@opentelemetry/sdk-node": "~0.52.0", + "@opentelemetry/api": "~1.9.0", + "@opentelemetry/auto-instrumentations-node": "~0.47.0", + "@opentelemetry/exporter-trace-otlp-http": "~0.52.0", + "@opentelemetry/sdk-trace-node": "~1.25.0", + "@opentelemetry/sdk-metrics": "~1.25.0", + "@opentelemetry/resources": "~1.25.0", + "@opentelemetry/semantic-conventions": "~1.25.0" }, "devDependencies": { - "typescript": "^5.4.0", - "nodemon": "^3.1.0", - "ts-node": "^10.9.0", - "@types/express": "^4.17.0", - "@types/cors": "^2.8.0", - "@types/node": "^20.12.0", - "jest": "^29.7.0", - "ts-jest": "^29.1.0", - "@types/jest": "^29.5.0" + "@types/compression": "~1.7.5", + "@types/swagger-ui-express": "~4.1.6", + "@types/cors": "~2.8.17", + "@types/express": "~4.17.21", + "@types/jest": "~29.5.14", + "@types/node": "~20.17.0", + "@types/uuid": "~9.0.8", + "jest": "~29.7.0", + "nodemon": "~3.1.9", + "ts-jest": "~29.1.5", + "ts-node": "~10.9.2", + "typescript": "~5.9.3" } } diff --git a/src/backend/src/db/client.ts b/src/backend/src/db/client.ts new file mode 100644 index 0000000..2827419 --- /dev/null +++ b/src/backend/src/db/client.ts @@ -0,0 +1,30 @@ +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; +import { logger } from '../utils/logger'; + +const isLocal = process.env.DYNAMODB_LOCAL === 'true'; +const endpoint = process.env.DYNAMODB_ENDPOINT ?? 'http://localhost:8000'; + +const baseClient = new DynamoDBClient({ + ...(isLocal && { endpoint }), + region: process.env.AWS_REGION ?? 'us-east-1', +}); + +export const docClient = DynamoDBDocumentClient.from(baseClient, { + marshallOptions: { + removeUndefinedValues: true, + convertEmptyValues: false, + convertClassInstanceToMap: true, + }, + unmarshallOptions: { + wrapNumbers: false, + }, +}); + +export const TABLE_NAME = process.env.DYNAMODB_TABLE_NAME ?? 'wellab-main'; + +logger.info('DynamoDB client initialized', { + table: TABLE_NAME, + local: isLocal, + ...(isLocal && { endpoint }), +}); diff --git a/src/backend/src/db/errors.ts b/src/backend/src/db/errors.ts new file mode 100644 index 0000000..aefc9b3 --- /dev/null +++ b/src/backend/src/db/errors.ts @@ -0,0 +1,31 @@ +/** Base class for all database errors. */ +export class DatabaseError extends Error { + constructor(message: string, public readonly cause?: unknown) { + super(message); + this.name = 'DatabaseError'; + } +} + +/** Thrown when a requested item does not exist. */ +export class NotFoundError extends DatabaseError { + constructor(entity: string, key: Record) { + super(`${entity} not found: ${JSON.stringify(key)}`); + this.name = 'NotFoundError'; + } +} + +/** Thrown when a put with a condition expression fails (duplicate key). */ +export class ConflictError extends DatabaseError { + constructor(entity: string, key: Record) { + super(`${entity} already exists: ${JSON.stringify(key)}`); + this.name = 'ConflictError'; + } +} + +/** Thrown when a conditional update/delete fails (stale data). */ +export class ConditionFailedError extends DatabaseError { + constructor(message: string) { + super(message); + this.name = 'ConditionFailedError'; + } +} diff --git a/src/backend/src/db/index.ts b/src/backend/src/db/index.ts new file mode 100644 index 0000000..e6f043d --- /dev/null +++ b/src/backend/src/db/index.ts @@ -0,0 +1,17 @@ +// --------------------------------------------------------------------------- +// DynamoDB data access layer — barrel export +// --------------------------------------------------------------------------- + +export { docClient, TABLE_NAME } from './client'; +export { participantRepository } from './repositories/participantRepository'; +export { observationRepository } from './repositories/observationRepository'; +export { healthRepository } from './repositories/healthRepository'; +export { cognitiveRepository } from './repositories/cognitiveRepository'; +export { interventionRepository } from './repositories/interventionRepository'; +export { lifespanRepository } from './repositories/lifespanRepository'; + +// Re-export error types for consumers +export { NotFoundError, ConflictError, ConditionFailedError, DatabaseError } from './errors'; + +// Re-export key builders for advanced use cases +export * from './keys'; diff --git a/src/backend/src/db/keys.ts b/src/backend/src/db/keys.ts new file mode 100644 index 0000000..54093c7 --- /dev/null +++ b/src/backend/src/db/keys.ts @@ -0,0 +1,84 @@ +// --------------------------------------------------------------------------- +// Primary key builders for the single-table design +// --------------------------------------------------------------------------- + +export interface TableKey { + PK: string; + SK: string; +} + +export interface GSIKey { + GSI1PK?: string; + GSI1SK?: string; + GSI2PK?: string; + GSI2SK?: string; +} + +// -- Participant --------------------------------------------------------------- + +export function buildParticipantKey(id: string): TableKey { + return { PK: `PARTICIPANT#${id}`, SK: 'PROFILE' }; +} + +// -- Observation (EMA) --------------------------------------------------------- + +export function buildObservationKey(participantId: string, timestamp: string): TableKey { + return { PK: `PARTICIPANT#${participantId}`, SK: `OBS#${timestamp}` }; +} + +export const OBSERVATION_SK_PREFIX = 'OBS#'; + +// -- Health Record ------------------------------------------------------------- + +export function buildHealthKey(participantId: string, date: string): TableKey { + return { PK: `PARTICIPANT#${participantId}`, SK: `HEALTH#${date}` }; +} + +export const HEALTH_SK_PREFIX = 'HEALTH#'; + +// -- Lifespan Assessment ------------------------------------------------------- + +export function buildLifespanKey(participantId: string, wave: number): TableKey { + const paddedWave = String(wave).padStart(3, '0'); + return { PK: `PARTICIPANT#${participantId}`, SK: `LIFESPAN#${paddedWave}` }; +} + +export const LIFESPAN_SK_PREFIX = 'LIFESPAN#'; + +// -- Cognitive Assessment ------------------------------------------------------ + +export function buildCognitiveKey(participantId: string, date: string): TableKey { + return { PK: `PARTICIPANT#${participantId}`, SK: `COGNITIVE#${date}` }; +} + +export const COGNITIVE_SK_PREFIX = 'COGNITIVE#'; + +// -- Intervention -------------------------------------------------------------- + +export function buildInterventionKey(participantId: string, timestamp: string): TableKey { + return { PK: `PARTICIPANT#${participantId}`, SK: `INTERVENTION#${timestamp}` }; +} + +export const INTERVENTION_SK_PREFIX = 'INTERVENTION#'; + +// -- GSI key builders ---------------------------------------------------------- + +export function buildStatusGSIKeys(status: string, participantId: string): GSIKey { + return { + GSI1PK: `STATUS#${status}`, + GSI1SK: `PARTICIPANT#${participantId}`, + }; +} + +export function buildCohortGSIKeys(cohort: string, participantId: string): GSIKey { + return { + GSI2PK: `COHORT#${cohort}`, + GSI2SK: `PARTICIPANT#${participantId}`, + }; +} + +// -- Utility ------------------------------------------------------------------- + +export function participantPK(participantId: string): string { + return `PARTICIPANT#${participantId}`; +} diff --git a/src/backend/src/db/local-setup.ts b/src/backend/src/db/local-setup.ts new file mode 100644 index 0000000..bce4f39 --- /dev/null +++ b/src/backend/src/db/local-setup.ts @@ -0,0 +1,95 @@ +/** + * Script to create the DynamoDB Local table with GSIs for development. + * + * Usage: + * DYNAMODB_LOCAL=true npx ts-node src/db/local-setup.ts + */ + +import { + DynamoDBClient, + CreateTableCommand, + DescribeTableCommand, + type CreateTableCommandInput, +} from '@aws-sdk/client-dynamodb'; + +const TABLE_NAME = process.env.DYNAMODB_TABLE_NAME ?? 'wellab-main'; +const ENDPOINT = process.env.DYNAMODB_ENDPOINT ?? 'http://localhost:8000'; +const REGION = process.env.AWS_REGION ?? 'us-east-1'; + +const client = new DynamoDBClient({ endpoint: ENDPOINT, region: REGION }); + +const tableDefinition: CreateTableCommandInput = { + TableName: TABLE_NAME, + KeySchema: [ + { AttributeName: 'PK', KeyType: 'HASH' }, + { AttributeName: 'SK', KeyType: 'RANGE' }, + ], + AttributeDefinitions: [ + { AttributeName: 'PK', AttributeType: 'S' }, + { AttributeName: 'SK', AttributeType: 'S' }, + { AttributeName: 'GSI1PK', AttributeType: 'S' }, + { AttributeName: 'GSI1SK', AttributeType: 'S' }, + { AttributeName: 'GSI2PK', AttributeType: 'S' }, + { AttributeName: 'GSI2SK', AttributeType: 'S' }, + ], + GlobalSecondaryIndexes: [ + { + IndexName: 'GSI1', + KeySchema: [ + { AttributeName: 'GSI1PK', KeyType: 'HASH' }, + { AttributeName: 'GSI1SK', KeyType: 'RANGE' }, + ], + Projection: { ProjectionType: 'ALL' }, + ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 }, + }, + { + IndexName: 'GSI2', + KeySchema: [ + { AttributeName: 'GSI2PK', KeyType: 'HASH' }, + { AttributeName: 'GSI2SK', KeyType: 'RANGE' }, + ], + Projection: { ProjectionType: 'ALL' }, + ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 }, + }, + ], + ProvisionedThroughput: { ReadCapacityUnits: 10, WriteCapacityUnits: 10 }, +}; + +async function tableExists(): Promise { + try { + await client.send(new DescribeTableCommand({ TableName: TABLE_NAME })); + return true; + } catch (err: unknown) { + if ( + typeof err === 'object' && + err !== null && + 'name' in err && + (err as { name: string }).name === 'ResourceNotFoundException' + ) { + return false; + } + throw err; + } +} + +async function main(): Promise { + console.log(`Checking for table "${TABLE_NAME}" at ${ENDPOINT}...`); + + if (await tableExists()) { + console.log(`Table "${TABLE_NAME}" already exists. Skipping creation.`); + return; + } + + console.log(`Creating table "${TABLE_NAME}" with GSI1 (status) and GSI2 (cohort)...`); + await client.send(new CreateTableCommand(tableDefinition)); + console.log(`Table "${TABLE_NAME}" created successfully.`); + console.log('Key schema:'); + console.log(' PK (HASH) / SK (RANGE)'); + console.log(' GSI1: GSI1PK (HASH) / GSI1SK (RANGE) — status queries'); + console.log(' GSI2: GSI2PK (HASH) / GSI2SK (RANGE) — cohort queries'); +} + +main().catch((err) => { + console.error('Failed to set up local DynamoDB table:', err); + process.exit(1); +}); diff --git a/src/backend/src/db/repositories/cognitiveRepository.ts b/src/backend/src/db/repositories/cognitiveRepository.ts new file mode 100644 index 0000000..108419f --- /dev/null +++ b/src/backend/src/db/repositories/cognitiveRepository.ts @@ -0,0 +1,128 @@ +import { v4 as uuidv4 } from 'uuid'; +import { BaseRepository } from '../repository'; +import { + buildCognitiveKey, + COGNITIVE_SK_PREFIX, + participantPK, +} from '../keys'; +import { logger } from '../../utils/logger'; +import type { CognitiveAssessment } from '../../types'; + +// --------------------------------------------------------------------------- +// DynamoDB item shape +// --------------------------------------------------------------------------- +interface CognitiveItem extends Record { + PK: string; + SK: string; + entityType: 'CognitiveAssessment'; + id: string; + participantId: string; + assessmentDate: string; + instrument: string; + domain: string; + score: number; + normalizedScore: number; + percentile: number; + created_at: string; + updated_at: string; +} + +// --------------------------------------------------------------------------- +// Options +// --------------------------------------------------------------------------- +export interface ListCognitiveOptions { + limit?: number; + exclusiveStartKey?: Record; +} + +export interface CognitivePage { + items: CognitiveAssessment[]; + lastEvaluatedKey?: Record; +} + +// --------------------------------------------------------------------------- +// Repository +// --------------------------------------------------------------------------- +class CognitiveRepository extends BaseRepository { + constructor() { + super('CognitiveAssessment'); + } + + /** Create a new cognitive assessment. */ + async create( + participantId: string, + input: Omit, + ): Promise { + const id = `CA-${uuidv4().slice(0, 8)}`; + const now = new Date().toISOString(); + const assessmentDate = input.assessmentDate ?? now.slice(0, 10); + + const key = buildCognitiveKey(participantId, assessmentDate); + + const item: CognitiveItem = { + ...key, + entityType: 'CognitiveAssessment', + id, + participantId, + assessmentDate, + instrument: input.instrument, + domain: input.domain, + score: input.score, + normalizedScore: input.normalizedScore, + percentile: input.percentile, + created_at: now, + updated_at: now, + }; + + await this.put(item); + logger.info('CognitiveAssessment created', { id, participantId }); + return this.toCognitive(item); + } + + /** List all cognitive assessments for a participant. */ + async listByParticipant( + participantId: string, + options: ListCognitiveOptions = {}, + ): Promise { + const { limit, exclusiveStartKey } = options; + const pk = participantPK(participantId); + + const result = await this.query(pk, COGNITIVE_SK_PREFIX, { + limit, + exclusiveStartKey, + }); + + return { + items: result.items.map((i) => this.toCognitive(i)), + lastEvaluatedKey: result.lastEvaluatedKey, + }; + } + + /** Get the most recent cognitive assessment. */ + async getLatest(participantId: string): Promise { + const pk = participantPK(participantId); + const result = await this.query(pk, COGNITIVE_SK_PREFIX, { + limit: 1, + scanForward: false, + }); + return result.items.length > 0 ? this.toCognitive(result.items[0]) : null; + } + + // ----------------------------------------------------------------------- + // Mapper + // ----------------------------------------------------------------------- + private toCognitive(item: CognitiveItem): CognitiveAssessment { + return { + id: item.id, + participantId: item.participantId, + assessmentDate: item.assessmentDate, + instrument: item.instrument, + domain: item.domain as CognitiveAssessment['domain'], + score: item.score, + normalizedScore: item.normalizedScore, + percentile: item.percentile, + }; + } +} + +export const cognitiveRepository = new CognitiveRepository(); diff --git a/src/backend/src/db/repositories/healthRepository.ts b/src/backend/src/db/repositories/healthRepository.ts new file mode 100644 index 0000000..4a714c0 --- /dev/null +++ b/src/backend/src/db/repositories/healthRepository.ts @@ -0,0 +1,132 @@ +import { v4 as uuidv4 } from 'uuid'; +import { BaseRepository } from '../repository'; +import { + buildHealthKey, + HEALTH_SK_PREFIX, + participantPK, +} from '../keys'; +import { logger } from '../../utils/logger'; +import type { HealthRecord } from '../../types'; + +// --------------------------------------------------------------------------- +// DynamoDB item shape +// --------------------------------------------------------------------------- +interface HealthItem extends Record { + PK: string; + SK: string; + entityType: 'HealthRecord'; + id: string; + participantId: string; + recordDate: string; + domain: string; + indicators: Record; + notes: string; + created_at: string; + updated_at: string; +} + +// --------------------------------------------------------------------------- +// Options +// --------------------------------------------------------------------------- +export interface ListHealthOptions { + startDate?: string; + endDate?: string; + limit?: number; + exclusiveStartKey?: Record; +} + +export interface HealthPage { + items: HealthRecord[]; + lastEvaluatedKey?: Record; +} + +// --------------------------------------------------------------------------- +// Repository +// --------------------------------------------------------------------------- +class HealthRepository extends BaseRepository { + constructor() { + super('HealthRecord'); + } + + /** Create a new health record. */ + async create( + participantId: string, + input: Omit, + ): Promise { + const id = `HR-${uuidv4().slice(0, 8)}`; + const now = new Date().toISOString(); + const recordDate = input.recordDate ?? now.slice(0, 10); + + const key = buildHealthKey(participantId, recordDate); + + const item: HealthItem = { + ...key, + entityType: 'HealthRecord', + id, + participantId, + recordDate, + domain: input.domain, + indicators: input.indicators, + notes: input.notes ?? '', + created_at: now, + updated_at: now, + }; + + await this.put(item); + logger.info('HealthRecord created', { id, participantId }); + return this.toHealthRecord(item); + } + + /** List health records for a participant, optionally within a date range. */ + async listByParticipant( + participantId: string, + options: ListHealthOptions = {}, + ): Promise { + const { startDate, endDate, limit, exclusiveStartKey } = options; + const pk = participantPK(participantId); + + let result; + + if (startDate && endDate) { + result = await this.queryRange( + pk, + `${HEALTH_SK_PREFIX}${startDate}`, + `${HEALTH_SK_PREFIX}${endDate}`, + { limit, exclusiveStartKey }, + ); + } else { + result = await this.query(pk, HEALTH_SK_PREFIX, { limit, exclusiveStartKey }); + } + + return { + items: result.items.map((i) => this.toHealthRecord(i)), + lastEvaluatedKey: result.lastEvaluatedKey, + }; + } + + /** Get the most recent health record for a participant. */ + async getLatest(participantId: string): Promise { + const pk = participantPK(participantId); + const result = await this.query(pk, HEALTH_SK_PREFIX, { + limit: 1, + scanForward: false, + }); + return result.items.length > 0 ? this.toHealthRecord(result.items[0]) : null; + } + + // ----------------------------------------------------------------------- + // Mapper + // ----------------------------------------------------------------------- + private toHealthRecord(item: HealthItem): HealthRecord { + return { + id: item.id, + participantId: item.participantId, + recordDate: item.recordDate, + domain: item.domain as HealthRecord['domain'], + indicators: item.indicators, + notes: item.notes, + }; + } +} + +export const healthRepository = new HealthRepository(); diff --git a/src/backend/src/db/repositories/interventionRepository.ts b/src/backend/src/db/repositories/interventionRepository.ts new file mode 100644 index 0000000..f494a90 --- /dev/null +++ b/src/backend/src/db/repositories/interventionRepository.ts @@ -0,0 +1,176 @@ +import { v4 as uuidv4 } from 'uuid'; +import { BaseRepository } from '../repository'; +import { + buildInterventionKey, + INTERVENTION_SK_PREFIX, + participantPK, +} from '../keys'; +import { logger } from '../../utils/logger'; +import type { Intervention } from '../../types'; + +// --------------------------------------------------------------------------- +// DynamoDB item shape +// --------------------------------------------------------------------------- +interface InterventionItem extends Record { + PK: string; + SK: string; + entityType: 'Intervention'; + id: string; + participantId: string; + type: string; + name: string; + startDate: string; + endDate?: string; + status: string; + dosage?: string; + frequency?: string; + outcomes: Record; + created_at: string; + updated_at: string; +} + +// --------------------------------------------------------------------------- +// Options +// --------------------------------------------------------------------------- +export interface ListInterventionOptions { + type?: Intervention['type']; + status?: Intervention['status']; + limit?: number; + exclusiveStartKey?: Record; +} + +export interface InterventionPage { + items: Intervention[]; + lastEvaluatedKey?: Record; +} + +// --------------------------------------------------------------------------- +// Repository +// --------------------------------------------------------------------------- +class InterventionRepository extends BaseRepository { + constructor() { + super('Intervention'); + } + + /** Create a new intervention record. */ + async create( + participantId: string, + input: Omit, + ): Promise { + const id = `INT-${uuidv4().slice(0, 8)}`; + const now = new Date().toISOString(); + const timestamp = input.startDate ?? now; + + const key = buildInterventionKey(participantId, timestamp); + + const item: InterventionItem = { + ...key, + entityType: 'Intervention', + id, + participantId, + type: input.type, + name: input.name, + startDate: input.startDate, + endDate: input.endDate, + status: input.status, + dosage: input.dosage, + frequency: input.frequency, + outcomes: input.outcomes ?? {}, + created_at: now, + updated_at: now, + }; + + await this.put(item); + logger.info('Intervention created', { id, participantId }); + return this.toIntervention(item); + } + + /** List interventions for a participant with optional type/status filters. */ + async listByParticipant( + participantId: string, + options: ListInterventionOptions = {}, + ): Promise { + const { type, status, limit, exclusiveStartKey } = options; + const pk = participantPK(participantId); + + // Build filter expression for type and/or status + const filterParts: string[] = []; + const filterValues: Record = {}; + const filterNames: Record = {}; + + if (type) { + filterParts.push('#intType = :intType'); + filterNames['#intType'] = 'type'; + filterValues[':intType'] = type; + } + if (status) { + filterParts.push('#intStatus = :intStatus'); + filterNames['#intStatus'] = 'status'; + filterValues[':intStatus'] = status; + } + + const result = await this.query(pk, INTERVENTION_SK_PREFIX, { + limit, + exclusiveStartKey, + filterExpression: filterParts.length > 0 ? filterParts.join(' AND ') : undefined, + filterValues: Object.keys(filterValues).length > 0 ? filterValues : undefined, + filterNames: Object.keys(filterNames).length > 0 ? filterNames : undefined, + }); + + return { + items: result.items.map((i) => this.toIntervention(i)), + lastEvaluatedKey: result.lastEvaluatedKey, + }; + } + + /** Mark an intervention as completed. Finds the item by scanning the participant's interventions. */ + async markCompleted( + participantId: string, + interventionId: string, + ): Promise { + // Find the intervention's sort key by querying all interventions for the participant + const pk = participantPK(participantId); + const result = await this.query(pk, INTERVENTION_SK_PREFIX, { + filterExpression: '#iid = :iid', + filterNames: { '#iid': 'id' }, + filterValues: { ':iid': interventionId }, + }); + + if (result.items.length === 0) { + const { NotFoundError } = await import('../errors'); + throw new NotFoundError('Intervention', { id: interventionId, participantId }); + } + + const existing = result.items[0]; + const key = { PK: existing.PK, SK: existing.SK }; + const now = new Date().toISOString(); + + const updated = await this.update(key, { + status: 'completed', + endDate: now, + }); + + logger.info('Intervention marked completed', { interventionId, participantId }); + return this.toIntervention(updated); + } + + // ----------------------------------------------------------------------- + // Mapper + // ----------------------------------------------------------------------- + private toIntervention(item: InterventionItem): Intervention { + return { + id: item.id, + participantId: item.participantId, + type: item.type as Intervention['type'], + name: item.name, + startDate: item.startDate, + endDate: item.endDate, + status: item.status as Intervention['status'], + dosage: item.dosage, + frequency: item.frequency, + outcomes: item.outcomes, + }; + } +} + +export const interventionRepository = new InterventionRepository(); diff --git a/src/backend/src/db/repositories/lifespanRepository.ts b/src/backend/src/db/repositories/lifespanRepository.ts new file mode 100644 index 0000000..0b6962e --- /dev/null +++ b/src/backend/src/db/repositories/lifespanRepository.ts @@ -0,0 +1,119 @@ +import { v4 as uuidv4 } from 'uuid'; +import { BaseRepository } from '../repository'; +import { + buildLifespanKey, + LIFESPAN_SK_PREFIX, + participantPK, +} from '../keys'; +import { logger } from '../../utils/logger'; +import type { LifespanTrajectory, TrajectoryPoint } from '../../types'; + +// --------------------------------------------------------------------------- +// DynamoDB item shape +// --------------------------------------------------------------------------- +interface LifespanItem extends Record { + PK: string; + SK: string; + entityType: 'LifespanAssessment'; + id: string; + participantId: string; + assessmentWave: number; + domain: string; + points: TrajectoryPoint[]; + clusterLabel?: string; + trajectoryClass: string; + created_at: string; + updated_at: string; +} + +// --------------------------------------------------------------------------- +// Options +// --------------------------------------------------------------------------- +export interface ListLifespanOptions { + limit?: number; + exclusiveStartKey?: Record; +} + +export interface LifespanPage { + items: LifespanTrajectory[]; + lastEvaluatedKey?: Record; +} + +// --------------------------------------------------------------------------- +// Repository +// --------------------------------------------------------------------------- +class LifespanRepository extends BaseRepository { + constructor() { + super('LifespanAssessment'); + } + + /** Create a new lifespan assessment for a given wave. */ + async create( + participantId: string, + input: Omit & { assessmentWave: number }, + ): Promise { + const id = `LS-${uuidv4().slice(0, 8)}`; + const now = new Date().toISOString(); + + const key = buildLifespanKey(participantId, input.assessmentWave); + + const item: LifespanItem = { + ...key, + entityType: 'LifespanAssessment', + id, + participantId, + assessmentWave: input.assessmentWave, + domain: input.domain, + points: input.points, + clusterLabel: input.clusterLabel, + trajectoryClass: input.trajectoryClass, + created_at: now, + updated_at: now, + }; + + await this.put(item); + logger.info('LifespanAssessment created', { id, participantId, wave: input.assessmentWave }); + return this.toTrajectory(item); + } + + /** List all lifespan assessments (all waves) for a participant. */ + async listByParticipant( + participantId: string, + options: ListLifespanOptions = {}, + ): Promise { + const { limit, exclusiveStartKey } = options; + const pk = participantPK(participantId); + + const result = await this.query(pk, LIFESPAN_SK_PREFIX, { + limit, + exclusiveStartKey, + }); + + return { + items: result.items.map((i) => this.toTrajectory(i)), + lastEvaluatedKey: result.lastEvaluatedKey, + }; + } + + /** Get a specific wave assessment for a participant. */ + async getByWave(participantId: string, wave: number): Promise { + const key = buildLifespanKey(participantId, wave); + const item = await this.get(key); + return this.toTrajectory(item); + } + + // ----------------------------------------------------------------------- + // Mapper + // ----------------------------------------------------------------------- + private toTrajectory(item: LifespanItem): LifespanTrajectory { + return { + participantId: item.participantId, + domain: item.domain, + points: item.points, + clusterLabel: item.clusterLabel, + trajectoryClass: item.trajectoryClass, + }; + } +} + +export const lifespanRepository = new LifespanRepository(); diff --git a/src/backend/src/db/repositories/observationRepository.ts b/src/backend/src/db/repositories/observationRepository.ts new file mode 100644 index 0000000..3acf4cd --- /dev/null +++ b/src/backend/src/db/repositories/observationRepository.ts @@ -0,0 +1,144 @@ +import { v4 as uuidv4 } from 'uuid'; +import { BaseRepository } from '../repository'; +import { + buildObservationKey, + OBSERVATION_SK_PREFIX, + participantPK, +} from '../keys'; +import { logger } from '../../utils/logger'; +import type { Observation } from '../../types'; + +// --------------------------------------------------------------------------- +// DynamoDB item shape +// --------------------------------------------------------------------------- +interface ObservationItem extends Record { + PK: string; + SK: string; + entityType: 'Observation'; + id: string; + participantId: string; + timestamp: string; + source: string; + measures: Record; + context: Record; + created_at: string; + updated_at: string; +} + +// --------------------------------------------------------------------------- +// Options +// --------------------------------------------------------------------------- +export interface ListObservationsOptions { + startDate?: string; + endDate?: string; + limit?: number; + exclusiveStartKey?: Record; +} + +export interface ObservationPage { + items: Observation[]; + lastEvaluatedKey?: Record; +} + +// --------------------------------------------------------------------------- +// Repository +// --------------------------------------------------------------------------- +class ObservationRepository extends BaseRepository { + constructor() { + super('Observation'); + } + + /** Create a new EMA observation. */ + async create( + participantId: string, + input: Omit, + ): Promise { + const id = `OBS-${uuidv4().slice(0, 8)}`; + const now = new Date().toISOString(); + const timestamp = input.timestamp ?? now; + + const key = buildObservationKey(participantId, timestamp); + + const item: ObservationItem = { + ...key, + entityType: 'Observation', + id, + participantId, + timestamp, + source: input.source, + measures: input.measures, + context: input.context as Record, + created_at: now, + updated_at: now, + }; + + await this.put(item); + logger.info('Observation created', { id, participantId }); + return this.toObservation(item); + } + + /** List observations for a participant, optionally within a date range. */ + async listByParticipant( + participantId: string, + options: ListObservationsOptions = {}, + ): Promise { + const { startDate, endDate, limit, exclusiveStartKey } = options; + const pk = participantPK(participantId); + + let result; + + if (startDate && endDate) { + result = await this.queryRange( + pk, + `${OBSERVATION_SK_PREFIX}${startDate}`, + `${OBSERVATION_SK_PREFIX}${endDate}`, + { limit, exclusiveStartKey }, + ); + } else if (startDate) { + result = await this.queryRange( + pk, + `${OBSERVATION_SK_PREFIX}${startDate}`, + `${OBSERVATION_SK_PREFIX}\uffff`, + { limit, exclusiveStartKey }, + ); + } else { + result = await this.query(pk, OBSERVATION_SK_PREFIX, { limit, exclusiveStartKey }); + } + + return { + items: result.items.map((i) => this.toObservation(i)), + lastEvaluatedKey: result.lastEvaluatedKey, + }; + } + + /** Get the most recent observations for a participant. */ + async getLatest(participantId: string, count = 1): Promise { + const pk = participantPK(participantId); + const result = await this.query(pk, OBSERVATION_SK_PREFIX, { + limit: count, + scanForward: false, + }); + return result.items.map((i) => this.toObservation(i)); + } + + // ----------------------------------------------------------------------- + // Mapper + // ----------------------------------------------------------------------- + private toObservation(item: ObservationItem): Observation { + return { + id: item.id, + participantId: item.participantId, + timestamp: item.timestamp, + source: item.source as Observation['source'], + measures: item.measures, + context: { + location: item.context.location as string | undefined, + activity: item.context.activity as string | undefined, + socialContext: item.context.socialContext as string | undefined, + deviceType: item.context.deviceType as string | undefined, + }, + }; + } +} + +export const observationRepository = new ObservationRepository(); diff --git a/src/backend/src/db/repositories/participantRepository.ts b/src/backend/src/db/repositories/participantRepository.ts new file mode 100644 index 0000000..a72daf1 --- /dev/null +++ b/src/backend/src/db/repositories/participantRepository.ts @@ -0,0 +1,220 @@ +import { v4 as uuidv4 } from 'uuid'; +import { BaseRepository, type QueryOptions, type QueryResult } from '../repository'; +import { + buildParticipantKey, + buildStatusGSIKeys, + buildCohortGSIKeys, + participantPK, + type TableKey, +} from '../keys'; +import { NotFoundError } from '../errors'; +import { logger } from '../../utils/logger'; +import type { Participant } from '../../types'; + +// --------------------------------------------------------------------------- +// DynamoDB item shape (Participant profile row) +// --------------------------------------------------------------------------- +interface ParticipantItem extends Record { + PK: string; + SK: string; + GSI1PK?: string; + GSI1SK?: string; + GSI2PK?: string; + GSI2SK?: string; + entityType: 'Participant'; + id: string; + externalId: string; + firstName: string; + lastName: string; + dateOfBirth: string; + enrollmentDate: string; + cohort: string; + status: string; + metadata: Record; + created_at: string; + updated_at: string; +} + +// --------------------------------------------------------------------------- +// Options for listing +// --------------------------------------------------------------------------- +export interface ListParticipantsOptions { + status?: Participant['status']; + cohort?: string; + limit?: number; + exclusiveStartKey?: Record; +} + +export interface ParticipantPage { + items: Participant[]; + lastEvaluatedKey?: Record; +} + +// --------------------------------------------------------------------------- +// Repository +// --------------------------------------------------------------------------- +class ParticipantRepository extends BaseRepository { + constructor() { + super('Participant'); + } + + /** Create a new participant with an auto-generated ID (unless provided). */ + async create(input: Omit & { id?: string }): Promise { + const id = input.id ?? `P-${uuidv4().slice(0, 5).toUpperCase()}`; + const now = new Date().toISOString(); + + const key = buildParticipantKey(id); + const gsi1 = buildStatusGSIKeys(input.status, id); + const gsi2 = buildCohortGSIKeys(input.cohort, id); + + const item: ParticipantItem = { + ...key, + ...gsi1, + ...gsi2, + entityType: 'Participant', + id, + externalId: input.externalId, + firstName: input.firstName, + lastName: input.lastName, + dateOfBirth: input.dateOfBirth, + enrollmentDate: input.enrollmentDate, + cohort: input.cohort, + status: input.status, + metadata: input.metadata ?? {}, + created_at: now, + updated_at: now, + }; + + await this.put(item); + logger.info('Participant created', { id }); + return this.toParticipant(item); + } + + /** Get a single participant by ID. */ + async getById(id: string): Promise { + const key = buildParticipantKey(id); + const item = await this.get(key); + return this.toParticipant(item); + } + + /** List participants, optionally filtered by status (GSI1) or cohort (GSI2). */ + async list(options: ListParticipantsOptions = {}): Promise { + const { status, cohort, limit, exclusiveStartKey } = options; + + let result: QueryResult; + + if (status) { + result = await this.queryIndex( + 'GSI1', + 'GSI1PK', + `STATUS#${status}`, + undefined, + undefined, + { limit, exclusiveStartKey }, + ); + } else if (cohort) { + result = await this.queryIndex( + 'GSI2', + 'GSI2PK', + `COHORT#${cohort}`, + undefined, + undefined, + { limit, exclusiveStartKey }, + ); + } else { + // Full scan is deliberately NOT implemented — require a filter. + // For a small dataset we can query GSI1 with status = 'active' as default. + result = await this.queryIndex( + 'GSI1', + 'GSI1PK', + 'STATUS#active', + undefined, + undefined, + { limit, exclusiveStartKey }, + ); + } + + return { + items: result.items.map((i) => this.toParticipant(i)), + lastEvaluatedKey: result.lastEvaluatedKey, + }; + } + + /** Partial update of participant fields. Updates GSI keys when status or cohort change. */ + async updateById(id: string, updates: Partial>): Promise { + const key = buildParticipantKey(id); + + // Re-derive GSI keys if status or cohort are changing + const gsiUpdates: Record = {}; + if (updates.status) { + const gsi1 = buildStatusGSIKeys(updates.status, id); + Object.assign(gsiUpdates, gsi1); + } + if (updates.cohort) { + const gsi2 = buildCohortGSIKeys(updates.cohort, id); + Object.assign(gsiUpdates, gsi2); + } + + const merged = { ...updates, ...gsiUpdates }; + const item = await this.update(key, merged); + logger.info('Participant updated', { id, fields: Object.keys(updates) }); + return this.toParticipant(item); + } + + /** + * Cascade-delete a participant and all their related items. + * Queries all SK values under the participant PK, then deletes in a transaction. + */ + async deleteById(id: string): Promise { + const pk = participantPK(id); + + // First verify participant exists + await this.getById(id); + + // Gather all items under this PK + const allKeys: TableKey[] = []; + let lastKey: Record | undefined; + + do { + const page = await this.query(pk, undefined, { exclusiveStartKey: lastKey, limit: 100 }); + for (const item of page.items) { + allKeys.push({ PK: item.PK as string, SK: item.SK as string }); + } + lastKey = page.lastEvaluatedKey; + } while (lastKey); + + if (allKeys.length === 0) { + throw new NotFoundError('Participant', { PK: pk, SK: 'PROFILE' }); + } + + // DynamoDB transactions max out at 100 items; chunk if needed + const CHUNK = 100; + for (let i = 0; i < allKeys.length; i += CHUNK) { + const chunk = allKeys.slice(i, i + CHUNK); + await this.transactWrite( + chunk.map((key) => ({ type: 'delete' as const, key })), + ); + } + + logger.info('Participant cascade-deleted', { id, itemsRemoved: allKeys.length }); + } + + // ----------------------------------------------------------------------- + // Mapper + // ----------------------------------------------------------------------- + private toParticipant(item: ParticipantItem): Participant { + return { + id: item.id, + externalId: item.externalId, + firstName: item.firstName, + lastName: item.lastName, + dateOfBirth: item.dateOfBirth, + enrollmentDate: item.enrollmentDate, + cohort: item.cohort, + status: item.status as Participant['status'], + metadata: item.metadata, + }; + } +} + +export const participantRepository = new ParticipantRepository(); diff --git a/src/backend/src/db/repository.ts b/src/backend/src/db/repository.ts new file mode 100644 index 0000000..5e6a706 --- /dev/null +++ b/src/backend/src/db/repository.ts @@ -0,0 +1,450 @@ +import { + PutCommand, + GetCommand, + QueryCommand, + UpdateCommand, + DeleteCommand, + BatchGetCommand, + TransactWriteCommand, + type TransactWriteCommandInput, +} from '@aws-sdk/lib-dynamodb'; +import { docClient, TABLE_NAME } from './client'; +import { type TableKey } from './keys'; +import { ConflictError, DatabaseError, NotFoundError, ConditionFailedError } from './errors'; +import { logger } from '../utils/logger'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface QueryOptions { + limit?: number; + scanForward?: boolean; + exclusiveStartKey?: Record; + filterExpression?: string; + filterValues?: Record; + filterNames?: Record; +} + +export interface QueryResult { + items: T[]; + lastEvaluatedKey?: Record; +} + +export type TransactOperation = + | { type: 'put'; item: Record; conditionExpression?: string } + | { type: 'update'; key: TableKey; updates: Record } + | { type: 'delete'; key: TableKey }; + +// --------------------------------------------------------------------------- +// BaseRepository +// --------------------------------------------------------------------------- + +export class BaseRepository> { + protected readonly entityName: string; + + constructor(entityName: string) { + this.entityName = entityName; + } + + // ----------------------------------------------------------------------- + // put — insert with optional duplicate guard + // ----------------------------------------------------------------------- + async put(item: T, preventOverwrite = true): Promise { + const pk = (item as Record).PK as string; + const sk = (item as Record).SK as string; + try { + await docClient.send( + new PutCommand({ + TableName: TABLE_NAME, + Item: item, + ...(preventOverwrite && { + ConditionExpression: 'attribute_not_exists(PK)', + }), + }), + ); + logger.debug(`${this.entityName} put`, { PK: pk, SK: sk }); + } catch (err: unknown) { + if (this.isConditionalCheckFailed(err)) { + throw new ConflictError(this.entityName, { PK: pk, SK: sk }); + } + throw this.wrap('put', err); + } + } + + // ----------------------------------------------------------------------- + // get — single item by key + // ----------------------------------------------------------------------- + async get(key: TableKey): Promise { + try { + const result = await docClient.send( + new GetCommand({ + TableName: TABLE_NAME, + Key: key, + }), + ); + if (!result.Item) { + throw new NotFoundError(this.entityName, { ...key }); + } + logger.debug(`${this.entityName} get`, { PK: key.PK, SK: key.SK }); + return result.Item as T; + } catch (err) { + if (err instanceof NotFoundError) throw err; + throw this.wrap('get', err); + } + } + + // ----------------------------------------------------------------------- + // query — by PK, optional SK prefix, filtering, pagination + // ----------------------------------------------------------------------- + async query(pk: string, skPrefix?: string, options: QueryOptions = {}): Promise> { + const { + limit, + scanForward = true, + exclusiveStartKey, + filterExpression, + filterValues, + filterNames, + } = options; + + const expressionNames: Record = { '#pk': 'PK' }; + const expressionValues: Record = { ':pk': pk }; + let keyCondition = '#pk = :pk'; + + if (skPrefix) { + expressionNames['#sk'] = 'SK'; + expressionValues[':skPrefix'] = skPrefix; + keyCondition += ' AND begins_with(#sk, :skPrefix)'; + } + + try { + const result = await docClient.send( + new QueryCommand({ + TableName: TABLE_NAME, + KeyConditionExpression: keyCondition, + ExpressionAttributeNames: { + ...expressionNames, + ...filterNames, + }, + ExpressionAttributeValues: { + ...expressionValues, + ...filterValues, + }, + ScanIndexForward: scanForward, + ...(limit && { Limit: limit }), + ...(exclusiveStartKey && { ExclusiveStartKey: exclusiveStartKey }), + ...(filterExpression && { FilterExpression: filterExpression }), + }), + ); + + logger.debug(`${this.entityName} query`, { pk, skPrefix, count: result.Items?.length ?? 0 }); + return { + items: (result.Items ?? []) as T[], + lastEvaluatedKey: result.LastEvaluatedKey as Record | undefined, + }; + } catch (err) { + throw this.wrap('query', err); + } + } + + // ----------------------------------------------------------------------- + // queryIndex — query a GSI + // ----------------------------------------------------------------------- + async queryIndex( + indexName: string, + pkName: string, + pkValue: string, + skName?: string, + skPrefix?: string, + options: QueryOptions = {}, + ): Promise> { + const { limit, scanForward = true, exclusiveStartKey } = options; + + const expressionNames: Record = { '#gsiPk': pkName }; + const expressionValues: Record = { ':gsiPk': pkValue }; + let keyCondition = '#gsiPk = :gsiPk'; + + if (skName && skPrefix) { + expressionNames['#gsiSk'] = skName; + expressionValues[':gsiSkPrefix'] = skPrefix; + keyCondition += ' AND begins_with(#gsiSk, :gsiSkPrefix)'; + } + + try { + const result = await docClient.send( + new QueryCommand({ + TableName: TABLE_NAME, + IndexName: indexName, + KeyConditionExpression: keyCondition, + ExpressionAttributeNames: expressionNames, + ExpressionAttributeValues: expressionValues, + ScanIndexForward: scanForward, + ...(limit && { Limit: limit }), + ...(exclusiveStartKey && { ExclusiveStartKey: exclusiveStartKey }), + }), + ); + + logger.debug(`${this.entityName} queryIndex`, { indexName, pkValue, count: result.Items?.length ?? 0 }); + return { + items: (result.Items ?? []) as T[], + lastEvaluatedKey: result.LastEvaluatedKey as Record | undefined, + }; + } catch (err) { + throw this.wrap('queryIndex', err); + } + } + + // ----------------------------------------------------------------------- + // queryRange — query with SK between two values + // ----------------------------------------------------------------------- + async queryRange( + pk: string, + skStart: string, + skEnd: string, + options: QueryOptions = {}, + ): Promise> { + const { limit, scanForward = true, exclusiveStartKey } = options; + + try { + const result = await docClient.send( + new QueryCommand({ + TableName: TABLE_NAME, + KeyConditionExpression: '#pk = :pk AND #sk BETWEEN :skStart AND :skEnd', + ExpressionAttributeNames: { '#pk': 'PK', '#sk': 'SK' }, + ExpressionAttributeValues: { ':pk': pk, ':skStart': skStart, ':skEnd': skEnd }, + ScanIndexForward: scanForward, + ...(limit && { Limit: limit }), + ...(exclusiveStartKey && { ExclusiveStartKey: exclusiveStartKey }), + }), + ); + + logger.debug(`${this.entityName} queryRange`, { pk, skStart, skEnd, count: result.Items?.length ?? 0 }); + return { + items: (result.Items ?? []) as T[], + lastEvaluatedKey: result.LastEvaluatedKey as Record | undefined, + }; + } catch (err) { + throw this.wrap('queryRange', err); + } + } + + // ----------------------------------------------------------------------- + // update — partial update with expression builder + // ----------------------------------------------------------------------- + async update(key: TableKey, updates: Record): Promise { + const entries = Object.entries(updates).filter( + ([k]) => k !== 'PK' && k !== 'SK', + ); + + if (entries.length === 0) { + return this.get(key); + } + + const expressionParts: string[] = []; + const expressionNames: Record = {}; + const expressionValues: Record = {}; + + for (const [field, value] of entries) { + const safeAlias = field.replace(/[^a-zA-Z0-9]/g, '_'); + expressionParts.push(`#${safeAlias} = :${safeAlias}`); + expressionNames[`#${safeAlias}`] = field; + expressionValues[`:${safeAlias}`] = value; + } + + // Always bump updated_at + expressionParts.push('#updatedAt = :updatedAt'); + expressionNames['#updatedAt'] = 'updated_at'; + expressionValues[':updatedAt'] = new Date().toISOString(); + + try { + const result = await docClient.send( + new UpdateCommand({ + TableName: TABLE_NAME, + Key: key, + UpdateExpression: `SET ${expressionParts.join(', ')}`, + ExpressionAttributeNames: expressionNames, + ExpressionAttributeValues: expressionValues, + ConditionExpression: 'attribute_exists(PK)', + ReturnValues: 'ALL_NEW', + }), + ); + + logger.debug(`${this.entityName} update`, { PK: key.PK, SK: key.SK, fields: entries.map(([k]) => k) }); + return result.Attributes as T; + } catch (err: unknown) { + if (this.isConditionalCheckFailed(err)) { + throw new NotFoundError(this.entityName, { ...key }); + } + throw this.wrap('update', err); + } + } + + // ----------------------------------------------------------------------- + // delete — single item + // ----------------------------------------------------------------------- + async delete(key: TableKey): Promise { + try { + await docClient.send( + new DeleteCommand({ + TableName: TABLE_NAME, + Key: key, + ConditionExpression: 'attribute_exists(PK)', + }), + ); + logger.debug(`${this.entityName} delete`, { PK: key.PK, SK: key.SK }); + } catch (err: unknown) { + if (this.isConditionalCheckFailed(err)) { + throw new NotFoundError(this.entityName, { ...key }); + } + throw this.wrap('delete', err); + } + } + + // ----------------------------------------------------------------------- + // batchGet — up to 100 items + // ----------------------------------------------------------------------- + async batchGet(keys: TableKey[]): Promise { + if (keys.length === 0) return []; + + const BATCH_SIZE = 100; + const results: T[] = []; + + for (let i = 0; i < keys.length; i += BATCH_SIZE) { + const batch = keys.slice(i, i + BATCH_SIZE); + try { + const result = await docClient.send( + new BatchGetCommand({ + RequestItems: { + [TABLE_NAME]: { Keys: batch }, + }, + }), + ); + + const items = result.Responses?.[TABLE_NAME] ?? []; + results.push(...(items as T[])); + + // Handle unprocessed keys with retry + let unprocessed = result.UnprocessedKeys?.[TABLE_NAME]?.Keys; + let retries = 0; + while (unprocessed && unprocessed.length > 0 && retries < 3) { + retries++; + const retry = await docClient.send( + new BatchGetCommand({ + RequestItems: { + [TABLE_NAME]: { Keys: unprocessed }, + }, + }), + ); + const retryItems = retry.Responses?.[TABLE_NAME] ?? []; + results.push(...(retryItems as T[])); + unprocessed = retry.UnprocessedKeys?.[TABLE_NAME]?.Keys; + } + + if (unprocessed && unprocessed.length > 0) { + logger.warn(`${this.entityName} batchGet: ${unprocessed.length} unprocessed keys after retries`); + } + } catch (err) { + throw this.wrap('batchGet', err); + } + } + + logger.debug(`${this.entityName} batchGet`, { requested: keys.length, returned: results.length }); + return results; + } + + // ----------------------------------------------------------------------- + // transactWrite — multi-item atomic writes (max 100 operations) + // ----------------------------------------------------------------------- + async transactWrite(operations: TransactOperation[]): Promise { + if (operations.length === 0) return; + if (operations.length > 100) { + throw new DatabaseError('TransactWrite supports a maximum of 100 operations'); + } + + const transactItems: TransactWriteCommandInput['TransactItems'] = operations.map((op) => { + switch (op.type) { + case 'put': + return { + Put: { + TableName: TABLE_NAME, + Item: op.item, + ...(op.conditionExpression && { + ConditionExpression: op.conditionExpression, + }), + }, + }; + case 'update': { + const entries = Object.entries(op.updates).filter( + ([k]) => k !== 'PK' && k !== 'SK', + ); + const parts: string[] = []; + const names: Record = {}; + const values: Record = {}; + for (const [field, value] of entries) { + const alias = field.replace(/[^a-zA-Z0-9]/g, '_'); + parts.push(`#${alias} = :${alias}`); + names[`#${alias}`] = field; + values[`:${alias}`] = value; + } + return { + Update: { + TableName: TABLE_NAME, + Key: op.key, + UpdateExpression: `SET ${parts.join(', ')}`, + ExpressionAttributeNames: names, + ExpressionAttributeValues: values, + }, + }; + } + case 'delete': + return { + Delete: { + TableName: TABLE_NAME, + Key: op.key, + }, + }; + } + }); + + try { + await docClient.send( + new TransactWriteCommand({ TransactItems: transactItems }), + ); + logger.debug(`${this.entityName} transactWrite`, { operationCount: operations.length }); + } catch (err: unknown) { + if (this.isTransactionCanceled(err)) { + throw new ConditionFailedError( + `${this.entityName} transaction canceled: one or more conditions failed`, + ); + } + throw this.wrap('transactWrite', err); + } + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + private isConditionalCheckFailed(err: unknown): boolean { + return ( + typeof err === 'object' && + err !== null && + 'name' in err && + (err as { name: string }).name === 'ConditionalCheckFailedException' + ); + } + + private isTransactionCanceled(err: unknown): boolean { + return ( + typeof err === 'object' && + err !== null && + 'name' in err && + (err as { name: string }).name === 'TransactionCanceledException' + ); + } + + protected wrap(operation: string, err: unknown): DatabaseError { + const message = err instanceof Error ? err.message : String(err); + logger.error(`${this.entityName}.${operation} failed`, { error: message }); + return new DatabaseError(`${this.entityName}.${operation}: ${message}`, err); + } +} diff --git a/src/backend/src/index.ts b/src/backend/src/index.ts index f64dbcd..9cf2767 100644 --- a/src/backend/src/index.ts +++ b/src/backend/src/index.ts @@ -1,7 +1,12 @@ -import express from 'express'; +import express, { Request, Response, NextFunction } from 'express'; import cors from 'cors'; +import helmet from 'helmet'; +import compression from 'compression'; +import { v4 as uuidv4 } from 'uuid'; import { logger } from './utils/logger'; import { authMiddleware } from './middleware/auth'; +import { swaggerRouter } from './openapi/setup'; +import { initTracing, shutdownTracing, metricsMiddleware, healthRouter as observabilityHealthRouter } from './observability'; import participantsRouter from './routes/participants'; import observationsRouter from './routes/observations'; @@ -10,63 +15,135 @@ import healthRouter from './routes/health'; import lifespanRouter from './routes/lifespan'; import cognitiveRouter from './routes/cognitive'; import interventionsRouter from './routes/interventions'; +import insightsRouter from './routes/insights'; +// Initialize OpenTelemetry tracing before anything else +if (process.env.OTEL_ENABLED !== 'false') { + initTracing(); +} + +// --------------------------------------------------------------------------- +// Environment validation +// --------------------------------------------------------------------------- +const REQUIRED_ENV_VARS = ['PORT']; +const OPTIONAL_ENV_VARS = ['CORS_ORIGIN', 'LOG_LEVEL', 'JWT_SECRET']; + +function validateEnvironment(): void { + const missing = REQUIRED_ENV_VARS.filter((v) => !process.env[v]); + if (missing.length > 0) { + logger.warn(`Missing recommended env vars: ${missing.join(', ')}. Using defaults.`); + } +} + +validateEnvironment(); + +// --------------------------------------------------------------------------- +// App setup +// --------------------------------------------------------------------------- const app = express(); -const PORT = process.env.PORT || 3001; +const PORT = parseInt(process.env.PORT || '3001', 10); + +// --------------------------------------------------------------------------- +// Security headers +// --------------------------------------------------------------------------- +app.use(helmet()); // --------------------------------------------------------------------------- -// Global middleware +// CORS - restrict origins via env // --------------------------------------------------------------------------- -app.use(cors()); -app.use(express.json()); +const corsOrigin = process.env.CORS_ORIGIN + ? process.env.CORS_ORIGIN.split(',').map((o) => o.trim()) + : undefined; // undefined = same-origin only in production; tests may override + +app.use( + cors({ + origin: corsOrigin || false, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'], + credentials: true, + }), +); + +// --------------------------------------------------------------------------- +// Body parsing with size limit +// --------------------------------------------------------------------------- +app.use(express.json({ limit: '1mb' })); + +// --------------------------------------------------------------------------- +// Response compression +// --------------------------------------------------------------------------- +app.use(compression()); + +// --------------------------------------------------------------------------- +// Request ID middleware +// --------------------------------------------------------------------------- +app.use((req: Request, res: Response, next: NextFunction) => { + const requestId = (req.headers['x-request-id'] as string) || uuidv4(); + req.requestId = requestId; + res.setHeader('X-Request-ID', requestId); + next(); +}); // --------------------------------------------------------------------------- -// Health check (unauthenticated) +// Request logging middleware // --------------------------------------------------------------------------- +app.use((req: Request, res: Response, next: NextFunction) => { + const start = Date.now(); -/** - * GET /api/health - * Simple health-check endpoint for readiness probes. - */ -app.get('/api/health', (_req, res) => { - res.json({ - status: 'ok', - service: 'wellab-api', - version: '0.1.0', - modules: [ - 'emotional-dynamics', - 'health', - 'lifespan-trajectory', - 'cognitive-health', - ], - timestamp: new Date().toISOString(), + res.on('finish', () => { + const duration = Date.now() - start; + logger.info('request', { + method: req.method, + url: req.originalUrl, + status: res.statusCode, + duration: `${duration}ms`, + requestId: req.requestId, + }); }); + + next(); }); // --------------------------------------------------------------------------- -// Auth middleware (applied to all /api routes below) +// Metrics middleware +// --------------------------------------------------------------------------- +app.use(metricsMiddleware); + +// --------------------------------------------------------------------------- +// API Documentation (unauthenticated) // --------------------------------------------------------------------------- -app.use('/api', authMiddleware); +app.use('/api/docs', swaggerRouter); // --------------------------------------------------------------------------- -// Route registration +// Health & readiness probes (unauthenticated) // --------------------------------------------------------------------------- -app.use('/api/participants', participantsRouter); -app.use('/api', observationsRouter); -app.use('/api', emotionalDynamicsRouter); -app.use('/api', healthRouter); -app.use('/api', lifespanRouter); -app.use('/api', cognitiveRouter); -app.use('/api/interventions', interventionsRouter); +app.use('/api', observabilityHealthRouter); + +// --------------------------------------------------------------------------- +// Auth middleware (applied to all /api/v1 routes below) +// --------------------------------------------------------------------------- +app.use('/api/v1', authMiddleware); + +// --------------------------------------------------------------------------- +// Route registration (all under /api/v1) +// --------------------------------------------------------------------------- +app.use('/api/v1/participants', participantsRouter); +app.use('/api/v1', observationsRouter); +app.use('/api/v1', emotionalDynamicsRouter); +app.use('/api/v1', healthRouter); +app.use('/api/v1', lifespanRouter); +app.use('/api/v1', cognitiveRouter); +app.use('/api/v1/interventions', interventionsRouter); +app.use('/api/v1', insightsRouter); // The interventions router also exposes a participant-scoped GET, so mount it -// at the top level /api as well for the /participants/:id/interventions path. -app.use('/api', interventionsRouter); +// at the top level /api/v1 as well for the /participants/:id/interventions path. +app.use('/api/v1', interventionsRouter); // --------------------------------------------------------------------------- // 404 fallback // --------------------------------------------------------------------------- -app.use((_req, res) => { +app.use((_req: Request, res: Response) => { res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Endpoint not found' }, @@ -74,11 +151,54 @@ app.use((_req, res) => { }); // --------------------------------------------------------------------------- -// Start server +// Global error handler +// --------------------------------------------------------------------------- +app.use((err: Error, req: Request, res: Response, _next: NextFunction) => { + logger.error('Unhandled error', { + message: err.message, + stack: err.stack, + method: req.method, + url: req.originalUrl, + requestId: req.requestId, + }); + + res.status(500).json({ + success: false, + error: { + code: 'INTERNAL_SERVER_ERROR', + message: 'An unexpected error occurred', + requestId: req.requestId, + }, + }); +}); + +// --------------------------------------------------------------------------- +// Start server with graceful shutdown // --------------------------------------------------------------------------- -app.listen(PORT, () => { +const server = app.listen(PORT, () => { logger.info(`WELLab API server running on port ${PORT}`); - logger.info('Registered modules: Emotional Dynamics, Health, Lifespan Trajectory, Cognitive Health'); + logger.info('API base path: /api/v1'); + logger.info( + 'Registered modules: Emotional Dynamics, Health, Lifespan Trajectory, Cognitive Health, AI Insights', + ); }); +async function gracefulShutdown(signal: string): Promise { + logger.info(`${signal} received. Starting graceful shutdown...`); + await shutdownTracing(); + server.close(() => { + logger.info('HTTP server closed. Exiting.'); + process.exit(0); + }); + + // Force exit after 30 seconds if connections are not drained + setTimeout(() => { + logger.error('Graceful shutdown timed out. Forcing exit.'); + process.exit(1); + }, 30_000); +} + +process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); +process.on('SIGINT', () => gracefulShutdown('SIGINT')); + export default app; diff --git a/src/backend/src/middleware/auth.ts b/src/backend/src/middleware/auth.ts index abd8177..a1f13a5 100644 --- a/src/backend/src/middleware/auth.ts +++ b/src/backend/src/middleware/auth.ts @@ -1,10 +1,31 @@ import { Request, Response, NextFunction } from 'express'; import { logger } from '../utils/logger'; +import { UserPayload } from '../types/express'; /** - * Authentication middleware stub. - * Checks for a Bearer token in the Authorization header. - * Currently performs placeholder validation -- replace with real JWT/OAuth verification. + * Decode a Base64url-encoded JWT payload (middle segment). + * Does NOT verify the signature -- real deployments should use a + * library such as `jsonwebtoken` with a proper secret/key. + */ +function decodeJwtPayload(token: string): UserPayload | null { + try { + const parts = token.split('.'); + if (parts.length !== 3) return null; + + const payload = parts[1]; + // Base64url -> Base64 + const base64 = payload.replace(/-/g, '+').replace(/_/g, '/'); + const json = Buffer.from(base64, 'base64').toString('utf-8'); + return JSON.parse(json) as UserPayload; + } catch { + return null; + } +} + +/** + * Authentication middleware. + * Validates the JWT structure, checks expiration, and attaches the decoded + * user payload to `req.user`. */ export function authMiddleware(req: Request, res: Response, next: NextFunction): void { const authHeader = req.headers.authorization; @@ -29,10 +50,7 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction): } const token = parts[1]; - - // Placeholder validation: accept any non-empty token. - // TODO: Replace with real JWT verification or OAuth token introspection. - if (!token || token.length < 1) { + if (!token) { logger.warn('Empty bearer token', { path: req.path }); res.status(401).json({ success: false, @@ -41,6 +59,75 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction): return; } - logger.debug('Auth passed (placeholder)', { path: req.path }); + // Decode and validate the JWT payload + const payload = decodeJwtPayload(token); + if (!payload) { + logger.warn('Failed to decode JWT', { path: req.path }); + res.status(401).json({ + success: false, + error: { code: 'UNAUTHORIZED', message: 'Malformed JWT token' }, + }); + return; + } + + // Validate required claims + if (!payload.sub || !payload.exp || !payload.iss) { + logger.warn('JWT missing required claims', { path: req.path }); + res.status(401).json({ + success: false, + error: { code: 'UNAUTHORIZED', message: 'Token missing required claims (sub, exp, iss)' }, + }); + return; + } + + // Check expiration + const nowSeconds = Math.floor(Date.now() / 1000); + if (payload.exp < nowSeconds) { + logger.warn('JWT expired', { path: req.path, exp: payload.exp }); + res.status(401).json({ + success: false, + error: { code: 'UNAUTHORIZED', message: 'Token has expired' }, + }); + return; + } + + // Attach user to request + req.user = payload; + + logger.debug('Auth passed', { path: req.path, sub: payload.sub, role: payload.role }); next(); } + +/** + * Role-based access control middleware factory. + * Must be used AFTER `authMiddleware` so that `req.user` is populated. + */ +export function requireRole(...roles: Array<'researcher' | 'participant' | 'admin'>) { + return (req: Request, res: Response, next: NextFunction): void => { + if (!req.user) { + res.status(401).json({ + success: false, + error: { code: 'UNAUTHORIZED', message: 'Authentication required' }, + }); + return; + } + + if (!roles.includes(req.user.role)) { + logger.warn('Insufficient role', { + path: req.path, + required: roles, + actual: req.user.role, + }); + res.status(403).json({ + success: false, + error: { + code: 'FORBIDDEN', + message: `Requires one of the following roles: ${roles.join(', ')}`, + }, + }); + return; + } + + next(); + }; +} diff --git a/src/backend/src/middleware/validation.ts b/src/backend/src/middleware/validation.ts index 72e2cff..6895e7e 100644 --- a/src/backend/src/middleware/validation.ts +++ b/src/backend/src/middleware/validation.ts @@ -1,65 +1,66 @@ import { Request, Response, NextFunction } from 'express'; -import { ValidationSchema } from '../types'; +import { ZodSchema, ZodError } from 'zod'; /** - * Creates a request body validation middleware from a simple schema definition. - * Checks required fields and basic type constraints. - * - * @param schema - Validation schema specifying required fields and expected types - * @returns Express middleware that validates req.body against the schema + * Format Zod validation errors into a human-readable list. */ -export function validateBody(schema: ValidationSchema) { +function formatZodErrors(error: ZodError): string[] { + return error.issues.map((issue) => { + const path = issue.path.length > 0 ? issue.path.join('.') : '(root)'; + return `${path}: ${issue.message}`; + }); +} + +/** + * Creates a middleware that validates `req.body` against a Zod schema. + * On success the parsed (and potentially transformed) body replaces `req.body`. + */ +export function validateBody(schema: ZodSchema) { return (req: Request, res: Response, next: NextFunction): void => { - const body = req.body; + const result = schema.safeParse(req.body); - if (!body || typeof body !== 'object') { + if (!result.success) { + const details = formatZodErrors(result.error); res.status(400).json({ success: false, - error: { code: 'VALIDATION_ERROR', message: 'Request body must be a JSON object' }, + error: { + code: 'VALIDATION_ERROR', + message: `Request body validation failed: ${details.join('; ')}`, + details, + }, }); return; } - // Check required fields - if (schema.required) { - const missing = schema.required.filter((field) => !(field in body)); - if (missing.length > 0) { - res.status(400).json({ - success: false, - error: { - code: 'VALIDATION_ERROR', - message: `Missing required fields: ${missing.join(', ')}`, - details: { missingFields: missing }, - }, - }); - return; - } - } + // Replace body with the parsed & coerced output + req.body = result.data; + next(); + }; +} - // Check types - if (schema.types) { - const typeErrors: string[] = []; - for (const [field, expectedType] of Object.entries(schema.types)) { - if (!(field in body)) continue; // skip missing optional fields - const value = body[field]; - const actualType = Array.isArray(value) ? 'array' : typeof value; - if (actualType !== expectedType) { - typeErrors.push(`${field}: expected ${expectedType}, got ${actualType}`); - } - } - if (typeErrors.length > 0) { - res.status(400).json({ - success: false, - error: { - code: 'VALIDATION_ERROR', - message: `Type errors: ${typeErrors.join('; ')}`, - details: { typeErrors }, - }, - }); - return; - } +/** + * Creates a middleware that validates `req.query` against a Zod schema. + * On success the parsed query replaces `req.query`. + */ +export function validateQuery(schema: ZodSchema) { + return (req: Request, res: Response, next: NextFunction): void => { + const result = schema.safeParse(req.query); + + if (!result.success) { + const details = formatZodErrors(result.error); + res.status(400).json({ + success: false, + error: { + code: 'VALIDATION_ERROR', + message: `Query parameter validation failed: ${details.join('; ')}`, + details, + }, + }); + return; } + // Overwrite query with parsed values + req.query = result.data; next(); }; } diff --git a/src/backend/src/observability/health.ts b/src/backend/src/observability/health.ts new file mode 100644 index 0000000..15d2d02 --- /dev/null +++ b/src/backend/src/observability/health.ts @@ -0,0 +1,92 @@ +import { Request, Response, Router } from 'express'; +import { logger } from '../utils/logger'; + +interface HealthCheckResult { + name: string; + status: 'healthy' | 'degraded' | 'unhealthy'; + latencyMs?: number; + message?: string; +} + +interface HealthResponse { + status: 'healthy' | 'degraded' | 'unhealthy'; + version: string; + uptime: number; + timestamp: string; + checks: HealthCheckResult[]; +} + +async function checkDynamoDB(): Promise { + const start = Date.now(); + try { + // In production, use DescribeTable to verify connectivity + // For now, verify env var is set + const tableName = process.env.DYNAMODB_TABLE_NAME; + if (!tableName) { + return { name: 'dynamodb', status: 'degraded', message: 'DYNAMODB_TABLE_NAME not configured' }; + } + return { name: 'dynamodb', status: 'healthy', latencyMs: Date.now() - start }; + } catch (err) { + return { name: 'dynamodb', status: 'unhealthy', latencyMs: Date.now() - start, message: (err as Error).message }; + } +} + +async function checkMemory(): Promise { + const used = process.memoryUsage(); + const heapUsedMB = Math.round(used.heapUsed / 1024 / 1024); + const heapTotalMB = Math.round(used.heapTotal / 1024 / 1024); + const threshold = parseInt(process.env.HEAP_THRESHOLD_MB || '512', 10); + + if (heapUsedMB > threshold) { + return { name: 'memory', status: 'degraded', message: `Heap ${heapUsedMB}MB / ${heapTotalMB}MB (threshold: ${threshold}MB)` }; + } + return { name: 'memory', status: 'healthy', message: `Heap ${heapUsedMB}MB / ${heapTotalMB}MB` }; +} + +async function checkClaudeApi(): Promise { + const apiKey = process.env.ANTHROPIC_API_KEY; + if (!apiKey || apiKey === 'YOUR_KEY_HERE') { + return { name: 'claude-api', status: 'degraded', message: 'ANTHROPIC_API_KEY not configured' }; + } + return { name: 'claude-api', status: 'healthy' }; +} + +async function runChecks(): Promise { + const checks = await Promise.all([checkDynamoDB(), checkMemory(), checkClaudeApi()]); + + const hasUnhealthy = checks.some((c) => c.status === 'unhealthy'); + const hasDegraded = checks.some((c) => c.status === 'degraded'); + const overallStatus = hasUnhealthy ? 'unhealthy' : hasDegraded ? 'degraded' : 'healthy'; + + return { + status: overallStatus, + version: '1.0.0', + uptime: process.uptime(), + timestamp: new Date().toISOString(), + checks, + }; +} + +const router = Router(); + +/** Deep health check — verifies all dependencies */ +router.get('/health', async (_req: Request, res: Response) => { + const result = await runChecks(); + const statusCode = result.status === 'healthy' ? 200 : result.status === 'degraded' ? 200 : 503; + logger.info('Health check', { status: result.status }); + res.status(statusCode).json(result); +}); + +/** Liveness probe — is the process alive? */ +router.get('/live', (_req: Request, res: Response) => { + res.status(200).json({ status: 'alive', timestamp: new Date().toISOString() }); +}); + +/** Readiness probe — can the service handle traffic? */ +router.get('/ready', async (_req: Request, res: Response) => { + const result = await runChecks(); + const ready = result.status !== 'unhealthy'; + res.status(ready ? 200 : 503).json({ ready, status: result.status, timestamp: new Date().toISOString() }); +}); + +export { router as healthRouter }; diff --git a/src/backend/src/observability/index.ts b/src/backend/src/observability/index.ts new file mode 100644 index 0000000..e68b345 --- /dev/null +++ b/src/backend/src/observability/index.ts @@ -0,0 +1,3 @@ +export { initTracing, shutdownTracing } from './tracing'; +export { metricsMiddleware, apiRequestsTotal, apiRequestDuration, claudeApiCallsTotal, emaObservationsTotal, fairnessViolationsTotal, mlInferenceDuration } from './metrics'; +export { healthRouter } from './health'; diff --git a/src/backend/src/observability/metrics.ts b/src/backend/src/observability/metrics.ts new file mode 100644 index 0000000..dad54a9 --- /dev/null +++ b/src/backend/src/observability/metrics.ts @@ -0,0 +1,60 @@ +import { Request, Response, NextFunction } from 'express'; +import { metrics, Counter, Histogram, ValueType } from '@opentelemetry/api'; + +const meter = metrics.getMeter('wellab-api', '1.0.0'); + +// --- Counters --- + +export const apiRequestsTotal: Counter = meter.createCounter('api_requests_total', { + description: 'Total number of API requests', + unit: '1', +}); + +export const claudeApiCallsTotal: Counter = meter.createCounter('claude_api_calls_total', { + description: 'Total number of Claude API calls', + unit: '1', +}); + +export const emaObservationsTotal: Counter = meter.createCounter('ema_observations_total', { + description: 'Total EMA observations recorded', + unit: '1', +}); + +export const fairnessViolationsTotal: Counter = meter.createCounter('fairness_audit_violations_total', { + description: 'Total fairness audit violations detected', + unit: '1', +}); + +// --- Histograms --- + +export const apiRequestDuration: Histogram = meter.createHistogram('api_request_duration_seconds', { + description: 'API request duration in seconds', + unit: 's', + valueType: ValueType.DOUBLE, +}); + +export const mlInferenceDuration: Histogram = meter.createHistogram('ml_inference_duration_seconds', { + description: 'ML model inference duration in seconds', + unit: 's', + valueType: ValueType.DOUBLE, +}); + +// --- Middleware --- + +export function metricsMiddleware(req: Request, res: Response, next: NextFunction): void { + const start = process.hrtime.bigint(); + + res.on('finish', () => { + const durationNs = Number(process.hrtime.bigint() - start); + const durationSec = durationNs / 1e9; + + const route = req.route?.path || req.path; + const method = req.method; + const status = res.statusCode.toString(); + + apiRequestsTotal.add(1, { method, route, status }); + apiRequestDuration.record(durationSec, { method, route, status }); + }); + + next(); +} diff --git a/src/backend/src/observability/tracing.ts b/src/backend/src/observability/tracing.ts new file mode 100644 index 0000000..2c766d7 --- /dev/null +++ b/src/backend/src/observability/tracing.ts @@ -0,0 +1,56 @@ +import { NodeSDK } from '@opentelemetry/sdk-node'; +import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; +import { ConsoleSpanExporter, BatchSpanProcessor, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-node'; +import { Resource } from '@opentelemetry/resources'; +import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions'; +import { diag, DiagConsoleLogger, DiagLogLevel } from '@opentelemetry/api'; +import { logger } from '../utils/logger'; + +const isDev = process.env.NODE_ENV !== 'production'; + +if (isDev) { + diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.INFO); +} + +let sdk: NodeSDK | null = null; + +export function initTracing(): void { + const otlpEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318'; + + const resource = new Resource({ + [ATTR_SERVICE_NAME]: 'wellab-api', + [ATTR_SERVICE_VERSION]: '1.0.0', + 'deployment.environment': process.env.NODE_ENV || 'development', + }); + + const spanProcessor = isDev + ? new SimpleSpanProcessor(new ConsoleSpanExporter()) + : new BatchSpanProcessor(new OTLPTraceExporter({ url: `${otlpEndpoint}/v1/traces` })); + + sdk = new NodeSDK({ + resource, + spanProcessors: [spanProcessor], + instrumentations: [ + getNodeAutoInstrumentations({ + '@opentelemetry/instrumentation-express': { enabled: true }, + '@opentelemetry/instrumentation-http': { enabled: true }, + '@opentelemetry/instrumentation-aws-sdk': { enabled: true }, + }), + ], + }); + + sdk.start(); + logger.info('OpenTelemetry tracing initialized', { endpoint: otlpEndpoint, env: process.env.NODE_ENV }); +} + +export async function shutdownTracing(): Promise { + if (sdk) { + try { + await sdk.shutdown(); + logger.info('OpenTelemetry tracing shut down'); + } catch (err) { + logger.error('Error shutting down tracing', { error: (err as Error).message }); + } + } +} diff --git a/src/backend/src/openapi/setup.ts b/src/backend/src/openapi/setup.ts new file mode 100644 index 0000000..8a0486c --- /dev/null +++ b/src/backend/src/openapi/setup.ts @@ -0,0 +1,24 @@ +import swaggerUi from 'swagger-ui-express'; +import { Router } from 'express'; +import { openApiSpec } from './spec'; + +const router = Router(); + +router.use('/', swaggerUi.serve); +router.get('/', swaggerUi.setup(openApiSpec, { + customCss: '.swagger-ui .topbar { display: none }', + customSiteTitle: 'WELLab API Documentation', + swaggerOptions: { + persistAuthorization: true, + docExpansion: 'list', + filter: true, + tagsSorter: 'alpha', + }, +})); + +// Serve raw spec as JSON +router.get('/spec.json', (_req, res) => { + res.json(openApiSpec); +}); + +export { router as swaggerRouter }; diff --git a/src/backend/src/openapi/spec.ts b/src/backend/src/openapi/spec.ts new file mode 100644 index 0000000..2d6e3a0 --- /dev/null +++ b/src/backend/src/openapi/spec.ts @@ -0,0 +1,350 @@ +/** + * OpenAPI 3.0 specification for the WELLab API. + */ +export const openApiSpec = { + openapi: '3.0.3', + info: { + title: 'WELLab API', + version: '1.0.0', + description: + 'AI-Enabled Research & Impact Platform for the WELLab at Washington University in St. Louis. ' + + 'Operationalizes lifespan wellbeing science with four AI modules: Emotional Dynamics, ' + + 'Behavioral Health, Lifespan Trajectories, and Cognitive Health & Dementia Prevention.', + contact: { name: 'WELLab', url: 'https://wellbeing.wustl.edu' }, + license: { name: 'Proprietary', url: 'https://wellbeing.wustl.edu/license' }, + }, + servers: [ + { url: 'http://localhost:3001/api/v1', description: 'Local development' }, + { url: 'https://api-staging.wellab.wustl.edu/api/v1', description: 'Staging' }, + { url: 'https://api.wellab.wustl.edu/api/v1', description: 'Production' }, + ], + tags: [ + { name: 'Participants', description: 'Participant CRUD operations' }, + { name: 'Observations', description: 'EMA / experience sampling data' }, + { name: 'Emotional Dynamics', description: 'Emotion coupling & volatility analysis' }, + { name: 'Health', description: 'Behavioral & physiological health engine' }, + { name: 'Lifespan', description: 'Lifespan trajectory analysis' }, + { name: 'Cognitive', description: 'Cognitive health & dementia prevention' }, + { name: 'Interventions', description: 'Intervention management' }, + { name: 'Insights', description: 'AI-generated insights (Claude API)' }, + ], + components: { + securitySchemes: { + BearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + description: 'Cognito JWT token', + }, + }, + schemas: { + Participant: { + type: 'object', + properties: { + id: { type: 'string', example: 'P-00001' }, + externalId: { type: 'string' }, + firstName: { type: 'string' }, + lastName: { type: 'string' }, + dateOfBirth: { type: 'string', format: 'date' }, + enrollmentDate: { type: 'string', format: 'date-time' }, + status: { type: 'string', enum: ['active', 'paused', 'withdrawn', 'completed'] }, + cohort: { type: 'string' }, + metadata: { type: 'object' }, + }, + }, + Observation: { + type: 'object', + properties: { + id: { type: 'string' }, + participantId: { type: 'string' }, + timestamp: { type: 'string', format: 'date-time' }, + type: { type: 'string' }, + measures: { type: 'object', example: { positive_affect: 3.5, negative_affect: 1.2 } }, + context: { type: 'object' }, + source: { type: 'string', enum: ['ema', 'wearable', 'clinical', 'self_report'] }, + }, + }, + HealthRecord: { + type: 'object', + properties: { + id: { type: 'string' }, + participantId: { type: 'string' }, + recordDate: { type: 'string', format: 'date-time' }, + domain: { type: 'string', enum: ['physical', 'behavioral', 'physiological', 'sleep'] }, + metrics: { type: 'object' }, + source: { type: 'string' }, + }, + }, + EmotionalDynamicsResult: { + type: 'object', + properties: { + participantId: { type: 'string' }, + couplingType: { type: 'string', enum: ['positive', 'negative', 'decoupled', 'complex'] }, + couplingStrength: { type: 'number', minimum: -1, maximum: 1 }, + volatilityIndex: { type: 'number' }, + trendDirection: { type: 'string', enum: ['improving', 'stable', 'declining'] }, + riskFlag: { type: 'boolean' }, + computedAt: { type: 'string', format: 'date-time' }, + }, + }, + CognitiveAssessment: { + type: 'object', + properties: { + id: { type: 'string' }, + participantId: { type: 'string' }, + assessmentDate: { type: 'string', format: 'date-time' }, + cognitiveScore: { type: 'number' }, + domainScores: { type: 'object' }, + riskScore: { type: 'number', minimum: 0, maximum: 1 }, + riskCategory: { type: 'string', enum: ['low', 'moderate', 'high', 'very_high'] }, + protectiveFactors: { type: 'array', items: { type: 'object' } }, + }, + }, + Intervention: { + type: 'object', + properties: { + id: { type: 'string' }, + participantId: { type: 'string' }, + type: { type: 'string', enum: ['coaching', 'activity_prompt', 'psychoeducation', 'referral'] }, + targetDomain: { type: 'string' }, + content: { type: 'object' }, + status: { type: 'string', enum: ['pending', 'delivered', 'completed', 'dismissed'] }, + deliveredAt: { type: 'string', format: 'date-time' }, + }, + }, + Insight: { + type: 'object', + properties: { + id: { type: 'string' }, + category: { type: 'string', enum: ['strength', 'pattern', 'growth-area'] }, + title: { type: 'string' }, + body: { type: 'string' }, + confidenceLevel: { type: 'string', enum: ['high', 'moderate', 'low'] }, + generatedAt: { type: 'string', format: 'date-time' }, + }, + }, + PaginationMeta: { + type: 'object', + properties: { + page: { type: 'integer' }, + pageSize: { type: 'integer' }, + total: { type: 'integer' }, + totalPages: { type: 'integer' }, + }, + }, + ApiError: { + type: 'object', + properties: { + success: { type: 'boolean', example: false }, + error: { + type: 'object', + properties: { + code: { type: 'string', example: 'VALIDATION_ERROR' }, + message: { type: 'string' }, + details: { type: 'object' }, + }, + }, + }, + }, + }, + parameters: { + PageParam: { name: 'page', in: 'query', schema: { type: 'integer', default: 1 } }, + PageSizeParam: { name: 'pageSize', in: 'query', schema: { type: 'integer', default: 20 } }, + SortByParam: { name: 'sortBy', in: 'query', schema: { type: 'string' } }, + SortOrderParam: { name: 'sortOrder', in: 'query', schema: { type: 'string', enum: ['asc', 'desc'] } }, + }, + }, + security: [{ BearerAuth: [] }], + paths: { + '/participants': { + get: { + tags: ['Participants'], + summary: 'List all participants', + parameters: [ + { $ref: '#/components/parameters/PageParam' }, + { $ref: '#/components/parameters/PageSizeParam' }, + { name: 'status', in: 'query', schema: { type: 'string', enum: ['active', 'paused', 'withdrawn', 'completed'] } }, + { name: 'cohort', in: 'query', schema: { type: 'string' } }, + ], + responses: { + 200: { + description: 'Paginated list of participants', + content: { 'application/json': { schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { type: 'array', items: { $ref: '#/components/schemas/Participant' } }, meta: { $ref: '#/components/schemas/PaginationMeta' } } } } }, + }, + 401: { description: 'Unauthorized', content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } } }, + }, + }, + post: { + tags: ['Participants'], + summary: 'Create a participant', + requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/Participant' } } } }, + responses: { + 201: { description: 'Participant created', content: { 'application/json': { schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { $ref: '#/components/schemas/Participant' } } } } } }, + 400: { description: 'Validation error', content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } } }, + }, + }, + }, + '/participants/{id}': { + get: { + tags: ['Participants'], + summary: 'Get participant by ID', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + 200: { description: 'Participant details', content: { 'application/json': { schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { $ref: '#/components/schemas/Participant' } } } } } }, + 404: { description: 'Not found', content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } } }, + }, + }, + put: { + tags: ['Participants'], + summary: 'Update participant', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/Participant' } } } }, + responses: { + 200: { description: 'Participant updated' }, + 404: { description: 'Not found' }, + }, + }, + }, + '/participants/{id}/observations': { + get: { + tags: ['Observations'], + summary: 'List observations for participant', + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, + { $ref: '#/components/parameters/PageParam' }, + { $ref: '#/components/parameters/PageSizeParam' }, + { name: 'startDate', in: 'query', schema: { type: 'string', format: 'date' } }, + { name: 'endDate', in: 'query', schema: { type: 'string', format: 'date' } }, + ], + responses: { 200: { description: 'Paginated observations' } }, + }, + post: { + tags: ['Observations'], + summary: 'Submit a new EMA observation', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/Observation' } } } }, + responses: { 201: { description: 'Observation recorded' } }, + }, + }, + '/participants/{id}/emotional-dynamics': { + get: { + tags: ['Emotional Dynamics'], + summary: 'Get emotion coupling and volatility for participant', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + responses: { 200: { description: 'Emotional dynamics result', content: { 'application/json': { schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { $ref: '#/components/schemas/EmotionalDynamicsResult' } } } } } } }, + }, + }, + '/emotional-dynamics/analyze': { + post: { + tags: ['Emotional Dynamics'], + summary: 'Run coupling analysis on submitted data', + requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', properties: { participantId: { type: 'string' }, observations: { type: 'array', items: { $ref: '#/components/schemas/Observation' } } } } } } }, + responses: { 200: { description: 'Analysis results', content: { 'application/json': { schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { $ref: '#/components/schemas/EmotionalDynamicsResult' } } } } } } }, + }, + }, + '/participants/{id}/health-records': { + get: { + tags: ['Health'], + summary: 'List health records for participant', + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, + { $ref: '#/components/parameters/PageParam' }, + { $ref: '#/components/parameters/PageSizeParam' }, + { name: 'domain', in: 'query', schema: { type: 'string' } }, + ], + responses: { 200: { description: 'Paginated health records' } }, + }, + }, + '/health/causal-analysis': { + post: { + tags: ['Health'], + summary: 'Run causal inference analysis on wellbeing-health relationship', + requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', properties: { treatment: { type: 'string' }, outcome: { type: 'string' }, confounders: { type: 'array', items: { type: 'string' } }, data: { type: 'array', items: { type: 'object' } } } } } } }, + responses: { 200: { description: 'Causal analysis results' } }, + }, + }, + '/participants/{id}/trajectory': { + get: { + tags: ['Lifespan'], + summary: 'Get lifespan trajectory and archetype for participant', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + responses: { 200: { description: 'Trajectory and cluster assignment' } }, + }, + }, + '/lifespan/cluster-analysis': { + post: { + tags: ['Lifespan'], + summary: 'Run trajectory clustering on cohort data', + responses: { 200: { description: 'Cluster analysis results' } }, + }, + }, + '/participants/{id}/cognitive': { + get: { + tags: ['Cognitive'], + summary: 'Get cognitive assessment history and risk score', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + responses: { 200: { description: 'Cognitive assessments with risk', content: { 'application/json': { schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { type: 'array', items: { $ref: '#/components/schemas/CognitiveAssessment' } } } } } } } }, + }, + }, + '/cognitive/risk-assessment': { + post: { + tags: ['Cognitive'], + summary: 'Compute cognitive decline risk for participant data', + responses: { 200: { description: 'Risk assessment result' } }, + }, + }, + '/participants/{id}/interventions': { + get: { + tags: ['Interventions'], + summary: 'List interventions for participant', + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, + { $ref: '#/components/parameters/PageParam' }, + { $ref: '#/components/parameters/PageSizeParam' }, + { name: 'type', in: 'query', schema: { type: 'string' } }, + { name: 'status', in: 'query', schema: { type: 'string' } }, + ], + responses: { 200: { description: 'Paginated interventions' } }, + }, + }, + '/interventions': { + post: { + tags: ['Interventions'], + summary: 'Create a new intervention', + requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/Intervention' } } } }, + responses: { 201: { description: 'Intervention created' } }, + }, + }, + '/participants/{id}/insights': { + get: { + tags: ['Insights'], + summary: 'Get AI-generated wellbeing insights for participant', + description: 'Returns strength-framed insights generated by Claude API. Cached for 7 days.', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + responses: { 200: { description: 'AI-generated insights', content: { 'application/json': { schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { type: 'array', items: { $ref: '#/components/schemas/Insight' } } } } } } } }, + }, + }, + '/insights/trend-narrative': { + post: { + tags: ['Insights'], + summary: 'Generate natural language trend narrative', + security: [{ BearerAuth: [] }], + responses: { 200: { description: 'Trend narrative' } }, + }, + }, + '/insights/research-summary': { + post: { + tags: ['Insights'], + summary: 'Auto-generate methods and results paragraphs', + responses: { 200: { description: 'Research summary with methods and results sections' } }, + }, + }, + '/insights/policy-brief': { + post: { + tags: ['Insights'], + summary: 'Generate plain-language policy brief', + responses: { 200: { description: 'Policy brief with key findings and recommendations' } }, + }, + }, + }, +}; diff --git a/src/backend/src/routes/cognitive.ts b/src/backend/src/routes/cognitive.ts index 1672924..6061dc6 100644 --- a/src/backend/src/routes/cognitive.ts +++ b/src/backend/src/routes/cognitive.ts @@ -1,58 +1,37 @@ import { Router, Request, Response } from 'express'; +import { z } from 'zod'; import { validateBody } from '../middleware/validation'; -import { CognitiveAssessment, CognitiveRiskResult, ApiResponse } from '../types'; +import { CognitiveRiskResult, ApiResponse } from '../types'; import { logger } from '../utils/logger'; +import { asyncHandler } from '../utils/asyncHandler'; +import { parsePagination, paginate } from '../utils/pagination'; +import { mockCognitiveAssessments } from '../services/mockData'; const router = Router(); +const cognitiveRiskSchema = z.object({ + participantId: z.string().min(1), + horizonYears: z.number().int().min(1).max(30), + includeModifiableFactors: z.boolean(), +}); + /** * GET /participants/:id/cognitive * Retrieve cognitive assessment records for a participant. */ -router.get('/participants/:id/cognitive', (req: Request, res: Response) => { - const { id } = req.params; - logger.info('Fetching cognitive assessments', { participantId: id }); +router.get( + '/participants/:id/cognitive', + asyncHandler(async (req: Request, res: Response) => { + const { id } = req.params; + logger.info('Fetching cognitive assessments', { participantId: id }); - const mockAssessments: CognitiveAssessment[] = [ - { - id: 'ca-001', - participantId: id, - assessmentDate: '2024-04-10', - instrument: 'MoCA', - domain: 'memory', - score: 26, - normalizedScore: 0.87, - percentile: 72, - }, - { - id: 'ca-002', - participantId: id, - assessmentDate: '2024-04-10', - instrument: 'Trail Making B', - domain: 'executive-function', - score: 85, - normalizedScore: 0.78, - percentile: 65, - }, - { - id: 'ca-003', - participantId: id, - assessmentDate: '2024-04-10', - instrument: 'Digit Symbol', - domain: 'processing-speed', - score: 52, - normalizedScore: 0.72, - percentile: 58, - }, - ]; + const results = mockCognitiveAssessments.filter((a) => a.participantId === id); - const response: ApiResponse = { - success: true, - data: mockAssessments, - meta: { total: mockAssessments.length, timestamp: new Date().toISOString() }, - }; - res.json(response); -}); + const params = parsePagination(req); + const response = paginate(results as unknown as Record[], params); + res.json(response); + }), +); /** * POST /cognitive/risk-assessment @@ -60,11 +39,8 @@ router.get('/participants/:id/cognitive', (req: Request, res: Response) => { */ router.post( '/cognitive/risk-assessment', - validateBody({ - required: ['participantId', 'horizonYears'], - types: { participantId: 'string', horizonYears: 'number', includeModifiableFactors: 'boolean' }, - }), - (req: Request, res: Response) => { + validateBody(cognitiveRiskSchema), + asyncHandler(async (req: Request, res: Response) => { const { participantId, horizonYears, includeModifiableFactors } = req.body; logger.info('Running cognitive risk assessment', { participantId, horizonYears }); @@ -74,16 +50,28 @@ router.post( riskCategory: 'moderate', modifiableFactors: includeModifiableFactors ? [ - { factor: 'physical-activity', impact: -0.15, recommendation: 'Increase aerobic exercise to 150 min/week' }, - { factor: 'sleep-quality', impact: -0.08, recommendation: 'Address sleep fragmentation' }, - { factor: 'social-engagement', impact: -0.06, recommendation: 'Increase weekly social interactions' }, + { + factor: 'physical-activity', + impact: -0.15, + recommendation: 'Increase aerobic exercise to 150 min/week', + }, + { + factor: 'sleep-quality', + impact: -0.08, + recommendation: 'Address sleep fragmentation', + }, + { + factor: 'social-engagement', + impact: -0.06, + recommendation: 'Increase weekly social interactions', + }, ] : [], projectedTrajectory: [ - { age: 70, value: 0.87, domain: 'global-cognition', confidence: 0.90 }, + { age: 70, value: 0.87, domain: 'global-cognition', confidence: 0.9 }, { age: 72, value: 0.84, domain: 'global-cognition', confidence: 0.85 }, { age: 75, value: 0.79, domain: 'global-cognition', confidence: 0.78 }, - { age: 78, value: 0.73, domain: 'global-cognition', confidence: 0.70 }, + { age: 78, value: 0.73, domain: 'global-cognition', confidence: 0.7 }, { age: 80, value: 0.68, domain: 'global-cognition', confidence: 0.62 }, ], }; @@ -94,7 +82,7 @@ router.post( meta: { timestamp: new Date().toISOString() }, }; res.json(response); - }, + }), ); export default router; diff --git a/src/backend/src/routes/emotional-dynamics.ts b/src/backend/src/routes/emotional-dynamics.ts index 164eab7..ccdab46 100644 --- a/src/backend/src/routes/emotional-dynamics.ts +++ b/src/backend/src/routes/emotional-dynamics.ts @@ -1,38 +1,41 @@ import { Router, Request, Response } from 'express'; +import { z } from 'zod'; import { validateBody } from '../middleware/validation'; import { EmotionalDynamicsResult, ApiResponse } from '../types'; import { logger } from '../utils/logger'; +import { asyncHandler } from '../utils/asyncHandler'; +import { getEmotionalDynamicsResult } from '../services/mockData'; const router = Router(); +const analyzeSchema = z.object({ + participantIds: z.array(z.string().min(1)).min(1), + period: z.object({ + start: z.string().min(1), + end: z.string().min(1), + }), +}); + /** * GET /participants/:id/emotional-dynamics * Retrieve emotion coupling analysis and volatility scores for a participant. */ -router.get('/participants/:id/emotional-dynamics', (req: Request, res: Response) => { - const { id } = req.params; - logger.info('Fetching emotional dynamics', { participantId: id }); +router.get( + '/participants/:id/emotional-dynamics', + asyncHandler(async (req: Request, res: Response) => { + const { id } = req.params; + logger.info('Fetching emotional dynamics', { participantId: id }); - const mockResult: EmotionalDynamicsResult = { - participantId: id, - period: { start: '2024-01-01', end: '2024-06-30' }, - volatility: 0.42, - inertia: 0.68, - couplings: [ - { emotionA: 'happiness', emotionB: 'energy', couplingStrength: 0.73, lag: 0, pValue: 0.001 }, - { emotionA: 'anxiety', emotionB: 'sadness', couplingStrength: 0.58, lag: 1, pValue: 0.01 }, - { emotionA: 'anger', emotionB: 'anxiety', couplingStrength: 0.35, lag: 0, pValue: 0.05 }, - ], - granularity: 0.61, - }; + const mockResult = getEmotionalDynamicsResult(id); - const response: ApiResponse = { - success: true, - data: mockResult, - meta: { timestamp: new Date().toISOString() }, - }; - res.json(response); -}); + const response: ApiResponse = { + success: true, + data: mockResult, + meta: { timestamp: new Date().toISOString() }, + }; + res.json(response); + }), +); /** * POST /emotional-dynamics/analyze @@ -40,11 +43,8 @@ router.get('/participants/:id/emotional-dynamics', (req: Request, res: Response) */ router.post( '/emotional-dynamics/analyze', - validateBody({ - required: ['participantIds', 'period'], - types: { participantIds: 'array', period: 'object' }, - }), - (req: Request, res: Response) => { + validateBody(analyzeSchema), + asyncHandler(async (req: Request, res: Response) => { const { participantIds, period } = req.body; logger.info('Running emotional dynamics analysis', { participantCount: participantIds.length, @@ -74,7 +74,7 @@ router.post( meta: { total: results.length, timestamp: new Date().toISOString() }, }; res.json(response); - }, + }), ); export default router; diff --git a/src/backend/src/routes/health.ts b/src/backend/src/routes/health.ts index 94ca50a..5081236 100644 --- a/src/backend/src/routes/health.ts +++ b/src/backend/src/routes/health.ts @@ -1,49 +1,42 @@ import { Router, Request, Response } from 'express'; +import { z } from 'zod'; import { validateBody } from '../middleware/validation'; -import { HealthRecord, CausalAnalysisResult, ApiResponse } from '../types'; +import { CausalAnalysisResult, ApiResponse } from '../types'; import { logger } from '../utils/logger'; +import { asyncHandler } from '../utils/asyncHandler'; +import { parsePagination, paginate } from '../utils/pagination'; +import { mockHealthRecords } from '../services/mockData'; const router = Router(); +const causalAnalysisSchema = z.object({ + participantIds: z.array(z.string().min(1)).min(1), + exposureVariable: z.string().min(1), + outcomeVariable: z.string().min(1), + covariates: z.array(z.string()).optional().default([]), + method: z.enum(['propensity-score', 'instrumental-variable', 'difference-in-differences']), +}); + /** * GET /participants/:id/health-records * Retrieve health records for a participant, optionally filtered by domain. */ -router.get('/participants/:id/health-records', (req: Request, res: Response) => { - const { id } = req.params; - logger.info('Fetching health records', { participantId: id, domain: req.query.domain }); - - const mockRecords: HealthRecord[] = [ - { - id: 'hr-001', - participantId: id, - recordDate: '2024-03-15', - domain: 'physical', - indicators: { bmi: 24.5, systolicBP: 128, diastolicBP: 82, gripStrength: 32 }, - notes: 'Routine physical assessment', - }, - { - id: 'hr-002', - participantId: id, - recordDate: '2024-03-15', - domain: 'mental', - indicators: { phq9: 4, gad7: 3, pss: 12 }, - notes: 'Quarterly mental health screening', - }, - ]; +router.get( + '/participants/:id/health-records', + asyncHandler(async (req: Request, res: Response) => { + const { id } = req.params; + logger.info('Fetching health records', { participantId: id, domain: req.query.domain }); - let results = mockRecords; - if (req.query.domain) { - results = results.filter((r) => r.domain === req.query.domain); - } + let results = mockHealthRecords.filter((r) => r.participantId === id); + if (req.query.domain) { + results = results.filter((r) => r.domain === req.query.domain); + } - const response: ApiResponse = { - success: true, - data: results, - meta: { total: results.length, timestamp: new Date().toISOString() }, - }; - res.json(response); -}); + const params = parsePagination(req); + const response = paginate(results as unknown as Record[], params); + res.json(response); + }), +); /** * POST /health/causal-analysis @@ -51,16 +44,8 @@ router.get('/participants/:id/health-records', (req: Request, res: Response) => */ router.post( '/health/causal-analysis', - validateBody({ - required: ['participantIds', 'exposureVariable', 'outcomeVariable', 'method'], - types: { - participantIds: 'array', - exposureVariable: 'string', - outcomeVariable: 'string', - method: 'string', - }, - }), - (req: Request, res: Response) => { + validateBody(causalAnalysisSchema), + asyncHandler(async (req: Request, res: Response) => { logger.info('Running causal analysis', { exposure: req.body.exposureVariable, outcome: req.body.outcomeVariable, @@ -81,7 +66,7 @@ router.post( meta: { timestamp: new Date().toISOString() }, }; res.json(response); - }, + }), ); export default router; diff --git a/src/backend/src/routes/insights.ts b/src/backend/src/routes/insights.ts new file mode 100644 index 0000000..d153aea --- /dev/null +++ b/src/backend/src/routes/insights.ts @@ -0,0 +1,256 @@ +/** + * Insights API Routes + * =================== + * Endpoints for AI-powered insight generation via the Claude API. + * + * Authentication: all routes require a valid JWT. + * Authorization: + * - GET /participants/:id/insights — participant (own data) or researcher/admin + * - POST /insights/* — researcher or admin only + */ + +import { Router, Request, Response } from 'express'; +import { z } from 'zod'; +import { requireRole } from '../middleware/auth'; +import { validateBody } from '../middleware/validation'; +import { asyncHandler } from '../utils/asyncHandler'; +import { logger } from '../utils/logger'; +import { ApiResponse } from '../types'; +import { + generateParticipantInsights, + generateTrendNarrative, + generateResearchSummary, + generatePolicyBrief, +} from '../services/claude/insightEngine'; +import { + InsightResponse, + TrendNarrativeResponse, + ResearchSummaryResponse, + PolicyBriefResponse, +} from '../services/claude/types'; + +const router = Router(); + +// --------------------------------------------------------------------------- +// Validation schemas +// --------------------------------------------------------------------------- + +const trendDataPointSchema = z.object({ + date: z.string().min(1), + positiveAffect: z.number(), + negativeAffect: z.number(), + lifeSatisfaction: z.number(), +}); + +const participantInsightQuerySchema = z.object({ + couplingType: z.enum(['positive', 'negative', 'decoupled', 'complex']), + couplingStrength: z.number().min(-1).max(1), + volatility: z.number().min(0), + inertia: z.number().min(0).max(1), + recentTrend: z.array(trendDataPointSchema).optional().default([]), +}); + +const trendNarrativeSchema = z.object({ + participantId: z.string().min(1), + windowDays: z.union([z.literal(7), z.literal(30)]), + dataPoints: z.array(trendDataPointSchema).min(1), +}); + +const analysisResultSchema = z.object({ + metric: z.string().min(1), + value: z.number(), + ci: z.tuple([z.number(), z.number()]).optional(), + pValue: z.number().optional(), + sampleSize: z.number().optional(), +}); + +const researchSummarySchema = z.object({ + moduleType: z.enum([ + 'emotional-dynamics', + 'cognitive-health', + 'lifespan-trajectory', + 'bidirectional', + ]), + analysisResults: z.array(analysisResultSchema).min(1), + cohortDescription: z.string().optional(), +}); + +const populationMetricSchema = z.object({ + label: z.string().min(1), + value: z.number(), + unit: z.string().min(1), + changeFromPrior: z.number().optional(), + demographicBreakdown: z.record(z.number()).optional(), +}); + +const policyBriefSchema = z.object({ + populationMetrics: z.array(populationMetricSchema).min(1), + periodLabel: z.string().min(1), + sampleSize: z.number().int().positive(), +}); + +// --------------------------------------------------------------------------- +// Participant ID pattern +// --------------------------------------------------------------------------- +const ID_PATTERN = /^p-\d{3,}$/; + +// --------------------------------------------------------------------------- +// Routes +// --------------------------------------------------------------------------- + +/** + * GET /participants/:id/insights + * Generate or retrieve cached strength-framed insights for a participant. + * + * Query params encode the emotional dynamics context so the endpoint is + * idempotent and cacheable. + */ +router.get( + '/participants/:id/insights', + asyncHandler(async (req: Request, res: Response) => { + const { id } = req.params; + + if (!ID_PATTERN.test(id)) { + res.status(400).json({ + success: false, + error: { code: 'INVALID_ID', message: `Invalid participant ID format: ${id}` }, + }); + return; + } + + // Participants can only view their own insights + if (req.user?.role === 'participant' && req.user.sub !== id) { + res.status(403).json({ + success: false, + error: { code: 'FORBIDDEN', message: 'Participants may only view their own insights' }, + }); + return; + } + + // Parse emotional dynamics context from query string + const parseResult = participantInsightQuerySchema.safeParse({ + couplingType: req.query.couplingType, + couplingStrength: req.query.couplingStrength + ? Number(req.query.couplingStrength) + : undefined, + volatility: req.query.volatility ? Number(req.query.volatility) : undefined, + inertia: req.query.inertia ? Number(req.query.inertia) : undefined, + recentTrend: req.query.recentTrend + ? JSON.parse(req.query.recentTrend as string) + : [], + }); + + if (!parseResult.success) { + res.status(400).json({ + success: false, + error: { + code: 'VALIDATION_ERROR', + message: 'Invalid insight request parameters', + details: parseResult.error.flatten(), + }, + }); + return; + } + + const { couplingType, couplingStrength, volatility, inertia, recentTrend } = + parseResult.data; + + logger.info('Generating participant insights', { participantId: id }); + + const insights = await generateParticipantInsights( + id, + { couplingType, couplingStrength, volatility, inertia }, + recentTrend, + ); + + const response: ApiResponse = { + success: true, + data: insights, + meta: { timestamp: new Date().toISOString() }, + }; + + res.json(response); + }), +); + +/** + * POST /insights/trend-narrative + * Generate a natural-language narrative from trend data. + * Requires researcher or admin role. + */ +router.post( + '/insights/trend-narrative', + requireRole('researcher', 'admin'), + validateBody(trendNarrativeSchema), + asyncHandler(async (req: Request, res: Response) => { + logger.info('Generating trend narrative', { + participantId: req.body.participantId, + windowDays: req.body.windowDays, + }); + + const narrative = await generateTrendNarrative(req.body); + + const response: ApiResponse = { + success: true, + data: narrative, + meta: { timestamp: new Date().toISOString() }, + }; + + res.json(response); + }), +); + +/** + * POST /insights/research-summary + * Auto-generate methods and results paragraphs. + * Requires researcher or admin role. + */ +router.post( + '/insights/research-summary', + requireRole('researcher', 'admin'), + validateBody(researchSummarySchema), + asyncHandler(async (req: Request, res: Response) => { + logger.info('Generating research summary', { + moduleType: req.body.moduleType, + }); + + const summary = await generateResearchSummary(req.body); + + const response: ApiResponse = { + success: true, + data: summary, + meta: { timestamp: new Date().toISOString() }, + }; + + res.json(response); + }), +); + +/** + * POST /insights/policy-brief + * Generate a plain-language policy brief from population metrics. + * Requires researcher or admin role. + */ +router.post( + '/insights/policy-brief', + requireRole('researcher', 'admin'), + validateBody(policyBriefSchema), + asyncHandler(async (req: Request, res: Response) => { + logger.info('Generating policy brief', { + periodLabel: req.body.periodLabel, + sampleSize: req.body.sampleSize, + }); + + const brief = await generatePolicyBrief(req.body); + + const response: ApiResponse = { + success: true, + data: brief, + meta: { timestamp: new Date().toISOString() }, + }; + + res.json(response); + }), +); + +export default router; diff --git a/src/backend/src/routes/interventions.ts b/src/backend/src/routes/interventions.ts index 7adcd15..b433094 100644 --- a/src/backend/src/routes/interventions.ts +++ b/src/backend/src/routes/interventions.ts @@ -1,55 +1,46 @@ import { Router, Request, Response } from 'express'; +import { z } from 'zod'; import { validateBody } from '../middleware/validation'; import { Intervention, ApiResponse } from '../types'; import { logger } from '../utils/logger'; +import { asyncHandler } from '../utils/asyncHandler'; +import { parsePagination, paginate } from '../utils/pagination'; +import { mockInterventions } from '../services/mockData'; const router = Router(); -/** Mock interventions store */ -const mockInterventions: Intervention[] = [ - { - id: 'int-001', - participantId: 'p-001', - type: 'behavioral', - name: 'Mindfulness-Based Stress Reduction', - startDate: '2024-03-01', - endDate: '2024-05-01', - status: 'completed', - frequency: '3x/week', - outcomes: { stressReduction: 0.35, wellBeingImprovement: 0.22 }, - }, - { - id: 'int-002', - participantId: 'p-001', - type: 'lifestyle', - name: 'Mediterranean Diet Program', - startDate: '2024-04-15', - status: 'active', - frequency: 'daily', - outcomes: {}, - }, -]; +const createInterventionSchema = z.object({ + participantId: z.string().min(1), + type: z.enum(['behavioral', 'pharmacological', 'cognitive-training', 'social', 'lifestyle']), + name: z.string().min(1), + startDate: z.string().min(1), + endDate: z.string().optional(), + status: z.enum(['planned', 'active', 'completed', 'discontinued']).optional().default('planned'), + dosage: z.string().optional(), + frequency: z.string().optional(), + outcomes: z.record(z.number()).optional().default({}), +}); /** * GET /participants/:id/interventions * Retrieve interventions assigned to a participant. */ -router.get('/participants/:id/interventions', (req: Request, res: Response) => { - const { id } = req.params; - logger.info('Fetching interventions', { participantId: id }); +router.get( + '/participants/:id/interventions', + asyncHandler(async (req: Request, res: Response) => { + const { id } = req.params; + logger.info('Fetching interventions', { participantId: id }); - let results = mockInterventions.filter((i) => i.participantId === id); - if (req.query.status) { - results = results.filter((i) => i.status === req.query.status); - } + let results = mockInterventions.filter((i) => i.participantId === id); + if (req.query.status) { + results = results.filter((i) => i.status === req.query.status); + } - const response: ApiResponse = { - success: true, - data: results, - meta: { total: results.length, timestamp: new Date().toISOString() }, - }; - res.json(response); -}); + const params = parsePagination(req); + const response = paginate(results as unknown as Record[], params); + res.json(response); + }), +); /** * POST /interventions @@ -57,16 +48,8 @@ router.get('/participants/:id/interventions', (req: Request, res: Response) => { */ router.post( '/', - validateBody({ - required: ['participantId', 'type', 'name', 'startDate'], - types: { - participantId: 'string', - type: 'string', - name: 'string', - startDate: 'string', - }, - }), - (req: Request, res: Response) => { + validateBody(createInterventionSchema), + asyncHandler(async (req: Request, res: Response) => { logger.info('Creating intervention', { participantId: req.body.participantId, name: req.body.name, @@ -79,10 +62,10 @@ router.post( name: req.body.name, startDate: req.body.startDate, endDate: req.body.endDate, - status: req.body.status || 'planned', + status: req.body.status, dosage: req.body.dosage, frequency: req.body.frequency, - outcomes: req.body.outcomes || {}, + outcomes: req.body.outcomes, }; mockInterventions.push(newIntervention); @@ -93,7 +76,7 @@ router.post( meta: { timestamp: new Date().toISOString() }, }; res.status(201).json(response); - }, + }), ); export default router; diff --git a/src/backend/src/routes/lifespan.ts b/src/backend/src/routes/lifespan.ts index a875ef3..903cff6 100644 --- a/src/backend/src/routes/lifespan.ts +++ b/src/backend/src/routes/lifespan.ts @@ -1,40 +1,42 @@ import { Router, Request, Response } from 'express'; +import { z } from 'zod'; import { validateBody } from '../middleware/validation'; -import { LifespanTrajectory, ClusterAnalysisResult, ApiResponse } from '../types'; +import { ClusterAnalysisResult, ApiResponse } from '../types'; import { logger } from '../utils/logger'; +import { asyncHandler } from '../utils/asyncHandler'; +import { getLifespanTrajectory } from '../services/mockData'; +import { LifespanTrajectory } from '../types'; const router = Router(); +const clusterAnalysisSchema = z.object({ + participantIds: z.array(z.string().min(1)).min(1), + domain: z.string().min(1), + nClusters: z.number().int().min(2).max(20), + method: z.enum(['gmm', 'lcga', 'k-means']), +}); + /** * GET /participants/:id/trajectory * Retrieve the lifespan trajectory for a participant, optionally filtered by domain. */ -router.get('/participants/:id/trajectory', (req: Request, res: Response) => { - const { id } = req.params; - const domain = (req.query.domain as string) || 'well-being'; - logger.info('Fetching lifespan trajectory', { participantId: id, domain }); +router.get( + '/participants/:id/trajectory', + asyncHandler(async (req: Request, res: Response) => { + const { id } = req.params; + const domain = (req.query.domain as string) || 'well-being'; + logger.info('Fetching lifespan trajectory', { participantId: id, domain }); - const mockTrajectory: LifespanTrajectory = { - participantId: id, - domain, - points: [ - { age: 50, value: 72, domain, confidence: 0.95 }, - { age: 55, value: 70, domain, confidence: 0.93 }, - { age: 60, value: 68, domain, confidence: 0.90 }, - { age: 65, value: 71, domain, confidence: 0.88 }, - { age: 70, value: 65, domain, confidence: 0.85 }, - ], - clusterLabel: 'resilient-stable', - trajectoryClass: 'U-shaped recovery', - }; + const mockTrajectory: LifespanTrajectory = getLifespanTrajectory(id, domain); - const response: ApiResponse = { - success: true, - data: mockTrajectory, - meta: { timestamp: new Date().toISOString() }, - }; - res.json(response); -}); + const response: ApiResponse = { + success: true, + data: mockTrajectory, + meta: { timestamp: new Date().toISOString() }, + }; + res.json(response); + }), +); /** * POST /lifespan/cluster-analysis @@ -42,16 +44,8 @@ router.get('/participants/:id/trajectory', (req: Request, res: Response) => { */ router.post( '/lifespan/cluster-analysis', - validateBody({ - required: ['participantIds', 'domain', 'nClusters', 'method'], - types: { - participantIds: 'array', - domain: 'string', - nClusters: 'number', - method: 'string', - }, - }), - (req: Request, res: Response) => { + validateBody(clusterAnalysisSchema), + asyncHandler(async (req: Request, res: Response) => { const { participantIds, domain, nClusters, method } = req.body; logger.info('Running cluster analysis', { domain, nClusters, method }); @@ -89,7 +83,7 @@ router.post( meta: { timestamp: new Date().toISOString() }, }; res.json(response); - }, + }), ); export default router; diff --git a/src/backend/src/routes/observations.ts b/src/backend/src/routes/observations.ts index 61d8523..b12b32e 100644 --- a/src/backend/src/routes/observations.ts +++ b/src/backend/src/routes/observations.ts @@ -1,47 +1,44 @@ import { Router, Request, Response } from 'express'; +import { z } from 'zod'; import { validateBody } from '../middleware/validation'; import { Observation, ApiResponse } from '../types'; import { logger } from '../utils/logger'; +import { asyncHandler } from '../utils/asyncHandler'; +import { parsePagination, paginate } from '../utils/pagination'; +import { mockObservations } from '../services/mockData'; const router = Router(); -/** Mock observations store */ -const mockObservations: Observation[] = [ - { - id: 'obs-001', - participantId: 'p-001', - timestamp: '2024-06-15T09:30:00Z', - source: 'ema', - measures: { happiness: 7, sadness: 2, anxiety: 3, energy: 6 }, - context: { activity: 'morning-routine', socialContext: 'alone', deviceType: 'mobile' }, - }, - { - id: 'obs-002', - participantId: 'p-001', - timestamp: '2024-06-15T14:00:00Z', - source: 'ema', - measures: { happiness: 5, sadness: 4, anxiety: 5, energy: 4 }, - context: { activity: 'work', socialContext: 'colleagues', deviceType: 'mobile' }, - }, -]; +const createObservationSchema = z.object({ + source: z.enum(['ema', 'sensor', 'clinical', 'self-report']), + measures: z.record(z.union([z.number(), z.string(), z.boolean()])), + context: z + .object({ + location: z.string().optional(), + activity: z.string().optional(), + socialContext: z.string().optional(), + deviceType: z.string().optional(), + }) + .optional(), +}); /** * GET /participants/:id/observations * List EMA observations for a given participant. */ -router.get('/participants/:id/observations', (req: Request, res: Response) => { - const { id } = req.params; - logger.info('Fetching observations', { participantId: id }); +router.get( + '/participants/:id/observations', + asyncHandler(async (req: Request, res: Response) => { + const { id } = req.params; + logger.info('Fetching observations', { participantId: id }); - const results = mockObservations.filter((o) => o.participantId === id); + const results = mockObservations.filter((o) => o.participantId === id); - const response: ApiResponse = { - success: true, - data: results, - meta: { total: results.length, timestamp: new Date().toISOString() }, - }; - res.json(response); -}); + const params = parsePagination(req); + const response = paginate(results as unknown as Record[], params); + res.json(response); + }), +); /** * POST /participants/:id/observations @@ -49,11 +46,8 @@ router.get('/participants/:id/observations', (req: Request, res: Response) => { */ router.post( '/participants/:id/observations', - validateBody({ - required: ['source', 'measures'], - types: { source: 'string', measures: 'object' }, - }), - (req: Request, res: Response) => { + validateBody(createObservationSchema), + asyncHandler(async (req: Request, res: Response) => { const { id } = req.params; logger.info('Recording observation', { participantId: id, source: req.body.source }); @@ -74,7 +68,7 @@ router.post( meta: { timestamp: new Date().toISOString() }, }; res.status(201).json(response); - }, + }), ); export default router; diff --git a/src/backend/src/routes/participants.ts b/src/backend/src/routes/participants.ts index dd38760..a4f77b7 100644 --- a/src/backend/src/routes/participants.ts +++ b/src/backend/src/routes/participants.ts @@ -1,81 +1,84 @@ import { Router, Request, Response } from 'express'; +import { z } from 'zod'; import { validateBody } from '../middleware/validation'; import { Participant, ApiResponse } from '../types'; import { logger } from '../utils/logger'; +import { asyncHandler } from '../utils/asyncHandler'; +import { parsePagination, paginate } from '../utils/pagination'; +import { mockParticipants } from '../services/mockData'; const router = Router(); -/** Mock participant store */ -const mockParticipants: Participant[] = [ - { - id: 'p-001', - externalId: 'WELL-2024-001', - firstName: 'Alice', - lastName: 'Chen', - dateOfBirth: '1955-03-12', - enrollmentDate: '2024-01-15', - cohort: 'aging-well-2024', - status: 'active', - metadata: { site: 'Boston', language: 'en' }, - }, - { - id: 'p-002', - externalId: 'WELL-2024-002', - firstName: 'Robert', - lastName: 'Johnson', - dateOfBirth: '1948-07-22', - enrollmentDate: '2024-02-01', - cohort: 'aging-well-2024', - status: 'active', - metadata: { site: 'Chicago', language: 'en' }, - }, -]; +/** Regex for participant ID format */ +const ID_PATTERN = /^p-\d{3,}$/; + +const createParticipantSchema = z.object({ + externalId: z.string().min(1), + firstName: z.string().min(1), + lastName: z.string().min(1), + dateOfBirth: z.string().min(1), + cohort: z.string().min(1), + metadata: z.record(z.unknown()).optional(), +}); /** * GET /participants * List all participants with optional filtering by cohort or status. */ -router.get('/', (req: Request, res: Response) => { - logger.info('Listing participants', { query: req.query }); - - let results = [...mockParticipants]; - if (req.query.cohort) { - results = results.filter((p) => p.cohort === req.query.cohort); - } - if (req.query.status) { - results = results.filter((p) => p.status === req.query.status); - } - - const response: ApiResponse = { - success: true, - data: results, - meta: { total: results.length, timestamp: new Date().toISOString() }, - }; - res.json(response); -}); +router.get( + '/', + asyncHandler(async (req: Request, res: Response) => { + logger.info('Listing participants', { query: req.query }); + + let results = [...mockParticipants]; + if (req.query.cohort) { + results = results.filter((p) => p.cohort === req.query.cohort); + } + if (req.query.status) { + results = results.filter((p) => p.status === req.query.status); + } + + const params = parsePagination(req); + const response = paginate(results as unknown as Record[], params); + res.json(response); + }), +); /** * GET /participants/:id * Retrieve a single participant by ID. */ -router.get('/:id', (req: Request, res: Response) => { - const participant = mockParticipants.find((p) => p.id === req.params.id); - - if (!participant) { - res.status(404).json({ - success: false, - error: { code: 'NOT_FOUND', message: `Participant ${req.params.id} not found` }, - }); - return; - } - - const response: ApiResponse = { - success: true, - data: participant, - meta: { timestamp: new Date().toISOString() }, - }; - res.json(response); -}); +router.get( + '/:id', + asyncHandler(async (req: Request, res: Response) => { + const { id } = req.params; + + if (!ID_PATTERN.test(id)) { + res.status(400).json({ + success: false, + error: { code: 'INVALID_ID', message: `Invalid participant ID format: ${id}` }, + }); + return; + } + + const participant = mockParticipants.find((p) => p.id === id); + + if (!participant) { + res.status(404).json({ + success: false, + error: { code: 'NOT_FOUND', message: `Participant ${id} not found` }, + }); + return; + } + + const response: ApiResponse = { + success: true, + data: participant, + meta: { timestamp: new Date().toISOString() }, + }; + res.json(response); + }), +); /** * POST /participants @@ -83,17 +86,8 @@ router.get('/:id', (req: Request, res: Response) => { */ router.post( '/', - validateBody({ - required: ['externalId', 'firstName', 'lastName', 'dateOfBirth', 'cohort'], - types: { - externalId: 'string', - firstName: 'string', - lastName: 'string', - dateOfBirth: 'string', - cohort: 'string', - }, - }), - (req: Request, res: Response) => { + validateBody(createParticipantSchema), + asyncHandler(async (req: Request, res: Response) => { logger.info('Creating participant', { externalId: req.body.externalId }); const newParticipant: Participant = { @@ -116,33 +110,46 @@ router.post( meta: { timestamp: new Date().toISOString() }, }; res.status(201).json(response); - }, + }), ); /** * PUT /participants/:id * Update an existing participant record. */ -router.put('/:id', (req: Request, res: Response) => { - const index = mockParticipants.findIndex((p) => p.id === req.params.id); - - if (index === -1) { - res.status(404).json({ - success: false, - error: { code: 'NOT_FOUND', message: `Participant ${req.params.id} not found` }, - }); - return; - } - - mockParticipants[index] = { ...mockParticipants[index], ...req.body, id: req.params.id }; - logger.info('Updated participant', { id: req.params.id }); - - const response: ApiResponse = { - success: true, - data: mockParticipants[index], - meta: { timestamp: new Date().toISOString() }, - }; - res.json(response); -}); +router.put( + '/:id', + asyncHandler(async (req: Request, res: Response) => { + const { id } = req.params; + + if (!ID_PATTERN.test(id)) { + res.status(400).json({ + success: false, + error: { code: 'INVALID_ID', message: `Invalid participant ID format: ${id}` }, + }); + return; + } + + const index = mockParticipants.findIndex((p) => p.id === id); + + if (index === -1) { + res.status(404).json({ + success: false, + error: { code: 'NOT_FOUND', message: `Participant ${id} not found` }, + }); + return; + } + + mockParticipants[index] = { ...mockParticipants[index], ...req.body, id }; + logger.info('Updated participant', { id }); + + const response: ApiResponse = { + success: true, + data: mockParticipants[index], + meta: { timestamp: new Date().toISOString() }, + }; + res.json(response); + }), +); export default router; diff --git a/src/backend/src/services/claude/client.ts b/src/backend/src/services/claude/client.ts new file mode 100644 index 0000000..76c1c9a --- /dev/null +++ b/src/backend/src/services/claude/client.ts @@ -0,0 +1,245 @@ +/** + * Claude API Client Wrapper + * ========================= + * Initialises the Anthropic SDK client with rate-limiting, + * exponential-backoff retry, structured error handling, + * and token-usage tracking. + */ + +import Anthropic from '@anthropic-ai/sdk'; +import { logger } from '../../utils/logger'; +import { ClaudeUsageMetrics } from './types'; + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +const DEFAULT_MODEL = 'claude-sonnet-4-6'; +const MAX_RETRIES = 3; +const INITIAL_BACKOFF_MS = 1_000; + +/** Simple sliding-window rate limiter (requests per minute). */ +const RATE_LIMIT_RPM = 50; +const RATE_WINDOW_MS = 60_000; + +// --------------------------------------------------------------------------- +// Rate limiter +// --------------------------------------------------------------------------- + +class SlidingWindowRateLimiter { + private timestamps: number[] = []; + + constructor(private maxRequests: number, private windowMs: number) {} + + /** Returns true if a request is allowed; false if rate-limited. */ + tryAcquire(): boolean { + const now = Date.now(); + // Prune timestamps outside the window + this.timestamps = this.timestamps.filter((t) => now - t < this.windowMs); + if (this.timestamps.length >= this.maxRequests) { + return false; + } + this.timestamps.push(now); + return true; + } + + /** Milliseconds until the next slot opens. */ + msUntilNextSlot(): number { + if (this.timestamps.length < this.maxRequests) return 0; + const oldest = this.timestamps[0]; + return Math.max(0, this.windowMs - (Date.now() - oldest)); + } +} + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +export class ClaudeClientError extends Error { + constructor( + message: string, + public readonly statusCode?: number, + public readonly retryable: boolean = false, + ) { + super(message); + this.name = 'ClaudeClientError'; + } +} + +// --------------------------------------------------------------------------- +// Client +// --------------------------------------------------------------------------- + +export class ClaudeClient { + private client: Anthropic; + private model: string; + private rateLimiter: SlidingWindowRateLimiter; + + constructor(model?: string) { + const apiKey = process.env.ANTHROPIC_API_KEY; + if (!apiKey) { + throw new ClaudeClientError( + 'ANTHROPIC_API_KEY environment variable is not set', + undefined, + false, + ); + } + + this.client = new Anthropic({ apiKey }); + this.model = model ?? DEFAULT_MODEL; + this.rateLimiter = new SlidingWindowRateLimiter(RATE_LIMIT_RPM, RATE_WINDOW_MS); + + logger.info('ClaudeClient initialised', { model: this.model }); + } + + // ----------------------------------------------------------------------- + // Public + // ----------------------------------------------------------------------- + + /** + * Send a message to Claude and return the text response together with + * usage metrics. Handles rate-limiting and retry internally. + */ + async createMessage( + systemPrompt: string, + userPrompt: string, + options: { + maxTokens?: number; + temperature?: number; + model?: string; + } = {}, + ): Promise<{ text: string; usage: ClaudeUsageMetrics }> { + const model = options.model ?? this.model; + const maxTokens = options.maxTokens ?? 1024; + const temperature = options.temperature ?? 0.3; + + // Rate-limit gate + if (!this.rateLimiter.tryAcquire()) { + const waitMs = this.rateLimiter.msUntilNextSlot(); + logger.warn('Rate-limited — waiting before retry', { waitMs }); + await this.sleep(waitMs); + // Try once more after waiting + if (!this.rateLimiter.tryAcquire()) { + throw new ClaudeClientError('Rate limit exceeded after wait', 429, true); + } + } + + return this.withRetry(async () => { + const start = Date.now(); + + const response = await this.client.messages.create({ + model, + max_tokens: maxTokens, + temperature, + system: systemPrompt, + messages: [{ role: 'user', content: userPrompt }], + }); + + const latencyMs = Date.now() - start; + + // Extract text from content blocks + const text = response.content + .filter((block) => block.type === 'text') + .map((block) => ('text' in block ? block.text : '')) + .join(''); + + const usage: ClaudeUsageMetrics = { + inputTokens: response.usage.input_tokens, + outputTokens: response.usage.output_tokens, + model, + latencyMs, + timestamp: new Date().toISOString(), + }; + + logger.info('Claude API call completed', { + model, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + latencyMs, + }); + + return { text, usage }; + }); + } + + // ----------------------------------------------------------------------- + // Private helpers + // ----------------------------------------------------------------------- + + /** + * Retry wrapper with exponential backoff on 429 (rate-limit) and 500+ + * (server errors). + */ + private async withRetry(fn: () => Promise): Promise { + let lastError: unknown; + + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + return await fn(); + } catch (err: unknown) { + lastError = err; + + const status = this.extractStatus(err); + const retryable = status === 429 || (status !== undefined && status >= 500); + + if (!retryable || attempt === MAX_RETRIES) { + throw new ClaudeClientError( + `Claude API error: ${err instanceof Error ? err.message : String(err)}`, + status, + retryable, + ); + } + + const backoff = INITIAL_BACKOFF_MS * Math.pow(2, attempt); + logger.warn('Retrying Claude API call', { + attempt: attempt + 1, + maxRetries: MAX_RETRIES, + backoffMs: backoff, + status, + }); + + await this.sleep(backoff); + } + } + + // Should never reach here, but satisfy TypeScript + throw lastError; + } + + /** Extract HTTP status from various error shapes. */ + private extractStatus(err: unknown): number | undefined { + if (err && typeof err === 'object') { + if ('status' in err && typeof (err as Record).status === 'number') { + return (err as Record).status as number; + } + if ( + 'statusCode' in err && + typeof (err as Record).statusCode === 'number' + ) { + return (err as Record).statusCode as number; + } + } + return undefined; + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} + +// --------------------------------------------------------------------------- +// Singleton factory +// --------------------------------------------------------------------------- + +let _instance: ClaudeClient | null = null; + +/** + * Return the shared ClaudeClient instance, creating it lazily. + * Throws immediately if ANTHROPIC_API_KEY is not set. + */ +export function getClaudeClient(): ClaudeClient { + if (!_instance) { + _instance = new ClaudeClient(); + } + return _instance; +} diff --git a/src/backend/src/services/claude/guardrails.ts b/src/backend/src/services/claude/guardrails.ts new file mode 100644 index 0000000..f3ce9f0 --- /dev/null +++ b/src/backend/src/services/claude/guardrails.ts @@ -0,0 +1,223 @@ +/** + * Claude Insight Guardrails + * ========================= + * Safety layer ensuring all AI-generated content follows WELLab ethics + * guidelines: strength-framed, no diagnoses, confidence qualifiers, + * no PII leakage. + * + * Ref: references/ethics.md -- sections 2 (informed consent), 7 (transparency) + */ + +import { Insight } from './types'; +import { logger } from '../../utils/logger'; + +// --------------------------------------------------------------------------- +// Banned terms — clinical diagnoses & labels that must never appear +// --------------------------------------------------------------------------- + +export const BANNED_TERMS: string[] = [ + // Psychiatric diagnoses + 'depression', + 'depressed', + 'major depressive disorder', + 'bipolar', + 'schizophrenia', + 'psychosis', + 'psychotic', + 'borderline personality', + 'antisocial personality', + 'ptsd', + 'post-traumatic stress', + 'anxiety disorder', + 'generalized anxiety', + 'panic disorder', + 'obsessive-compulsive', + 'ocd', + 'adhd', + 'eating disorder', + 'anorexia', + 'bulimia', + 'substance use disorder', + 'addiction', + 'suicidal', + 'self-harm', + + // Cognitive/dementia labels + 'dementia', + 'alzheimer', + "alzheimer's", + 'mild cognitive impairment', + 'mci', + 'cognitive decline', + 'cognitive impairment', + 'neurodegeneration', + + // Clinical framing + 'diagnosis', + 'diagnosed', + 'clinical assessment', + 'pathological', + 'abnormal', + 'disorder', + 'disease', + 'syndrome', + 'deficit', + 'impaired', + 'dysfunction', + 'at risk', + 'high risk', + 'risk score', + 'risk level', +]; + +/** Pre-compiled lowercase set for O(1) substring checks */ +const BANNED_SET = BANNED_TERMS.map((t) => t.toLowerCase()); + +// --------------------------------------------------------------------------- +// Negative framing patterns +// --------------------------------------------------------------------------- + +const NEGATIVE_PATTERNS: RegExp[] = [ + /\byou(?:'re| are) (?:not|failing|struggling|unable)\b/i, + /\byour (?:problem|weakness|failure|inability)\b/i, + /\b(?:unfortunately|sadly|alarmingly|worryingly)\b/i, + /\b(?:poor|bad|terrible|awful|horrible) (?:score|result|outcome|performance)\b/i, + /\b(?:worse|worst|declining|deteriorating) (?:than|over)\b/i, + /\braw score\b/i, +]; + +// --------------------------------------------------------------------------- +// PII patterns +// --------------------------------------------------------------------------- + +const PII_PATTERNS: RegExp[] = [ + // Email + /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, + // US phone numbers + /\b\d{3}[-.]?\d{3}[-.]?\d{4}\b/g, + // SSN-like + /\b\d{3}-\d{2}-\d{4}\b/g, + // Full names preceded by common labels + /(?:name|patient|subject|participant):\s*[A-Z][a-z]+ [A-Z][a-z]+/gi, + // IP addresses + /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g, +]; + +// --------------------------------------------------------------------------- +// Confidence qualifiers +// --------------------------------------------------------------------------- + +const CONFIDENCE_QUALIFIERS: Record = { + high: 'Our data suggest', + medium: 'It appears that', + low: 'Based on limited observations, it seems', +}; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export interface ValidationResult { + valid: boolean; + issues: string[]; +} + +/** + * Validate that an AI-generated insight does not contain banned terms, + * clinical diagnoses, raw scores, or negative framing. + */ +export function validateInsightOutput(insight: Insight): ValidationResult { + const issues: string[] = []; + const fullText = `${insight.title} ${insight.body} ${insight.confidenceQualifier}`.toLowerCase(); + + // Check banned terms + for (const term of BANNED_SET) { + if (fullText.includes(term)) { + issues.push(`Contains banned term: "${term}"`); + } + } + + // Check negative framing + const combinedText = `${insight.title} ${insight.body} ${insight.confidenceQualifier}`; + for (const pattern of NEGATIVE_PATTERNS) { + if (pattern.test(combinedText)) { + issues.push(`Contains negative framing matching pattern: ${pattern.source}`); + } + } + + // Check for raw numeric scores exposed to participants + const rawScorePattern = /\b(?:score|rating)\s*(?:is|was|=|:)\s*\d+\.?\d*/i; + if (rawScorePattern.test(combinedText)) { + issues.push('Contains raw numeric score'); + } + + if (issues.length > 0) { + logger.warn('Insight failed guardrail validation', { + insightTitle: insight.title, + issues, + }); + } + + return { valid: issues.length === 0, issues }; +} + +/** + * Ensure text uses strength-based framing. + * Replaces common negative phrases with constructive alternatives. + */ +export function reframeToStrength(text: string): string { + const replacements: Array<[RegExp, string]> = [ + [/\byou(?:'re| are) struggling with\b/gi, 'you have room to grow in'], + [/\byou(?:'re| are) failing at\b/gi, 'you are building skills in'], + [/\byour weakness (?:is|in)\b/gi, 'an area for growth is'], + [/\bpoor performance in\b/gi, 'an emerging area for you is'], + [/\bdeclined\b/gi, 'has shifted'], + [/\bdeteriorated\b/gi, 'has changed'], + [/\bworse than\b/gi, 'different from'], + [/\bunfortunately\b/gi, 'notably'], + [/\bsadly\b/gi, 'interestingly'], + [/\bproblem\b/gi, 'opportunity'], + ]; + + let result = text; + for (const [pattern, replacement] of replacements) { + result = result.replace(pattern, replacement); + } + return result; +} + +/** + * Prepend an appropriate confidence qualifier to the text based on the + * confidence level. + * + * @param confidenceLevel - "high" | "medium" | "low" + */ +export function addConfidenceQualifier( + text: string, + confidenceLevel: 'high' | 'medium' | 'low', +): string { + const qualifier = CONFIDENCE_QUALIFIERS[confidenceLevel] ?? CONFIDENCE_QUALIFIERS.medium; + + // If the text already starts with a known qualifier, skip + const lowerText = text.toLowerCase(); + const alreadyQualified = Object.values(CONFIDENCE_QUALIFIERS).some((q) => + lowerText.startsWith(q.toLowerCase()), + ); + if (alreadyQualified) return text; + + // Lowercase the first character of text when joining + const body = text.charAt(0).toLowerCase() + text.slice(1); + return `${qualifier} ${body}`; +} + +/** + * Redact any personally-identifiable information that may have leaked + * through the Claude response. + */ +export function redactSensitiveData(text: string): string { + let result = text; + for (const pattern of PII_PATTERNS) { + result = result.replace(pattern, '[REDACTED]'); + } + return result; +} diff --git a/src/backend/src/services/claude/insightEngine.ts b/src/backend/src/services/claude/insightEngine.ts new file mode 100644 index 0000000..396d2a1 --- /dev/null +++ b/src/backend/src/services/claude/insightEngine.ts @@ -0,0 +1,324 @@ +/** + * Insight Generation Engine + * ========================= + * Main service layer that orchestrates calls to the Claude API, + * applies guardrails, caches results, and returns structured outputs. + */ + +import { logger } from '../../utils/logger'; +import { getClaudeClient } from './client'; +import { + PARTICIPANT_INSIGHT_SYSTEM_PROMPT, + generateParticipantInsightPrompt, + generateTrendNarrativePrompt, + generateResearchSummaryPrompt, + generatePolicyBriefPrompt, +} from './prompts'; +import { + validateInsightOutput, + reframeToStrength, + addConfidenceQualifier, + redactSensitiveData, +} from './guardrails'; +import { + Insight, + InsightCategory, + InsightRequest, + InsightResponse, + TrendNarrativeRequest, + TrendNarrativeResponse, + ResearchSummaryRequest, + ResearchSummaryResponse, + PolicyBriefRequest, + PolicyBriefResponse, + TrendDataPoint, + ClaudeUsageMetrics, +} from './types'; + +// --------------------------------------------------------------------------- +// Simple in-memory cache (insights regenerated weekly, not per-request) +// --------------------------------------------------------------------------- + +interface CacheEntry { + data: T; + expiresAt: number; +} + +const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days +const insightCache = new Map>(); + +function getCached(cache: Map>, key: string): T | null { + const entry = cache.get(key); + if (!entry) return null; + if (Date.now() > entry.expiresAt) { + cache.delete(key); + return null; + } + return entry.data; +} + +function setCache(cache: Map>, key: string, data: T): void { + cache.set(key, { data, expiresAt: Date.now() + CACHE_TTL_MS }); +} + +// --------------------------------------------------------------------------- +// Research / policy prompt system prompts +// --------------------------------------------------------------------------- + +const RESEARCH_SYSTEM_PROMPT = `You are an academic research writing assistant for the WELLab platform. +You produce concise, precise methods and results paragraphs suitable for +peer-reviewed publication. Use third-person past tense, include statistical +notation, and do not over-interpret the data. + +Respond ONLY with valid JSON matching the requested schema.`; + +const POLICY_SYSTEM_PROMPT = `You are a plain-language science communicator preparing policy briefs for +non-scientist stakeholders. Your writing is clear, jargon-free, and +action-oriented. All data referenced is population-level and aggregated — +never mention individuals. + +Respond ONLY with valid JSON matching the requested schema.`; + +// --------------------------------------------------------------------------- +// Participant insights +// --------------------------------------------------------------------------- + +/** + * Generate up to 3 strength-framed insights for a participant. + * Results are cached for 7 days per participant. + */ +export async function generateParticipantInsights( + participantId: string, + emotionalDynamics: { + couplingType: 'positive' | 'negative' | 'decoupled' | 'complex'; + couplingStrength: number; + volatility: number; + inertia: number; + }, + recentTrend: TrendDataPoint[], +): Promise { + // Check cache first + const cacheKey = `insights:${participantId}`; + const cached = getCached(insightCache, cacheKey); + if (cached) { + logger.info('Returning cached insights', { participantId }); + return cached; + } + + const request: InsightRequest = { + participantId, + couplingType: emotionalDynamics.couplingType, + couplingStrength: emotionalDynamics.couplingStrength, + volatility: emotionalDynamics.volatility, + inertia: emotionalDynamics.inertia, + recentTrend, + lastObservationDate: + recentTrend.length > 0 + ? recentTrend[recentTrend.length - 1].date + : new Date().toISOString(), + }; + + const client = getClaudeClient(); + const userPrompt = generateParticipantInsightPrompt(request); + + const { text, usage } = await client.createMessage( + PARTICIPANT_INSIGHT_SYSTEM_PROMPT, + userPrompt, + { maxTokens: 1024, temperature: 0.4 }, + ); + + // Parse response + const parsed = safeParseJSON<{ insights: RawInsight[] }>(text); + if (!parsed || !Array.isArray(parsed.insights)) { + logger.error('Failed to parse Claude insight response', { text }); + throw new Error('Claude returned an unparseable response for participant insights'); + } + + // Validate, sanitise, and cap at 3 insights + const insights: Insight[] = parsed.insights.slice(0, 3).map((raw) => { + const insight = normaliseInsight(raw); + const validation = validateInsightOutput(insight); + + if (!validation.valid) { + // Auto-flag for human review when guardrails trip + insight.requiresHumanReview = true; + logger.warn('Insight flagged for human review', { + participantId, + title: insight.title, + issues: validation.issues, + }); + } + + return insight; + }); + + const response: InsightResponse = { + insights, + generatedAt: new Date().toISOString(), + usage, + }; + + setCache(insightCache, cacheKey, response); + return response; +} + +// --------------------------------------------------------------------------- +// Trend narrative +// --------------------------------------------------------------------------- + +/** + * Generate a natural-language description of a participant's wellbeing + * trajectory over 7 or 30 days. + */ +export async function generateTrendNarrative( + data: TrendNarrativeRequest, +): Promise { + const client = getClaudeClient(); + const userPrompt = generateTrendNarrativePrompt(data); + + const { text, usage } = await client.createMessage( + PARTICIPANT_INSIGHT_SYSTEM_PROMPT, + userPrompt, + { maxTokens: 512, temperature: 0.3 }, + ); + + const parsed = safeParseJSON<{ narrative: string }>(text); + if (!parsed || typeof parsed.narrative !== 'string') { + logger.error('Failed to parse trend narrative response', { text }); + throw new Error('Claude returned an unparseable response for trend narrative'); + } + + const narrative = redactSensitiveData(reframeToStrength(parsed.narrative)); + + return { + narrative, + generatedAt: new Date().toISOString(), + usage, + }; +} + +// --------------------------------------------------------------------------- +// Research summary +// --------------------------------------------------------------------------- + +/** + * Auto-generate methods and results paragraphs for the researcher dashboard. + */ +export async function generateResearchSummary( + request: ResearchSummaryRequest, +): Promise { + const client = getClaudeClient(); + const userPrompt = generateResearchSummaryPrompt(request); + + const { text, usage } = await client.createMessage(RESEARCH_SYSTEM_PROMPT, userPrompt, { + maxTokens: 1024, + temperature: 0.2, + }); + + const parsed = safeParseJSON<{ methods: string; results: string }>(text); + if (!parsed || typeof parsed.methods !== 'string' || typeof parsed.results !== 'string') { + logger.error('Failed to parse research summary response', { text }); + throw new Error('Claude returned an unparseable response for research summary'); + } + + return { + methods: redactSensitiveData(parsed.methods), + results: redactSensitiveData(parsed.results), + generatedAt: new Date().toISOString(), + usage, + }; +} + +// --------------------------------------------------------------------------- +// Policy brief +// --------------------------------------------------------------------------- + +/** + * Generate a plain-language policy brief from population-level metrics. + */ +export async function generatePolicyBrief( + request: PolicyBriefRequest, +): Promise { + const client = getClaudeClient(); + const userPrompt = generatePolicyBriefPrompt(request); + + const { text, usage } = await client.createMessage(POLICY_SYSTEM_PROMPT, userPrompt, { + maxTokens: 1024, + temperature: 0.3, + }); + + const parsed = safeParseJSON<{ + summary: string; + keyFindings: string[]; + recommendations: string[]; + }>(text); + + if ( + !parsed || + typeof parsed.summary !== 'string' || + !Array.isArray(parsed.keyFindings) || + !Array.isArray(parsed.recommendations) + ) { + logger.error('Failed to parse policy brief response', { text }); + throw new Error('Claude returned an unparseable response for policy brief'); + } + + return { + summary: redactSensitiveData(parsed.summary), + keyFindings: parsed.keyFindings.map(redactSensitiveData), + recommendations: parsed.recommendations.map(redactSensitiveData), + generatedAt: new Date().toISOString(), + usage, + }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +interface RawInsight { + title?: string; + body?: string; + category?: string; + confidenceQualifier?: string; + requiresHumanReview?: boolean; +} + +/** Safely parse JSON, returning null on failure. */ +function safeParseJSON(text: string): T | null { + try { + // Strip markdown fences if Claude included them despite instructions + const cleaned = text.replace(/^```(?:json)?\n?/m, '').replace(/\n?```$/m, ''); + return JSON.parse(cleaned) as T; + } catch { + return null; + } +} + +/** Normalise a raw parsed insight into a well-typed Insight. */ +function normaliseInsight(raw: RawInsight): Insight { + const categoryMap: Record = { + strength: InsightCategory.Strength, + pattern: InsightCategory.Pattern, + 'growth-area': InsightCategory.GrowthArea, + }; + + const category = categoryMap[raw.category ?? ''] ?? InsightCategory.Pattern; + + const body = redactSensitiveData(reframeToStrength(raw.body ?? '')); + + // Determine confidence level heuristically + const confidenceLevel: 'high' | 'medium' | 'low' = raw.requiresHumanReview ? 'low' : 'medium'; + const qualifier = raw.confidenceQualifier ?? ''; + const bodyWithQualifier = qualifier + ? body + : addConfidenceQualifier(body, confidenceLevel); + + return { + title: raw.title ?? 'Insight', + body: bodyWithQualifier, + category, + confidenceQualifier: qualifier || `Confidence: ${confidenceLevel}`, + requiresHumanReview: raw.requiresHumanReview ?? false, + }; +} diff --git a/src/backend/src/services/claude/prompts.ts b/src/backend/src/services/claude/prompts.ts new file mode 100644 index 0000000..5f804b6 --- /dev/null +++ b/src/backend/src/services/claude/prompts.ts @@ -0,0 +1,230 @@ +/** + * Claude Prompt Templates + * ======================= + * All prompts used to communicate with Claude for WELLab insight + * generation. Each prompt enforces output-format instructions + * (JSON with specific fields) so the caller can parse deterministically. + * + * Guardrails embedded in every system prompt: + * - Strength-framed language only + * - No clinical diagnoses or labels + * - Confidence qualifiers on every claim + * - Human-review flag when uncertain + */ + +import { + InsightRequest, + TrendNarrativeRequest, + ResearchSummaryRequest, + PolicyBriefRequest, +} from './types'; + +// --------------------------------------------------------------------------- +// System prompt (shared across participant-facing use-cases) +// --------------------------------------------------------------------------- + +export const PARTICIPANT_INSIGHT_SYSTEM_PROMPT = `You are a wellbeing science communicator for the WELLab research platform. +Your role is to translate statistical findings about a participant's emotional +patterns into warm, accessible, and encouraging narratives. + +ABSOLUTE RULES — violating any of these is unacceptable: +1. STRENGTH-FRAMED: Always emphasise what is going well. Growth areas must be + framed constructively (e.g., "an area with room for growth" not "a weakness"). +2. NO DIAGNOSES: Never use psychiatric or medical diagnostic labels (e.g., + depression, dementia, PTSD, anxiety disorder, cognitive impairment). +3. NO RAW SCORES: Never expose numeric risk scores, p-values, or statistical + values directly to the participant. +4. CONFIDENCE QUALIFIERS: Every claim must be hedged appropriately. + Use phrases like "Our data suggest...", "It appears that...", + "Based on your recent patterns..." — never "You have..." or "You are...". +5. NO PII: Never include names, emails, phone numbers, or other identifying + information in your output. +6. HUMAN REVIEW: If you are uncertain about any statement, set + "requiresHumanReview" to true in that insight. + +Your tone is warm, encouraging, and scientifically grounded — like a supportive +coach who respects the participant's intelligence. + +Respond ONLY with valid JSON matching the requested schema. No markdown fences, +no commentary outside the JSON object.`; + +// --------------------------------------------------------------------------- +// Participant insight prompt +// --------------------------------------------------------------------------- + +export function generateParticipantInsightPrompt(data: InsightRequest): string { + const couplingDescriptions: Record = { + positive: + 'positive emotions and life satisfaction tend to move together — when positive affect rises, so does overall satisfaction', + negative: + 'negative emotions tend to weigh on life satisfaction — when distress increases, satisfaction dips', + decoupled: + 'emotional experiences and life satisfaction appear to operate somewhat independently', + complex: + 'the relationship between emotions and life satisfaction shows a nuanced, context-dependent pattern', + }; + + const couplingNarrative = couplingDescriptions[data.couplingType] ?? couplingDescriptions.complex; + + const trendSummary = + data.recentTrend.length > 0 + ? `Over the last ${data.recentTrend.length} observation(s), average positive affect was ${mean(data.recentTrend.map((d) => d.positiveAffect)).toFixed(2)}, average negative affect was ${mean(data.recentTrend.map((d) => d.negativeAffect)).toFixed(2)}, and average life satisfaction was ${mean(data.recentTrend.map((d) => d.lifeSatisfaction)).toFixed(2)}.` + : 'Trend data is limited for this period.'; + + return `Generate exactly 3 strength-framed insights for participant ${data.participantId}. + +Context: +- Coupling pattern: Their ${couplingNarrative} (coupling strength ~${data.couplingStrength.toFixed(2)}). +- Emotional volatility: ${describeVolatility(data.volatility)}. +- Emotional inertia: ${describeInertia(data.inertia)}. +- Recent trend: ${trendSummary} + +Return a JSON object with this exact schema: +{ + "insights": [ + { + "title": "", + "body": "<1-3 sentences, strength-framed>", + "category": "", + "confidenceQualifier": "", + "requiresHumanReview": + } + ] +} + +Important: Exactly 3 insights. One per category (strength, pattern, growth-area). +The growth-area insight MUST be framed constructively.`; +} + +// --------------------------------------------------------------------------- +// Trend narrative prompt +// --------------------------------------------------------------------------- + +export function generateTrendNarrativePrompt(data: TrendNarrativeRequest): string { + const rows = data.dataPoints + .map( + (d) => + ` ${d.date}: PA=${d.positiveAffect.toFixed(2)}, NA=${d.negativeAffect.toFixed(2)}, LS=${d.lifeSatisfaction.toFixed(2)}`, + ) + .join('\n'); + + return `Write a brief, participant-friendly narrative describing the wellbeing +trajectory over the past ${data.windowDays} days for participant ${data.participantId}. + +Data (PA = positive affect, NA = negative affect, LS = life satisfaction): +${rows} + +Rules: +- 2-4 sentences maximum +- Strength-framed: highlight improvements or stability first +- No raw numbers — describe trends qualitatively (e.g., "gradually improving", + "holding steady", "somewhat variable") +- Include a confidence qualifier + +Return a JSON object: +{ + "narrative": "" +}`; +} + +// --------------------------------------------------------------------------- +// Research summary prompt +// --------------------------------------------------------------------------- + +export function generateResearchSummaryPrompt(request: ResearchSummaryRequest): string { + const moduleNames: Record = { + 'emotional-dynamics': 'Intraindividual Dynamics of Emotion and Life Satisfaction (IDELS)', + 'cognitive-health': 'Cognitive Health and Dementia Prevention', + 'lifespan-trajectory': 'Lifespan Trajectory Clustering', + bidirectional: 'Bidirectional Causal Modeling (RI-CLPM / DoWhy)', + }; + + const moduleName = moduleNames[request.moduleType] ?? request.moduleType; + + const resultsTable = request.analysisResults + .map((r) => { + let line = ` - ${r.metric}: ${r.value.toFixed(4)}`; + if (r.ci) line += ` [95% CI: ${r.ci[0].toFixed(4)}, ${r.ci[1].toFixed(4)}]`; + if (r.pValue !== undefined) line += `, p = ${r.pValue.toFixed(4)}`; + if (r.sampleSize !== undefined) line += `, N = ${r.sampleSize}`; + return line; + }) + .join('\n'); + + const cohortCtx = request.cohortDescription + ? `\nCohort: ${request.cohortDescription}` + : ''; + + return `Generate a methods paragraph and a results paragraph suitable for an +academic research report, based on the following analysis output from the +${moduleName} module.${cohortCtx} + +Analysis results: +${resultsTable} + +Return a JSON object: +{ + "methods": "<1 paragraph describing the analytical approach>", + "results": "<1 paragraph summarising the findings with effect sizes and CIs>" +} + +Write in third-person past tense. Include appropriate statistical notation. +Do not over-interpret; report what the data show.`; +} + +// --------------------------------------------------------------------------- +// Policy brief prompt +// --------------------------------------------------------------------------- + +export function generatePolicyBriefPrompt(request: PolicyBriefRequest): string { + const metricsBlock = request.populationMetrics + .map((m) => { + let line = ` - ${m.label}: ${m.value} ${m.unit}`; + if (m.changeFromPrior !== undefined) { + const dir = m.changeFromPrior >= 0 ? '+' : ''; + line += ` (${dir}${m.changeFromPrior.toFixed(1)}% from prior period)`; + } + return line; + }) + .join('\n'); + + return `Write a plain-language policy brief based on the following population-level +wellbeing metrics for the period "${request.periodLabel}" (N = ${request.sampleSize}). + +Metrics: +${metricsBlock} + +Return a JSON object: +{ + "summary": "<2-3 sentence executive summary in plain language, no jargon>", + "keyFindings": ["", "", "..."], + "recommendations": ["", "", "..."] +} + +Rules: +- Plain language: a non-scientist policymaker must be able to understand every sentence +- No individual-level data — everything is population-level and aggregated +- 3-5 key findings, 2-4 recommendations +- Use concrete, actionable language for recommendations`; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function mean(values: number[]): number { + if (values.length === 0) return 0; + return values.reduce((a, b) => a + b, 0) / values.length; +} + +function describeVolatility(v: number): string { + if (v < 0.3) return 'Their emotional experience has been quite stable recently'; + if (v < 0.6) return 'They show moderate emotional variability, which is typical'; + return 'Their emotional experience has been notably variable'; +} + +function describeInertia(i: number): string { + if (i < 0.3) return 'Emotions shift fairly readily from one moment to the next'; + if (i < 0.7) return 'Emotions show a moderate carry-over effect between observations'; + return 'Emotions tend to persist strongly from one observation to the next, suggesting high carry-over'; +} diff --git a/src/backend/src/services/claude/types.ts b/src/backend/src/services/claude/types.ts new file mode 100644 index 0000000..cc90808 --- /dev/null +++ b/src/backend/src/services/claude/types.ts @@ -0,0 +1,145 @@ +/** + * Claude API Integration — Type Definitions + * ========================================== + * Typed interfaces for all Claude-powered insight generation workflows + * in the WELLab platform. + */ + +// --------------------------------------------------------------------------- +// Enums +// --------------------------------------------------------------------------- + +/** Categories for participant-facing insights */ +export enum InsightCategory { + /** Something the participant is doing well */ + Strength = 'strength', + /** A notable pattern in the data */ + Pattern = 'pattern', + /** An area with room for improvement, framed constructively */ + GrowthArea = 'growth-area', +} + +// --------------------------------------------------------------------------- +// Shared primitives +// --------------------------------------------------------------------------- + +/** Token-level usage tracking for a single Claude API call */ +export interface ClaudeUsageMetrics { + inputTokens: number; + outputTokens: number; + model: string; + latencyMs: number; + timestamp: string; +} + +// --------------------------------------------------------------------------- +// Participant insights +// --------------------------------------------------------------------------- + +/** A single strength-framed insight shown on the participant dashboard */ +export interface Insight { + /** Short title, e.g. "Social Connections" */ + title: string; + /** 1-3 sentence narrative */ + body: string; + /** Category tag */ + category: InsightCategory; + /** Confidence qualifier already prepended (e.g. "Our data suggest...") */ + confidenceQualifier: string; + /** Whether a human reviewer must approve before display */ + requiresHumanReview: boolean; +} + +/** Input data sent to Claude for participant insight generation */ +export interface InsightRequest { + participantId: string; + couplingType: 'positive' | 'negative' | 'decoupled' | 'complex'; + couplingStrength: number; + volatility: number; + inertia: number; + recentTrend: TrendDataPoint[]; + /** ISO date of the most recent observation */ + lastObservationDate: string; +} + +/** Structured response expected from Claude for participant insights */ +export interface InsightResponse { + insights: Insight[]; + generatedAt: string; + usage: ClaudeUsageMetrics; +} + +// --------------------------------------------------------------------------- +// Trend narrative +// --------------------------------------------------------------------------- + +export interface TrendDataPoint { + date: string; + positiveAffect: number; + negativeAffect: number; + lifeSatisfaction: number; +} + +export interface TrendNarrativeRequest { + participantId: string; + windowDays: 7 | 30; + dataPoints: TrendDataPoint[]; +} + +export interface TrendNarrativeResponse { + narrative: string; + generatedAt: string; + usage: ClaudeUsageMetrics; +} + +// --------------------------------------------------------------------------- +// Research summary +// --------------------------------------------------------------------------- + +export interface AnalysisResultEntry { + metric: string; + value: number; + ci?: [number, number]; + pValue?: number; + sampleSize?: number; +} + +export interface ResearchSummaryRequest { + moduleType: 'emotional-dynamics' | 'cognitive-health' | 'lifespan-trajectory' | 'bidirectional'; + analysisResults: AnalysisResultEntry[]; + /** Optional context describing the cohort or study arm */ + cohortDescription?: string; +} + +export interface ResearchSummaryResponse { + methods: string; + results: string; + generatedAt: string; + usage: ClaudeUsageMetrics; +} + +// --------------------------------------------------------------------------- +// Policy brief +// --------------------------------------------------------------------------- + +export interface PopulationMetric { + label: string; + value: number; + unit: string; + changeFromPrior?: number; + demographicBreakdown?: Record; +} + +export interface PolicyBriefRequest { + populationMetrics: PopulationMetric[]; + periodLabel: string; + sampleSize: number; +} + +export interface PolicyBriefResponse { + summary: string; + keyFindings: string[]; + recommendations: string[]; + generatedAt: string; + usage: ClaudeUsageMetrics; +} diff --git a/src/backend/src/services/mockData.ts b/src/backend/src/services/mockData.ts new file mode 100644 index 0000000..09788ee --- /dev/null +++ b/src/backend/src/services/mockData.ts @@ -0,0 +1,181 @@ +import { + Participant, + Observation, + HealthRecord, + Intervention, + CognitiveAssessment, + EmotionalDynamicsResult, + LifespanTrajectory, +} from '../types'; + +// --------------------------------------------------------------------------- +// Participants +// --------------------------------------------------------------------------- +export const mockParticipants: Participant[] = [ + { + id: 'p-001', + externalId: 'WELL-2024-001', + firstName: 'Alice', + lastName: 'Chen', + dateOfBirth: '1955-03-12', + enrollmentDate: '2024-01-15', + cohort: 'aging-well-2024', + status: 'active', + metadata: { site: 'Boston', language: 'en' }, + }, + { + id: 'p-002', + externalId: 'WELL-2024-002', + firstName: 'Robert', + lastName: 'Johnson', + dateOfBirth: '1948-07-22', + enrollmentDate: '2024-02-01', + cohort: 'aging-well-2024', + status: 'active', + metadata: { site: 'Chicago', language: 'en' }, + }, +]; + +// --------------------------------------------------------------------------- +// Observations +// --------------------------------------------------------------------------- +export const mockObservations: Observation[] = [ + { + id: 'obs-001', + participantId: 'p-001', + timestamp: '2024-06-15T09:30:00Z', + source: 'ema', + measures: { happiness: 7, sadness: 2, anxiety: 3, energy: 6 }, + context: { activity: 'morning-routine', socialContext: 'alone', deviceType: 'mobile' }, + }, + { + id: 'obs-002', + participantId: 'p-001', + timestamp: '2024-06-15T14:00:00Z', + source: 'ema', + measures: { happiness: 5, sadness: 4, anxiety: 5, energy: 4 }, + context: { activity: 'work', socialContext: 'colleagues', deviceType: 'mobile' }, + }, +]; + +// --------------------------------------------------------------------------- +// Health Records +// --------------------------------------------------------------------------- +export const mockHealthRecords: HealthRecord[] = [ + { + id: 'hr-001', + participantId: 'p-001', + recordDate: '2024-03-15', + domain: 'physical', + indicators: { bmi: 24.5, systolicBP: 128, diastolicBP: 82, gripStrength: 32 }, + notes: 'Routine physical assessment', + }, + { + id: 'hr-002', + participantId: 'p-001', + recordDate: '2024-03-15', + domain: 'mental', + indicators: { phq9: 4, gad7: 3, pss: 12 }, + notes: 'Quarterly mental health screening', + }, +]; + +// --------------------------------------------------------------------------- +// Interventions +// --------------------------------------------------------------------------- +export const mockInterventions: Intervention[] = [ + { + id: 'int-001', + participantId: 'p-001', + type: 'behavioral', + name: 'Mindfulness-Based Stress Reduction', + startDate: '2024-03-01', + endDate: '2024-05-01', + status: 'completed', + frequency: '3x/week', + outcomes: { stressReduction: 0.35, wellBeingImprovement: 0.22 }, + }, + { + id: 'int-002', + participantId: 'p-001', + type: 'lifestyle', + name: 'Mediterranean Diet Program', + startDate: '2024-04-15', + status: 'active', + frequency: 'daily', + outcomes: {}, + }, +]; + +// --------------------------------------------------------------------------- +// Cognitive Assessments +// --------------------------------------------------------------------------- +export const mockCognitiveAssessments: CognitiveAssessment[] = [ + { + id: 'ca-001', + participantId: 'p-001', + assessmentDate: '2024-04-10', + instrument: 'MoCA', + domain: 'memory', + score: 26, + normalizedScore: 0.87, + percentile: 72, + }, + { + id: 'ca-002', + participantId: 'p-001', + assessmentDate: '2024-04-10', + instrument: 'Trail Making B', + domain: 'executive-function', + score: 85, + normalizedScore: 0.78, + percentile: 65, + }, + { + id: 'ca-003', + participantId: 'p-001', + assessmentDate: '2024-04-10', + instrument: 'Digit Symbol', + domain: 'processing-speed', + score: 52, + normalizedScore: 0.72, + percentile: 58, + }, +]; + +// --------------------------------------------------------------------------- +// Emotional Dynamics (factory - per-participant) +// --------------------------------------------------------------------------- +export function getEmotionalDynamicsResult(participantId: string): EmotionalDynamicsResult { + return { + participantId, + period: { start: '2024-01-01', end: '2024-06-30' }, + volatility: 0.42, + inertia: 0.68, + couplings: [ + { emotionA: 'happiness', emotionB: 'energy', couplingStrength: 0.73, lag: 0, pValue: 0.001 }, + { emotionA: 'anxiety', emotionB: 'sadness', couplingStrength: 0.58, lag: 1, pValue: 0.01 }, + { emotionA: 'anger', emotionB: 'anxiety', couplingStrength: 0.35, lag: 0, pValue: 0.05 }, + ], + granularity: 0.61, + }; +} + +// --------------------------------------------------------------------------- +// Lifespan Trajectory (factory - per-participant) +// --------------------------------------------------------------------------- +export function getLifespanTrajectory(participantId: string, domain: string): LifespanTrajectory { + return { + participantId, + domain, + points: [ + { age: 50, value: 72, domain, confidence: 0.95 }, + { age: 55, value: 70, domain, confidence: 0.93 }, + { age: 60, value: 68, domain, confidence: 0.90 }, + { age: 65, value: 71, domain, confidence: 0.88 }, + { age: 70, value: 65, domain, confidence: 0.85 }, + ], + clusterLabel: 'resilient-stable', + trajectoryClass: 'U-shaped recovery', + }; +} diff --git a/src/backend/src/types/express.d.ts b/src/backend/src/types/express.d.ts new file mode 100644 index 0000000..905b73f --- /dev/null +++ b/src/backend/src/types/express.d.ts @@ -0,0 +1,19 @@ +import { Request } from 'express'; + +export interface UserPayload { + sub: string; + role: 'researcher' | 'participant' | 'admin'; + iss: string; + exp: number; + iat?: number; + email?: string; +} + +declare global { + namespace Express { + interface Request { + user?: UserPayload; + requestId?: string; + } + } +} diff --git a/src/backend/src/utils/asyncHandler.ts b/src/backend/src/utils/asyncHandler.ts new file mode 100644 index 0000000..e06c4d2 --- /dev/null +++ b/src/backend/src/utils/asyncHandler.ts @@ -0,0 +1,13 @@ +import { Request, Response, NextFunction } from 'express'; + +/** + * Wraps an async Express route handler so that rejected promises + * are forwarded to the Express error-handling middleware. + */ +export function asyncHandler( + fn: (req: Request, res: Response, next: NextFunction) => Promise, +) { + return (req: Request, res: Response, next: NextFunction): void => { + Promise.resolve(fn(req, res, next)).catch(next); + }; +} diff --git a/src/backend/src/utils/pagination.ts b/src/backend/src/utils/pagination.ts new file mode 100644 index 0000000..98b532a --- /dev/null +++ b/src/backend/src/utils/pagination.ts @@ -0,0 +1,75 @@ +import { Request } from 'express'; + +export interface PaginationParams { + page: number; + pageSize: number; + sortBy?: string; + sortOrder: 'asc' | 'desc'; +} + +export interface PaginationMeta { + page: number; + pageSize: number; + total: number; + totalPages: number; +} + +export interface PaginatedResponse { + success: boolean; + data: T[]; + meta: PaginationMeta & { timestamp: string }; +} + +/** + * Extract pagination parameters from the request query string. + */ +export function parsePagination(req: Request): PaginationParams { + const page = Math.max(1, parseInt(req.query.page as string, 10) || 1); + const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string, 10) || 20)); + const sortBy = (req.query.sortBy as string) || undefined; + const sortOrder = req.query.sortOrder === 'desc' ? 'desc' : 'asc'; + + return { page, pageSize, sortBy, sortOrder }; +} + +/** + * Apply pagination (and optional sorting) to an in-memory array and + * return the paginated response object. + */ +export function paginate>( + items: T[], + params: PaginationParams, +): PaginatedResponse { + let sorted = [...items]; + + if (params.sortBy) { + const key = params.sortBy; + sorted.sort((a, b) => { + const aVal = a[key]; + const bVal = b[key]; + if (aVal == null && bVal == null) return 0; + if (aVal == null) return 1; + if (bVal == null) return -1; + if (aVal < bVal) return params.sortOrder === 'asc' ? -1 : 1; + if (aVal > bVal) return params.sortOrder === 'asc' ? 1 : -1; + return 0; + }); + } + + const total = sorted.length; + const totalPages = Math.ceil(total / params.pageSize); + const start = (params.page - 1) * params.pageSize; + const data = sorted.slice(start, start + params.pageSize); + + return { + success: true, + data, + meta: { + page: params.page, + pageSize: params.pageSize, + total, + totalPages, + timestamp: new Date().toISOString(), + }, + }; +} diff --git a/src/frontend/package.json b/src/frontend/package.json index d018e53..53dcc35 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run" }, "dependencies": { "react": "^18.3.1", @@ -23,6 +24,11 @@ "postcss": "^8.4.38", "tailwindcss": "^3.4.4", "typescript": "^5.4.5", - "vite": "^5.3.1" + "vite": "^5.3.1", + "vitest": "^1.6.0", + "@testing-library/react": "^15.0.7", + "@testing-library/jest-dom": "^6.4.5", + "@testing-library/user-event": "^14.5.2", + "jsdom": "^24.1.0" } } diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 712175b..3177fce 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -1,18 +1,40 @@ +import { lazy, Suspense } from "react"; import { Routes, Route } from "react-router-dom"; import Layout from "./components/Layout"; -import ParticipantDashboard from "./pages/ParticipantDashboard"; -import ResearcherDashboard from "./pages/ResearcherDashboard"; -import PolicyDashboard from "./pages/PolicyDashboard"; +import ErrorBoundary from "./components/ErrorBoundary"; +import Spinner from "./components/ui/Spinner"; + +const ParticipantDashboard = lazy( + () => import("./pages/ParticipantDashboard") +); +const ResearcherDashboard = lazy( + () => import("./pages/ResearcherDashboard") +); +const PolicyDashboard = lazy(() => import("./pages/PolicyDashboard")); +const NotFound = lazy(() => import("./pages/NotFound")); + +function LoadingFallback() { + return ( +
+ +
+ ); +} function App() { return ( - - - } /> - } /> - } /> - - + + + }> + + } /> + } /> + } /> + } /> + + + + ); } diff --git a/src/frontend/src/api/client.ts b/src/frontend/src/api/client.ts index 65f2197..c3b5e04 100644 --- a/src/frontend/src/api/client.ts +++ b/src/frontend/src/api/client.ts @@ -1,6 +1,90 @@ -import type { ApiResponse } from "../types"; +import type { + ApiResponse, + WellbeingScore, + TrendPoint, + Insight, + CohortSummary, + RiskBucket, + InterventionROI, +} from "../types"; + +const BASE_URL = + (typeof import.meta !== "undefined" && + import.meta.env?.VITE_API_BASE_URL) || + "/api"; + +const DEFAULT_TIMEOUT_MS = 30_000; +const MAX_RETRIES = 3; +const INITIAL_BACKOFF_MS = 500; + +function getStoredToken(): string | null { + try { + const stored = localStorage.getItem("wellab_auth"); + if (stored) { + const parsed = JSON.parse(stored); + return parsed.token ?? null; + } + } catch { + // ignore parse errors + } + return null; +} + +async function withRetry( + fn: (signal: AbortSignal) => Promise, + signal?: AbortSignal +): Promise { + let lastError: unknown; + + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + try { + const controller = new AbortController(); + const timeoutId = setTimeout( + () => controller.abort(), + DEFAULT_TIMEOUT_MS + ); + + // Forward external abort to our controller + if (signal) { + if (signal.aborted) { + clearTimeout(timeoutId); + throw new DOMException("Aborted", "AbortError"); + } + signal.addEventListener( + "abort", + () => controller.abort(), + { once: true } + ); + } + + try { + const result = await fn(controller.signal); + clearTimeout(timeoutId); + return result; + } finally { + clearTimeout(timeoutId); + } + } catch (err: unknown) { + lastError = err; + + // Do not retry abort errors or client errors (4xx) + if (err instanceof DOMException && err.name === "AbortError") { + throw err; + } + if (signal?.aborted) { + throw new DOMException("Aborted", "AbortError"); + } + + // Only retry on network/server errors, not 4xx + if (attempt < MAX_RETRIES - 1) { + const backoff = INITIAL_BACKOFF_MS * Math.pow(2, attempt); + await new Promise((resolve) => setTimeout(resolve, backoff)); + } + } + } -const BASE_URL = "/api"; + throw lastError; +} class ApiClient { private baseUrl: string; @@ -11,77 +95,119 @@ class ApiClient { private async request( endpoint: string, - options: RequestInit = {} + options: RequestInit = {}, + externalSignal?: AbortSignal ): Promise> { - const url = `${this.baseUrl}${endpoint}`; - const headers: HeadersInit = { - "Content-Type": "application/json", - ...options.headers, - }; - - const response = await fetch(url, { ...options, headers }); - - if (!response.ok) { - const error = await response.json().catch(() => ({ - message: response.statusText, - })); - throw new Error(error.message || `Request failed: ${response.status}`); - } - - return response.json(); + return withRetry(async (signal) => { + const url = `${this.baseUrl}${endpoint}`; + + const headers: HeadersInit = { + "Content-Type": "application/json", + ...options.headers, + }; + + const token = getStoredToken(); + if (token) { + (headers as Record)["Authorization"] = + `Bearer ${token}`; + } + + const response = await fetch(url, { + ...options, + headers, + signal, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ + message: response.statusText, + })); + const err = new Error( + error.message || `Request failed: ${response.status}` + ); + // Attach status for retry logic + (err as Error & { status?: number }).status = response.status; + throw err; + } + + return response.json(); + }, externalSignal); } - async get(endpoint: string): Promise> { - return this.request(endpoint, { method: "GET" }); + async get( + endpoint: string, + signal?: AbortSignal + ): Promise> { + return this.request(endpoint, { method: "GET" }, signal); } - async post(endpoint: string, body: unknown): Promise> { - return this.request(endpoint, { - method: "POST", - body: JSON.stringify(body), - }); + async post( + endpoint: string, + body: unknown, + signal?: AbortSignal + ): Promise> { + return this.request( + endpoint, + { method: "POST", body: JSON.stringify(body) }, + signal + ); } - async put(endpoint: string, body: unknown): Promise> { - return this.request(endpoint, { - method: "PUT", - body: JSON.stringify(body), - }); + async put( + endpoint: string, + body: unknown, + signal?: AbortSignal + ): Promise> { + return this.request( + endpoint, + { method: "PUT", body: JSON.stringify(body) }, + signal + ); } - async delete(endpoint: string): Promise> { - return this.request(endpoint, { method: "DELETE" }); + async delete( + endpoint: string, + signal?: AbortSignal + ): Promise> { + return this.request(endpoint, { method: "DELETE" }, signal); } // Domain-specific methods - getParticipantScore(participantId: string) { - return this.get( - `/participants/${participantId}/score` + getParticipantScore(participantId: string, signal?: AbortSignal) { + return this.get( + `/participants/${participantId}/score`, + signal ); } - getParticipantTrend(participantId: string, days = 30) { - return this.get( - `/participants/${participantId}/trend?days=${days}` + getParticipantTrend( + participantId: string, + days = 30, + signal?: AbortSignal + ) { + return this.get( + `/participants/${participantId}/trend?days=${days}`, + signal ); } - getParticipantInsights(participantId: string) { - return this.get( - `/participants/${participantId}/insights` + getParticipantInsights(participantId: string, signal?: AbortSignal) { + return this.get( + `/participants/${participantId}/insights`, + signal ); } - getCohorts() { - return this.get("/cohorts"); + getCohorts(signal?: AbortSignal) { + return this.get("/cohorts", signal); } - getPopulationRisk() { - return this.get("/population/risk"); + getPopulationRisk(signal?: AbortSignal) { + return this.get("/population/risk", signal); } - getInterventionROI() { - return this.get("/interventions/roi"); + getInterventionROI(signal?: AbortSignal) { + return this.get("/interventions/roi", signal); } } diff --git a/src/frontend/src/components/ErrorBoundary.tsx b/src/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..8f190ca --- /dev/null +++ b/src/frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,66 @@ +import { Component } from "react"; +import type { ErrorInfo, ReactNode } from "react"; + +interface ErrorBoundaryProps { + children: ReactNode; + fallback?: ReactNode; +} + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +export default class ErrorBoundary extends Component< + ErrorBoundaryProps, + ErrorBoundaryState +> { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + // Stub for future error reporting (e.g., Sentry, LogRocket) + console.error("[ErrorBoundary] Uncaught error:", error, errorInfo); + } + + private handleReset = () => { + this.setState({ hasError: false, error: null }); + }; + + render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + + return ( +
+

+ Something went wrong +

+

+ An unexpected error occurred. Please try again or contact support if + the problem persists. +

+ +
+ ); + } + + return this.props.children; + } +} diff --git a/src/frontend/src/components/Layout.tsx b/src/frontend/src/components/Layout.tsx index c2d27ea..499225d 100644 --- a/src/frontend/src/components/Layout.tsx +++ b/src/frontend/src/components/Layout.tsx @@ -14,6 +14,14 @@ const navItems = [ export default function Layout({ children }: LayoutProps) { return (
+ {/* Skip to main content link for keyboard/screen-reader users */} + + Skip to main content + +
@@ -25,14 +33,14 @@ export default function Layout({ children }: LayoutProps) { WELLab Platform
-
-
+
{children}
+ {/* Note: lang attribute should be set on the root element in index.html */} WELLab AI-Enabled Research & Impact Platform
diff --git a/src/frontend/src/components/TrendChart.tsx b/src/frontend/src/components/TrendChart.tsx index dba5a51..f3acd02 100644 --- a/src/frontend/src/components/TrendChart.tsx +++ b/src/frontend/src/components/TrendChart.tsx @@ -31,30 +31,35 @@ export default function TrendChart({ return (

{title}

- - - - v.slice(5)} - /> - - - - {lineConfig.map((cfg) => ( - + + + + v.slice(5)} /> - ))} - - + + + + {lineConfig.map((cfg) => ( + + ))} + + +
); } diff --git a/src/frontend/src/components/WellbeingScoreCard.tsx b/src/frontend/src/components/WellbeingScoreCard.tsx index ad5e297..81dc2aa 100644 --- a/src/frontend/src/components/WellbeingScoreCard.tsx +++ b/src/frontend/src/components/WellbeingScoreCard.tsx @@ -42,10 +42,16 @@ export default function WellbeingScoreCard({ label = "Your Wellbeing Today", }: WellbeingScoreCardProps) { return ( -
+

{label}

- + {overall} / 100 @@ -67,6 +73,11 @@ export default function WellbeingScoreCard({
@@ -77,6 +88,6 @@ export default function WellbeingScoreCard({ )}
)} -
+ ); } diff --git a/src/frontend/src/components/ui/Badge.tsx b/src/frontend/src/components/ui/Badge.tsx new file mode 100644 index 0000000..f362625 --- /dev/null +++ b/src/frontend/src/components/ui/Badge.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from "react"; + +interface BadgeProps { + children: ReactNode; + color?: "green" | "amber" | "blue" | "red" | "gray"; + className?: string; +} + +const colorClasses = { + green: "bg-wellab-100 text-wellab-800", + amber: "bg-amber-100 text-amber-800", + blue: "bg-blue-100 text-blue-800", + red: "bg-red-100 text-red-800", + gray: "bg-gray-100 text-gray-800", +}; + +export default function Badge({ + children, + color = "gray", + className = "", +}: BadgeProps) { + return ( + + {children} + + ); +} diff --git a/src/frontend/src/components/ui/Button.tsx b/src/frontend/src/components/ui/Button.tsx new file mode 100644 index 0000000..f25bf6d --- /dev/null +++ b/src/frontend/src/components/ui/Button.tsx @@ -0,0 +1,31 @@ +import type { ButtonHTMLAttributes, ReactNode } from "react"; + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: "primary" | "secondary" | "ghost"; + children: ReactNode; +} + +const variantClasses = { + primary: + "bg-wellab-600 text-white hover:bg-wellab-700 focus:ring-wellab-500", + secondary: + "bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 focus:ring-wellab-500", + ghost: + "bg-transparent text-gray-600 hover:bg-gray-100 focus:ring-wellab-500", +}; + +export default function Button({ + variant = "primary", + children, + className = "", + ...props +}: ButtonProps) { + return ( + + ); +} diff --git a/src/frontend/src/components/ui/Card.tsx b/src/frontend/src/components/ui/Card.tsx new file mode 100644 index 0000000..746b353 --- /dev/null +++ b/src/frontend/src/components/ui/Card.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from "react"; + +interface CardProps { + title?: string; + subtitle?: string; + children: ReactNode; + className?: string; +} + +export default function Card({ + title, + subtitle, + children, + className = "", +}: CardProps) { + return ( +
+ {title && ( +

{title}

+ )} + {subtitle && ( +

{subtitle}

+ )} + {children} +
+ ); +} diff --git a/src/frontend/src/components/ui/SectionHeader.tsx b/src/frontend/src/components/ui/SectionHeader.tsx new file mode 100644 index 0000000..810dd0c --- /dev/null +++ b/src/frontend/src/components/ui/SectionHeader.tsx @@ -0,0 +1,20 @@ +interface SectionHeaderProps { + title: string; + subtitle?: string; + className?: string; +} + +export default function SectionHeader({ + title, + subtitle, + className = "", +}: SectionHeaderProps) { + return ( +
+

{title}

+ {subtitle && ( +

{subtitle}

+ )} +
+ ); +} diff --git a/src/frontend/src/components/ui/Spinner.tsx b/src/frontend/src/components/ui/Spinner.tsx new file mode 100644 index 0000000..4085e54 --- /dev/null +++ b/src/frontend/src/components/ui/Spinner.tsx @@ -0,0 +1,20 @@ +interface SpinnerProps { + size?: "sm" | "md" | "lg"; + className?: string; +} + +const sizeClasses = { + sm: "h-4 w-4 border-2", + md: "h-8 w-8 border-2", + lg: "h-12 w-12 border-3", +}; + +export default function Spinner({ size = "md", className = "" }: SpinnerProps) { + return ( +
+ ); +} diff --git a/src/frontend/src/context/AuthContext.tsx b/src/frontend/src/context/AuthContext.tsx new file mode 100644 index 0000000..9b1720b --- /dev/null +++ b/src/frontend/src/context/AuthContext.tsx @@ -0,0 +1,74 @@ +import { createContext, useState, useCallback } from "react"; +import type { ReactNode } from "react"; + +export type UserRole = "participant" | "researcher" | "admin"; + +export interface AuthState { + token: string | null; + role: UserRole | null; + participantId: string | null; +} + +export interface AuthContextValue extends AuthState { + isAuthenticated: boolean; + login: (token: string, role: UserRole, participantId?: string) => void; + logout: () => void; +} + +const initialState: AuthState = { + token: null, + role: null, + participantId: null, +}; + +export const AuthContext = createContext({ + ...initialState, + isAuthenticated: false, + login: () => {}, + logout: () => {}, +}); + +interface AuthProviderProps { + children: ReactNode; +} + +export function AuthProvider({ children }: AuthProviderProps) { + const [authState, setAuthState] = useState(() => { + const stored = localStorage.getItem("wellab_auth"); + if (stored) { + try { + return JSON.parse(stored) as AuthState; + } catch { + return initialState; + } + } + return initialState; + }); + + const login = useCallback( + (token: string, role: UserRole, participantId?: string) => { + const newState: AuthState = { + token, + role, + participantId: participantId ?? null, + }; + setAuthState(newState); + localStorage.setItem("wellab_auth", JSON.stringify(newState)); + }, + [] + ); + + const logout = useCallback(() => { + setAuthState(initialState); + localStorage.removeItem("wellab_auth"); + }, []); + + const value: AuthContextValue = { + ...authState, + isAuthenticated: authState.token !== null, + login, + logout, + }; + + return {children}; +} diff --git a/src/frontend/src/hooks/useApi.ts b/src/frontend/src/hooks/useApi.ts new file mode 100644 index 0000000..43600db --- /dev/null +++ b/src/frontend/src/hooks/useApi.ts @@ -0,0 +1,65 @@ +import { useState, useEffect, useCallback, useRef } from "react"; + +interface UseApiState { + data: T | null; + loading: boolean; + error: string | null; +} + +interface UseApiResult extends UseApiState { + retry: () => void; +} + +export function useApi( + fetcher: (signal: AbortSignal) => Promise, + deps: unknown[] = [], + timeoutMs = 30000 +): UseApiResult { + const [state, setState] = useState>({ + data: null, + loading: true, + error: null, + }); + + const retryCount = useRef(0); + + const execute = useCallback(() => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + setState((prev) => ({ ...prev, loading: true, error: null })); + + fetcher(controller.signal) + .then((data) => { + setState({ data, loading: false, error: null }); + }) + .catch((err: unknown) => { + if (controller.signal.aborted) return; + const message = + err instanceof Error ? err.message : "An unexpected error occurred"; + setState({ data: null, loading: false, error: message }); + }) + .finally(() => { + clearTimeout(timeoutId); + }); + + return () => { + clearTimeout(timeoutId); + controller.abort(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); + + useEffect(() => { + retryCount.current = 0; + const cleanup = execute(); + return cleanup; + }, [execute]); + + const retry = useCallback(() => { + retryCount.current += 1; + execute(); + }, [execute]); + + return { ...state, retry }; +} diff --git a/src/frontend/src/hooks/useAuth.ts b/src/frontend/src/hooks/useAuth.ts new file mode 100644 index 0000000..2ccc82e --- /dev/null +++ b/src/frontend/src/hooks/useAuth.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +import { AuthContext } from "../context/AuthContext"; +import type { AuthContextValue } from "../context/AuthContext"; + +export function useAuth(): AuthContextValue { + const context = useContext(AuthContext); + if (!context) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +} diff --git a/src/frontend/src/hooks/useParticipant.ts b/src/frontend/src/hooks/useParticipant.ts new file mode 100644 index 0000000..aaeb20d --- /dev/null +++ b/src/frontend/src/hooks/useParticipant.ts @@ -0,0 +1,33 @@ +import { useApi } from "./useApi"; +import apiClient from "../api/client"; +import type { WellbeingScore, TrendPoint, Insight } from "../types"; + +export function useParticipantScore(participantId: string) { + return useApi( + async (signal) => { + const response = await apiClient.getParticipantScore(participantId, signal); + return response.data; + }, + [participantId] + ); +} + +export function useParticipantTrend(participantId: string, days = 30) { + return useApi( + async (signal) => { + const response = await apiClient.getParticipantTrend(participantId, days, signal); + return response.data; + }, + [participantId, days] + ); +} + +export function useParticipantInsights(participantId: string) { + return useApi( + async (signal) => { + const response = await apiClient.getParticipantInsights(participantId, signal); + return response.data; + }, + [participantId] + ); +} diff --git a/src/frontend/src/main.tsx b/src/frontend/src/main.tsx index fe05248..9ffb77c 100644 --- a/src/frontend/src/main.tsx +++ b/src/frontend/src/main.tsx @@ -1,13 +1,16 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; +import { AuthProvider } from "./context/AuthContext"; import App from "./App"; import "./index.css"; ReactDOM.createRoot(document.getElementById("root")!).render( - + + + ); diff --git a/src/frontend/src/pages/NotFound.tsx b/src/frontend/src/pages/NotFound.tsx new file mode 100644 index 0000000..ac6853c --- /dev/null +++ b/src/frontend/src/pages/NotFound.tsx @@ -0,0 +1,21 @@ +import { Link } from "react-router-dom"; + +export default function NotFound() { + return ( +
+

404

+

+ Page Not Found +

+

+ The page you are looking for does not exist or has been moved. +

+ + Back to Home + +
+ ); +} diff --git a/src/frontend/src/pages/ParticipantDashboard.tsx b/src/frontend/src/pages/ParticipantDashboard.tsx index b5a5dff..cf6b562 100644 --- a/src/frontend/src/pages/ParticipantDashboard.tsx +++ b/src/frontend/src/pages/ParticipantDashboard.tsx @@ -1,10 +1,20 @@ import WellbeingScoreCard from "../components/WellbeingScoreCard"; import TrendChart from "../components/TrendChart"; +import Card from "../components/ui/Card"; +import Badge from "../components/ui/Badge"; +import Button from "../components/ui/Button"; +import Spinner from "../components/ui/Spinner"; +import SectionHeader from "../components/ui/SectionHeader"; +import { + useParticipantScore, + useParticipantTrend, + useParticipantInsights, +} from "../hooks/useParticipant"; import type { TrendPoint, Insight, WellbeingDomain } from "../types"; -// ---------- placeholder data ---------- +// ---------- placeholder data (used as fallback when API is unavailable) ---------- -const currentScore = { +const fallbackScore = { overall: 74, domains: { physical: 82, @@ -16,7 +26,7 @@ const currentScore = { confidence: 0.87, }; -const trendData: TrendPoint[] = Array.from({ length: 14 }, (_, i) => { +const fallbackTrendData: TrendPoint[] = Array.from({ length: 14 }, (_, i) => { const d = new Date(); d.setDate(d.getDate() - (13 - i)); return { @@ -28,7 +38,7 @@ const trendData: TrendPoint[] = Array.from({ length: 14 }, (_, i) => { }; }); -const insights: Insight[] = [ +const fallbackInsights: Insight[] = [ { id: "1", title: "Strong social connections", @@ -67,24 +77,71 @@ const insights: Insight[] = [ }, ]; -const badgeColor: Record = { - strength: "bg-wellab-100 text-wellab-800", - "growth-area": "bg-amber-100 text-amber-800", - pattern: "bg-blue-100 text-blue-800", +const badgeColorMap: Record = { + strength: "green", + "growth-area": "amber", + pattern: "blue", }; // ---------- component ---------- +const PARTICIPANT_ID = "demo-participant"; + export default function ParticipantDashboard() { + const scoreApi = useParticipantScore(PARTICIPANT_ID); + const trendApi = useParticipantTrend(PARTICIPANT_ID, 14); + const insightsApi = useParticipantInsights(PARTICIPANT_ID); + + // Use API data if available, otherwise fall back to placeholder + const score = scoreApi.data ?? fallbackScore; + const trendData = trendApi.data ?? fallbackTrendData; + const insights = insightsApi.data ?? fallbackInsights; + + const isLoading = scoreApi.loading && trendApi.loading && insightsApi.loading; + const hasError = scoreApi.error || trendApi.error || insightsApi.error; + + if (isLoading) { + return ( +
+ +
+ ); + } + return (

Participant Experience

+ {hasError && ( + +
+

+ Could not load live data. Showing sample data instead. +

+ +
+
+ )} +
>) + : undefined + } + confidence={score.confidence} />
@@ -94,21 +151,14 @@ export default function ParticipantDashboard() { {/* Strength-framed insights */}
-

- Strength-Framed Insights -

+
{insights.map((ins) => ( -
+
- + {ins.type === "growth-area" ? "growth area" : ins.type} - + {ins.domain} @@ -118,7 +168,7 @@ export default function ParticipantDashboard() {

Confidence: {Math.round(ins.confidence * 100)}%

-
+
))}
diff --git a/src/frontend/src/pages/PolicyDashboard.tsx b/src/frontend/src/pages/PolicyDashboard.tsx index 58fd665..971b90b 100644 --- a/src/frontend/src/pages/PolicyDashboard.tsx +++ b/src/frontend/src/pages/PolicyDashboard.tsx @@ -1,3 +1,4 @@ +import Card from "../components/ui/Card"; import type { InterventionROI, RiskBucket } from "../types"; // ---------- placeholder data ---------- @@ -44,19 +45,16 @@ export default function PolicyDashboard() {

Policy Dashboard

{/* Population wellbeing map placeholder */} -
-

- Population Wellbeing by Region -

+
- - - - - + + + + + @@ -94,13 +92,10 @@ export default function PolicyDashboard() { Map visualization will render here once a mapping library is integrated (e.g., Mapbox GL, Leaflet).

- + {/* Risk distribution */} -
-

- Risk Distribution -

+
{riskBuckets.map((b) => (
))}
-
+ {/* Intervention ROI table */} -
-

- Intervention ROI Analysis -

+
RegionPopulationAvg WellbeingTrendDistributionRegionPopulationAvg WellbeingTrendDistribution
- - - + + - - + @@ -182,7 +174,7 @@ export default function PolicyDashboard() {
InterventionTarget Population + InterventionTarget Population Cost / Person + Wellbeing Gain ROIROI
-
+
); } diff --git a/src/frontend/src/pages/ResearcherDashboard.tsx b/src/frontend/src/pages/ResearcherDashboard.tsx index adb4a5b..559e887 100644 --- a/src/frontend/src/pages/ResearcherDashboard.tsx +++ b/src/frontend/src/pages/ResearcherDashboard.tsx @@ -1,3 +1,6 @@ +import { memo } from "react"; +import Card from "../components/ui/Card"; +import Badge from "../components/ui/Badge"; import type { CohortSummary, WellbeingDomain } from "../types"; // ---------- placeholder data ---------- @@ -44,6 +47,81 @@ const qualityMetrics = [ { label: "Outlier flags", value: "12", status: "warn" }, ]; +// ---------- memoized subcomponents ---------- + +const CouplingHeatmap = memo(function CouplingHeatmap() { + return ( + +
+ + + + + ))} + + + + {domains.map((rowD, ri) => ( + + + {couplingMatrix[ri].map((val, ci) => ( + + ))} + + ))} + +
+ {domains.map((d) => ( + + {d.slice(0, 4)} +
+ {rowD.slice(0, 4)} + +
+ {val.toFixed(2)} +
+
+
+
+ ); +}); + +const TrajectoryClusters = memo(function TrajectoryClusters() { + const total = clusterLabels.reduce((s, x) => s + x.count, 0); + + return ( + +
+ {clusterLabels.map((cl) => { + const pct = Math.round((cl.count / total) * 100); + return ( +
+ + {cl.label} +
+
+
+ + {cl.count} ({pct}%) + +
+ ); + })} +
+

+ Clusters derived from 30-day trajectory similarity (DTW + k-means). +

+ + ); +}); + // ---------- component ---------- export default function ResearcherDashboard() { @@ -52,15 +130,12 @@ export default function ResearcherDashboard() {

Researcher Dashboard

{/* Cohort selector */} -
-

- Cohort Selector -

+
{cohorts.map((c) => ( ))}
-
+
- {/* Coupling heatmap */} -
-

- Domain Coupling Heatmap -

-
- - - - - ))} - - - - {domains.map((rowD, ri) => ( - - - {couplingMatrix[ri].map((val, ci) => ( - - ))} - - ))} - -
- {domains.map((d) => ( - - {d.slice(0, 4)} -
- {rowD.slice(0, 4)} - -
- {val.toFixed(2)} -
-
-
-
- - {/* Trajectory clusters */} -
-

- Trajectory Clusters -

-
- {clusterLabels.map((cl) => { - const total = clusterLabels.reduce((s, x) => s + x.count, 0); - const pct = Math.round((cl.count / total) * 100); - return ( -
- - {cl.label} -
-
-
- - {cl.count} ({pct}%) - -
- ); - })} -
-

- Clusters derived from 30-day trajectory similarity (DTW + k-means). -

-
+ +
{/* Data quality monitor */} -
-

- Data Quality Monitor -

+
{qualityMetrics.map((m) => (
{m.label} - + {m.status} - +
))}
-
+
); } diff --git a/src/frontend/tsconfig.json b/src/frontend/tsconfig.json index ef3600f..fc84e35 100644 --- a/src/frontend/tsconfig.json +++ b/src/frontend/tsconfig.json @@ -12,8 +12,8 @@ "noEmit": true, "jsx": "react-jsx", "strict": true, - "noUnusedLocals": false, - "noUnusedParameters": false, + "noUnusedLocals": true, + "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "baseUrl": ".", "paths": { diff --git a/src/ml/__init__.py b/src/ml/__init__.py index bcd132b..087d6b5 100644 --- a/src/ml/__init__.py +++ b/src/ml/__init__.py @@ -10,6 +10,9 @@ - health_engine : Causal behavioral & physiological health modeling - lifespan_trajectory: Growth-curve and trajectory clustering across the lifespan - cognitive_health : Cognitive risk prediction and dementia prevention +- drift : Data drift detection between reference and production data +- serve : FastAPI serving layer for model predictions +- exceptions : Custom exception types for the ML pipeline - utils : Shared data-loading and reproducibility helpers - config : ML configuration constants """ @@ -21,6 +24,9 @@ "health_engine", "lifespan_trajectory", "cognitive_health", + "drift", + "serve", + "exceptions", "utils", "config", ] diff --git a/src/ml/cognitive_health.py b/src/ml/cognitive_health.py index e0ea3fb..5155ae9 100644 --- a/src/ml/cognitive_health.py +++ b/src/ml/cognitive_health.py @@ -8,8 +8,10 @@ from __future__ import annotations import logging +from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Tuple +import joblib import numpy as np import pandas as pd from sklearn.ensemble import GradientBoostingClassifier @@ -24,10 +26,13 @@ _HAS_LIFELINES = False from src.ml.config import COGNITIVE_RISK_PARAMS, RANDOM_SEED +from src.ml.exceptions import ModelNotFittedError from src.ml.utils import set_reproducible_seed logger = logging.getLogger(__name__) +_MODEL_VERSION = "1.0.0" + class CognitiveRiskModel: """Predict cognitive-decline risk and identify protective factors. @@ -136,7 +141,7 @@ def predict_risk( ``high_risk`` columns. """ if not self.is_fitted: - raise RuntimeError("Call fit() before predict_risk().") + raise ModelNotFittedError("CognitiveRiskModel") X = participant_data[self._feature_names].values probas = self._classifier.predict_proba(X)[:, 1] @@ -177,7 +182,7 @@ def identify_protective_factors( (most negative importance = most protective). """ if not self.is_fitted: - raise RuntimeError("Call fit() before identify_protective_factors().") + raise ModelNotFittedError("CognitiveRiskModel") X = data[self._feature_names] y = data[target_col] @@ -205,6 +210,59 @@ def identify_protective_factors( logger.info("Top %d protective factors: %s", top_n, protective) return protective + # ------------------------------------------------------------------ + # Serialization + # ------------------------------------------------------------------ + + def save(self, path: str) -> None: + """Save the model to disk with metadata. + + Parameters + ---------- + path : str + File path for the serialized model. + """ + payload = { + "model": self, + "metadata": { + "model_version": _MODEL_VERSION, + "training_timestamp": datetime.now(timezone.utc).isoformat(), + "config": { + "risk_threshold": self.risk_threshold, + "seed": self.seed, + "n_estimators": self._classifier.n_estimators, + "max_depth": self._classifier.max_depth, + }, + "feature_names": list(self._feature_names), + }, + } + joblib.dump(payload, path) + logger.info("CognitiveRiskModel saved to %s", path) + + @classmethod + def load(cls, path: str) -> "CognitiveRiskModel": + """Load a previously saved model from disk. + + Parameters + ---------- + path : str + File path to the serialized model. + + Returns + ------- + CognitiveRiskModel + The deserialized model instance. + """ + payload = joblib.load(path) + model = payload["model"] + logger.info( + "CognitiveRiskModel loaded from %s (version=%s, trained=%s)", + path, + payload["metadata"]["model_version"], + payload["metadata"]["training_timestamp"], + ) + return model + def survival_analysis( self, time_to_event_data: pd.DataFrame, diff --git a/src/ml/config.py b/src/ml/config.py index c0bc74b..be919d5 100644 --- a/src/ml/config.py +++ b/src/ml/config.py @@ -6,7 +6,11 @@ pipeline modules. """ -from typing import Dict, Any +from __future__ import annotations + +import copy +import os +from typing import Any, Dict, Optional # --------------------------------------------------------------------------- # Reproducibility @@ -77,3 +81,105 @@ "cognitive_risk": COGNITIVE_RISK_PARAMS, "fairness": FAIRNESS_PARAMS, } + + +# --------------------------------------------------------------------------- +# Config loader +# --------------------------------------------------------------------------- + +def _deep_merge(base: Dict[str, Any], overrides: Dict[str, Any]) -> Dict[str, Any]: + """Recursively merge *overrides* into *base*, returning a new dict.""" + merged = copy.deepcopy(base) + for key, value in overrides.items(): + if ( + key in merged + and isinstance(merged[key], dict) + and isinstance(value, dict) + ): + merged[key] = _deep_merge(merged[key], value) + else: + merged[key] = value + return merged + + +def _coerce_value(value: str) -> Any: + """Attempt to coerce a string environment variable to a Python type.""" + if value.lower() in ("true", "yes"): + return True + if value.lower() in ("false", "no"): + return False + try: + return int(value) + except ValueError: + pass + try: + return float(value) + except ValueError: + pass + return value + + +def _env_overrides(prefix: str = "WELLAB_ML_") -> Dict[str, Any]: + """Collect environment variables with the given prefix and return as a dict. + + Environment variable names are lowered and split on ``__`` to create + nested keys. For example, ``WELLAB_ML_HEALTH_ENGINE__SIGNIFICANCE_LEVEL=0.01`` + becomes ``{"health_engine": {"significance_level": 0.01}}``. + """ + overrides: Dict[str, Any] = {} + + for env_key, env_val in os.environ.items(): + if not env_key.startswith(prefix): + continue + + stripped = env_key[len(prefix):].lower() + parts = stripped.split("__") + + current = overrides + for part in parts[:-1]: + current = current.setdefault(part, {}) + current[parts[-1]] = _coerce_value(env_val) + + return overrides + + +def load_config(path: Optional[str] = None) -> Dict[str, Any]: + """Load ML configuration with file -> env-override -> defaults merge. + + Parameters + ---------- + path : str, optional + Path to a YAML configuration file. When *None*, only the + built-in defaults and environment-variable overrides are used. + + Returns + ------- + dict[str, Any] + Fully merged configuration dictionary. + """ + defaults = copy.deepcopy(ML_CONFIG) + + # Layer 1: YAML file (if provided) + file_config: Dict[str, Any] = {} + if path is not None: + try: + import yaml + except ImportError as exc: + raise ImportError( + "PyYAML is required to load YAML config files. " + "Install it with: pip install pyyaml" + ) from exc + + with open(path, "r") as fh: + loaded = yaml.safe_load(fh) + if isinstance(loaded, dict): + file_config = loaded + + # Layer 2: Environment variable overrides + env_config = _env_overrides() + + # Merge: defaults <- file <- env + merged = _deep_merge(defaults, file_config) + merged = _deep_merge(merged, env_config) + + return merged diff --git a/src/ml/drift.py b/src/ml/drift.py new file mode 100644 index 0000000..3fe6b58 --- /dev/null +++ b/src/ml/drift.py @@ -0,0 +1,323 @@ +""" +Data Drift Detection +==================== +Monitors distributional shifts between reference (training) data and +incoming production data for the WELLab ML pipeline. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +import numpy as np +import pandas as pd +from scipy import stats + +from src.ml.exceptions import DataDriftError + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Report dataclass +# --------------------------------------------------------------------------- + +@dataclass +class FeatureDriftResult: + """Drift result for a single feature.""" + + feature: str + statistic: float + p_value: float + test_name: str + drifted: bool + psi: Optional[float] = None + + +@dataclass +class DriftReport: + """Aggregated drift report across all features. + + Attributes + ---------- + feature_results : list[FeatureDriftResult] + Per-feature drift test outcomes. + overall_drifted : bool + Whether overall drift was detected (any feature exceeds threshold). + severity : str + ``"none"``, ``"low"``, ``"moderate"``, or ``"severe"`` based on + the fraction of drifted features. + summary : dict[str, Any] + High-level summary statistics. + """ + + feature_results: List[FeatureDriftResult] = field(default_factory=list) + overall_drifted: bool = False + severity: str = "none" + summary: Dict[str, Any] = field(default_factory=dict) + + @property + def drifted_features(self) -> List[str]: + return [r.feature for r in self.feature_results if r.drifted] + + +# --------------------------------------------------------------------------- +# Detector +# --------------------------------------------------------------------------- + +class DataDriftDetector: + """Detect distributional drift between reference and new data. + + Parameters + ---------- + p_value_threshold : float + Statistical significance level for KS / chi-squared tests. + psi_threshold : float + Population Stability Index threshold above which drift is flagged. + categorical_columns : list[str], optional + Columns to treat as categorical. All others are assumed continuous. + """ + + def __init__( + self, + p_value_threshold: float = 0.05, + psi_threshold: float = 0.20, + categorical_columns: Optional[List[str]] = None, + ) -> None: + self.p_value_threshold = p_value_threshold + self.psi_threshold = psi_threshold + self.categorical_columns: List[str] = categorical_columns or [] + + self._reference: Optional[pd.DataFrame] = None + self._is_fitted: bool = False + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def fit(self, reference_data: pd.DataFrame) -> "DataDriftDetector": + """Store reference distributions. + + Parameters + ---------- + reference_data : pd.DataFrame + The baseline (training) dataset. + + Returns + ------- + DataDriftDetector + ``self``, for method chaining. + """ + self._reference = reference_data.copy() + self._is_fitted = True + logger.info( + "DataDriftDetector fitted on reference data with %d rows, %d columns", + len(reference_data), + len(reference_data.columns), + ) + return self + + def detect(self, new_data: pd.DataFrame) -> DriftReport: + """Compare *new_data* against the stored reference distributions. + + Parameters + ---------- + new_data : pd.DataFrame + Incoming production data to test for drift. + + Returns + ------- + DriftReport + Per-feature results and overall drift status. + """ + if not self._is_fitted or self._reference is None: + raise DataDriftError( + message="DataDriftDetector has not been fitted. Call fit() first." + ) + + common_cols = [ + c for c in self._reference.columns if c in new_data.columns + ] + + feature_results: List[FeatureDriftResult] = [] + + for col in common_cols: + ref_col = self._reference[col].dropna() + new_col = new_data[col].dropna() + + if col in self.categorical_columns: + result = self._test_categorical(col, ref_col, new_col) + else: + result = self._test_continuous(col, ref_col, new_col) + + feature_results.append(result) + + n_drifted = sum(1 for r in feature_results if r.drifted) + n_total = max(len(feature_results), 1) + drift_fraction = n_drifted / n_total + + if drift_fraction == 0: + severity = "none" + elif drift_fraction < 0.2: + severity = "low" + elif drift_fraction < 0.5: + severity = "moderate" + else: + severity = "severe" + + overall_drifted = n_drifted > 0 + + report = DriftReport( + feature_results=feature_results, + overall_drifted=overall_drifted, + severity=severity, + summary={ + "n_features_tested": len(feature_results), + "n_drifted": n_drifted, + "drift_fraction": drift_fraction, + }, + ) + + logger.info( + "Drift detection complete: %d/%d features drifted (severity=%s)", + n_drifted, + len(feature_results), + severity, + ) + return report + + # ------------------------------------------------------------------ + # Statistical tests + # ------------------------------------------------------------------ + + @staticmethod + def population_stability_index( + reference: np.ndarray, + current: np.ndarray, + n_bins: int = 10, + ) -> float: + """Compute the Population Stability Index (PSI) for a feature. + + Parameters + ---------- + reference : np.ndarray + Reference distribution values. + current : np.ndarray + Current distribution values. + n_bins : int + Number of quantile-based bins. + + Returns + ------- + float + PSI value. Values > 0.20 typically indicate significant shift. + """ + eps = 1e-6 + # Use reference quantiles to define bin edges + edges = np.quantile(reference, np.linspace(0, 1, n_bins + 1)) + edges = np.unique(edges) + + ref_counts, _ = np.histogram(reference, bins=edges) + cur_counts, _ = np.histogram(current, bins=edges) + + ref_pct = ref_counts / max(ref_counts.sum(), 1) + eps + cur_pct = cur_counts / max(cur_counts.sum(), 1) + eps + + psi = float(np.sum((cur_pct - ref_pct) * np.log(cur_pct / ref_pct))) + return psi + + @staticmethod + def kolmogorov_smirnov_test( + reference: np.ndarray, + current: np.ndarray, + ) -> tuple: + """Run a two-sample Kolmogorov-Smirnov test. + + Returns + ------- + tuple[float, float] + (statistic, p_value) + """ + stat, p_value = stats.ks_2samp(reference, current) + return float(stat), float(p_value) + + @staticmethod + def chi_squared_test( + reference: np.ndarray, + current: np.ndarray, + ) -> tuple: + """Run a chi-squared test for categorical features. + + Computes observed vs expected frequencies based on the reference + distribution proportions. + + Returns + ------- + tuple[float, float] + (statistic, p_value) + """ + ref_series = pd.Series(reference) + cur_series = pd.Series(current) + + all_categories = list(set(ref_series.unique()) | set(cur_series.unique())) + + ref_counts = ref_series.value_counts() + cur_counts = cur_series.value_counts() + + ref_freq = np.array([ref_counts.get(c, 0) for c in all_categories], dtype=float) + cur_freq = np.array([cur_counts.get(c, 0) for c in all_categories], dtype=float) + + # Expected frequencies scaled to current sample size + ref_total = ref_freq.sum() + cur_total = cur_freq.sum() + if ref_total == 0 or cur_total == 0: + return 0.0, 1.0 + + expected = (ref_freq / ref_total) * cur_total + # Avoid zero expected + expected = np.where(expected == 0, 1e-6, expected) + + stat, p_value = stats.chisquare(cur_freq, f_exp=expected) + return float(stat), float(p_value) + + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ + + def _test_continuous( + self, col: str, ref: pd.Series, new: pd.Series, + ) -> FeatureDriftResult: + ref_arr = ref.values.astype(float) + new_arr = new.values.astype(float) + + ks_stat, ks_p = self.kolmogorov_smirnov_test(ref_arr, new_arr) + psi = self.population_stability_index(ref_arr, new_arr) + + drifted = ks_p < self.p_value_threshold or psi > self.psi_threshold + + return FeatureDriftResult( + feature=col, + statistic=ks_stat, + p_value=ks_p, + test_name="kolmogorov_smirnov", + drifted=drifted, + psi=psi, + ) + + def _test_categorical( + self, col: str, ref: pd.Series, new: pd.Series, + ) -> FeatureDriftResult: + chi_stat, chi_p = self.chi_squared_test(ref.values, new.values) + + drifted = chi_p < self.p_value_threshold + + return FeatureDriftResult( + feature=col, + statistic=chi_stat, + p_value=chi_p, + test_name="chi_squared", + drifted=drifted, + psi=None, + ) diff --git a/src/ml/emotional_dynamics.py b/src/ml/emotional_dynamics.py index 5d7b4c2..9cccd87 100644 --- a/src/ml/emotional_dynamics.py +++ b/src/ml/emotional_dynamics.py @@ -15,8 +15,10 @@ from __future__ import annotations import logging -from typing import Dict, List, Optional +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional +import joblib import numpy as np import pandas as pd from sklearn.linear_model import LinearRegression # noqa: F401 (stub dep) @@ -28,10 +30,13 @@ EMOTION_VOLATILITY_WINDOW, RANDOM_SEED, ) +from src.ml.exceptions import ModelNotFittedError, SchemaValidationError from src.ml.utils import set_reproducible_seed, validate_data_schema logger = logging.getLogger(__name__) +_MODEL_VERSION = "1.0.0" + # Expected schema for the input data _INPUT_SCHEMA = { "participant_id": "object", @@ -110,9 +115,7 @@ def fit(self, data: pd.DataFrame) -> "EmotionCouplingAnalyzer": errors = validate_data_schema(data, _INPUT_SCHEMA) if errors: - raise ValueError( - f"Input data failed schema validation: {errors}" - ) + raise SchemaValidationError(errors=errors) self._data = data.copy() logger.info( @@ -150,7 +153,7 @@ def predict_coupling_type(self, participant_id: str) -> str: or ``"complex"``. """ if self._data is None: - raise RuntimeError("Call fit() before predict_coupling_type().") + raise ModelNotFittedError("EmotionCouplingAnalyzer") subset = self._data.loc[ self._data["participant_id"] == participant_id @@ -203,6 +206,58 @@ def compute_volatility(self, time_series: np.ndarray) -> np.ndarray: ).std() return volatility.to_numpy() + # ------------------------------------------------------------------ + # Serialization + # ------------------------------------------------------------------ + + def save(self, path: str) -> None: + """Save the analyzer to disk with metadata. + + Parameters + ---------- + path : str + File path for the serialized model. + """ + payload = { + "model": self, + "metadata": { + "model_version": _MODEL_VERSION, + "training_timestamp": datetime.now(timezone.utc).isoformat(), + "config": { + "coupling_threshold": self.coupling_threshold, + "volatility_window": self.volatility_window, + "seed": self.seed, + }, + "feature_names": list(_INPUT_SCHEMA.keys()), + }, + } + joblib.dump(payload, path) + logger.info("EmotionCouplingAnalyzer saved to %s", path) + + @classmethod + def load(cls, path: str) -> "EmotionCouplingAnalyzer": + """Load a previously saved analyzer from disk. + + Parameters + ---------- + path : str + File path to the serialized model. + + Returns + ------- + EmotionCouplingAnalyzer + The deserialized analyzer instance. + """ + payload = joblib.load(path) + model = payload["model"] + logger.info( + "EmotionCouplingAnalyzer loaded from %s (version=%s, trained=%s)", + path, + payload["metadata"]["model_version"], + payload["metadata"]["training_timestamp"], + ) + return model + # ------------------------------------------------------------------ # Private helpers # ------------------------------------------------------------------ diff --git a/src/ml/exceptions.py b/src/ml/exceptions.py new file mode 100644 index 0000000..f52cce3 --- /dev/null +++ b/src/ml/exceptions.py @@ -0,0 +1,124 @@ +""" +Custom exceptions for the WELLab ML pipeline. +============================================== +Provides domain-specific error types to replace generic ValueError +and RuntimeError throughout the codebase. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + + +class ModelNotFittedError(Exception): + """Raised when a prediction or inspection method is called before fit().""" + + def __init__(self, model_name: str = "Model") -> None: + self.model_name = model_name + super().__init__( + f"{model_name} has not been fitted yet. Call fit() before using this method." + ) + + +class SchemaValidationError(Exception): + """Raised when input data does not conform to the expected schema. + + Attributes + ---------- + field_errors : dict[str, str] + Mapping of field name to a human-readable description of the + validation failure for that field. + errors : list[str] + Flat list of all validation error messages. + """ + + def __init__( + self, + errors: Optional[List[str]] = None, + field_errors: Optional[Dict[str, str]] = None, + ) -> None: + self.errors: List[str] = errors or [] + self.field_errors: Dict[str, str] = field_errors or {} + detail = "; ".join(self.errors) if self.errors else str(self.field_errors) + super().__init__(f"Schema validation failed: {detail}") + + +class DataDriftError(Exception): + """Raised when significant data drift is detected between reference and new data. + + Attributes + ---------- + drifted_features : list[str] + Names of features that exceeded the drift threshold. + severity : str + Overall drift severity (e.g. "low", "moderate", "severe"). + """ + + def __init__( + self, + drifted_features: Optional[List[str]] = None, + severity: str = "unknown", + message: Optional[str] = None, + ) -> None: + self.drifted_features: List[str] = drifted_features or [] + self.severity = severity + msg = message or ( + f"Data drift detected (severity={severity}) in features: " + f"{self.drifted_features}" + ) + super().__init__(msg) + + +class InsufficientDataError(Exception): + """Raised when there is not enough data to perform the requested analysis. + + Attributes + ---------- + required : int + Minimum number of observations needed. + actual : int + Number of observations actually provided. + """ + + def __init__( + self, + required: int, + actual: int, + context: str = "", + ) -> None: + self.required = required + self.actual = actual + self.context = context + detail = f" ({context})" if context else "" + super().__init__( + f"Insufficient data{detail}: need at least {required} observations, " + f"got {actual}." + ) + + +class FairnessViolationError(Exception): + """Raised when a fairness metric falls below the acceptable threshold. + + Attributes + ---------- + metric : str + Name of the fairness metric that was violated. + value : float + The computed metric value. + threshold : float + The threshold that was not met. + """ + + def __init__( + self, + metric: str, + value: float, + threshold: float, + ) -> None: + self.metric = metric + self.value = value + self.threshold = threshold + super().__init__( + f"Fairness violation: {metric}={value:.4f} " + f"(threshold={threshold:.4f})" + ) diff --git a/src/ml/health_engine.py b/src/ml/health_engine.py index 009553b..6678d40 100644 --- a/src/ml/health_engine.py +++ b/src/ml/health_engine.py @@ -13,8 +13,10 @@ import logging from dataclasses import dataclass, field +from datetime import datetime, timezone from typing import Any, Dict, List, Optional +import joblib import numpy as np import pandas as pd @@ -33,10 +35,13 @@ _HAS_STATSMODELS = False from src.ml.config import HEALTH_ENGINE_PARAMS, RANDOM_SEED +from src.ml.exceptions import InsufficientDataError, SchemaValidationError from src.ml.utils import set_reproducible_seed logger = logging.getLogger(__name__) +_MODEL_VERSION = "1.0.0" + @dataclass class CausalEstimateResult: @@ -109,7 +114,7 @@ def estimate_causal_effect( set_reproducible_seed(self.seed) if data is None: - raise ValueError("data must be provided") + raise InsufficientDataError(required=1, actual=0, context="data must be provided") logger.info( "Estimating causal effect: %s -> %s | %s", @@ -220,6 +225,58 @@ def run_longitudinal_regression( "method": "stub_ols_per_group", } + # ------------------------------------------------------------------ + # Serialization + # ------------------------------------------------------------------ + + def save(self, path: str) -> None: + """Save the analyzer to disk with metadata. + + Parameters + ---------- + path : str + File path for the serialized model. + """ + payload = { + "model": self, + "metadata": { + "model_version": _MODEL_VERSION, + "training_timestamp": datetime.now(timezone.utc).isoformat(), + "config": { + "significance_level": self.significance_level, + "causal_method": self.causal_method, + "seed": self.seed, + }, + "feature_names": [], + }, + } + joblib.dump(payload, path) + logger.info("CausalHealthAnalyzer saved to %s", path) + + @classmethod + def load(cls, path: str) -> "CausalHealthAnalyzer": + """Load a previously saved analyzer from disk. + + Parameters + ---------- + path : str + File path to the serialized model. + + Returns + ------- + CausalHealthAnalyzer + The deserialized analyzer instance. + """ + payload = joblib.load(path) + model = payload["model"] + logger.info( + "CausalHealthAnalyzer loaded from %s (version=%s, trained=%s)", + path, + payload["metadata"]["model_version"], + payload["metadata"]["training_timestamp"], + ) + return model + def bidirectional_analysis( self, wellbeing_data: pd.DataFrame, @@ -257,7 +314,10 @@ def bidirectional_analysis( ) if merged.empty: - raise ValueError("Merge produced an empty DataFrame; check join keys.") + raise InsufficientDataError( + required=1, actual=0, + context="Merge produced an empty DataFrame; check join keys", + ) logger.info("Bidirectional analysis on %d merged rows", len(merged)) diff --git a/src/ml/lifespan_trajectory.py b/src/ml/lifespan_trajectory.py index c4efa48..514a79a 100644 --- a/src/ml/lifespan_trajectory.py +++ b/src/ml/lifespan_trajectory.py @@ -8,8 +8,10 @@ from __future__ import annotations import logging +from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Tuple +import joblib import numpy as np import pandas as pd from sklearn.cluster import KMeans @@ -23,10 +25,13 @@ _HAS_STATSMODELS = False from src.ml.config import RANDOM_SEED, TRAJECTORY_PARAMS +from src.ml.exceptions import InsufficientDataError from src.ml.utils import set_reproducible_seed logger = logging.getLogger(__name__) +_MODEL_VERSION = "1.0.0" + class TrajectoryAnalyzer: """Model lifespan developmental trajectories. @@ -185,6 +190,58 @@ def cluster_trajectories( "inertia": float(self._cluster_model.inertia_), } + # ------------------------------------------------------------------ + # Serialization + # ------------------------------------------------------------------ + + def save(self, path: str) -> None: + """Save the analyzer to disk with metadata. + + Parameters + ---------- + path : str + File path for the serialized model. + """ + payload = { + "model": self, + "metadata": { + "model_version": _MODEL_VERSION, + "training_timestamp": datetime.now(timezone.utc).isoformat(), + "config": { + "max_degree": self.max_degree, + "n_clusters": self.n_clusters, + "seed": self.seed, + }, + "feature_names": list(self._growth_models.keys()), + }, + } + joblib.dump(payload, path) + logger.info("TrajectoryAnalyzer saved to %s", path) + + @classmethod + def load(cls, path: str) -> "TrajectoryAnalyzer": + """Load a previously saved analyzer from disk. + + Parameters + ---------- + path : str + File path to the serialized model. + + Returns + ------- + TrajectoryAnalyzer + The deserialized analyzer instance. + """ + payload = joblib.load(path) + model = payload["model"] + logger.info( + "TrajectoryAnalyzer loaded from %s (version=%s, trained=%s)", + path, + payload["metadata"]["model_version"], + payload["metadata"]["training_timestamp"], + ) + return model + def cross_cultural_comparison( self, cohort_a: pd.DataFrame, diff --git a/src/ml/serve.py b/src/ml/serve.py new file mode 100644 index 0000000..2de1923 --- /dev/null +++ b/src/ml/serve.py @@ -0,0 +1,273 @@ +""" +FastAPI Serving Layer +===================== +Production API endpoints for the WELLab ML pipeline models. +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel, Field + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Pydantic request / response models +# --------------------------------------------------------------------------- + + +class EmotionalDynamicsRequest(BaseModel): + """Request body for emotional dynamics prediction.""" + + participant_ids: List[str] = Field(..., description="Participant IDs to analyse") + time: List[float] = Field(..., description="Time points") + positive_affect: List[float] = Field(..., description="Positive affect scores") + negative_affect: List[float] = Field(..., description="Negative affect scores") + coupling_threshold: Optional[float] = Field(None, description="Override coupling threshold") + + +class EmotionalDynamicsResponse(BaseModel): + """Response body for emotional dynamics prediction.""" + + coupling_results: Dict[str, str] + n_participants: int + model_version: str + + +class CognitiveRiskRequest(BaseModel): + """Request body for cognitive risk assessment.""" + + features: Dict[str, List[float]] = Field( + ..., description="Feature columns as {name: [values]}" + ) + participant_ids: Optional[List[str]] = Field(None, description="Optional participant IDs") + + +class CognitiveRiskResponse(BaseModel): + """Response body for cognitive risk assessment.""" + + risk_probabilities: List[float] + high_risk_flags: List[bool] + model_version: str + + +class TrajectoryRequest(BaseModel): + """Request body for trajectory clustering.""" + + participant_ids: List[str] = Field(..., description="Participant IDs") + age: List[float] = Field(..., description="Age values") + wellbeing: List[float] = Field(..., description="Well-being scores") + n_clusters: Optional[int] = Field(None, description="Override cluster count") + + +class TrajectoryResponse(BaseModel): + """Response body for trajectory clustering.""" + + assignments: Dict[str, int] + centroids: List[List[float]] + n_clusters: int + model_version: str + + +class HealthResponse(BaseModel): + """Health-check response.""" + + status: str + timestamp: str + models_loaded: Dict[str, bool] + + +class ModelInfo(BaseModel): + """Information about a loaded model.""" + + name: str + version: str + is_loaded: bool + + +class ModelsResponse(BaseModel): + """Response listing all available models.""" + + models: List[ModelInfo] + + +# --------------------------------------------------------------------------- +# Application state +# --------------------------------------------------------------------------- + +_MODEL_REGISTRY: Dict[str, Any] = {} +_MODEL_VERSIONS: Dict[str, str] = { + "emotional_dynamics": "1.0.0", + "cognitive_risk": "1.0.0", + "trajectory": "1.0.0", +} + + +def _load_models() -> None: + """Load models into the registry on startup.""" + import pandas as pd + + from src.ml.cognitive_health import CognitiveRiskModel + from src.ml.emotional_dynamics import EmotionCouplingAnalyzer + from src.ml.lifespan_trajectory import TrajectoryAnalyzer + + _MODEL_REGISTRY["emotional_dynamics"] = EmotionCouplingAnalyzer() + _MODEL_REGISTRY["cognitive_risk"] = CognitiveRiskModel() + _MODEL_REGISTRY["trajectory"] = TrajectoryAnalyzer() + + logger.info("All models loaded into registry") + + +# --------------------------------------------------------------------------- +# FastAPI app +# --------------------------------------------------------------------------- + +app = FastAPI( + title="WELLab ML API", + description="Production serving layer for WELLab ML pipeline models.", + version="1.0.0", +) + + +@app.on_event("startup") +async def startup_event() -> None: + """Load models on application startup.""" + _load_models() + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + + +@app.post("/predict/emotional-dynamics", response_model=EmotionalDynamicsResponse) +async def predict_emotional_dynamics(request: EmotionalDynamicsRequest) -> EmotionalDynamicsResponse: + """Run emotion coupling analysis on provided affect data.""" + import pandas as pd + + from src.ml.exceptions import ModelNotFittedError, SchemaValidationError + + analyzer = _MODEL_REGISTRY.get("emotional_dynamics") + if analyzer is None: + raise HTTPException(status_code=503, detail="Emotional dynamics model not loaded") + + try: + data = pd.DataFrame({ + "participant_id": request.participant_ids, + "time": request.time, + "positive_affect": request.positive_affect, + "negative_affect": request.negative_affect, + }) + + if request.coupling_threshold is not None: + analyzer.coupling_threshold = request.coupling_threshold + + analyzer.fit(data) + + return EmotionalDynamicsResponse( + coupling_results=analyzer.coupling_results_, + n_participants=data["participant_id"].nunique(), + model_version=_MODEL_VERSIONS["emotional_dynamics"], + ) + except SchemaValidationError as exc: + raise HTTPException(status_code=422, detail=str(exc)) + except Exception as exc: + logger.exception("Error in emotional dynamics prediction") + raise HTTPException(status_code=500, detail=str(exc)) + + +@app.post("/predict/cognitive-risk", response_model=CognitiveRiskResponse) +async def predict_cognitive_risk(request: CognitiveRiskRequest) -> CognitiveRiskResponse: + """Run cognitive risk assessment on provided features.""" + import pandas as pd + + from src.ml.exceptions import ModelNotFittedError + + model = _MODEL_REGISTRY.get("cognitive_risk") + if model is None: + raise HTTPException(status_code=503, detail="Cognitive risk model not loaded") + + try: + data = pd.DataFrame(request.features) + + if not model.is_fitted: + raise ModelNotFittedError("CognitiveRiskModel") + + result = model.predict_risk(data) + + return CognitiveRiskResponse( + risk_probabilities=result["risk_probability"].tolist(), + high_risk_flags=result["high_risk"].tolist(), + model_version=_MODEL_VERSIONS["cognitive_risk"], + ) + except ModelNotFittedError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + except KeyError as exc: + raise HTTPException(status_code=422, detail=f"Missing feature column: {exc}") + except Exception as exc: + logger.exception("Error in cognitive risk prediction") + raise HTTPException(status_code=500, detail=str(exc)) + + +@app.post("/predict/trajectory", response_model=TrajectoryResponse) +async def predict_trajectory(request: TrajectoryRequest) -> TrajectoryResponse: + """Run trajectory clustering on provided lifespan data.""" + import pandas as pd + + analyzer = _MODEL_REGISTRY.get("trajectory") + if analyzer is None: + raise HTTPException(status_code=503, detail="Trajectory model not loaded") + + try: + data = pd.DataFrame({ + "participant_id": request.participant_ids, + "age": request.age, + "wellbeing": request.wellbeing, + }) + + n_clusters = request.n_clusters + result = analyzer.cluster_trajectories(data, n_clusters=n_clusters) + + return TrajectoryResponse( + assignments=result["assignments"], + centroids=result["centroids"], + n_clusters=result["n_clusters"], + model_version=_MODEL_VERSIONS["trajectory"], + ) + except Exception as exc: + logger.exception("Error in trajectory prediction") + raise HTTPException(status_code=500, detail=str(exc)) + + +@app.get("/health", response_model=HealthResponse) +async def health_check() -> HealthResponse: + """Health check with model status.""" + models_loaded = { + name: name in _MODEL_REGISTRY + for name in _MODEL_VERSIONS + } + + return HealthResponse( + status="healthy", + timestamp=datetime.now(timezone.utc).isoformat(), + models_loaded=models_loaded, + ) + + +@app.get("/models", response_model=ModelsResponse) +async def list_models() -> ModelsResponse: + """List loaded models with versions.""" + models = [] + for name, version in _MODEL_VERSIONS.items(): + models.append( + ModelInfo( + name=name, + version=version, + is_loaded=name in _MODEL_REGISTRY, + ) + ) + return ModelsResponse(models=models) diff --git a/src/ml/utils.py b/src/ml/utils.py index 53a695b..ab0abdd 100644 --- a/src/ml/utils.py +++ b/src/ml/utils.py @@ -8,6 +8,8 @@ from __future__ import annotations import logging +import os +import random from typing import Any, Dict, List, Optional import numpy as np @@ -30,11 +32,20 @@ def set_reproducible_seed(seed: int = RANDOM_SEED) -> None: seed : int The seed value to use across all RNGs. """ + random.seed(seed) + os.environ['PYTHONHASHSEED'] = str(seed) np.random.seed(seed) logger.info("NumPy random seed set to %d", seed) - # TODO: Add torch.manual_seed(seed) when PyTorch is added as a dependency - # TODO: Add tf.random.set_seed(seed) if TensorFlow is ever used + try: + import torch + torch.manual_seed(seed) + torch.cuda.manual_seed_all(seed) + torch.backends.cudnn.deterministic = True + torch.backends.cudnn.benchmark = False + logger.info("PyTorch random seeds set to %d", seed) + except ImportError: + pass # --------------------------------------------------------------------------- diff --git a/tests/test_emotional_dynamics.py b/tests/test_emotional_dynamics.py index ee4791f..9f3b318 100644 --- a/tests/test_emotional_dynamics.py +++ b/tests/test_emotional_dynamics.py @@ -7,6 +7,7 @@ import pytest from src.ml.emotional_dynamics import EmotionCouplingAnalyzer +from src.ml.exceptions import ModelNotFittedError, SchemaValidationError # --------------------------------------------------------------------------- @@ -94,7 +95,7 @@ def test_decoupled_detected(self) -> None: def test_predict_coupling_type_before_fit_raises(self) -> None: analyzer = EmotionCouplingAnalyzer() - with pytest.raises(RuntimeError, match="fit"): + with pytest.raises(ModelNotFittedError): analyzer.predict_coupling_type("P000") def test_predict_coupling_type_unknown_participant(self) -> None: @@ -119,7 +120,7 @@ def test_compute_volatility_values_nonnegative(self) -> None: def test_fit_rejects_bad_schema(self) -> None: bad_data = pd.DataFrame({"x": [1, 2], "y": [3, 4]}) analyzer = EmotionCouplingAnalyzer() - with pytest.raises(ValueError, match="schema validation"): + with pytest.raises(SchemaValidationError): analyzer.fit(bad_data) def test_coupling_types_constant(self) -> None: diff --git a/tests/test_fairness_audit.py b/tests/test_fairness_audit.py index c60376a..23cf777 100644 --- a/tests/test_fairness_audit.py +++ b/tests/test_fairness_audit.py @@ -187,3 +187,142 @@ def test_cli_missing_column_exits(self) -> None: ]) finally: os.unlink(tmp_path) + + +# --------------------------------------------------------------------------- +# Tests -- Equalized Odds +# --------------------------------------------------------------------------- + +class TestEqualizedOdds: + """Tests for equalized_odds.""" + + def test_equal_tpr_fpr_passes(self) -> None: + preds = np.array([1, 0, 1, 0, 1, 0, 1, 0]) + labels = np.array([1, 0, 1, 0, 1, 0, 1, 0]) + groups = np.array(["A", "A", "A", "A", "B", "B", "B", "B"]) + auditor = FairnessAuditor(demographic_parity_tolerance=0.1) + result = auditor.equalized_odds(preds, labels, groups) + assert result["passed"] is True + assert result["max_tpr_difference"] <= 0.1 + assert result["max_fpr_difference"] <= 0.1 + + def test_unequal_tpr_fails(self) -> None: + # Group A: all correct, Group B: all wrong predictions for positives + preds = np.array([1, 1, 0, 0, 0, 0, 0, 0]) + labels = np.array([1, 1, 0, 0, 1, 1, 0, 0]) + groups = np.array(["A", "A", "A", "A", "B", "B", "B", "B"]) + auditor = FairnessAuditor(demographic_parity_tolerance=0.05) + result = auditor.equalized_odds(preds, labels, groups) + assert result["passed"] is False + assert result["max_tpr_difference"] > 0.05 + + def test_returns_per_group_rates(self) -> None: + preds = np.array([1, 0, 1, 0]) + labels = np.array([1, 0, 1, 0]) + groups = np.array(["A", "A", "B", "B"]) + auditor = FairnessAuditor() + result = auditor.equalized_odds(preds, labels, groups) + assert "group_tpr" in result + assert "group_fpr" in result + assert set(result["group_tpr"].keys()) == {"A", "B"} + + +# --------------------------------------------------------------------------- +# Tests -- Calibration by Group +# --------------------------------------------------------------------------- + +class TestCalibrationByGroup: + """Tests for calibration_by_group.""" + + def test_well_calibrated_returns_low_ece(self) -> None: + rng = np.random.RandomState(42) + n = 200 + probs = rng.uniform(0, 1, n) + labels = (rng.uniform(0, 1, n) < probs).astype(int) + groups = np.array(["A"] * 100 + ["B"] * 100) + auditor = FairnessAuditor() + result = auditor.calibration_by_group(probs, labels, groups, n_bins=5) + assert "group_calibration" in result + for g_name, g_data in result["group_calibration"].items(): + assert "expected_calibration_error" in g_data + # Well-calibrated data should have relatively low ECE + assert g_data["expected_calibration_error"] < 0.3 + + def test_max_ece_difference_computed(self) -> None: + probs = np.array([0.1, 0.9, 0.1, 0.9, 0.5, 0.5, 0.5, 0.5]) + labels = np.array([0, 1, 0, 1, 1, 1, 0, 0]) + groups = np.array(["A", "A", "A", "A", "B", "B", "B", "B"]) + auditor = FairnessAuditor() + result = auditor.calibration_by_group(probs, labels, groups) + assert "max_ece_difference" in result + assert isinstance(result["max_ece_difference"], float) + + def test_single_group_calibration(self) -> None: + probs = np.array([0.2, 0.8, 0.5]) + labels = np.array([0, 1, 1]) + groups = np.array(["X", "X", "X"]) + auditor = FairnessAuditor() + result = auditor.calibration_by_group(probs, labels, groups) + assert "X" in result["group_calibration"] + assert result["max_ece_difference"] == 0.0 + + +# --------------------------------------------------------------------------- +# Tests -- Intersectional Audit +# --------------------------------------------------------------------------- + +class TestIntersectionalAudit: + """Tests for intersectional_audit.""" + + def test_intersectional_groups_created(self) -> None: + preds = np.array([1, 0, 1, 0, 1, 0, 1, 0]) + auditor = FairnessAuditor() + result = auditor.intersectional_audit( + preds, + protected_attributes=["gender", "race"], + attribute_arrays={ + "gender": np.array(["M", "M", "F", "F", "M", "M", "F", "F"]), + "race": np.array(["W", "B", "W", "B", "W", "B", "W", "B"]), + }, + ) + assert "intersections" in result + # Should have intersections like M_x_W, M_x_B, F_x_W, F_x_B + assert len(result["intersections"]) == 4 + + def test_intersectional_fair_data_passes(self) -> None: + preds = np.array([1, 0, 1, 0, 1, 0, 1, 0]) + auditor = FairnessAuditor(disparate_impact_floor=0.80) + result = auditor.intersectional_audit( + preds, + protected_attributes=["group"], + attribute_arrays={"group": np.array(["A", "A", "B", "B", "A", "A", "B", "B"])}, + ) + assert result["passed"] is True + + +# --------------------------------------------------------------------------- +# Tests -- Bootstrap Confidence Intervals +# --------------------------------------------------------------------------- + +class TestBootstrapCI: + """Tests for confidence interval methods.""" + + def test_dp_with_ci_has_interval(self) -> None: + preds, groups = _fair_predictions() + auditor = FairnessAuditor() + result = auditor.compute_demographic_parity_with_ci( + preds, groups, n_bootstrap=100, + ) + assert "max_difference_ci" in result + ci_low, ci_high = result["max_difference_ci"] + assert ci_low <= ci_high + + def test_di_with_ci_has_interval(self) -> None: + preds, groups = _fair_predictions() + auditor = FairnessAuditor() + result = auditor.compute_disparate_impact_with_ci( + preds, groups, n_bootstrap=100, + ) + assert "disparate_impact_ratio_ci" in result + ci_low, ci_high = result["disparate_impact_ratio_ci"] + assert ci_low <= ci_high