diff --git a/.codespell-ignore.txt b/.codespell-ignore.txt new file mode 100644 index 0000000000000000000000000000000000000000..1187b008a6d04fce205d238926285a7777d50b5b --- /dev/null +++ b/.codespell-ignore.txt @@ -0,0 +1,4 @@ +Demog +ONS +Claus +claus diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000000000000000000000000000000000000..0a553a334d248ee060332e795d021bd80b3451ed --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,23 @@ +// """Dev container Local development""" +{ + "name": "sentinel", + // "dockerFile": "Dockerfile", + "image": "python:3.12-slim", + // "initializeCommand": ". ./.env", + "postCreateCommand": "bash ./.devcontainer/setup.sh", + "build": { + "args": {}, + "options": [ + "--platform=linux/amd64" + ] + }, + "runArgs": [ + "--platform=linux/amd64", + "--add-host=host.docker.internal:host-gateway" + ], + "remoteUser": "root", + "containerUser": "root", + "mounts": [ + "source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind" + ] + } diff --git a/.devcontainer/setup.sh b/.devcontainer/setup.sh new file mode 100644 index 0000000000000000000000000000000000000000..02efc56fd4c19c4585083e19643abc1e3f476627 --- /dev/null +++ b/.devcontainer/setup.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -ex + +# Update package lists +apt-get update + +# ----- Linux Packages ----- # + +apt-get install -y curl wget + +# ----- Locales ----- # +# Install locales and configure +apt-get install -y locales +echo "en_US.UTF-8 UTF-8" > /etc/locale.gen +locale-gen en_US.UTF-8 +update-locale LANG=en_US.UTF-8 + +# ----------------- Python ----------------- + +# Update package lists +apt-get update + +# Install necessary packages +apt-get install -y ssh locales git + +# Configure locale +echo "en_US.UTF-8 UTF-8" > /etc/locale.gen +locale-gen + +# Git configuration +git config --global --add safe.directory /workspaces/sentinel + +# Install Python package in editable mode +pip install --editable . + +# Stash any changes before rebuilding the container +git stash push -m "Stashed changes before (re)building the container" +git stash apply 0 + + +# ----------------- Docker ----------------- + +apt-get update && apt-get install -y docker.io && apt-get clean -y + +# ----------------- Google Cloud SDK ----------------- + +# Install prerequisites for Google Cloud SDK +apt-get install -y apt-transport-https ca-certificates gnupg curl + +# Import the Google Cloud public key +curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | gpg --dearmor -o /usr/share/keyrings/cloud.google.gpg + +# Add the Google Cloud SDK repository +echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" | tee /etc/apt/sources.list.d/google-cloud-sdk.list + +# Update package lists again with new repository +apt-get update + +# Install Google Cloud CLI +apt-get install -y google-cloud-cli + +# Authenticate Docker with Google Cloud +gcloud auth configure-docker -q gcr.io + +# gcloud auth login --project --no-launch-browser diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..ab17b846e24c0be1b9a2280154d9fa0ff060d55f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,57 @@ +# Python cache +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python + +# Virtual environments +.venv/ +venv/ +ENV/ +env/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Git +.git/ +.gitignore +.github/ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +*.cover + +# Documentation +*.md +!README.md +docs/ + +# Environment files +.env +.env.* + +# Build artifacts +build/ +dist/ +*.egg-info/ + +# Jupyter +.ipynb_checkpoints/ +*.ipynb + +# OS +.DS_Store +Thumbs.db + +# Temporary files +tmp/ +temp/ +*.log diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..c92503d01c9fc66e65f800440776cf58c4ef263b --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Rename this file to .env and fill in your API keys +GOOGLE_API_KEY="your_google_api_key_here" +OPENAI_API_KEY="your_openai_api_key_here" + +# Local Ollama server +OLLAMA_BASE_URL=http://localhost:11434 diff --git a/.github/ISSUE_TEMPLATE/build.yaml b/.github/ISSUE_TEMPLATE/build.yaml new file mode 100644 index 0000000000000000000000000000000000000000..16e48ed71538953cd1c4a450ccf51312cf8ded54 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/build.yaml @@ -0,0 +1,19 @@ +name: "Build request" +description: Changes to the build system or dependencies, such as build scripts or configuration updates. +title: "build(): " +labels: [build] +projects: [instadeepai/141] +body: + - type: textarea + id: unclear_section + attributes: + label: What dependency/dockerfile/image should be changed? + description: Inform what should be changed. + placeholder: Inform where and what should be changed. + + - type: textarea + id: solution_description + attributes: + label: Describe the change you'd like + description: Inform the change requested. + placeholder: Bump package X from version 1.0.0 to version 1.0.1 diff --git a/.github/ISSUE_TEMPLATE/chore.yaml b/.github/ISSUE_TEMPLATE/chore.yaml new file mode 100644 index 0000000000000000000000000000000000000000..afb162bbb0ec285368706e87fafae0f28ec08b59 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/chore.yaml @@ -0,0 +1,12 @@ +name: "Chore request" +description: Routine tasks or administrative updates not directly related to code functionality. +title: "chore(): " +labels: [chore] +projects: [instadeepai/141] +body: + - type: textarea + id: solution_description + attributes: + label: Describe the solution you'd like + description: The solution that should be implemented. + placeholder: E.g. Add secrets to GitHub Secret. diff --git a/.github/ISSUE_TEMPLATE/ci.yaml b/.github/ISSUE_TEMPLATE/ci.yaml new file mode 100644 index 0000000000000000000000000000000000000000..83c80552285830a2363e5b16f28a3efaca567b0b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/ci.yaml @@ -0,0 +1,11 @@ +name: "CI request" +description: Modifications related to continuous integration and deployment processes. +title: "ci: " +labels: [ci] +projects: [instadeepai/141] +body: + - type: textarea + id: unclear_section + attributes: + label: What CI modification is needed? + description: Provide a clear and concise description of what should be done and where. diff --git a/.github/ISSUE_TEMPLATE/docs.yaml b/.github/ISSUE_TEMPLATE/docs.yaml new file mode 100644 index 0000000000000000000000000000000000000000..dffa880108f31780c5e8c3208bc161ba5ca3caf6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/docs.yaml @@ -0,0 +1,12 @@ +name: "Documentation request" +description: Request documentation for functions, scripts, modules, etc. +title: "docs(): " +labels: [docs] +projects: [instadeepai/141] +body: + - type: textarea + id: unclear_section + attributes: + label: What is not clear for you? + description: Provide a clear and concise description of what is unclear. For example, mention specific parts of the code or documentation that are difficult to understand. + placeholder: Describe the problem you encountered. diff --git a/.github/ISSUE_TEMPLATE/feat.yaml b/.github/ISSUE_TEMPLATE/feat.yaml new file mode 100644 index 0000000000000000000000000000000000000000..5d121c5223897905db3f1b68ce05c730f2e4023b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feat.yaml @@ -0,0 +1,19 @@ +name: "Feature request" +description: Request a new feature or enhancement to existing functionality. +title: "feat(): " +labels: [feat] +projects: [instadeepai/141] +body: + - type: textarea + id: tasks + attributes: + label: What are the tasks? + description: Provide a clear and concise description of the tasks to be completed. Include details about priority, urgency, and due dates. + placeholder: List and describe the tasks. + + - type: textarea + id: deliverables + attributes: + label: What are the expected deliverables? + description: Provide a detailed description of the expected deliverables, including minimal deliverables, nice-to-have features, and follow-up actions. + placeholder: Describe the deliverables and outcomes. diff --git a/.github/ISSUE_TEMPLATE/fix.yaml b/.github/ISSUE_TEMPLATE/fix.yaml new file mode 100644 index 0000000000000000000000000000000000000000..47c3464a43c995d128defe69b65ab198851c2367 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/fix.yaml @@ -0,0 +1,24 @@ +name: "Fix request" +description: Bug fixes or patches to resolve issues in the codebase. +title: "fix(): " +labels: [bug, fix] +projects: [instadeepai/141] +body: + - type: textarea + id: description + attributes: + label: Describe the bug + description: A clear and concise description of what the bug is. Include the current behavior versus the expected behavior. Add any other context about the problem here as well. + placeholder: What brings you to realize this bug? + + - type: textarea + id: to_reproduce + attributes: + label: To reproduce + description: Code snippet to reproduce the bug if possible. + + - type: textarea + id: solution + attributes: + label: Proposed solution + description: What solution do you propose to fix the bug? What are the alternatives? diff --git a/.github/ISSUE_TEMPLATE/perf.yaml b/.github/ISSUE_TEMPLATE/perf.yaml new file mode 100644 index 0000000000000000000000000000000000000000..4504950aec05fdd938c04e8da4a21286c031f833 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/perf.yaml @@ -0,0 +1,12 @@ +name: "Performance refactor request" +description: Performance improvements, such as optimizations to make the code faster or more efficient. +title: "perf(): " +labels: [perf] +projects: [instadeepai/141] +body: + - type: textarea + id: challenges + attributes: + label: What should have performance improvement? + description: Performance improvements, such as optimizations to make the code faster or more efficient. + placeholder: A clear and concise description of what should have a performance improvement. diff --git a/.github/ISSUE_TEMPLATE/refactor.yaml b/.github/ISSUE_TEMPLATE/refactor.yaml new file mode 100644 index 0000000000000000000000000000000000000000..8cf5a1a8e88ace9676c7830ec5df659332a84e52 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/refactor.yaml @@ -0,0 +1,19 @@ +name: "Code refactor request" +description: Request for code refactoring to improve code quality and structure. +title: "refactor(): " +labels: [refactor] +projects: [instadeepai/141] +body: + - type: textarea + id: challenges + attributes: + label: What should be changed? + description: A clear and concise description of what and where should be changed. + placeholder: What issues have you identified in the current code? + + - type: textarea + id: suggestions + attributes: + label: What are the suggestions? + description: A description of the proposed code or file structure. Include advantages and disadvantages. Note refactoring should not break existing functionality. + placeholder: What changes do you propose? diff --git a/.github/ISSUE_TEMPLATE/style.yaml b/.github/ISSUE_TEMPLATE/style.yaml new file mode 100644 index 0000000000000000000000000000000000000000..57c7bd77cb339206775eb50d96d19a52b5272351 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/style.yaml @@ -0,0 +1,12 @@ +name: " Style refactor request" +description: Changes to code formatting and style, without affecting functionality. +title: "style(): " +labels: [style] +projects: [instadeepai/141] +body: + - type: textarea + id: challenges + attributes: + label: What are the style that needs to be changed? + description: A clear and concise description of what needs to be changed. + placeholder: What issues have you identified in the current code? diff --git a/.github/ISSUE_TEMPLATE/test.yaml b/.github/ISSUE_TEMPLATE/test.yaml new file mode 100644 index 0000000000000000000000000000000000000000..693aa9659e9dd69048eb75157899bddbd10bc8c6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/test.yaml @@ -0,0 +1,12 @@ +name: "Tests" +description: Updates related to testing, including adding or modifying test cases. +title: "test(): " +labels: [test] +projects: [instadeepai/141] +body: + - type: textarea + id: challenges + attributes: + label: What needs to be tested? + description: A clear and concise description of what needs to be tested and where. + placeholder: What needs to be tested in the current code? diff --git a/.github/actions/tools/huggingface/action.yaml b/.github/actions/tools/huggingface/action.yaml new file mode 100644 index 0000000000000000000000000000000000000000..a81e7a1c47e88983b95a917e37090e967bddc29d --- /dev/null +++ b/.github/actions/tools/huggingface/action.yaml @@ -0,0 +1,68 @@ +name: 'HuggingFace Space' +description: 'Push to a HuggingFace Space repository' +inputs: + token: + description: 'Hugging Face API token' + required: true + space: + description: 'Hugging Face Space name' + required: true + branch: + description: 'Branch to push to' + required: true + runtime-secrets: + description: 'Runtime secrets to sync to HuggingFace Space' + required: false +runs: + using: 'composite' + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + lfs: true + + - name: Check large files + uses: ActionsDesk/lfs-warning@v2.0 + with: + filesizelimit: 10485760 # this is 10MB so we can sync to HF Spaces + + - name: Install HuggingFace CLI + shell: bash + run: pip install -U "huggingface_hub[cli]" + + - name: Push to HuggingFace Space + shell: bash + env: + HF_TOKEN: ${{ inputs.token }} + run: | + export PATH="$HOME/.local/bin:$PATH" + hf auth login --token $HF_TOKEN + hf upload ${{ inputs.space }} . . --repo-type=space --revision=${{ inputs.branch }} --commit-message="Sync from GitHub (${{ inputs.branch }})" + + - name: Configure Space Secrets + if: ${{ inputs.runtime-secrets != '' }} + shell: bash + env: + HF_SPACE: ${{ inputs.space }} + HF_TOKEN: ${{ inputs.token }} + RUNTIME_SECRETS: ${{ inputs.runtime-secrets }} + run: | + python3 ${GITHUB_ACTION_PATH}/secrets.py + + - name: Create deployment summary + shell: bash + run: | + if [ "${{ inputs.branch }}" = "main" ]; then + SPACE_URL="https://huggingface.co/spaces/${{ inputs.space }}" + BRANCH_TEXT="main" + else + SPACE_URL="https://huggingface.co/spaces/${{ inputs.space }}/tree/${{ inputs.branch }}" + BRANCH_TEXT="${{ inputs.branch }}" + fi + + echo "## 🚀 HuggingFace Space Deployment" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "✅ Successfully deployed to **${BRANCH_TEXT}** branch" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "🔗 **App URL:** ${SPACE_URL}" >> $GITHUB_STEP_SUMMARY diff --git a/.github/actions/tools/huggingface/secrets.py b/.github/actions/tools/huggingface/secrets.py new file mode 100644 index 0000000000000000000000000000000000000000..2d80a5180f86e09ea0450f6c7142f0d749bc70f7 --- /dev/null +++ b/.github/actions/tools/huggingface/secrets.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +""" +Sync secrets from GitHub Actions to HuggingFace Space. + +Reads configuration from environment variables: + HF_SPACE: HuggingFace Space repository ID (e.g., "InstaDeepAI/sentinel") + HF_TOKEN: HuggingFace API token + RUNTIME_SECRETS: Multi-line string with secrets in format "KEY: value" +""" + +import logging +import os + +from huggingface_hub import HfApi + +# Configure logging +logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + + +def extract(payload): + """Parse secrets from YAML-like format. + + Args: + payload: Multi-line string with secrets in format "KEY: value" + + Returns: + Dictionary mapping secret keys to values + """ + secrets = {} + if not payload: + return secrets + + for line in payload.strip().split("\n"): + line = line.strip() + if ":" in line and line: + key, value = line.split(":", 1) + key = key.strip() + value = value.strip() + + if key and value: # Only add if both key and value are non-empty + secrets[key] = value + + return secrets + + +def upload(repository, token, payload): + """Sync secrets to HuggingFace Space. + + Args: + repository: HuggingFace Space repository ID + token: HuggingFace API token + payload: Multi-line string with secrets in format "KEY: value" + + Raises: + RuntimeError: If any secret fails to sync + """ + client = HfApi(token=token) + secrets = extract(payload) + + if not secrets: + logging.info("No runtime secrets to configure") + return + + count = 0 + for key, value in secrets.items(): + try: + client.add_space_secret(repo_id=repository, key=key, value=value) + logging.info("Added %s secret to HuggingFace Space", key) + count += 1 + except Exception as e: + logging.error("Failed to add %s: %s", key, e) + raise RuntimeError(f"Failed to sync secret {key}") from e + + logging.info("Successfully configured %d secret(s)", count) + + +if __name__ == "__main__": + # Read configuration from environment variables + repository = os.getenv("HF_SPACE") + token = os.getenv("HF_TOKEN") + payload = os.getenv("RUNTIME_SECRETS", "") + + # Validate required environment variables + if not repository: + raise ValueError("HF_SPACE environment variable is required") + + if not token: + raise ValueError("HF_TOKEN environment variable is required") + + # Run the sync - any exceptions will naturally exit with code 1 + upload(repository, token, payload) diff --git a/.github/actions/tools/pr-title-generator/action.yaml b/.github/actions/tools/pr-title-generator/action.yaml new file mode 100644 index 0000000000000000000000000000000000000000..fda9859ad64ebc556375c363fa72c270467054a7 --- /dev/null +++ b/.github/actions/tools/pr-title-generator/action.yaml @@ -0,0 +1,52 @@ +name: 'PR Title Generator' +description: 'Updates PR title and body based on issue number from branch name' + +inputs: + github-token: + description: 'GitHub token for API access' + required: true + +runs: + using: 'composite' + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Git config + shell: bash + run: | + git config --global --add safe.directory '*' + + - name: Install GitHub CLI + shell: bash + run: | + (type -p wget >/dev/null || (sudo apt update && sudo apt-get install wget -y)) \ + && sudo mkdir -p -m 755 /etc/apt/keyrings \ + && out=$(mktemp) && wget -nv -O$out https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + && cat $out | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \ + && sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \ + && sudo apt update \ + && sudo apt install gh -y + + - name: Update PR Title and Body + shell: bash + env: + GITHUB_TOKEN: ${{ inputs.github-token }} + run: | + branch_name="${{ github.event.pull_request.head.ref }}" + issue_number=$(echo "$branch_name" | grep -o '^[0-9]\+') + + if [ -z "$issue_number" ]; then + echo "Error: Branch name does not start with an issue number" + exit 1 + fi + + # Update PR title + issue_title=$(gh api "/repos/instadeepai/sentinel/issues/$issue_number" --jq '.title') + gh pr edit ${{ github.event.pull_request.number }} --title "$issue_title" + + # Update PR body + current_body=$(gh pr view ${{ github.event.pull_request.number }} --json body --jq '.body') + updated_body=$(echo "$current_body" | sed "s/(issue)/#$issue_number/g") + gh pr edit ${{ github.event.pull_request.number }} --body "$updated_body" diff --git a/.github/actions/tools/pre-commit/action.yaml b/.github/actions/tools/pre-commit/action.yaml new file mode 100644 index 0000000000000000000000000000000000000000..8027c1bde43694cfff27aebe0d13320ff2b63708 --- /dev/null +++ b/.github/actions/tools/pre-commit/action.yaml @@ -0,0 +1,74 @@ +name: 'Pre-commit' +description: 'Pre-commit' + +runs: + using: 'composite' + steps: + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + + - name: Install dependencies + shell: bash + run: | + uv sync --frozen --all-extras + + - name: Install pre-commit hooks + shell: bash + run: | + source .venv/bin/activate + uv run pre-commit install-hooks + + - name: Run Pre-commit + id: precommit + shell: bash + run: | + echo "## Pre-commit Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + if uv run pre-commit run --all-files 2>&1 | tee output.txt; then + echo "✅ **All pre-commit hooks passed!**" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Hook | Status |" >> $GITHUB_STEP_SUMMARY + echo "|------|--------|" >> $GITHUB_STEP_SUMMARY + grep -E "\.\.\.*Passed|\.\.\.*Skipped" output.txt | while read line; do + hook=$(echo "$line" | sed 's/\.\.\..*Passed.*//' | sed 's/\.\.\..*Skipped.*//' | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//') + if echo "$line" | grep -q "Passed"; then + echo "| $hook | ✅ Passed |" >> $GITHUB_STEP_SUMMARY + else + echo "| $hook | ⏭️ Skipped |" >> $GITHUB_STEP_SUMMARY + fi + done + else + echo "❌ **Some pre-commit hooks failed**" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Hook | Status |" >> $GITHUB_STEP_SUMMARY + echo "|------|--------|" >> $GITHUB_STEP_SUMMARY + grep -E "\.\.\.*Passed|\.\.\.*Failed|\.\.\.*Skipped" output.txt | while read line; do + hook=$(echo "$line" | sed 's/\.\.\..*Passed.*//' | sed 's/\.\.\..*Failed.*//' | sed 's/\.\.\..*Skipped.*//' | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//') + if echo "$line" | grep -q "Passed"; then + echo "| $hook | ✅ Passed |" >> $GITHUB_STEP_SUMMARY + elif echo "$line" | grep -q "Failed"; then + echo "| $hook | ❌ Failed |" >> $GITHUB_STEP_SUMMARY + else + echo "| $hook | ⏭️ Skipped |" >> $GITHUB_STEP_SUMMARY + fi + done + + echo "" >> $GITHUB_STEP_SUMMARY + echo "
" >> $GITHUB_STEP_SUMMARY + echo "📋 Click to see detailed error output" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + cat output.txt >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "
" >> $GITHUB_STEP_SUMMARY + + exit 1 + fi diff --git a/.github/actions/tools/pytest/action.yaml b/.github/actions/tools/pytest/action.yaml new file mode 100644 index 0000000000000000000000000000000000000000..0ac5c3ab9cb255b716f3231405bd802cbdd14430 --- /dev/null +++ b/.github/actions/tools/pytest/action.yaml @@ -0,0 +1,104 @@ +name: 'Pytest' +description: 'Pytest' + +runs: + using: 'composite' + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + + - name: Install dependencies + shell: bash + run: | + uv sync --frozen + + - name: Run all tests with coverage + id: pytest + shell: bash + run: | + echo "## Pytest Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + if uv run pytest tests/ -v --tb=short --junitxml=pytest-results.xml --cov=src --cov-report=term-missing --cov-report=xml --cov-report=html 2>&1 | tee pytest-output.txt; then + echo "✅ **All tests passed!**" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + # Extract test summary from pytest output + if grep -q "passed" pytest-output.txt; then + passed_count=$(grep -o '[0-9]\+ passed' pytest-output.txt | grep -o '[0-9]\+' | head -1) + echo "| Status | Count |" >> $GITHUB_STEP_SUMMARY + echo "|--------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| ✅ Passed | $passed_count |" >> $GITHUB_STEP_SUMMARY + fi + + if grep -q "skipped" pytest-output.txt; then + skipped_count=$(grep -o '[0-9]\+ skipped' pytest-output.txt | grep -o '[0-9]\+' | head -1) + echo "| ⏭️ Skipped | $skipped_count |" >> $GITHUB_STEP_SUMMARY + fi + + if grep -q "warnings" pytest-output.txt; then + warnings_count=$(grep -o '[0-9]\+ warnings' pytest-output.txt | grep -o '[0-9]\+' | head -1) + echo "| ⚠️ Warnings | $warnings_count |" >> $GITHUB_STEP_SUMMARY + fi + + echo "" >> $GITHUB_STEP_SUMMARY + echo "### 📊 Test Summary" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + tail -10 pytest-output.txt >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + + else + echo "❌ **Some tests failed**" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + # Extract test summary from pytest output + if grep -q "passed" pytest-output.txt; then + passed_count=$(grep -o '[0-9]\+ passed' pytest-output.txt | grep -o '[0-9]\+' | head -1) + echo "| Status | Count |" >> $GITHUB_STEP_SUMMARY + echo "|--------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| ✅ Passed | $passed_count |" >> $GITHUB_STEP_SUMMARY + fi + + if grep -q "failed" pytest-output.txt; then + failed_count=$(grep -o '[0-9]\+ failed' pytest-output.txt | grep -o '[0-9]\+' | head -1) + echo "| ❌ Failed | $failed_count |" >> $GITHUB_STEP_SUMMARY + fi + + if grep -q "skipped" pytest-output.txt; then + skipped_count=$(grep -o '[0-9]\+ skipped' pytest-output.txt | grep -o '[0-9]\+' | head -1) + echo "| ⏭️ Skipped | $skipped_count |" >> $GITHUB_STEP_SUMMARY + fi + + if grep -q "warnings" pytest-output.txt; then + warnings_count=$(grep -o '[0-9]\+ warnings' pytest-output.txt | grep -o '[0-9]\+' | head -1) + echo "| ⚠️ Warnings | $warnings_count |" >> $GITHUB_STEP_SUMMARY + fi + + echo "" >> $GITHUB_STEP_SUMMARY + echo "
" >> $GITHUB_STEP_SUMMARY + echo "📋 Click to see detailed test output" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + cat pytest-output.txt >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "
" >> $GITHUB_STEP_SUMMARY + + exit 1 + fi + + # Coverage report (shown for both success and failure) + echo "" >> $GITHUB_STEP_SUMMARY + echo "### 📈 Coverage Report" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + # Convert coverage output to markdown table + python3 ${GITHUB_ACTION_PATH}/markdown.py pytest-output.txt >> $GITHUB_STEP_SUMMARY diff --git a/.github/actions/tools/pytest/markdown.py b/.github/actions/tools/pytest/markdown.py new file mode 100644 index 0000000000000000000000000000000000000000..be47ff0906c5ad9eeab844972764c6b1626c2d3d --- /dev/null +++ b/.github/actions/tools/pytest/markdown.py @@ -0,0 +1,89 @@ +""" +Convert pytest coverage output to markdown table format. +""" + +import re +import sys + + +def coverage_to_markdown(output_file: str) -> None: + """Convert pytest coverage output to markdown table. + + Args: + output_file: Path to the pytest coverage text report. + + Returns: + None: The function prints the markdown table to stdout. + """ + try: + with open(output_file) as f: + content = f.read() + except FileNotFoundError: + print("| Error | Coverage output file not found | - | - | - |") + return + + # Find the coverage section + lines = content.split("\n") + in_coverage_section = False + coverage_lines = [] + total_line = "" + + for line in lines: + if "Name" in line and "Stmts" in line and "Miss" in line and "Cover" in line: + in_coverage_section = True + continue + elif in_coverage_section: + if line.strip() == "" or line.startswith("="): + continue + elif line.startswith("TOTAL"): + total_line = line.strip() + break + elif line.strip(): + coverage_lines.append(line.strip()) + + # Print markdown table header + print("| File | Statements | Missing | Coverage | Missing Lines |") + print("|------|------------|---------|----------|---------------|") + + # Parse each coverage line + for line in coverage_lines: + # Match pattern: filename.py 123 45 67% 12, 34-56, 78 + match = re.match(r"^([^\s]+\.py)\s+(\d+)\s+(\d+)\s+(\d+)%\s*(.*)$", line) + if match: + filename = match.group(1) + statements = int(match.group(2)) + missing = int(match.group(3)) + coverage_pct = int(match.group(4)) + missing_details = match.group(5).strip() + + # Clean up filename (remove src/ prefix if present) + clean_filename = filename.replace("src/", "") + + # Format missing lines + if missing_details and missing_details != "-": + # Limit the missing details to avoid overly long tables + if len(missing_details) > 40: + missing_details = missing_details[:37] + "..." + missing_cell = f"`{missing_details}`" + else: + missing_cell = "None" + + print( + f"| {clean_filename} | {statements} | {missing} | {coverage_pct}% | {missing_cell} |" + ) + + # Add total row + if total_line: + match = re.match(r"^TOTAL\s+(\d+)\s+(\d+)\s+(\d+)%", total_line) + if match: + statements = int(match.group(1)) + missing = int(match.group(2)) + coverage_pct = int(match.group(3)) + print( + f"| **TOTAL** | **{statements}** | **{missing}** | **{coverage_pct}%** | - |" + ) + + +if __name__ == "__main__": + output_file = sys.argv[1] if len(sys.argv) > 1 else "pytest-output.txt" + coverage_to_markdown(output_file) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000000000000000000000000000000000000..058132d80b1c6157288f2fa234d726abd76a04f2 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,5 @@ +### Description + + + +Fixes (issue) diff --git a/.github/workflows/chore.yaml b/.github/workflows/chore.yaml new file mode 100644 index 0000000000000000000000000000000000000000..bab32648dced213b8dc50e4dbe0a8709f28bcacd --- /dev/null +++ b/.github/workflows/chore.yaml @@ -0,0 +1,28 @@ +name: Chore + +on: + pull_request: + types: [opened, edited] + +jobs: + update-pr-title: + if: github.event_name == 'pull_request' && (github.event.action == 'opened' || github.event.action == 'edited') + runs-on: instadeep-ci + container: + image: ghcr.io/catthehacker/ubuntu:runner-latest + credentials: + username: ${{ github.actor }} + password: ${{ secrets.github_token }} + permissions: write-all + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + + - name: Update PR Title and Body + uses: ./.github/actions/tools/pr-title-generator + with: + github-token: ${{ github.token }} diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml new file mode 100644 index 0000000000000000000000000000000000000000..fc224a4a2c24eafc049ef4701999e35a19e30d20 --- /dev/null +++ b/.github/workflows/main.yaml @@ -0,0 +1,67 @@ +name: Main Workflow + +on: + push: + +concurrency: + group: ${{ github.ref_name }} + cancel-in-progress: true + +jobs: + pre-commit: + runs-on: + group: kao-products-runners + labels: instadeep-ci-4 + container: + image: ghcr.io/catthehacker/ubuntu:runner-latest + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + + - name: Pre-commit + uses: ./.github/actions/tools/pre-commit + + pytest: + runs-on: + group: kao-products-runners + labels: instadeep-ci + container: + image: ghcr.io/catthehacker/ubuntu:runner-latest + env: + CI: 1 + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + + - name: Pytest + uses: ./.github/actions/tools/pytest + + hugging-face: + if: github.ref == 'refs/heads/main' + runs-on: + group: kao-products-runners + labels: instadeep-ci + container: + image: ghcr.io/catthehacker/ubuntu:runner-latest + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + ref: ${{ github.head_ref || github.ref_name }} + fetch-depth: 0 + lfs: true + + - name: Hugging Face + uses: ./.github/actions/tools/huggingface + with: + token: ${{ secrets.HF_TOKEN }} + space: "InstaDeepAI/sentinel" + branch: main + runtime-secrets: | + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..38e2f203cc4b25bdfc3a8f1f7eeb94878ffeca69 --- /dev/null +++ b/.gitignore @@ -0,0 +1,100 @@ +# Python general +__pycache__/ +*.py[cod] +*.so +*.egg +*.egg-info/ +*.pyd +.DS_Store + +# Virtual environments +.venv/ + +# Byte-compiled / optimized / DLL files +*.pyc + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +.eggs/ +.eggs-info/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# dotenv +.env +.env.* +!.env.example + +# mypy +.mypy_cache/ +.dmypy.json +compiled/ + +# Pyre type checker +.pyre/ + +# pyright type checker +pyrightconfig.json + +# pytype +.pytype/ + +# Cython debug symbols +cython_debug/ + +# Reports +outputs/ +*.xlsx +*.pdf + +# Cursor +.cursor/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..cf283b6d25225551974c129e02b3f2e9d4821508 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,111 @@ +default_language_version: + python: python3.12 + +default_stages: [pre-commit] + +repos: + - repo: https://github.com/hakancelikdev/unimport + rev: 1.3.0 + hooks: + - id: unimport + args: + - --remove + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.13.1 + hooks: + - id: ruff-format + - id: ruff-check + args: [--fix, --exit-non-zero-on-fix] + + - repo: https://github.com/kynan/nbstripout + rev: 0.8.1 + hooks: + - id: nbstripout + + - repo: https://github.com/codespell-project/codespell + rev: v2.4.1 + hooks: + - id: codespell + name: codespell + description: Checks for common misspellings in text files. + entry: codespell --skip="*.js,*.html,*.css, *.svg" --ignore-words=.codespell-ignore.txt + language: python + types: [text] + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: debug-statements + - id: check-ast # Simply check whether the files parse as valid python + - id: check-case-conflict # Check for files that would conflict in case-insensitive filesystems + - id: check-builtin-literals # Require literal syntax when initializing empty or zero Python builtin types + - id: check-docstring-first # Check a common error of defining a docstring after code + - id: check-merge-conflict # Check for files that contain merge conflict strings + - id: check-yaml # Check yaml files + args: ["--unsafe"] # Allows special tags in mkdocs.yaml + - id: end-of-file-fixer # Ensure that a file is either empty, or ends with one newline + exclude: end-to-end-pipeline/web/.* + - id: mixed-line-ending # Replace or checks mixed line ending + - id: trailing-whitespace # This hook trims trailing whitespace + - id: file-contents-sorter # Sort the lines in specified files + files: .*requirements*\.txt$ + + - repo: https://github.com/google/yamlfmt + rev: v0.17.2 + hooks: + - id: yamlfmt + args: ["-formatter", "retain_line_breaks_single=true,pad_line_comments=2"] + + - repo: https://github.com/asottile/pyupgrade + rev: v3.20.0 + hooks: + - id: pyupgrade + args: [--py312-plus] + + # The following hook sorts and formats toml files + - repo: https://github.com/pappasam/toml-sort + rev: v0.24.3 + hooks: + - id: toml-sort + description: "Sort and format toml files." + args: + - --all + - --in-place + + # The following hook checks for secrets in the code + - repo: https://github.com/zricethezav/gitleaks + rev: v8.28.0 + hooks: + - id: gitleaks + + # The following hook checks for secrets in the code + - repo: https://github.com/trufflesecurity/trufflehog + rev: v3.90.8 + hooks: + - id: trufflehog + + - repo: local + hooks: + - id: pylint + name: pylint + entry: pylint + language: python + additional_dependencies: ["pylint"] + types: [python] + args: ["--disable=all", "--enable=missing-docstring,unused-argument"] + exclude: 'test_\.py$' + + # The following hook check docstrings quality + - repo: https://github.com/terrencepreilly/darglint + rev: v1.8.1 + hooks: + - id: darglint + args: ["--docstring-style=google"] + exclude: 'src/sentinel/risk_models/qcancer\.py$' + + # The following hook checks for docstring in functions + - repo: https://github.com/pycqa/pydocstyle + rev: 6.3.0 + hooks: + - id: pydocstyle + args: ["--select=D103", "--match-dir=(genomics_research|projects)"] diff --git a/.streamlit/config.toml b/.streamlit/config.toml new file mode 100644 index 0000000000000000000000000000000000000000..f7ce48454dc943b476920db73aeb9aedcabc5756 --- /dev/null +++ b/.streamlit/config.toml @@ -0,0 +1,6 @@ +[theme] +backgroundColor = "#FFFFFF" +font = "Roboto" +primaryColor = "#007AFF" +secondaryBackgroundColor = "#F8FBFF" +textColor = "#0059B3" diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..21023c58c2ff997cd8febea6264aaa4b7b093464 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,55 @@ +# Repo Guidelines + +This repository contains the LLM-based Cancer Risk Assessment Assistant. + +## Core Technologies +- **FastAPI** for the web framework +- **LangChain** for LLM orchestration +- **uv** for environment and dependency management +- **hydra:** for configuration management + +## Coding Philosophy +- Prioritize clarity and reusability. +- Favor simple replication over heavy abstraction. +- Keep comments short and only where the code isn't self-explanatory. +- Avoid verbose docstrings for simple functions. + +## Testing +- Write meaningful tests that verify core functionality and prevent regressions. +- Run tests with `uv run pytest`. + +## Development Setup +- Create the virtual environment (at '.venv') with `uv sync`. + +## Running commands +- As the repository uses uv, the uv should be used to run all commands, e.g., "uv run python ..." NOT "python ...". + +These guidelines apply to the entire repository. A multi-page Streamlit +interface for expert feedback can be launched with `uv run streamlit run +apps/streamlit_ui/main.py`. +The first page, **User Profile**, allows experts to load or create a profile +stored in `st.session_state.user_profile`. +The second page, **Configuration**, lets experts choose the model and knowledge base modules while previewing the generated prompt. +The third page, **Assessment**, runs the AI analysis, displays a results dashboard, and provides export and chat options. + +## Important Note for Developers + +When making changes to the project, ensure that the following files are updated to reflect the changes: + +- `README.md` +- `AGENTS.md` +- `GEMINI.md` + +## Risk Model Coverage + +Implemented risk calculators include: +- **Gail** - Breast cancer risk +- **Claus** - Breast cancer risk based on family history +- **PLCOm2012** - Lung cancer risk +- **CRC-PRO** - Colorectal cancer risk +- **PCPT** - Prostate cancer risk +- **Extended PBCG** - Prostate cancer risk (extended model) +- **BOADICEA** - Breast and ovarian cancer risk (via CanRisk API) +- **QCancer** - Multi-site cancer differential + +Additional models should follow the interfaces under `src/sentinel/risk_models`. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..68fd1d723508436d1479fb3e7cc60b25f748c447 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +FROM python:3.12-slim + +# Set working directory +WORKDIR /app + +# Install uv +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +# Copy dependency files first for better caching +COPY pyproject.toml uv.lock* ./ + +# Copy the entire project +COPY . . + +# Set UV cache directory to a writable location +ENV UV_CACHE_DIR=/tmp/uv-cache +ENV HOME=/tmp + +# Install dependencies with uv +RUN uv sync --frozen --no-dev + +# Create cache directory and set permissions +RUN mkdir -p /tmp/uv-cache && chmod -R 777 /tmp/uv-cache + +# Make /app directory writable for non-root users (required for HuggingFace Spaces) +RUN chmod -R 777 /app + +# Expose Streamlit port +EXPOSE 8501 + +# Set environment variables for Streamlit +ENV STREAMLIT_SERVER_PORT=8501 +ENV STREAMLIT_SERVER_ADDRESS=0.0.0.0 +ENV STREAMLIT_SERVER_HEADLESS=true +ENV STREAMLIT_BROWSER_GATHER_USAGE_STATS=false + +# Run Streamlit app +CMD ["uv", "run", "streamlit", "run", "apps/streamlit_ui/main.py"] diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000000000000000000000000000000000000..025c5f003806ad3cb26d25ac91e402c12db0c36f --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,55 @@ +# Repo Guidelines + +This repository contains the LLM-based Cancer Risk Assessment Assistant. + +## Core Technologies +- **FastAPI** for the web framework +- **LangChain** for LLM orchestration +- **uv** for environment and dependency management +- **hydra:** for configuration management + +## Coding Philosophy +- Prioritize clarity and reusability. +- Favor simple replication over heavy abstraction. +- Keep comments short and only where the code isn't self-explanatory. +- Avoid verbose docstrings for simple functions. + +## Testing +- Write meaningful tests that verify core functionality and prevent regressions. +- Run tests with `uv run pytest`. + +## Development Setup +- Create the virtual environment (at '.venv') with `uv sync`. + +## Running commands +- As the repository uses uv, the uv should be used to run all commands, e.g., "uv run python ..." NOT "python ...". + +These guidelines apply to the entire repository. A multi-page Streamlit +interface for expert feedback can be launched with `uv run streamlit run +apps/streamlit_ui/main.py`. +The first page, **User Profile**, allows experts to load or create a profile +stored in `st.session_state.user_profile`. +The second page, **Configuration**, lets experts choose the model and knowledge base modules while previewing the generated prompt. +The third page, **Assessment**, runs the AI analysis, displays a results dashboard, and provides export and chat options. + +## Important Note for Developers + +When making changes to the project, ensure that the following files are updated to reflect the changes: + +- `README.md` +- `AGENTS.md` +- `GEMINI.md` + +## Risk Model Availability + +Risk calculators exposed to Gemini-based agents include: +- **Gail** - Breast cancer risk +- **Claus** - Breast cancer risk based on family history +- **PLCOm2012** - Lung cancer risk +- **CRC-PRO** - Colorectal cancer risk +- **PCPT** - Prostate cancer risk +- **Extended PBCG** - Prostate cancer risk (extended model) +- **BOADICEA** - Breast and ovarian cancer risk (via CanRisk API) +- **QCancer** - Multi-site cancer differential + +Register additional models in `src/sentinel/risk_models/__init__.py` so they are available system-wide. diff --git a/README.md b/README.md index f404dd4b3d80be0bf269d86c9c4f2e41e7caa441..74093ab37d97bbfdb928a651c18ab925239b66f2 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,173 @@ --- -title: Sentinel -emoji: 📚 -colorFrom: red -colorTo: indigo -sdk: gradio -sdk_version: 5.49.1 -app_file: app.py +title: Sentinel - Cancer Risk Assessment Assistant +emoji: 🏥 +colorFrom: blue +colorTo: purple +sdk: docker +app_port: 8501 pinned: false --- -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +# LLM-based Cancer Risk Assessment Assistant + +This project is an API service that provides preliminary cancer risk assessments based on user-provided data. It is built using FastAPI and LangChain, with a flexible architecture that supports both local and API-based LLMs. + +## Development Setup + +1. Create the virtual environment: + +```bash +uv sync +``` + +## External API Configuration + +For risk models that require external APIs, such as CanRisk (BOADICEA model), fill in the following section of the `.env` file: + +```bash +# .env +CANRISK_USERNAME=your_canrisk_username +CANRISK_PASSWORD=your_canrisk_password +``` + +Then source it: `source .env` + +For CanRisk API access , register at https://www.canrisk.org/. + +## Using a Local LLM (Ollama) + +1. Install [Ollama](https://ollama.com) for your platform. +2. Pull the default model from the command line: + +```bash +ollama pull gemma3:4b +``` +3. Ensure the Ollama desktop app or server is running. You can check your installed models with `ollama list`. + +## Using API-based LLMs (Google) + +1. Create a `.env` file in the project root with your `GOOGLE_API_KEY`: + + ```bash + echo "GOOGLE_API_KEY=your_key_here" > .env + ``` + + Make sure the Generative AI API is enabled for your Google Cloud project. + +2. Run the command line demo with the Google provider (default): + + ```bash + uv run python apps/cli/main.py + ``` + + Switch to the local model with: + + ```bash + uv run python apps/cli/main.py model=gemma3_4b + ``` + +3. The `model` override also works with the Streamlit and FastAPI interfaces. + + +## Interactive Demo + +Run a simple command line demo with: + +```bash +uv run python apps/cli/main.py +``` + +Enable developer mode and load user data from a file with: + +```bash +uv run python apps/cli/main.py dev_mode=true user_file=examples/user_example.yaml +``` + +The script collects user data, prints the structured JSON assessment, and then allows follow-up questions in a chat-like loop. Type `quit` to exit. + +The multi-page Streamlit interface provides an expert feedback interface located at +`apps/streamlit_ui/main.py`. +The first page, **User Profile**, lets you upload or manually create a profile +before running assessments. +The **Configuration** page allows you to choose the model and knowledge base modules and shows a live preview of the full LLM prompt. +The **Assessment** page runs the model, shows a dashboard of results, and lets you export or chat with the assistant. + +### Exporting Reports + +After the initial assessment is displayed in the terminal, you will be prompted to export the full report to a formatted file. You can choose to generate a PDF, an Excel file, or both. The generated files (e.g., `Cancer_Risk_Report_20250626_213000.pdf`) will be saved in the root directory of the project. + +**Note:** This feature requires the `openpyxl` and `reportlab` libraries. + +You can also provide a JSON or YAML file with all user information to skip the +interactive prompts: + +```bash +uv run python apps/cli/main.py user_file=examples/user_example.yaml +``` + +To launch the Streamlit interface, run the following command from the root of the +project: + +```bash +uv run streamlit run apps/streamlit_ui/main.py +``` + +*Note* To serve the app locally you can use `ngrok` +```bash + ngrok http 8501 + ``` + +## Important Note for Developers + +When making changes to the project, check if the following files should also updated to reflect the changes: + +- `README.md` +- `AGENTS.md` +- `GEMINI.md` + +## Available Risk Models + +The assistant currently includes the following built-in risk calculators: + +- Gail Model (Breast Cancer) +- PLCOm2012 (Lung Cancer) +- CRC-PRO (Colorectal Cancer) +- PCPT (Prostate Cancer) +- QCancer (Multi-site cancer differential) + +## Generating Documentation + +The project includes a comprehensive PDF documentation generator that creates detailed documentation of all implemented risk models and their input requirements. + +### Generate Risk Model Documentation + +To generate the PDF documentation: + +```bash +uv run python scripts/generate_documentation.py +``` + +This will create a comprehensive PDF document (`docs/risk_model_documentation.pdf`) that includes: + +1. **Overview Section**: + - Cancer type coverage chart + - Statistics on implemented risk scores and cancer types covered + +2. **Detailed Model Information**: + - Description, interpretation, and references for each risk model + - Complete input requirements with field details, required status, units, and possible values/choices + +3. **Input-to-Cancer Mapping**: + - Reverse mapping showing which cancer types use each input field + - Possible values for each field + - Comprehensive coverage analysis + +The documentation is automatically regenerated based on the current codebase, ensuring it stays up-to-date as new risk models and input fields are added. + +### Documentation Features + +- **Comprehensive Coverage**: Documents all risk models and their input requirements +- **Visual Charts**: Includes cancer type coverage visualization +- **Detailed Tables**: Shows field specifications, constraints, and valid values +- **Professional Layout**: Clean, readable PDF format suitable for sharing +- **Auto-Generated**: Stays synchronized with code changes automatically diff --git a/RISK_MODELS.md b/RISK_MODELS.md new file mode 100644 index 0000000000000000000000000000000000000000..b1f957ffc09626af5990b354095e7e8e3d51aade --- /dev/null +++ b/RISK_MODELS.md @@ -0,0 +1,587 @@ +# Risk Models Specification + +This document outlines the requirements and specifications for implementing risk models in the Sentinel cancer risk assessment system. + +## Overview + +Risk models in Sentinel are designed to calculate cancer risk scores using structured user input data. All risk models must follow a consistent architecture, use the new `UserInput` structure, implement proper validation, and maintain comprehensive test coverage. + +## Core Architecture + +### Base Class + +All risk models must inherit from `RiskModel` in `src/sentinel/risk_models/base.py`: + +```python +from sentinel.risk_models.base import RiskModel + +class YourRiskModel(RiskModel): + def __init__(self): + super().__init__("your_model_name") +``` + +### Required Methods + +Every risk model must implement these abstract methods: + +```python +def compute_score(self, user: UserInput) -> str: + """Compute the risk score for a given user profile. + + Args: + user: The user profile containing demographics, medical history, etc. + + Returns: + str: Risk percentage as a string or an N/A message if inapplicable. + + Raises: + ValueError: If required inputs are missing or invalid. + """ + +def cancer_type(self) -> str: + """Return the cancer type this model assesses.""" + return "breast" # or "lung", "prostate", etc. + +def description(self) -> str: + """Return a detailed description of the model.""" + +def interpretation(self) -> str: + """Return guidance on how to interpret the results.""" + +def references(self) -> list[str]: + """Return list of reference citations.""" +``` + +## UserInput Structure + +### Required Imports + +```python +from typing import Annotated +from pydantic import Field +from sentinel.risk_models.base import RiskModel +from sentinel.user_input import ( + # Import specific enums and models you need + CancerType, + ChronicCondition, + Demographics, + Ethnicity, + FamilyMemberCancer, + FamilyRelation, + FamilySide, + RelationshipDegree, + Sex, + SymptomEntry, + UserInput, + # ... other specific imports +) +``` + +### UserInput Hierarchy + +The `UserInput` class follows a hierarchical structure: + +``` +UserInput +├── demographics: Demographics +│ ├── age_years: int +│ ├── sex: Sex (enum) +│ ├── ethnicity: Ethnicity | None +│ └── anthropometrics: Anthropometrics +│ ├── height_cm: float | None +│ └── weight_kg: float | None +├── lifestyle: Lifestyle +│ ├── smoking: SmokingHistory +│ └── alcohol: AlcoholConsumption +├── personal_medical_history: PersonalMedicalHistory +│ ├── chronic_conditions: list[ChronicCondition] +│ ├── previous_cancers: list[CancerType] +│ ├── genetic_mutations: list[GeneticMutation] +│ ├── tyrer_cuzick_polygenic_risk_score: float | None +│ └── # ... other fields +├── female_specific: FemaleSpecific | None +│ ├── menstrual: MenstrualHistory +│ ├── parity: ParityHistory +│ └── breast_health: BreastHealthHistory +├── symptoms: list[SymptomEntry] +└── family_history: list[FamilyMemberCancer] +``` + +## REQUIRED_INPUTS Specification + +### Structure + +Every risk model must define a `REQUIRED_INPUTS` class attribute using Pydantic's `Annotated` types with `Field` constraints: + +```python +REQUIRED_INPUTS: dict[str, tuple[type, bool]] = { + "demographics.age_years": (Annotated[int, Field(ge=18, le=100)], True), + "demographics.sex": (Sex, True), + "demographics.ethnicity": (Ethnicity | None, False), + "demographics.anthropometrics.height_cm": (Annotated[float, Field(gt=0)], False), + "demographics.anthropometrics.weight_kg": (Annotated[float, Field(gt=0)], False), + "female_specific.menstrual.age_at_menarche": (Annotated[int, Field(ge=8, le=25)], False), + "personal_medical_history.tyrer_cuzick_polygenic_risk_score": (Annotated[float, Field(gt=0)], False), + "family_history": (list, False), # list[FamilyMemberCancer] + "symptoms": (list, False), # list[SymptomEntry] +} +``` + +### Field Constraints + +Use appropriate `Field` constraints for validation: + +- `ge=X`: Greater than or equal to X +- `le=X`: Less than or equal to X +- `gt=X`: Greater than X +- `lt=X`: Less than X + +### Required vs Optional + +- `True`: Field is required for the model +- `False`: Field is optional but validated if present + +## Input Validation + +### Validation in compute_score + +Every `compute_score` method must start with input validation: + +```python +def compute_score(self, user: UserInput) -> str: + """Compute the risk score for a given user profile.""" + # Validate inputs first + is_valid, errors = self.validate_inputs(user) + if not is_valid: + raise ValueError(f"Invalid inputs for {self.name}: {'; '.join(errors)}") + + # Continue with model-specific logic... +``` + +### Model-Specific Validation + +Add additional validation as needed: + +```python +# Check sex applicability +if user.demographics.sex != Sex.FEMALE: + return "N/A: Model is only applicable to female patients." + +# Check age range +if not (35 <= user.demographics.age_years <= 85): + return "N/A: Age is outside the validated range." + +# Check required data availability +if user.female_specific is None: + return "N/A: Missing female-specific information required for model." +``` + +## Extending UserInput + +### When to Extend + +If a risk model requires fields or enums that don't exist in `UserInput`, **do not** use replacement values or hacks. Instead, propose extending `UserInput`: + +1. **Missing Enums**: Add new values to existing enums (e.g., `ChronicCondition`, `SymptomType`) +2. **Missing Fields**: Add new fields to appropriate sections (e.g., `PersonalMedicalHistory`, `BreastHealthHistory`) +3. **Missing Models**: Create new Pydantic models if needed + +### Extension Process + +1. **Identify Missing Elements**: Document what's needed for the model +2. **Propose Extension**: Suggest specific additions to `UserInput` +3. **Implement Extension**: Add the new fields/enums to `src/sentinel/user_input.py` +4. **Update Tests**: Add tests for new fields in `tests/test_user_input.py` +5. **Update Model**: Use the new fields in your risk model +6. **Run Tests**: Ensure all tests pass + +### Example Extensions + +```python +# Adding new ChronicCondition enum values +class ChronicCondition(str, Enum): + # ... existing values + ENDOMETRIAL_POLYPS = "endometrial_polyps" + ANAEMIA = "anaemia" + +# Adding new fields to PersonalMedicalHistory +class PersonalMedicalHistory(StrictBaseModel): + # ... existing fields + tyrer_cuzick_polygenic_risk_score: float | None = Field( + None, + gt=0, + description="Tyrer-Cuzick polygenic risk score as relative risk multiplier", + ) + +# Adding new fields to BreastHealthHistory +class BreastHealthHistory(StrictBaseModel): + # ... existing fields + lobular_carcinoma_in_situ: bool | None = Field( + None, + description="History of lobular carcinoma in situ (LCIS) diagnosis", + ) +``` + +## Data Access Patterns + +### Demographics + +```python +age = user.demographics.age_years +sex = user.demographics.sex +ethnicity = user.demographics.ethnicity +height_cm = user.demographics.anthropometrics.height_cm +weight_kg = user.demographics.anthropometrics.weight_kg +``` + +### Female-Specific Data + +```python +if user.female_specific is not None: + fs = user.female_specific + menarche_age = fs.menstrual.age_at_menarche + menopause_age = fs.menstrual.age_at_menopause + num_births = fs.parity.num_live_births + first_birth_age = fs.parity.age_at_first_live_birth + num_biopsies = fs.breast_health.num_biopsies + atypical_hyperplasia = fs.breast_health.atypical_hyperplasia + lcis = fs.breast_health.lobular_carcinoma_in_situ +``` + +### Medical History + +```python +chronic_conditions = user.personal_medical_history.chronic_conditions +previous_cancers = user.personal_medical_history.previous_cancers +genetic_mutations = user.personal_medical_history.genetic_mutations +polygenic_score = user.personal_medical_history.tyrer_cuzick_polygenic_risk_score +``` + +### Family History + +```python +for member in user.family_history: + if member.cancer_type == CancerType.BREAST: + relation = member.relation + age_at_diagnosis = member.age_at_diagnosis + degree = member.degree + side = member.side +``` + +### Symptoms + +```python +for symptom in user.symptoms: + symptom_type = symptom.symptom_type + severity = symptom.severity + duration_days = symptom.duration_days +``` + +## Enum Usage + +### Always Use Enums + +Never use string literals. Always use the appropriate enums: + +```python +# ✅ Correct +if user.demographics.sex == Sex.FEMALE: +if member.cancer_type == CancerType.BREAST: +if member.relation == FamilyRelation.MOTHER: +if member.degree == RelationshipDegree.FIRST: +if member.side == FamilySide.MATERNAL: + +# ❌ Incorrect +if user.demographics.sex == "female": +if member.cancer_type == "breast": +if member.relation == "mother": +``` + +### Enum Mapping + +When you need to map enums to model-specific codes: + +```python +def _race_code_from_ethnicity(ethnicity: Ethnicity | None) -> int: + """Map ethnicity enum to model-specific race code.""" + if not ethnicity: + return 1 # Default + + if ethnicity == Ethnicity.BLACK: + return 2 + if ethnicity in {Ethnicity.ASIAN, Ethnicity.PACIFIC_ISLANDER}: + return 3 + if ethnicity == Ethnicity.HISPANIC: + return 6 + return 1 # Default to White +``` + +## Testing Requirements + +### Test File Structure + +Create comprehensive test files following this pattern: + +```python +import pytest +from sentinel.user_input import ( + # Import all needed models and enums + Anthropometrics, + BreastHealthHistory, + CancerType, + Demographics, + Ethnicity, + FamilyMemberCancer, + FamilyRelation, + FamilySide, + FemaleSpecific, + Lifestyle, + MenstrualHistory, + ParityHistory, + PersonalMedicalHistory, + RelationshipDegree, + Sex, + SmokingHistory, + SmokingStatus, + UserInput, +) +from sentinel.risk_models import YourRiskModel + +# Ground truth test cases +GROUND_TRUTH_CASES = [ + { + "name": "test_case_name", + "input": UserInput( + demographics=Demographics( + age_years=40, + sex=Sex.FEMALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + female_specific=FemaleSpecific( + menstrual=MenstrualHistory(age_at_menarche=13), + parity=ParityHistory(num_live_births=1, age_at_first_live_birth=25), + breast_health=BreastHealthHistory(), + ), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=55, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ) + ], + ), + "expected": 1.5, # Expected risk percentage + }, + # ... more test cases +] + +class TestYourRiskModel: + """Test suite for YourRiskModel.""" + + def setup_method(self): + """Initialize model instance for testing.""" + self.model = YourRiskModel() + + @pytest.mark.parametrize("case", GROUND_TRUTH_CASES, ids=lambda x: x["name"]) + def test_ground_truth_validation(self, case): + """Test against ground truth results.""" + user_input = case["input"] + expected_risk = case["expected"] + + actual_risk_str = self.model.compute_score(user_input) + + if "N/A" in actual_risk_str: + pytest.fail(f"Model returned N/A: {actual_risk_str}") + + actual_risk = float(actual_risk_str) + assert actual_risk == pytest.approx(expected_risk, abs=0.01) + + def test_validation_errors(self): + """Test that model raises ValueError for invalid inputs.""" + # Test invalid age + user_input = UserInput( + demographics=Demographics( + age_years=30, # Below minimum + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + # ... rest of input + ) + + with pytest.raises(ValueError, match=r"Invalid inputs for.*:"): + self.model.compute_score(user_input) + + def test_inapplicable_cases(self): + """Test cases where model returns N/A.""" + # Test male patient + user_input = UserInput( + demographics=Demographics( + age_years=50, + sex=Sex.MALE, # Wrong sex + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=70.0), + ), + # ... rest of input + ) + + score = self.model.compute_score(user_input) + assert "N/A" in score +``` + +### Test Coverage Requirements + +- **Ground Truth Validation**: Test against known reference values +- **Input Validation**: Test that invalid inputs raise `ValueError` +- **Edge Cases**: Test boundary conditions and edge cases +- **Inapplicable Cases**: Test cases where model should return "N/A" +- **Enum Usage**: Test that all enums are used correctly +- **Family History**: Test various family relationship combinations +- **Error Handling**: Test error conditions and exception handling + +## Code Quality Requirements + +### Pre-commit Hooks + +All code must pass these pre-commit hooks: + +- **unimport**: Remove unused imports +- **ruff format**: Code formatting +- **ruff check**: Linting and style checks +- **pylint**: Code quality analysis +- **darglint**: Docstring validation +- **pydocstyle**: Docstring style checks +- **codespell**: Spell checking + +### Code Style + +- Use type hints throughout +- Write clear, concise docstrings +- Follow PEP 8 style guidelines +- Use meaningful variable names +- Add comments for complex logic +- Handle edge cases gracefully + +### Error Handling + +```python +def compute_score(self, user: UserInput) -> str: + """Compute the risk score for a given user profile.""" + try: + # Validate inputs + is_valid, errors = self.validate_inputs(user) + if not is_valid: + raise ValueError(f"Invalid inputs for {self.name}: {'; '.join(errors)}") + + # Model-specific validation + if user.demographics.sex != Sex.FEMALE: + return "N/A: Model is only applicable to female patients." + + # Calculate risk + risk = self._calculate_risk(user) + return f"{risk:.2f}" + + except Exception as e: + return f"N/A: Error calculating risk - {e!s}" +``` + +## Migration Checklist + +When adapting an existing risk model to the new structure: + +- [ ] Update imports to use new `user_input` module +- [ ] Add `REQUIRED_INPUTS` with Pydantic validation +- [ ] Refactor `compute_score` to use new `UserInput` structure +- [ ] Replace string literals with enums +- [ ] Update parameter extraction logic +- [ ] Add input validation at start of `compute_score` +- [ ] Update all test cases to use new `UserInput` structure +- [ ] Run full test suite to ensure 100% pass rate +- [ ] Run pre-commit hooks to ensure code quality +- [ ] Document any `UserInput` extensions needed +- [ ] Update model documentation and references + +## Examples + +### Complete Risk Model Template + +```python +"""Your cancer risk model implementation.""" + +from typing import Annotated +from pydantic import Field +from sentinel.risk_models.base import RiskModel +from sentinel.user_input import ( + CancerType, + Demographics, + Ethnicity, + FamilyMemberCancer, + FamilyRelation, + RelationshipDegree, + Sex, + UserInput, +) + +class YourRiskModel(RiskModel): + """Compute cancer risk using the Your model.""" + + def __init__(self): + super().__init__("your_model") + + REQUIRED_INPUTS: dict[str, tuple[type, bool]] = { + "demographics.age_years": (Annotated[int, Field(ge=18, le=100)], True), + "demographics.sex": (Sex, True), + "demographics.ethnicity": (Ethnicity | None, False), + "family_history": (list, False), # list[FamilyMemberCancer] + } + + def compute_score(self, user: UserInput) -> str: + """Compute the risk score for a given user profile.""" + # Validate inputs first + is_valid, errors = self.validate_inputs(user) + if not is_valid: + raise ValueError(f"Invalid inputs for Your: {'; '.join(errors)}") + + # Model-specific validation + if user.demographics.sex != Sex.FEMALE: + return "N/A: Model is only applicable to female patients." + + # Extract parameters + age = user.demographics.age_years + ethnicity = user.demographics.ethnicity + + # Count family history + family_count = sum( + 1 for member in user.family_history + if member.cancer_type == CancerType.BREAST + and member.degree == RelationshipDegree.FIRST + ) + + # Calculate risk (example) + risk = self._calculate_risk(age, family_count, ethnicity) + return f"{risk:.2f}" + + def _calculate_risk(self, age: int, family_count: int, ethnicity: Ethnicity | None) -> float: + """Calculate the actual risk value.""" + # Implementation here + return 1.5 # Example + + def cancer_type(self) -> str: + return "breast" + + def description(self) -> str: + return "Your model description here." + + def interpretation(self) -> str: + return "Interpretation guidance here." + + def references(self) -> list[str]: + return ["Your reference here."] +``` + +This specification ensures consistency, maintainability, and quality across all risk models in the Sentinel system. diff --git a/apps/__init__.py b/apps/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..fcf403d366eb543c25a103aae1f66fe09db0484c --- /dev/null +++ b/apps/__init__.py @@ -0,0 +1 @@ +# Apps package for the Sentinel project diff --git a/apps/api/__init__.py b/apps/api/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..28b07eff63d8fa415650eba576f1d1bf3844d915 --- /dev/null +++ b/apps/api/__init__.py @@ -0,0 +1 @@ +# API package diff --git a/apps/api/main.py b/apps/api/main.py new file mode 100644 index 0000000000000000000000000000000000000000..3d32f80ce9ba08fe7e5ee54cb50142095e9ac962 --- /dev/null +++ b/apps/api/main.py @@ -0,0 +1,121 @@ +"""FastAPI application exposing cancer risk assessment endpoints.""" + +from pathlib import Path + +from fastapi import FastAPI, HTTPException + +from sentinel.config import AppConfig, ModelConfig, ResourcePaths +from sentinel.factory import SentinelFactory +from sentinel.models import InitialAssessment, UserInput + +app = FastAPI( + title="Cancer Risk Assessment Assistant", + description="API for assessing cancer risks using LLMs.", +) + +# Define base paths relative to the project root +BASE_DIR = Path(__file__).resolve().parents[2] # Go up to project root +CONFIGS_DIR = BASE_DIR / "configs" +PROMPTS_DIR = BASE_DIR / "prompts" + + +def create_knowledge_base_paths() -> ResourcePaths: + """Build resource path configuration resolved from the repository root. + + Returns: + ResourcePaths: Paths pointing to persona, prompt, and configuration + assets required by the API routes. + """ + + return ResourcePaths( + persona=PROMPTS_DIR / "persona" / "default.md", + instruction_assessment=PROMPTS_DIR / "instruction" / "assessment.md", + instruction_conversation=PROMPTS_DIR / "instruction" / "conversation.md", + output_format_assessment=CONFIGS_DIR / "output_format" / "assessment.yaml", + output_format_conversation=CONFIGS_DIR / "output_format" / "conversation.yaml", + cancer_modules_dir=CONFIGS_DIR / "knowledge_base" / "cancer_modules", + dx_protocols_dir=CONFIGS_DIR / "knowledge_base" / "dx_protocols", + ) + + +@app.get("/") +async def read_root() -> dict: + """Return a simple greeting message. + + Returns: + dict: A dictionary containing a greeting message. + """ + return {"message": "Hello, world!"} + + +@app.post("/assess/{provider}", response_model=InitialAssessment) +async def assess( + provider: str, + user_input: UserInput, + model: str | None = None, + cancer_modules: list[str] | None = None, + dx_protocols: list[str] | None = None, +) -> InitialAssessment: + """Assess cancer risk for a user. + + Args: + provider (str): LLM provider identifier (for example ``"openai"`` or + ``"anthropic"``). + user_input (UserInput): Structured demographics and clinical + information supplied by the client. + model (str | None): Optional model name overriding the provider + default. + cancer_modules (list[str] | None): Optional list of cancer module slugs + to include in the knowledge base. + dx_protocols (list[str] | None): Optional list of diagnostic protocol + slugs to include. + + Returns: + InitialAssessment: Parsed model output describing the initial + assessment. + + Raises: + HTTPException: 400 for invalid input, 500 for unexpected errors. + """ + try: + # Create knowledge base paths + knowledge_base_paths = create_knowledge_base_paths() + + # Set default model name if not provided + if model is None: + model_defaults = { + "openai": "gpt-4o-mini", + "anthropic": "claude-3-5-sonnet-20241022", + "google": "gemini-1.5-pro", + } + model = model_defaults.get(provider, "gpt-4o-mini") + + # Set default modules if not provided + if cancer_modules is None: + cancer_modules_dir = knowledge_base_paths.cancer_modules_dir + cancer_modules = [p.stem for p in cancer_modules_dir.glob("*.yaml")] + + if dx_protocols is None: + dx_protocols_dir = knowledge_base_paths.dx_protocols_dir + dx_protocols = [p.stem for p in dx_protocols_dir.glob("*.yaml")] + + # Create AppConfig + app_config = AppConfig( + model=ModelConfig(provider=provider, model_name=model), + knowledge_base_paths=knowledge_base_paths, + selected_cancer_modules=cancer_modules, + selected_dx_protocols=dx_protocols, + ) + + # Create factory and conversation manager + factory = SentinelFactory(app_config) + conversation_manager = factory.create_conversation_manager() + + # Run assessment + response = conversation_manager.initial_assessment(user_input) + return response + + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Internal Server Error: {e!s}") diff --git a/apps/cli/__init__.py b/apps/cli/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..1a448425a2b8eb549b631a69616b3f940b7dd043 --- /dev/null +++ b/apps/cli/__init__.py @@ -0,0 +1 @@ +# CLI package diff --git a/apps/cli/main.py b/apps/cli/main.py new file mode 100644 index 0000000000000000000000000000000000000000..459dcc03468a862239b3cf0e5e02c989382b4fc9 --- /dev/null +++ b/apps/cli/main.py @@ -0,0 +1,539 @@ +"""Command-line interface for running assessments and exporting reports.""" + +import json +from datetime import datetime +from pathlib import Path + +import hydra +from hydra.utils import to_absolute_path +from omegaconf import DictConfig + +from sentinel.config import AppConfig, ModelConfig, ResourcePaths +from sentinel.factory import SentinelFactory +from sentinel.models import ( + ConversationResponse, + Demographics, + FamilyMemberCancer, + FemaleSpecific, + InitialAssessment, + Lifestyle, + PersonalMedicalHistory, + UserInput, +) +from sentinel.reporting import generate_excel_report, generate_pdf_report +from sentinel.risk_models import RISK_MODELS +from sentinel.utils import load_user_file + + +# Color codes for terminal output +class Colors: + """ANSI color codes for terminal output formatting.""" + + HEADER = "\033[95m" + OKBLUE = "\033[94m" + OKCYAN = "\033[96m" + OKGREEN = "\033[92m" + WARNING = "\033[93m" + FAIL = "\033[91m" + ENDC = "\033[0m" + BOLD = "\033[1m" + UNDERLINE = "\033[4m" + + +def _get_input(prompt: str, optional: bool = False) -> str: + """Get a line of input from the user. + + Args: + prompt: Message to display to the user. + optional: If True, allow empty input to be returned as an empty string. + + Returns: + The raw string entered by the user (may be empty if optional). + """ + suffix = " (optional, press Enter to skip)" if optional else "" + return input(f"{Colors.OKCYAN}{prompt}{suffix}:{Colors.ENDC} ") + + +def _get_int_input(prompt: str, optional: bool = False) -> int | None: + """Get an integer from the user. + + Args: + prompt: Message to display to the user. + optional: If True, allow empty input and return None. + + Returns: + The parsed integer value, or None if optional and left empty. + """ + while True: + val = _get_input(prompt, optional) + if not val and optional: + return None + try: + return int(val) + except (ValueError, TypeError): + print(f"{Colors.WARNING}Please enter a valid number.{Colors.ENDC}") + + +def collect_user_input() -> UserInput: + """Collect user profile data interactively. + + Returns: + UserInput: Structured demographics, lifestyle, and clinical data + assembled from CLI prompts. + """ + print( + f"\n{Colors.HEADER}{Colors.BOLD}=== User Information Collection ==={Colors.ENDC}" + ) + print("Please provide the following details for your assessment.") + + # --- DEMOGRAPHICS --- + print(f"\n{Colors.OKBLUE}{Colors.BOLD}--- Demographics ---{Colors.ENDC}") + age = _get_int_input("Age") + sex = _get_input("Biological Sex (e.g., Male, Female)") + ethnicity = _get_input("Ethnicity", optional=True) + demographics = Demographics(age=age, sex=sex, ethnicity=ethnicity) + + # --- LIFESTYLE --- + print(f"\n{Colors.OKBLUE}{Colors.BOLD}--- Lifestyle ---{Colors.ENDC}") + smoking_status = _get_input("Smoking Status (e.g., never, former, current)") + smoking_pack_years = ( + _get_int_input("Smoking Pack-Years", optional=True) + if smoking_status in ["former", "current"] + else None + ) + alcohol_consumption = _get_input( + "Alcohol Consumption (e.g., none, light, moderate, heavy)" + ) + dietary_habits = _get_input("Dietary Habits", optional=True) + physical_activity_level = _get_input("Physical Activity Level", optional=True) + lifestyle = Lifestyle( + smoking_status=smoking_status, + smoking_pack_years=smoking_pack_years, + alcohol_consumption=alcohol_consumption, + dietary_habits=dietary_habits, + physical_activity_level=physical_activity_level, + ) + + # --- PERSONAL MEDICAL HISTORY --- + print( + f"\n{Colors.OKBLUE}{Colors.BOLD}--- Personal Medical History ---{Colors.ENDC}" + ) + mutations = _get_input("Known genetic mutations (comma-separated)", optional=True) + cancers = _get_input("Previous cancers (comma-separated)", optional=True) + illnesses = _get_input( + "Chronic illnesses (e.g., IBD, comma-separated)", optional=True + ) + personal_medical_history = PersonalMedicalHistory( + known_genetic_mutations=[m.strip() for m in mutations.split(",")] + if mutations + else [], + previous_cancers=[c.strip() for c in cancers.split(",")] if cancers else [], + chronic_illnesses=[i.strip() for i in illnesses.split(",")] + if illnesses + else [], + ) + + # --- CLINICAL OBSERVATIONS --- + print( + f"\n{Colors.OKBLUE}{Colors.BOLD}--- Clinical Observations / Test Results (Optional) ---{Colors.ENDC}" + ) + clinical_observations = [] + while True: + add_test = _get_input( + "Add a clinical observation or test result? (y/N)" + ).lower() + if add_test not in ["y", "yes"]: + break + test_name = _get_input("Test/Observation Name") + value = _get_input("Value") + unit = _get_input("Unit (e.g., ng/mL, or N/A)") + reference_range = _get_input("Reference Range", optional=True) + date = _get_input("Date of Test (YYYY-MM-DD)", optional=True) + clinical_observations.append( + { + "test_name": test_name, + "value": value, + "unit": unit, + "reference_range": reference_range or None, + "date": date or None, + } + ) + + # --- FAMILY HISTORY --- + print( + f"\n{Colors.OKBLUE}{Colors.BOLD}--- Family History of Cancer ---{Colors.ENDC}" + ) + family_history = [] + while True: + add_relative = _get_input("Add a family member with cancer? (y/N)").lower() + if add_relative not in ["y", "yes"]: + break + relative = _get_input("Relative (e.g., mother, sister)") + cancer_type = _get_input("Cancer Type") + age_at_diagnosis = _get_int_input("Age at Diagnosis", optional=True) + family_history.append( + FamilyMemberCancer( + relative=relative, + cancer_type=cancer_type, + age_at_diagnosis=age_at_diagnosis, + ) + ) + + # --- FEMALE-SPECIFIC --- + female_specific = None + if sex.lower() == "female": + print( + f"\n{Colors.OKBLUE}{Colors.BOLD}--- Female-Specific Information ---{Colors.ENDC}" + ) + age_at_first_period = _get_int_input("Age at first period", optional=True) + age_at_menopause = _get_int_input("Age at menopause", optional=True) + num_live_births = _get_int_input("Number of live births", optional=True) + age_at_first_live_birth = _get_int_input( + "Age at first live birth", optional=True + ) + hormone_therapy_use = _get_input("Hormone therapy use", optional=True) + female_specific = FemaleSpecific( + age_at_first_period=age_at_first_period, + age_at_menopause=age_at_menopause, + num_live_births=num_live_births, + age_at_first_live_birth=age_at_first_live_birth, + hormone_therapy_use=hormone_therapy_use, + ) + + # --- CURRENT CONCERNS --- + print(f"\n{Colors.OKBLUE}{Colors.BOLD}--- Current Concerns ---{Colors.ENDC}") + current_concerns_or_symptoms = _get_input( + "Current symptoms or health concerns", optional=True + ) + + return UserInput( + demographics=demographics, + lifestyle=lifestyle, + family_history=family_history, + personal_medical_history=personal_medical_history, + female_specific=female_specific, + current_concerns_or_symptoms=current_concerns_or_symptoms, + clinical_observations=clinical_observations, + ) + + +def format_risk_assessment(response: InitialAssessment, dev_mode: bool = False) -> None: + """Pretty-print an initial risk assessment payload. + + Args: + response (InitialAssessment): Parsed result returned by the assessment + chain. + dev_mode (bool): Flag enabling verbose debugging output. + """ + # In dev mode, show everything + if dev_mode: + print( + f"\n{Colors.WARNING}{Colors.BOLD}--- DEV MODE: RAW MODEL OUTPUT ---{Colors.ENDC}" + ) + # Use model_dump instead of model_dump_json for direct printing + print(json.dumps(response.model_dump(), indent=2)) + print( + f"\n{Colors.WARNING}{Colors.BOLD}--- DEV MODE: PARSED & VALIDATED PYDANTIC OBJECT ---{Colors.ENDC}" + ) + if response.thinking: + print( + f"{Colors.OKCYAN}{Colors.BOLD}🤔 Chain of Thought (`` block):{Colors.ENDC}" + ) + print(response.thinking) + print(f"{Colors.WARNING}{Colors.BOLD}{'-' * 30}{Colors.ENDC}") + if response.reasoning: + print( + f"{Colors.OKCYAN}{Colors.BOLD}🧠 Reasoning (`` block):{Colors.ENDC}" + ) + print(response.reasoning) + print(f"{Colors.WARNING}{Colors.BOLD}{'-' * 30}{Colors.ENDC}") + print(f"{Colors.OKCYAN}{Colors.BOLD}Full Pydantic Object:{Colors.ENDC}") + + # return + print( + f"\n{Colors.WARNING}{Colors.BOLD}--- DEV MODE: FORMATTED MODEL OUTPUT ---{Colors.ENDC}" + ) + + # User-friendly formatting + print(f"\n{Colors.HEADER}{Colors.BOLD}{'=' * 60}") + print("🏥 CANCER RISK ASSESSMENT REPORT") + print(f"{'=' * 60}{Colors.ENDC}") + + # Display the primary user-facing response first + if response.response: + print(f"\n{Colors.OKCYAN}{Colors.BOLD}🤖 BiOS:{Colors.ENDC}") + print(response.response) + + # Then display the structured summary and details + print(f"\n{Colors.OKBLUE}{Colors.BOLD}📋 OVERALL SUMMARY{Colors.ENDC}") + if response.overall_risk_score is not None: + print( + f"{Colors.OKCYAN}Overall Risk Score: {Colors.BOLD}{response.overall_risk_score}/100{Colors.ENDC}" + ) + if response.overall_summary: + print(f"{Colors.OKCYAN}{response.overall_summary}{Colors.ENDC}") + + # Risk assessments + risk_assessments = response.risk_assessments + if risk_assessments: + print( + f"\n{Colors.OKBLUE}{Colors.BOLD}🎯 DETAILED RISK ASSESSMENTS{Colors.ENDC}" + ) + print(f"{Colors.OKBLUE}{'─' * 40}{Colors.ENDC}") + + for i, assessment in enumerate(risk_assessments, 1): + cancer_type = assessment.cancer_type + risk_level = assessment.risk_level + explanation = assessment.explanation + + # Color code risk levels + if risk_level is None: + risk_color = Colors.ENDC + elif risk_level <= 2: + risk_color = Colors.OKGREEN + elif risk_level == 3: + risk_color = Colors.WARNING + else: # 4-5 + risk_color = Colors.FAIL + + print(f"\n{Colors.BOLD}{i}. {cancer_type.upper()}{Colors.ENDC}") + print( + f" 🎚️ Risk Level: {risk_color}{Colors.BOLD}{risk_level or 'N/A'}{Colors.ENDC}" + ) + print(f" 💭 Explanation: {explanation}") + + # Optional fields + if assessment.recommended_steps: + print(" 📝 Recommended Steps:") + if isinstance(assessment.recommended_steps, list): + for step in assessment.recommended_steps: + print(f" • {step}") + else: + print(f" • {assessment.recommended_steps}") + + if assessment.lifestyle_advice: + print(f" 🌟 Lifestyle Advice: {assessment.lifestyle_advice}") + + if i < len(risk_assessments): + print(f" {Colors.OKBLUE}{'─' * 40}{Colors.ENDC}") + + # Diagnostic recommendations + dx_recommendations = response.dx_recommendations + if dx_recommendations: + print( + f"\n{Colors.OKBLUE}{Colors.BOLD}🔬 DIAGNOSTIC RECOMMENDATIONS{Colors.ENDC}" + ) + print(f"{Colors.OKBLUE}{'─' * 40}{Colors.ENDC}") + + for i, dx_rec in enumerate(dx_recommendations, 1): + test_name = dx_rec.test_name + frequency = dx_rec.frequency + rationale = dx_rec.rationale + recommendation_level = dx_rec.recommendation_level + + level_text = "" + if recommendation_level is not None: + level_map = { + 1: "Unsuitable", + 2: "Unnecessary", + 3: "Optional", + 4: "Recommended", + 5: "Critical - Do not skip", + } + level_text = f" ({level_map.get(recommendation_level, 'Unknown')})" + + print(f"\n{Colors.BOLD}{i}. {test_name.upper()}{Colors.ENDC}") + if recommendation_level is not None: + print( + f" ⭐ Recommendation Level: {Colors.BOLD}{recommendation_level}/5{level_text}{Colors.ENDC}" + ) + print(f" 📅 Frequency: {Colors.OKGREEN}{frequency}{Colors.ENDC}") + print(f" 💭 Rationale: {rationale}") + + if dx_rec.applicable_guideline: + print(f" 📜 Applicable Guideline: {dx_rec.applicable_guideline}") + + if i < len(dx_recommendations): + print(f" {Colors.OKBLUE}{'─' * 40}{Colors.ENDC}") + + print( + f"\n{Colors.WARNING}⚠️ IMPORTANT: This assessment does not replace professional medical advice.{Colors.ENDC}" + ) + print(f"{Colors.HEADER}{'=' * 60}{Colors.ENDC}") + + +def format_followup_response( + response: ConversationResponse, dev_mode: bool = False +) -> None: + """Display follow-up conversation output. + + Args: + response (ConversationResponse): Conversation exchange returned by the + LLM chain. + dev_mode (bool): Flag enabling verbose debugging output. + """ + if dev_mode: + print( + f"\n{Colors.WARNING}{Colors.BOLD}--- DEV MODE: RAW MODEL OUTPUT ---{Colors.ENDC}" + ) + # Use model_dump instead of model_dump_json for direct printing + print(json.dumps(response.model_dump(), indent=2)) + print( + f"\n{Colors.WARNING}{Colors.BOLD}--- DEV MODE: PARSED RESPONSE ---{Colors.ENDC}" + ) + if response.thinking: + print(f"\n{Colors.OKCYAN}{Colors.BOLD}🤔 Chain of Thought:{Colors.ENDC}") + print(f"{Colors.OKCYAN}{response.thinking}{Colors.ENDC}") + + print(f"\n{Colors.OKCYAN}{Colors.BOLD}🤖 BiOS:{Colors.ENDC}") + print(f"{response.response}") + + +@hydra.main(config_path="../../configs", config_name="config", version_base=None) +def main(cfg: DictConfig) -> None: + """Entry point for the CLI tool invoked via Hydra. + + Args: + cfg (DictConfig): Hydra configuration containing model, knowledge base, + and runtime settings. + """ + print( + f"{Colors.HEADER}{Colors.BOLD}Welcome to the Cancer Risk Assessment Tool{Colors.ENDC}" + ) + print( + f"{Colors.OKBLUE}This tool provides preliminary cancer risk assessments based on your input.{Colors.ENDC}\n" + ) + + dev_mode = cfg.dev_mode + + if dev_mode: + print( + f"{Colors.WARNING}🔧 Running in developer mode - raw JSON output enabled{Colors.ENDC}" + ) + else: + print( + f"{Colors.OKGREEN}👤 Running in user mode - formatted output enabled{Colors.ENDC}" + ) + + model = cfg.model.model_name + provider = cfg.model.provider + print(f"{Colors.OKBLUE}🤖 Using model: {model} from {provider}{Colors.ENDC}") + + # Create ResourcePaths with resolved absolute paths + knowledge_base_paths = ResourcePaths( + persona=Path(to_absolute_path("prompts/persona/default.md")), + instruction_assessment=Path( + to_absolute_path("prompts/instruction/assessment.md") + ), + instruction_conversation=Path( + to_absolute_path("prompts/instruction/conversation.md") + ), + output_format_assessment=Path( + to_absolute_path("configs/output_format/assessment.yaml") + ), + output_format_conversation=Path( + to_absolute_path("configs/output_format/conversation.yaml") + ), + cancer_modules_dir=Path( + to_absolute_path("configs/knowledge_base/cancer_modules") + ), + dx_protocols_dir=Path(to_absolute_path("configs/knowledge_base/dx_protocols")), + ) + + # Create AppConfig from Hydra config + app_config = AppConfig( + model=ModelConfig(provider=cfg.model.provider, model_name=cfg.model.model_name), + knowledge_base_paths=knowledge_base_paths, + selected_cancer_modules=list(cfg.knowledge_base.cancer_modules), + selected_dx_protocols=list(cfg.knowledge_base.dx_protocols), + ) + + # Create factory and conversation manager + factory = SentinelFactory(app_config) + conversation = factory.create_conversation_manager() + + if cfg.user_file: + print(f"{Colors.OKBLUE}📂 Loading user data from: {cfg.user_file}{Colors.ENDC}") + user = load_user_file(cfg.user_file) + else: + user = collect_user_input() + + print(f"\n{Colors.OKCYAN}🔄 Running risk scoring tools...{Colors.ENDC}") + risks_scores = [] + for model in RISK_MODELS: + risk_score = model().run(user) + risks_scores.append(risk_score) + + user.risks_scores = risks_scores + for risk_score in risks_scores: + print(f"{Colors.OKCYAN}🔄 {risk_score.name}: {risk_score.score}{Colors.ENDC}") + + print(f"\n{Colors.OKGREEN}🔄 Analyzing your information...{Colors.ENDC}") + response = None + try: + response = conversation.initial_assessment(user) + format_risk_assessment(response, dev_mode) + except Exception as e: + print(f"{Colors.FAIL}❌ Error generating assessment: {e}{Colors.ENDC}") + return + + if response: + export_choice = input( + f"\n{Colors.OKCYAN}Export full report to a file? (pdf/excel/both/N):{Colors.ENDC} " + ).lower() + if export_choice in ["pdf", "excel", "both"]: + output_dir = Path("outputs") + output_dir.mkdir(exist_ok=True) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + base_filename = f"Cancer_Risk_Report_{timestamp}" + + if export_choice in ["pdf", "both"]: + pdf_filename = output_dir / f"{base_filename}.pdf" + try: + print(f"{Colors.OKCYAN}Generating PDF report...{Colors.ENDC}") + generate_pdf_report(response, user, str(pdf_filename)) + print( + f"{Colors.OKGREEN}✅ Successfully generated {pdf_filename}{Colors.ENDC}" + ) + except Exception as e: + print( + f"{Colors.FAIL}❌ Error generating PDF report: {e}{Colors.ENDC}" + ) + + if export_choice in ["excel", "both"]: + excel_filename = output_dir / f"{base_filename}.xlsx" + try: + print(f"{Colors.OKCYAN}Generating Excel report...{Colors.ENDC}") + generate_excel_report(response, user, str(excel_filename)) + print( + f"{Colors.OKGREEN}✅ Successfully generated {excel_filename}{Colors.ENDC}" + ) + except Exception as e: + print( + f"{Colors.FAIL}❌ Error generating Excel report: {e}{Colors.ENDC}" + ) + + # Follow-up conversation loop + print( + f"\n{Colors.OKBLUE}{Colors.BOLD}💬 You can now ask follow-up questions. Type 'quit' to exit.{Colors.ENDC}" + ) + while True: + q = input(f"\n{Colors.BOLD}You: {Colors.ENDC}") + if q.lower() in {"quit", "exit", "q"}: + print( + f"{Colors.OKGREEN}👋 Thank you for using the Cancer Risk Assessment Tool!{Colors.ENDC}" + ) + break + + if not q.strip(): + continue + + try: + text = conversation.follow_up(q) + format_followup_response(text, dev_mode) + except Exception as e: + print(f"{Colors.FAIL}❌ Error: {e}{Colors.ENDC}") + + +if __name__ == "__main__": + main() diff --git a/apps/streamlit_ui/__init__.py b/apps/streamlit_ui/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..a455176d01465b642ada7f225accecb64c126cfe --- /dev/null +++ b/apps/streamlit_ui/__init__.py @@ -0,0 +1 @@ +# Streamlit UI package diff --git a/apps/streamlit_ui/main.py b/apps/streamlit_ui/main.py new file mode 100644 index 0000000000000000000000000000000000000000..5b4e3c9603a8db7a7000448812c0a167d2628c61 --- /dev/null +++ b/apps/streamlit_ui/main.py @@ -0,0 +1,71 @@ +"""Streamlit entry point for the Sentinel expert feedback UI.""" + +import streamlit as st + +# --- Page Configuration --- +st.set_page_config( + page_title="Sentinel | AI Cancer Risk Assessment", page_icon="⚕️", layout="wide" +) + +# --- Header Section --- +st.title("Sentinel: AI-Powered Cancer Risk Assessment") +st.markdown(""" +Welcome to **Sentinel**, an advanced demonstration of an AI-powered assistant for evidence-based cancer risk assessment. +This tool analyzes user-provided health data to generate a preliminary risk profile and personalized diagnostic recommendations based on a configurable knowledge base. +""") + +st.divider() + +# --- Key Features Section --- +st.header("How It Works", anchor=False) +col1, col2, col3 = st.columns(3, gap="large") + +with col1: + st.subheader("👤 1. Build Your Profile") + st.write( + "Navigate to the **Profile** page to input your health information. " + "You can either upload a pre-filled YAML file or create a new profile from scratch using our guided form." + ) + +with col2: + st.subheader("⚙️ 2. Configure the AI") + st.write( + "On the **Configuration** page, you can select the AI model and the specific cancer modules and diagnostic protocols " + "from our knowledge base that will be used for your assessment." + ) + +with col3: + st.subheader("🔬 3. Run the Assessment") + st.write( + "Finally, visit the **Assessment** page to run the analysis. You'll receive a full dashboard of your results, " + "and you can interact with the AI assistant via a chat interface." + ) + +# --- Call to Action / How to Get Started --- +st.header("Get Started", anchor=False) +st.page_link( + "pages/1_Profile.py", label="**Go to the Profile Page to begin →**", icon="👤" +) + +st.divider() + +st.warning( + "**Disclaimer:** This is a demo application - please report any bugs or issues to Tom!" +) + + +# --- Footer / About Section --- +with st.sidebar: + st.info("Created by **Tom Barrett**") + with st.expander("About Sentinel"): + st.markdown(""" + This application uses a Large Language Model (LLM) to synthesize user data with an evidence-based knowledge base, + providing a nuanced, preliminary cancer risk assessment. + + **Powered by:** + - Streamlit + - FastAPI + - LangChain + - ChatGPT, Google Gemini, Llama, etc. + - ☕ Coffee + """) diff --git a/apps/streamlit_ui/page_versions/profile/v1.py b/apps/streamlit_ui/page_versions/profile/v1.py new file mode 100644 index 0000000000000000000000000000000000000000..8e8a0e6246543a3fdd644b0885cb31508ada091d --- /dev/null +++ b/apps/streamlit_ui/page_versions/profile/v1.py @@ -0,0 +1,20 @@ +"""Legacy v1 profile page components for Streamlit UI.""" + +import streamlit as st + + +def render(): + """Renders the V1 view of the Profile page (JSON Viewer).""" + + st.markdown("### V1: Simple JSON Viewer") + st.info( + "This view displays the raw JSON of the loaded user profile. It is not editable." + ) + + profile = st.session_state.get("user_profile") + + if profile is not None: + # Display the profile using st.json for clarity and robustness + st.json(profile.model_dump_json()) + else: + st.warning("No user profile loaded. Please create or upload one.") diff --git a/apps/streamlit_ui/page_versions/profile/v2.py b/apps/streamlit_ui/page_versions/profile/v2.py new file mode 100644 index 0000000000000000000000000000000000000000..80ca0f04a37ddf54057d0a6f7e6dd815a071d206 --- /dev/null +++ b/apps/streamlit_ui/page_versions/profile/v2.py @@ -0,0 +1,246 @@ +"""V2 profile page with editable form for Streamlit UI.""" + +import pandas as pd +import streamlit as st + +from sentinel.models import ( + ClinicalObservation, + Demographics, + FamilyMemberCancer, + FemaleSpecific, + Lifestyle, + PersonalMedicalHistory, + UserInput, +) +from sentinel.risk_models import RISK_MODELS + + +def render(): + """Renders the V2 view of the Profile page (Editable Form).""" + + st.markdown("### V2: Editable Profile Form") + st.info( + "This view populates an editable form with the loaded profile data, allowing you to make and save changes." + ) + + profile = st.session_state.get("user_profile") + + if profile is None: + st.warning("No user profile loaded. Please create or upload one.") + return + + with st.container(border=True): + # This selectbox stays outside the form but inside the container. + sex_options = ["Female", "Male", "Other"] + try: + current_sex_index = sex_options.index(profile.demographics.sex) + except ValueError: + current_sex_index = 0 + + sex = st.selectbox( + "Biological Sex", + options=sex_options, + index=current_sex_index, + key="edit_profile_sex", + help="Changing this will dynamically show or hide sex-specific fields in the form below.", + ) + + # The form starts here and should contain all the fields and the submit button. + with st.form(key="edit_profile_form"): + st.subheader("Demographics") + age = st.number_input( + "Age", min_value=0, step=1, value=profile.demographics.age + ) + ethnicity = st.text_input( + "Ethnicity", value=profile.demographics.ethnicity or "" + ) + + st.subheader("Lifestyle") + smoking_options = ["never", "former", "current"] + try: + smoking_index = smoking_options.index(profile.lifestyle.smoking_status) + except ValueError: + st.warning( + f"Invalid 'smoking_status' ('{profile.lifestyle.smoking_status}') in file. Defaulting to '{smoking_options[0]}'." + ) + smoking_index = 0 + smoking_status = st.selectbox( + "Smoking Status", smoking_options, index=smoking_index + ) + smoking_pack_years = st.number_input( + "Pack-Years", + min_value=0, + step=1, + value=profile.lifestyle.smoking_pack_years or 0, + ) + alcohol_options = ["none", "light", "moderate", "heavy"] + try: + alcohol_index = alcohol_options.index( + profile.lifestyle.alcohol_consumption + ) + except ValueError: + st.warning( + f"Invalid 'alcohol_consumption' ('{profile.lifestyle.alcohol_consumption}') in file. Defaulting to '{alcohol_options[0]}'." + ) + alcohol_index = 0 + alcohol_consumption = st.selectbox( + "Alcohol Consumption", alcohol_options, index=alcohol_index + ) + dietary_habits = st.text_area( + "Dietary Habits", value=profile.lifestyle.dietary_habits or "" + ) + physical_activity_level = st.text_area( + "Physical Activity", + value=profile.lifestyle.physical_activity_level or "", + ) + + st.subheader("Personal Medical History") + known_genetic_mutations = st.text_input( + "Known Genetic Mutations (comma-separated)", + value=", ".join( + profile.personal_medical_history.known_genetic_mutations + ), + ) + previous_cancers = st.text_input( + "Previous Cancers (comma-separated)", + value=", ".join(profile.personal_medical_history.previous_cancers), + ) + chronic_illnesses = st.text_input( + "Chronic Illnesses (comma-separated)", + value=", ".join(profile.personal_medical_history.chronic_illnesses), + ) + + st.subheader("Family History") + fam_cols = ["relative", "cancer_type", "age_at_diagnosis"] + fam_history_data = [m.model_dump() for m in profile.family_history] + fam_history_df = ( + pd.DataFrame(fam_history_data, columns=fam_cols) + if fam_history_data + else pd.DataFrame(columns=fam_cols) + ) + edited_fam_history = st.data_editor( + fam_history_df, + num_rows="dynamic", + key="edit_family_history_editor", + use_container_width=True, + ) + + st.subheader("Clinical Observations") + obs_cols = ["test_name", "value", "unit", "reference_range", "date"] + obs_data = [o.model_dump() for o in profile.clinical_observations] + obs_df = ( + pd.DataFrame(obs_data, columns=obs_cols) + if obs_data + else pd.DataFrame(columns=obs_cols) + ) + edited_obs = st.data_editor( + obs_df, + num_rows="dynamic", + key="edit_clinical_obs_editor", + use_container_width=True, + ) + + female_specific_data = {} + if sex == "Female": + st.subheader("Female-Specific") + fs_profile = profile.female_specific or FemaleSpecific() + female_specific_data["age_at_first_period"] = st.number_input( + "Age at First Period", + min_value=0, + step=1, + value=fs_profile.age_at_first_period or 0, + ) + female_specific_data["age_at_menopause"] = st.number_input( + "Age at Menopause", + min_value=0, + step=1, + value=fs_profile.age_at_menopause or 0, + ) + female_specific_data["num_live_births"] = st.number_input( + "Number of Live Births", + min_value=0, + step=1, + value=fs_profile.num_live_births or 0, + ) + female_specific_data["age_at_first_live_birth"] = st.number_input( + "Age at First Live Birth", + min_value=0, + step=1, + value=fs_profile.age_at_first_live_birth or 0, + ) + female_specific_data["hormone_therapy_use"] = st.text_input( + "Hormone Therapy Use", value=fs_profile.hormone_therapy_use or "" + ) + + current_concerns = st.text_area( + "Current Concerns or Symptoms", + value=profile.current_concerns_or_symptoms or "", + ) + + # The submit button MUST be inside the 'with st.form' block. + submitted = st.form_submit_button("Save Changes") + if submitted: + try: + demographics = Demographics( + age=int(age), sex=sex, ethnicity=ethnicity or None + ) + lifestyle = Lifestyle( + smoking_status=smoking_status, + smoking_pack_years=int(smoking_pack_years) or None, + alcohol_consumption=alcohol_consumption, + dietary_habits=dietary_habits or None, + physical_activity_level=physical_activity_level or None, + ) + pmh = PersonalMedicalHistory( + known_genetic_mutations=[ + m.strip() + for m in known_genetic_mutations.split(",") + if m.strip() + ], + previous_cancers=[ + c.strip() for c in previous_cancers.split(",") if c.strip() + ], + chronic_illnesses=[ + i.strip() for i in chronic_illnesses.split(",") if i.strip() + ], + ) + family_history = [ + FamilyMemberCancer(**row.to_dict()) + for _, row in edited_fam_history.dropna(how="all").iterrows() + ] + observations = [ + ClinicalObservation(**row.to_dict()) + for _, row in edited_obs.dropna(how="all").iterrows() + ] + + female_specific = None + if sex == "Female": + if any(female_specific_data.values()): + female_specific = FemaleSpecific(**female_specific_data) + + updated_profile = UserInput( + demographics=demographics, + lifestyle=lifestyle, + family_history=family_history, + personal_medical_history=pmh, + female_specific=female_specific, + current_concerns_or_symptoms=current_concerns or None, + clinical_observations=observations, + ) + + with st.spinner("Calculating risk scores..."): + risks_scores = [] + for model in RISK_MODELS: + risk_score = model().run(updated_profile) + risks_scores.append(risk_score) + + # Attach the scores to the object before saving + updated_profile.risks_scores = risks_scores + + # Now save the fully updated object to the session state + st.session_state.user_profile = updated_profile + st.success("Profile updated and risk scores calculated!") + st.rerun() + + except Exception as e: + st.error(f"Error updating profile: {e}") diff --git a/apps/streamlit_ui/pages/1_Profile.py b/apps/streamlit_ui/pages/1_Profile.py new file mode 100644 index 0000000000000000000000000000000000000000..4e16e495d195ff622405814d338d9cc8e6c390bc --- /dev/null +++ b/apps/streamlit_ui/pages/1_Profile.py @@ -0,0 +1,266 @@ +"""User profile management page.""" + +import sys +from pathlib import Path + +# Add the project root to the Python path +# This is necessary for Streamlit to find modules in the 'apps' directory +project_root = Path(__file__).resolve().parents[3] +if str(project_root) not in sys.path: + sys.path.append(str(project_root)) + +import pandas as pd +import streamlit as st + +from apps.streamlit_ui.page_versions.profile import v1, v2 +from sentinel.models import ( + ClinicalObservation, + Demographics, + FamilyMemberCancer, + FemaleSpecific, + Lifestyle, + PersonalMedicalHistory, + UserInput, +) +from sentinel.utils import load_user_file + + +# --- Helper Functions --- +def clear_profile_state(): + """Callback function to reset profile-related session state.""" + st.session_state.user_profile = None + if "profile_upload" in st.session_state: + del st.session_state["profile_upload"] + + +# --- Main Page Layout --- +st.title("👤 User Profile") + +# --- Sidebar for Version Selection and Upload --- +with st.sidebar: + st.header("Controls") + + # Version selection + version_options = ["V2 (Editable Form)", "V1 (JSON Viewer)"] + version = st.radio( + "Select Demo Version", + version_options, + help="Choose the version of the profile page to display.", + ) + + st.divider() + + # Example Profile Selector + examples_dir = project_root / "examples" + + # Collect all example profiles + profile_files = [] + if examples_dir.exists(): + # Get profiles from dev/ + dev_dir = examples_dir / "dev" + if dev_dir.exists(): + profile_files.extend(sorted(dev_dir.glob("*.yaml"))) + profile_files.extend(sorted(dev_dir.glob("*.json"))) + + # Get profiles from synthetic/ + synthetic_dir = examples_dir / "synthetic" + if synthetic_dir.exists(): + for subdir in sorted(synthetic_dir.iterdir()): + if subdir.is_dir(): + profile_files.extend(sorted(subdir.glob("*.yaml"))) + profile_files.extend(sorted(subdir.glob("*.json"))) + + # Create display names (relative to examples/) + profile_options = {} + if profile_files: + for p in profile_files: + rel_path = p.relative_to(examples_dir) + profile_options[str(rel_path)] = p + + # Dropdown selector + if profile_options: + selected = st.selectbox( + "Load Example Profile", + options=["-- Select a profile --", *profile_options.keys()], + key="profile_selector", + ) + + if selected != "-- Select a profile --": + try: + profile_path = profile_options[selected] + st.session_state.user_profile = load_user_file(str(profile_path)) + st.success(f"✅ Loaded: {selected}") + except Exception as e: + st.error(f"Failed to load profile: {e}") + + # Clear Profile Button + if st.session_state.get("user_profile"): + st.button( + "Clear Loaded Profile", + on_click=clear_profile_state, + use_container_width=True, + ) + + +# --- Page Content Dispatcher --- +# Render the selected page version +if version == "V1 (JSON Viewer)": + v1.render() +else: # Default to V2 + v2.render() + +# The manual creation form can be a persistent feature at the bottom of the page +with st.expander("Create New Profile Manually"): + # --- STEP 1: Move the sex selector OUTSIDE the form. --- + # This allows it to trigger a rerun and update the UI dynamically. + # Give it a unique key to avoid conflicts with other widgets. + sex = st.selectbox( + "Biological Sex", ["Male", "Female", "Other"], key="manual_profile_sex" + ) + + with st.form("manual_profile_form"): + st.subheader("Demographics") + age = st.number_input("Age", min_value=0, step=1) + # The 'sex' variable is now taken from the selector above the form. + ethnicity = st.text_input("Ethnicity") + + st.subheader("Lifestyle") + smoking_status = st.selectbox("Smoking Status", ["never", "former", "current"]) + smoking_pack_years = st.number_input("Pack-Years", min_value=0, step=1) + alcohol_consumption = st.selectbox( + "Alcohol Consumption", ["none", "light", "moderate", "heavy"] + ) + dietary_habits = st.text_area("Dietary Habits") + physical_activity_level = st.text_area("Physical Activity") + + st.subheader("Personal Medical History") + known_genetic_mutations = st.text_input( + "Known Genetic Mutations (comma-separated)" + ) + previous_cancers = st.text_input("Previous Cancers (comma-separated)") + chronic_illnesses = st.text_input("Chronic Illnesses (comma-separated)") + + st.subheader("Family History") + fam_cols = ["relative", "cancer_type", "age_at_diagnosis"] + fam_df = st.data_editor( + pd.DataFrame(columns=fam_cols), + num_rows="dynamic", + key="family_history_editor", + ) + + st.subheader("Clinical Observations") + obs_cols = ["test_name", "value", "unit", "reference_range", "date"] + obs_df = st.data_editor( + pd.DataFrame(columns=obs_cols), + num_rows="dynamic", + key="clinical_obs_editor", + ) + + female_specific_data = {} + # --- STEP 2: The conditional check now works correctly. --- + # The 'if' statement is evaluated on each rerun when the 'sex' selector changes. + if sex == "Female": + st.subheader("Female-Specific") + female_specific_data["age_at_first_period"] = st.number_input( + "Age at First Period", min_value=0, step=1 + ) + female_specific_data["age_at_menopause"] = st.number_input( + "Age at Menopause", min_value=0, step=1 + ) + female_specific_data["num_live_births"] = st.number_input( + "Number of Live Births", min_value=0, step=1 + ) + female_specific_data["age_at_first_live_birth"] = st.number_input( + "Age at First Live Birth", min_value=0, step=1 + ) + female_specific_data["hormone_therapy_use"] = st.text_input( + "Hormone Therapy Use" + ) + + current_concerns = st.text_area("Current Concerns or Symptoms") + + submitted = st.form_submit_button("Save New Profile") + if submitted: + # --- STEP 3: Use the 'sex' variable from the external selector during submission. --- + demographics = Demographics( + age=int(age), sex=sex, ethnicity=ethnicity or None + ) + lifestyle = Lifestyle( + smoking_status=smoking_status, + smoking_pack_years=int(smoking_pack_years) or None, + alcohol_consumption=alcohol_consumption, + dietary_habits=dietary_habits or None, + physical_activity_level=physical_activity_level or None, + ) + pmh = PersonalMedicalHistory( + known_genetic_mutations=[ + m.strip() for m in known_genetic_mutations.split(",") if m.strip() + ], + previous_cancers=[ + c.strip() for c in previous_cancers.split(",") if c.strip() + ], + chronic_illnesses=[ + i.strip() for i in chronic_illnesses.split(",") if i.strip() + ], + ) + family_history = [] + for _, row in fam_df.dropna(how="all").iterrows(): + if row.get("relative") and row.get("cancer_type"): + family_history.append( + FamilyMemberCancer( + relative=str(row["relative"]), + cancer_type=str(row["cancer_type"]), + age_at_diagnosis=int(row["age_at_diagnosis"]) + if row["age_at_diagnosis"] not in ["", None] + else None, + ) + ) + + observations = [] + for _, row in obs_df.dropna(how="all").iterrows(): + if row.get("test_name") and row.get("value") and row.get("unit"): + observations.append( + ClinicalObservation( + test_name=str(row["test_name"]), + value=str(row["value"]), + unit=str(row["unit"]), + reference_range=( + str(row["reference_range"]) + if row["reference_range"] not in ["", None] + else None + ), + date=str(row["date"]) + if row["date"] not in ["", None] + else None, + ) + ) + + female_specific = None + if sex == "Female": + female_specific = FemaleSpecific(**female_specific_data) + + new_profile = UserInput( + demographics=demographics, + lifestyle=lifestyle, + family_history=family_history, + personal_medical_history=pmh, + female_specific=female_specific, + current_concerns_or_symptoms=current_concerns or None, + clinical_observations=observations, + ) + st.success("Profile saved") + + # --- STEP 4: Compute the risk scores --- + with st.spinner("Calculating risk scores..."): + from sentinel.risk_models import RISK_MODELS + + risks_scores = [] + for model in RISK_MODELS: + risk_score = model().run(new_profile) + risks_scores.append(risk_score) + + new_profile.risks_scores = risks_scores + + st.session_state.user_profile = new_profile + st.success("Risk scores calculated!") + st.rerun() diff --git a/apps/streamlit_ui/pages/2_Configuration.py b/apps/streamlit_ui/pages/2_Configuration.py new file mode 100644 index 0000000000000000000000000000000000000000..825e177b5377adb70429f49c8fb748188eaf61c0 --- /dev/null +++ b/apps/streamlit_ui/pages/2_Configuration.py @@ -0,0 +1,131 @@ +"""Streamlit page: Configuration.""" + +from pathlib import Path + +import streamlit as st +import yaml +from ui_utils import initialize_session_state + +from sentinel.config import AppConfig, ModelConfig, ResourcePaths +from sentinel.factory import SentinelFactory + +initialize_session_state() + +st.title("⚙️ Model Configuration") + +# Define base paths relative to project root +root = Path(__file__).resolve().parents[3] +model_dir = root / "configs" / "model" +model_options = sorted([p.stem for p in model_dir.glob("*.yaml")]) +default_model = ( + "gemini_2.5_pro" if ("gemini_2.5_pro" in model_options) else model_options[0] +) + +# Model selection +current_model = st.session_state.config.get("model") or default_model +selected_model = st.selectbox( + "Model Config", + model_options, + index=model_options.index(current_model) if current_model in model_options else 0, +) +st.session_state.config["model"] = selected_model + +# Cancer modules selection +cancer_dir = root / "configs" / "knowledge_base" / "cancer_modules" +cancer_options = sorted([p.stem for p in cancer_dir.glob("*.yaml")]) +selected_cancers = st.multiselect( + "Cancer Modules", + cancer_options, + default=st.session_state.config.get("cancer_modules", cancer_options), +) +st.session_state.config["cancer_modules"] = selected_cancers + +# Diagnostic protocols selection +protocol_dir = root / "configs" / "knowledge_base" / "dx_protocols" +protocol_options = sorted([p.stem for p in protocol_dir.glob("*.yaml")]) +selected_protocols = st.multiselect( + "Diagnostic Protocols", + protocol_options, + default=st.session_state.config.get("dx_protocols", protocol_options), +) +st.session_state.config["dx_protocols"] = selected_protocols + + +@st.cache_data(show_spinner=False) +def generate_prompt_preview( + model_config: str, cancer_modules: list, dx_protocols: list, _user_profile=None +) -> str: + """Generate prompt preview using the factory system. + + Args: + model_config (str): Name of the Hydra model configuration to load. + cancer_modules (list): Cancer module slugs selected by the user. + dx_protocols (list): Diagnostic protocol slugs to include. + _user_profile: Optional cached profile used when formatting prompts. + + Returns: + str: Markdown-formatted prompt or an error message if generation fails. + """ + try: + # Load model config to get provider and model name + model_config_path = root / "configs" / "model" / f"{model_config}.yaml" + with open(model_config_path) as f: + model_data = yaml.safe_load(f) + + # Create knowledge base paths + knowledge_base_paths = ResourcePaths( + persona=root / "prompts" / "persona" / "default.md", + instruction_assessment=root / "prompts" / "instruction" / "assessment.md", + instruction_conversation=root + / "prompts" + / "instruction" + / "conversation.md", + output_format_assessment=root + / "configs" + / "output_format" + / "assessment.yaml", + output_format_conversation=root + / "configs" + / "output_format" + / "conversation.yaml", + cancer_modules_dir=root / "configs" / "knowledge_base" / "cancer_modules", + dx_protocols_dir=root / "configs" / "knowledge_base" / "dx_protocols", + ) + + # Create app config + app_config = AppConfig( + model=ModelConfig( + provider=model_data["provider"], model_name=model_data["model_name"] + ), + knowledge_base_paths=knowledge_base_paths, + selected_cancer_modules=cancer_modules, + selected_dx_protocols=dx_protocols, + ) + + # Create factory and get prompt builder + factory = SentinelFactory(app_config) + + # Generate assessment prompt + prompt = factory.prompt_builder.build_assessment_prompt() + + # Format prompt with user data if available + user_json = _user_profile.model_dump_json() if _user_profile is not None else "" + formatted_prompt = prompt.format(user_data=user_json) + + return formatted_prompt + + except Exception as e: + return f"Error generating prompt preview: {e!s}" + + +# Generate prompt preview +if selected_model: + prompt_text = generate_prompt_preview( + selected_model, + selected_cancers, + selected_protocols, + st.session_state.user_profile, + ) + + st.subheader("Prompt Preview") + st.text_area("System Prompt", value=prompt_text, height=500, disabled=True) diff --git a/apps/streamlit_ui/pages/3_Assessment.py b/apps/streamlit_ui/pages/3_Assessment.py new file mode 100644 index 0000000000000000000000000000000000000000..ee591a1ca916967feaa72afec7d974a33fabbce8 --- /dev/null +++ b/apps/streamlit_ui/pages/3_Assessment.py @@ -0,0 +1,249 @@ +"""Streamlit page: Assessment.""" + +import os +import tempfile +from pathlib import Path + +import streamlit as st +import yaml + +# Configure page layout to be wider +st.set_page_config(layout="wide") +from collections import Counter + +import pandas as pd +import plotly.graph_objects as go +from ui_utils import initialize_session_state + +from sentinel.config import AppConfig, ModelConfig, ResourcePaths +from sentinel.conversation import ConversationManager +from sentinel.factory import SentinelFactory +from sentinel.reporting import generate_excel_report, generate_pdf_report + +initialize_session_state() + +if st.session_state.user_profile is None: + st.warning( + "Please complete your profile on the Profile page before running an assessment." + ) + st.stop() + + +def create_conversation_manager(config: dict) -> ConversationManager: + """Create a conversation manager from the current configuration. + + Args: + config: A dictionary containing the current configuration. + + Returns: + ConversationManager: A conversation manager instance. + """ + # Define base paths relative to project root + root = Path(__file__).resolve().parents[3] + + # Load model config to get provider and model name + model_config_path = root / "configs" / "model" / f"{config['model']}.yaml" + with open(model_config_path) as f: + model_data = yaml.safe_load(f) + + # Create knowledge base paths + knowledge_base_paths = ResourcePaths( + persona=root / "prompts" / "persona" / "default.md", + instruction_assessment=root / "prompts" / "instruction" / "assessment.md", + instruction_conversation=root / "prompts" / "instruction" / "conversation.md", + output_format_assessment=root / "configs" / "output_format" / "assessment.yaml", + output_format_conversation=root + / "configs" + / "output_format" + / "conversation.yaml", + cancer_modules_dir=root / "configs" / "knowledge_base" / "cancer_modules", + dx_protocols_dir=root / "configs" / "knowledge_base" / "dx_protocols", + ) + + # Create app config + app_config = AppConfig( + model=ModelConfig( + provider=model_data["provider"], model_name=model_data["model_name"] + ), + knowledge_base_paths=knowledge_base_paths, + selected_cancer_modules=config.get("cancer_modules", []), + selected_dx_protocols=config.get("dx_protocols", []), + ) + + # Create factory and conversation manager + factory = SentinelFactory(app_config) + return factory.create_conversation_manager() + + +manager = create_conversation_manager(st.session_state.config) +st.session_state.conversation_manager = manager + +st.title("🔬 Assessment") + +if st.button("Run Assessment", type="primary"): + with st.spinner("Running..."): + result = manager.initial_assessment(st.session_state.user_profile) + st.session_state.assessment = result + +assessment = st.session_state.get("assessment") + +if assessment: + # --- 1. PRE-SORT DATA --- + sorted_risk_assessments = sorted( + assessment.risk_assessments, key=lambda x: x.risk_level or 0, reverse=True + ) + sorted_dx_recommendations = sorted( + assessment.dx_recommendations, + key=lambda x: x.recommendation_level or 0, + reverse=True, + ) + + # --- 2. ROW 1: OVERALL RISK SCORE --- + st.subheader("Overall Risk Score") + if assessment.overall_risk_score is not None: + fig = go.Figure( + go.Indicator( + mode="gauge+number", + value=assessment.overall_risk_score, + title={"text": "Overall Score"}, + gauge={"axis": {"range": [0, 100]}}, + ) + ) + fig.update_layout(height=300, margin=dict(t=50, b=40, l=40, r=40)) + st.plotly_chart(fig, use_container_width=True) + st.divider() + + # --- 3. ROW 2: RISK & RECOMMENDATION CHARTS --- + col1, col2 = st.columns(2) + with col1: + st.subheader("Cancer Risk Levels") + if sorted_risk_assessments: + cancers = [ra.cancer_type for ra in sorted_risk_assessments] + levels = [ra.risk_level or 0 for ra in sorted_risk_assessments] + short_cancers = [c[:28] + "..." if len(c) > 28 else c for c in cancers] + fig = go.Figure( + go.Bar( + x=levels, + y=short_cancers, + orientation="h", + hovertext=cancers, + hovertemplate="%{hovertext}
Risk Level: %{x}", + ) + ) + fig.update_layout( + xaxis=dict(range=[0, 5], title="Risk Level"), + yaxis=dict(autorange="reversed"), + margin=dict(t=20, b=40, l=40, r=40), + ) + st.plotly_chart(fig, use_container_width=True) + + with col2: + st.subheader("Dx Recommendations") + if sorted_dx_recommendations: + tests = [dx.test_name for dx in sorted_dx_recommendations] + recs = [dx.recommendation_level or 0 for dx in sorted_dx_recommendations] + short_tests = [t[:28] + "..." if len(t) > 28 else t for t in tests] + fig = go.Figure( + go.Bar( + x=recs, + y=short_tests, + orientation="h", + hovertext=tests, + hovertemplate="%{hovertext}
Recommendation: %{x}", + ) + ) + fig.update_layout( + xaxis=dict(range=[0, 5], title="Recommendation"), + yaxis=dict(autorange="reversed"), + margin=dict(t=20, b=40, l=40, r=40), + ) + st.plotly_chart(fig, use_container_width=True) + st.divider() + + # --- 4. ROW 3: RISK FACTOR VISUALIZATIONS --- + if assessment.identified_risk_factors: + col3, col4 = st.columns(2) + with col3: + st.subheader("Risk Factor Summary") + categories = [ + rf.category.value for rf in assessment.identified_risk_factors + ] + category_counts = Counter(categories) + pie_fig = go.Figure( + go.Pie( + labels=list(category_counts.keys()), + values=list(category_counts.values()), + hole=0.3, + ) + ) + pie_fig.update_layout( + height=400, + margin=dict(t=20, b=40, l=40, r=40), + legend=dict( + orientation="v", yanchor="middle", y=0.5, xanchor="left", x=1.05 + ), + ) + st.plotly_chart(pie_fig, use_container_width=True) + + with col4: + st.subheader("Identified Risk Factors") + risk_factor_data = [ + {"Category": rf.category.value, "Description": rf.description} + for rf in assessment.identified_risk_factors + ] + rf_df = pd.DataFrame(risk_factor_data) + st.dataframe(rf_df, use_container_width=True, height=400, hide_index=True) + + # --- 5. EXPANDERS (using sorted data) --- + with st.expander("Overall Summary"): + st.markdown(assessment.overall_summary, unsafe_allow_html=True) + + with st.expander("Risk Assessments"): + for ra in sorted_risk_assessments: + st.markdown(f"**{ra.cancer_type}** - {ra.risk_level or 'N/A'}/5") + st.write(ra.explanation) + if ra.recommended_steps: + st.write("**Recommended Steps:**") + steps = ra.recommended_steps + if isinstance(steps, list): + for step in steps: + st.write(f"- {step}") + else: + st.write(f"- {steps}") + if ra.lifestyle_advice: + st.write(f"*{ra.lifestyle_advice}*") + st.divider() + + with st.expander("Dx Recommendations"): + for dx in sorted_dx_recommendations: + st.markdown(f"**{dx.test_name}** - {dx.recommendation_level or 'N/A'}/5") + if dx.frequency: + st.write(f"Frequency: {dx.frequency}") + st.write(dx.rationale) + if dx.applicable_guideline: + st.write(f"Guideline: {dx.applicable_guideline}") + st.divider() + + # --- 6. EXISTING DOWNLOAD AND CHAT LOGIC --- + with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f: + generate_pdf_report(assessment, st.session_state.user_profile, f.name) + f.seek(0) + pdf_data = f.read() + st.download_button("Download PDF", pdf_data, file_name="assessment.pdf") + os.unlink(f.name) + + with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as f: + generate_excel_report(assessment, st.session_state.user_profile, f.name) + f.seek(0) + xls_data = f.read() + st.download_button("Download Excel", xls_data, file_name="assessment.xlsx") + + # for q, a in manager.history: + # st.chat_message("user").write(q) + # st.chat_message("assistant").write(a) + + if question := st.chat_input("Ask a follow-up question"): + with st.spinner("Thinking..."): + resp = manager.follow_up(question) + st.chat_message("user").write(question) + st.chat_message("assistant").write(resp.response) diff --git a/apps/streamlit_ui/pages/4_Risk_Scores.py b/apps/streamlit_ui/pages/4_Risk_Scores.py new file mode 100644 index 0000000000000000000000000000000000000000..8b49e7c5f7e18a06721c084c6a81420a14dc98e6 --- /dev/null +++ b/apps/streamlit_ui/pages/4_Risk_Scores.py @@ -0,0 +1,62 @@ +"""Streamlit page: Risk Scores.""" + +import streamlit as st + +st.set_page_config(page_title="Risk Scores", page_icon="🧮") + +st.title("🧮 Calculated Risk Scores") + +profile = st.session_state.get("user_profile") + +if profile is None: + st.info( + "⬅️ Please load or create a user profile on the 'Profile' page to view the calculated scores." + ) + st.stop() + +if not profile.risks_scores: + st.warning("Risk scores have not been calculated for the current profile yet.") + st.info( + "⬅️ Please go to the 'Profile' page and click the 'Save' button to trigger the calculation." + ) + st.stop() + +st.header("Applicable Risk Scores") +st.caption( + "The following risk scores were applicable to the provided user profile. Models that were not applicable are not shown." +) + +# Filter out scores where the score string contains "N/A". +applicable_scores = [ + s for s in profile.risks_scores if s is not None and "N/A" not in s.score +] + +if not applicable_scores: + st.success("✅ No major risk models were applicable or triggered for this profile.") + st.stop() + +# Loop through and display only the applicable scores +for score in applicable_scores: + model_name = score.name.replace("_", " ").title() + if score.cancer_type: + cancer_type = score.cancer_type.replace("_", " ").title() + title = f"{model_name} ({cancer_type} Risk)" + else: + title = model_name + + with st.expander(title, expanded=True): + col1, col2 = st.columns(2) + with col1: + st.metric(label="Risk Score", value=f"{score.score}") + + if score.interpretation: + st.markdown("**Interpretation:**") + st.info(score.interpretation) + + if score.description: + st.markdown(f"**Model Description:** {score.description}") + + if score.references: + st.markdown("**References:**") + for ref in score.references: + st.write(f"- {ref}") diff --git a/apps/streamlit_ui/pages/__init__.py b/apps/streamlit_ui/pages/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/apps/streamlit_ui/ui_utils.py b/apps/streamlit_ui/ui_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..a95576d1f38c1a7326c0538ae404c578482bb00a --- /dev/null +++ b/apps/streamlit_ui/ui_utils.py @@ -0,0 +1,41 @@ +"""Utilities for Streamlit UI components and helpers.""" + +from pathlib import Path + +import streamlit as st + + +def initialize_session_state() -> None: + """Initialize Streamlit session state with default values.""" + if "user_profile" not in st.session_state: + st.session_state.user_profile = None + if "config" not in st.session_state: + # Load all available options as defaults + root = Path(__file__).resolve().parents[2] # Go up to project root + + cancer_dir = root / "configs" / "knowledge_base" / "cancer_modules" + all_cancer_modules = sorted([p.stem for p in cancer_dir.glob("*.yaml")]) + + protocol_dir = root / "configs" / "knowledge_base" / "dx_protocols" + all_dx_protocols = sorted([p.stem for p in protocol_dir.glob("*.yaml")]) + + model_dir = root / "configs" / "model" + model_options = sorted([p.stem for p in model_dir.glob("*.yaml")]) + if model_options: + default_model = ( + "gemini_2.5_pro" + if ("gemini_2.5_pro" in model_options) + else model_options[0] + ) + else: + default_model = None + + st.session_state.config = { + "model": default_model, + "cancer_modules": all_cancer_modules, + "dx_protocols": all_dx_protocols, + } + if "assessment" not in st.session_state: + st.session_state.assessment = None + if "conversation_manager" not in st.session_state: + st.session_state.conversation_manager = None diff --git a/configs/config.yaml b/configs/config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..e33015af23619772aefebf69f844e8820470d675 --- /dev/null +++ b/configs/config.yaml @@ -0,0 +1,14 @@ +defaults: + - model: gemma3_4b + - _self_ + +user_file: null +dev_mode: false + +knowledge_base: + # Cancer modules removed - risk models handle this logic directly + cancer_modules: [] + + dx_protocols: + # Keep one protocol as reference template for future additions + - mammography_screening diff --git a/configs/knowledge_base/dx_protocols/mammography_screening.yaml b/configs/knowledge_base/dx_protocols/mammography_screening.yaml new file mode 100644 index 0000000000000000000000000000000000000000..b91c90ef343765cd7c2100206584721b28ee60eb --- /dev/null +++ b/configs/knowledge_base/dx_protocols/mammography_screening.yaml @@ -0,0 +1,46 @@ +key: mammography_screening +name: "Mammogram for Breast Cancer Screening" +description: "A mammogram is a low-dose X-ray of the breast used to find early signs of cancer, often before they can be seen or felt as a lump. Finding breast cancer early greatly increases the chances of successful treatment." +typical_frequency: "Every 1 to 2 years for women of screening age, depending on specific guidelines and risk factors." +additional_information: | + #### CORE GUIDANCE FOR AVERAGE-RISK INDIVIDUALS + This information is for individuals at average risk of breast cancer. You are generally considered average risk if you do not have a personal history of breast cancer, a known high-risk genetic mutation like BRCA1/2, or a history of radiation therapy to the chest at a young age. + + ##### Guideline Nuances: + It's important to know that different expert groups have slightly different recommendations. This can be confusing, but it reflects that they weigh the benefits and harms of screening differently. + - **U.S. Preventive Services Task Force (USPSTF):** Recommends a mammogram every 2 years for women ages 40 to 74. + - **American Cancer Society (ACS):** Recommends women ages 40-44 have the option to start yearly mammograms. It recommends yearly mammograms for women 45-54. At age 55, women can switch to every 2 years or continue yearly screening. + - **NHS (UK):** Invites women for a mammogram every 3 years between the ages of 50 and 71. + This assistant's primary logic is based on the USPSTF guidelines, which recommend starting at age 40. You should discuss with your doctor which schedule is best for you, considering your personal health, values, and local practices. + + #### RISK STRATIFICATION: IDENTIFYING HIGH-RISK INDIVIDUALS + Certain factors place you at a significantly higher risk for breast cancer and mean you need a different, more intensive screening plan. If any of the following apply to you, the standard recommendations are NOT sufficient. You should speak with your doctor about a referral to a high-risk breast clinic or genetic counselor. + + ##### High-Risk Triggers: + - **Known Genetic Mutation:** You or a first-degree relative (parent, sibling, child) have a known mutation in a gene like *BRCA1*, *BRCA2*, *TP53*, *PALB2*, etc.. + - **Strong Family History:** Even without genetic testing, a strong family history may qualify you for high-risk screening. This can be complex, but often includes having multiple first-degree relatives with breast cancer, or relatives diagnosed at a young age (e.g., before 50). + - **Calculated Lifetime Risk:** Risk assessment tools (like the Tyrer-Cuzick or Gail models) estimate your lifetime risk of breast cancer to be 20% or higher. + - **History of Chest Radiation:** You received radiation therapy to the chest between the ages of 10 and 30 (e.g., for Hodgkin lymphoma). + - **Personal History:** You have a personal history of lobular carcinoma in situ (LCIS), atypical ductal hyperplasia (ADH), or atypical lobular hyperplasia (ALH). + + **If you meet high-risk criteria, guidelines often recommend annual screening with both a breast MRI and a mammogram, typically starting at age 30.** + + #### KEY CONSIDERATIONS & ALTERNATIVE OPTIONS + + ##### Breast Density: + Breast density refers to the amount of fibrous and glandular tissue in a breast compared to fatty tissue. Nearly half of all women have dense breasts. + - **What it means:** Having dense breasts is common and is a risk factor for breast cancer. It can also make it harder for mammograms to detect cancer, as both dense tissue and tumors can appear white on an X-ray. + - **Supplemental Screening:** Because of this, there is ongoing research into whether additional tests, like a breast ultrasound or MRI, could help find cancers missed by mammography in women with dense breasts. Currently, the USPSTF states there is not enough evidence to make a recommendation for or against these extra tests for women at average risk. This is an important topic to discuss with your doctor. + + ##### Alternative Mammography Technology: + - **3D Mammography (Digital Breast Tomosynthesis or DBT):** This is an advanced type of mammogram that takes pictures of the breast from multiple angles to create a 3D-like image. Studies show it can find slightly more cancers and reduce the number of "false alarms" (when you are called back for more testing for something that isn't cancer), especially in women with dense breasts. Both 2D and 3D mammography are considered effective screening methods. + + ##### Benefits and Harms of Screening: + - **Benefit:** The main benefit of screening is finding cancer early, when it is most treatable and curable. + - **Harms:** Screening is not perfect. It can lead to: + - **False Positives:** A result that looks like cancer but is not. This leads to anxiety and the need for more tests (like biopsies). + - **Overdiagnosis:** Finding and treating cancers that are so slow-growing they would never have caused a problem in a person's lifetime. + - **Radiation Exposure:** Mammograms use a very low dose of radiation. The benefit of finding cancer early is widely believed to outweigh this small risk. + + ##### Breast Awareness: + Screening tests are important, but they don't find every cancer. It's crucial to be familiar with how your breasts normally look and feel. If you notice any changes—such as a new lump, skin dimpling, nipple changes, or persistent pain—see a doctor right away, even if your last mammogram was normal. diff --git a/configs/model/chatgpt_o1.yaml b/configs/model/chatgpt_o1.yaml new file mode 100644 index 0000000000000000000000000000000000000000..f4438d9a4767bfc46a3c2207d30874df6c45fab8 --- /dev/null +++ b/configs/model/chatgpt_o1.yaml @@ -0,0 +1,2 @@ +provider: openai +model_name: o1 diff --git a/configs/model/chatgpt_o3.yaml b/configs/model/chatgpt_o3.yaml new file mode 100644 index 0000000000000000000000000000000000000000..455955a76bbf7b24b41aec557e00e35e78ded0ed --- /dev/null +++ b/configs/model/chatgpt_o3.yaml @@ -0,0 +1,2 @@ +provider: openai +model_name: o3 diff --git a/configs/model/chatgpt_o3_mini.yaml b/configs/model/chatgpt_o3_mini.yaml new file mode 100644 index 0000000000000000000000000000000000000000..ed72fa7984fdf98cfbc29d1c790f29ab2138f07e --- /dev/null +++ b/configs/model/chatgpt_o3_mini.yaml @@ -0,0 +1,2 @@ +provider: openai +model_name: o3-mini diff --git a/configs/model/chatgpt_o4_mini.yaml b/configs/model/chatgpt_o4_mini.yaml new file mode 100644 index 0000000000000000000000000000000000000000..fde30c063ca01d525bb56b58fbf5c749e355164c --- /dev/null +++ b/configs/model/chatgpt_o4_mini.yaml @@ -0,0 +1,2 @@ +provider: openai +model_name: o4-mini diff --git a/configs/model/gemini_1.5_pro.yaml b/configs/model/gemini_1.5_pro.yaml new file mode 100644 index 0000000000000000000000000000000000000000..c7fa51fff0d2be3d4cdab3a5b83b9b1d0c6497bd --- /dev/null +++ b/configs/model/gemini_1.5_pro.yaml @@ -0,0 +1,2 @@ +provider: google +model_name: gemini-1.5-pro-latest diff --git a/configs/model/gemini_2.5_pro.yaml b/configs/model/gemini_2.5_pro.yaml new file mode 100644 index 0000000000000000000000000000000000000000..59cf0554feb15c1f12858235a422be9f5dea1340 --- /dev/null +++ b/configs/model/gemini_2.5_pro.yaml @@ -0,0 +1,2 @@ +provider: google +model_name: gemini-2.5-pro diff --git a/configs/model/gemma3_4b.yaml b/configs/model/gemma3_4b.yaml new file mode 100644 index 0000000000000000000000000000000000000000..256d258502dd51bc519a49f3ff094b009d5dc424 --- /dev/null +++ b/configs/model/gemma3_4b.yaml @@ -0,0 +1,2 @@ +provider: local +model_name: gemma3:4b diff --git a/configs/output_format/assessment.yaml b/configs/output_format/assessment.yaml new file mode 100644 index 0000000000000000000000000000000000000000..69047da11d0944bdcbd69817f3f3c2ca5aaba018 --- /dev/null +++ b/configs/output_format/assessment.yaml @@ -0,0 +1,53 @@ +format_instructions: | + CRITICAL: + - Return ONLY valid JSON. Do not include any explanatory text, disclaimers, or additional content before or after the JSON. + - Provide a risk assessment for each cancer type that has a computed risk score in the `RISK SCORES` section of `USER INFORMATION`. + - Provide a diagnostic recommendation for EVERY diagnostic protocol provided in the `DIAGNOSTIC PROTOCOLS` (i.e. {diagnostic_protocols}). + - The ONLY allowed values for the "category" field in "identified_risk_factors" and "contributing_factors" objects are: {allowed_categories}. You MUST prioritize placing factors into the primary, specific categories. The 'Other' category is to be used ONLY as a last resort when a factor is clinically significant but genuinely cannot be classified into any other available category. + - The ONLY allowed values for the "strength" field in "contributing_factors" objects are: {allowed_strengths}. + + The output must be formatted as a valid JSON instance with the following structure: + + {{ + "reasoning": "string or null - Your internal step-by-step reasoning process for the assessment. Put all of your reasoning as specified in the `INSTRUCTIONS` here.", + "identified_risk_factors": [ + {{ + "description": "string - A human-readable description of the risk factor identified from the user's profile.", + "category": "string - One of the predefined categories (Lifestyle, Family History, Personal Medical History, Demographics, Female-Specific, Clinical Observation, Other). You MUST prioritize placing factors into the primary, specific categories. The 'Other' category is to be used ONLY as a last resort when a factor is clinically significant but genuinely cannot be classified into any other available category. ", + }} + ] + "risk_assessments": [ + {{ + "cancer_type": "string - Type of cancer", + "risk_level": "number - A score from 1 (lowest risk), 2 (low risk - proactive screening not needed but user should be aware of symptoms), 3 (moderate risk - some screening recommended), 4 (high risk - screening important), 5 (very high risk - screening critical, short-term action required)", + "explanation": "string - Reasoning behind the assessment. Always relate the explanation to information provided in the `User Information` and `Clinical Observations` as much as possible.", + "recommended_steps": ["string"] or null - Optional steps to mitigate risk. This field is only required if the risk level is 3 or higher, otherwise leave this field blank. + "contributing_factors": [ + {{ + "description": "string - A human-readable description of the risk factor", + "category": "string - One of the predefined categories (Lifestyle, Family History, Personal Medical History, Demographics, Female-Specific, Clinical Observation, Other). You MUST prioritize placing factors into the primary, specific categories. The 'Other' category is to be used ONLY as a last resort when a factor is clinically significant but genuinely cannot be classified into any other available category. ", + "strength": "string - The assessed contribution strength (Major, Moderate, Minor)" + }} + ] + }} + ], + "dx_recommendations": [ + {{ + "test_name": "string - Name of the diagnostic test", + "recommendation_level": "number - A score from 1 to 5, where 1 is unsuitable, 2 is unnecessary, 3 is optional, 4 is recommended, and 5 is critical - do not skip.", + "frequency": "string - Recommended testing frequency. With a recommendation level of 3 or higher, YOU MUST include the frequency of the test in the frequency field.", + "rationale": "string - Short explanation for why this test is recommended. Always relate the recommendation to the `User Information` and `Clinical Observations` as much as possible.", + "applicable_guideline": "string - The rule or guideline that triggered this recommendation. This field is only included if the recommendation is 3 or higher and is triggered by a rule or guideline - otherwise, leave this field blank." + }} + ], + "overall_summary": "string - Provide a detailed summary of your the user's cancer risk profile, your diagnostic recommendations, and your reasoning. This should start with a single summary paragraph, and then provide a detailed breakdown of the reasoning for each assessment, diagnostic recommendation, and other insights, advice or guidance. Where possible reference information about the user, cancer types, and diagnostic protocols from your input. Consider also providing discussion of where the user's profile aligns or differs from the normal, and how situations or advice may change over time. Use only plain text, or simple HTML (bold, italic and lists ONLY). Explicitly include line breaks in the output." + "response": "string or null - The user-facing empathetic and educational narrative that introduces and summarizes the assessment.", + "overall_risk_score": "number or null - A holistic score from 0 (lowest possible risk across all cancer types) to 100 (critically high risk of any cancer in the short term). This should synthesize all risk factors into a single metric.", + }} + + IMPORTANT: + - The `reasoning` field is mandatory for your internal monologue. You must put any and all reasoning you were asked to do in here. This is your internal monologue, and should be as detailed as possible. + - Do not add disclaimers; they are handled separately. + - Use null for optional fields that don't apply. + - Return ONLY the JSON object, nothing else. + - Ensure all required fields are included. diff --git a/configs/output_format/conversation.yaml b/configs/output_format/conversation.yaml new file mode 100644 index 0000000000000000000000000000000000000000..6d46adec377a708e62d7618cf5fecf3488b79d49 --- /dev/null +++ b/configs/output_format/conversation.yaml @@ -0,0 +1 @@ +format_instructions: "Provide a clear, conversational response. Do not use JSON formatting." diff --git a/examples/benchmark/benchmark_female.yaml b/examples/benchmark/benchmark_female.yaml new file mode 100644 index 0000000000000000000000000000000000000000..d673197f2444010d041cb45b1a6f38cd5e206e5d --- /dev/null +++ b/examples/benchmark/benchmark_female.yaml @@ -0,0 +1,45 @@ +demographics: + age: 52 + sex: female + ethnicity: "Asian" + +lifestyle: + smoking: + status: never + alcohol: + consumption_level: light + +family_history: + - relative: mother + cancer_type: breast + age_at_diagnosis: 48 + - relative: aunt + cancer_type: ovarian + age_at_diagnosis: 55 + +personal_medical_history: {} + +female_specific: + menstrual: + age_at_menarche: 13 + parity: + num_live_births: 2 + age_at_first_live_birth: 28 + +current_concerns_or_symptoms: "Small lump in left breast. Fatigue and irregular periods." + +lab_results: + - test_name: "CA 15-3" + value: "32" + unit: "U/mL" + date: "2025-09-20" + - test_name: "Hemoglobin" + value: "12.8" + unit: "g/dL" + date: "2025-09-20" + +clinical_observations: + - test_name: "Mammogram" + value: "BI-RADS 4" + unit: "category" + date: "2025-09-20" diff --git a/examples/benchmark/benchmark_male.yaml b/examples/benchmark/benchmark_male.yaml new file mode 100644 index 0000000000000000000000000000000000000000..ba2b560b93829650e494702ac5e97df3e1a32bc6 --- /dev/null +++ b/examples/benchmark/benchmark_male.yaml @@ -0,0 +1,42 @@ +demographics: + age: 58 + sex: male + ethnicity: "Caucasian" + +lifestyle: + smoking: + status: former + pack_years: 20 + alcohol: + consumption_level: moderate + +family_history: + - relative: father + cancer_type: lung + age_at_diagnosis: 67 + - relative: brother + cancer_type: prostate + age_at_diagnosis: 62 + +personal_medical_history: + chronic_conditions: + - "Type 2 diabetes" + - "Hypertension" + +current_concerns_or_symptoms: "Difficulty with urination and persistent cough." + +lab_results: + - test_name: "PSA" + value: "5.8" + unit: "ng/mL" + date: "2025-09-15" + - test_name: "Hemoglobin A1c" + value: "7.2" + unit: "%" + date: "2025-09-15" + +clinical_observations: + - test_name: "Blood Pressure" + value: "142/88" + unit: "mmHg" + date: "2025-09-15" diff --git a/examples/dev/profile_1.yaml b/examples/dev/profile_1.yaml new file mode 100644 index 0000000000000000000000000000000000000000..9e2670317bb6449e969726fe2ce4bdae44acc8f3 --- /dev/null +++ b/examples/dev/profile_1.yaml @@ -0,0 +1,51 @@ +demographics: + age: 65 + sex: male + ethnicity: "African American" +lifestyle: + smoking_status: former + smoking_pack_years: 10 + alcohol_consumption: moderate +family_history: + - relative: father + cancer_type: prostate + age_at_diagnosis: 65 +personal_medical_history: + previous_cancers: [] +current_concerns_or_symptoms: "Difficulty with urination." +clinical_observations: + - test_name: "PSA" + value: "6.1" + unit: "ng/mL" + reference_range: "< 4.0 ng/mL" + date: "2025-05-20" + - test_name: "Vitamin D, 25-Hydroxy" + value: "28" + unit: "ng/mL" + reference_range: "30-100 ng/mL" + date: "2025-05-20" + - test_name: "Hemoglobin" + value: "13.2" + unit: "g/dL" + reference_range: "13.5-17.5 g/dL" + date: "2025-05-20" + - test_name: "White Blood Cell Count" + value: "7.2" + unit: "K/uL" + reference_range: "4.5 - 11.0 K/uL" + date: "2025-05-20" + - test_name: "Glucose" + value: "115" + unit: "mg/dL" + reference_range: "70-99 mg/dL" + date: "2025-05-20" + - test_name: "Creatinine" + value: "1.4" + unit: "mg/dL" + reference_range: "0.7-1.3 mg/dL" + date: "2025-05-20" + - test_name: "LDL Cholesterol" + value: "140" + unit: "mg/dL" + reference_range: "< 100 mg/dL" + date: "2025-05-20" diff --git a/examples/dev/profile_2.yaml b/examples/dev/profile_2.yaml new file mode 100644 index 0000000000000000000000000000000000000000..c809ab8482db61121cb610e10bae734e1e984626 --- /dev/null +++ b/examples/dev/profile_2.yaml @@ -0,0 +1,42 @@ +# Older Female with Lung Cancer Risk + +demographics: + age: 72 + sex: female + ethnicity: "Caucasian" +lifestyle: + smoking_status: current + smoking_pack_years: 40 + alcohol_consumption: occasional + exercise_frequency: "Rarely" + diet: "Standard Western diet" +family_history: + - relative: mother + cancer_type: lung + age_at_diagnosis: 70 + - relative: sister + cancer_type: breast + age_at_diagnosis: 50 +personal_medical_history: + previous_cancers: [] + chronic_conditions: + - "COPD" + - "Osteoporosis" + - "Hypertension" +current_concerns_or_symptoms: "Persistent cough, unintended weight loss, shortness of breath." +clinical_observations: + - test_name: "Chest X-ray" + value: "Suspicious mass in right upper lobe" + unit: "N/A" + reference_range: "Clear" + date: "2025-06-10" + - test_name: "Spirometry" + value: "FEV1 55% predicted" + unit: "%" + reference_range: "> 80%" + date: "2025-06-05" + - test_name: "Bone Density Scan" + value: "T-score -2.6" + unit: "T-score" + reference_range: "> -1.0" + date: "2025-05-20" diff --git a/examples/dev/profile_3.yaml b/examples/dev/profile_3.yaml new file mode 100644 index 0000000000000000000000000000000000000000..8c9602efb8ce84613371208fc56ce1988e43e897 --- /dev/null +++ b/examples/dev/profile_3.yaml @@ -0,0 +1,41 @@ +# Middle-aged Hispanic Male with Colon Cancer Risk + +demographics: + age: 50 + sex: male + ethnicity: "Hispanic" +lifestyle: + smoking_status: never + alcohol_consumption: heavy + exercise_frequency: "Rarely" + diet: "High in processed foods" +family_history: + - relative: father + cancer_type: colorectal + age_at_diagnosis: 55 + - relative: paternal uncle + cancer_type: stomach + age_at_diagnosis: 60 +personal_medical_history: + previous_cancers: [] + chronic_conditions: + - "Type 2 Diabetes" + - "Obesity" + - "High cholesterol" +current_concerns_or_symptoms: "Intermittent rectal bleeding, abdominal discomfort, fatigue." +clinical_observations: + - test_name: "Fecal Occult Blood Test" + value: "Positive" + unit: "N/A" + reference_range: "Negative" + date: "2025-04-15" + - test_name: "HbA1c" + value: "7.8" + unit: "%" + reference_range: "<5.7%" + date: "2025-03-10" + - test_name: "Lipid Panel" + value: "LDL 165" + unit: "mg/dL" + reference_range: "<100 mg/dL" + date: "2025-03-10" diff --git a/examples/dev/profile_4.yaml b/examples/dev/profile_4.yaml new file mode 100644 index 0000000000000000000000000000000000000000..9be3a3077dfbdd7b71eaec1cc4c010c3abafc0f2 --- /dev/null +++ b/examples/dev/profile_4.yaml @@ -0,0 +1,34 @@ +# Young Asian-American Female, Risk of Thyroid Cancer + +demographics: + age: 29 + sex: female + ethnicity: "Asian-American" +lifestyle: + smoking_status: never + alcohol_consumption: none + exercise_frequency: "Regularly" + diet: "Balanced diet, mostly plant-based" +family_history: + - relative: mother + cancer_type: thyroid + age_at_diagnosis: 35 + - relative: father + cancer_type: melanoma + age_at_diagnosis: 45 +personal_medical_history: + previous_cancers: [] + chronic_conditions: + - "Anxiety" +current_concerns_or_symptoms: "Swelling and discomfort in the neck, fatigue, occasional headaches." +clinical_observations: + - test_name: "Thyroid Ultrasound" + value: "1.5 cm hypoechoic nodule" + unit: "cm" + reference_range: "Nodule-free" + date: "2025-06-01" + - test_name: "TSH" + value: "4.8" + unit: "μIU/mL" + reference_range: "0.4 - 4.0 μIU/mL" + date: "2025-05-15" diff --git a/examples/dev/profile_5.yaml b/examples/dev/profile_5.yaml new file mode 100644 index 0000000000000000000000000000000000000000..4cfd681b2a2302ff2541c496499b0c77f4a493cb --- /dev/null +++ b/examples/dev/profile_5.yaml @@ -0,0 +1,38 @@ +# African American Male, Risk of Prostate Cancer + +demographics: + age: 58 + sex: male + ethnicity: "African American" +lifestyle: + smoking_status: former + smoking_pack_years: 20 + alcohol_consumption: moderate + exercise_frequency: "Occasional" + diet: "Mixed diet, moderate meat consumption" +family_history: + - relative: brother + cancer_type: prostate + age_at_diagnosis: 60 + - relative: mother + cancer_type: hypertension-related stroke + age_at_diagnosis: 72 +personal_medical_history: + previous_cancers: [] + chronic_conditions: + - "Hypertension" + - "Pre-diabetes" +current_medications: + - "Amlodipine" +current_concerns_or_symptoms: "Frequent nighttime urination, mild lower back pain, increased fatigue." +clinical_observations: + - test_name: "Prostate-Specific Antigen (PSA)" + value: "5.5" + unit: "ng/mL" + reference_range: "<4.0 ng/mL" + date: "2025-06-10" + - test_name: "Blood Pressure" + value: "145/90" + unit: "mmHg" + reference_range: "<120/80 mmHg" + date: "2025-06-05" diff --git a/examples/dev/profile_6.yaml b/examples/dev/profile_6.yaml new file mode 100644 index 0000000000000000000000000000000000000000..ffd46414c1b25b89b801d42138cdd985fd0e9418 --- /dev/null +++ b/examples/dev/profile_6.yaml @@ -0,0 +1,43 @@ +# Young Female, BRCA Mutation, High Breast Cancer Risk + +demographics: + age: 32 + sex: female + ethnicity: "Ashkenazi Jewish" + education_level: 4 # college graduate +lifestyle: + smoking_status: never + alcohol_consumption: occasional + exercise_frequency: "Regularly" + diet: "Mediterranean diet" +female_specific: + age_at_first_period: 12 + num_live_births: 1 + age_at_first_live_birth: 25 + hormone_therapy_use: "never" +family_history: + - relative: mother + cancer_type: breast + age_at_diagnosis: 42 + - relative: maternal grandmother + cancer_type: ovarian + age_at_diagnosis: 60 +personal_medical_history: + previous_cancers: [] + chronic_conditions: [] + genetic_testing: + - mutation: "BRCA1" + status: "Positive" + date: "2025-03-20" +current_concerns_or_symptoms: "No current symptoms, proactive health management, fertility counseling ongoing." +clinical_observations: + - test_name: "Breast MRI" + value: "No abnormalities detected" + unit: "N/A" + reference_range: "No abnormalities" + date: "2025-05-01" + - test_name: "CA-125" + value: "15" + unit: "U/mL" + reference_range: "<35 U/mL" + date: "2025-05-01" diff --git a/examples/dev/profile_mjs_1.yaml b/examples/dev/profile_mjs_1.yaml new file mode 100644 index 0000000000000000000000000000000000000000..b82f29d8c3f3827adfe782bf6babd94a84c872ab --- /dev/null +++ b/examples/dev/profile_mjs_1.yaml @@ -0,0 +1,77 @@ +# Relatively healthy female in her 50s with a family history of CRC, entering menopause. + +demographics: + age: 49 + sex: female + ethnicity: "Japanese" + years_edu: 12 + height_in: 60.0 + weight_lb: 100.5 + height: 1.52 + weight: 45.6 + +lifestyle: + smoking_status: former + smoking_pack_years: 1 + alcohol_consumption: moderate + alcohol_drinks_per_day: 1.00 + multivitamin_usage: True + diabetes_status: False + activity: 2.0 + total_meat: 4.0 + pain_med: "no" + nsaid_use: False + estrogen: "no" + estrogen_use: False + estrogen_type: "none" + estrogen_use_duration: 0 + estrogen_use_duration_unit: "years" + estrogen_use_duration_value: 0 + estrogen_use_duration_unit: "years" +personal_medical_history: + previous_cancers: [] + family_crc: False + aspirin: "yes" + +current_concerns_or_symptoms: "Irritability and night sweats." +clinical_observations: + nsaid_use: False + estrogen: "no" + estrogen_use: False + estrogen_type: "none" + estrogen_use_duration: 0 + estrogen_use_duration_unit: "years" + estrogen_use_duration_value: 0 + estrogen_use_duration_unit: "years" + +clinical_observations: + - test_name: "Vitamin D, 25-Hydroxy" + value: "28" + unit: "ng/mL" + reference_range: "30-100 ng/mL" + date: "2025-05-20" + - test_name: "Hemoglobin" + value: "13.2" + unit: "g/dL" + reference_range: "13.5-17.5 g/dL" + date: "2025-05-20" + - test_name: "White Blood Cell Count" + value: "7.2" + unit: "K/uL" + reference_range: "4.5 - 11.0 K/uL" + date: "2025-05-20" + - test_name: "Glucose" + value: "115" + unit: "mg/dL" + reference_range: "70-99 mg/dL" + date: "2025-05-20" + - test_name: "Creatinine" + value: "1.1" + unit: "mg/dL" + reference_range: "0.7-1.3 mg/dL" + date: "2025-05-20" + - test_name: "LDL Cholesterol" + value: "120" + unit: "mg/dL" + reference_range: "< 100 mg/dL" + date: "2025-05-20" diff --git a/examples/dev/profile_mjs_2.yaml b/examples/dev/profile_mjs_2.yaml new file mode 100644 index 0000000000000000000000000000000000000000..f5a4e7116518f4bfd5e73eac05ae5643aaf3ea69 --- /dev/null +++ b/examples/dev/profile_mjs_2.yaml @@ -0,0 +1,48 @@ +# African American Male, Risk of Prostate Cancer + +demographics: + age: 58 + sex: male + bmi: 24.9 + ethnicity: "African American" + height: 1.85 + weight: 85 +lifestyle: + smoking_status: former + smoking_pack_years: 5 + alcohol_consumption: abstain + alcohol_drinks_per_day: 0.0 + multivitamin_usage: True + diabetes_status: False + activity: 2.0 + total_meat: 4.0 + pain_med: "no" + nsaid_use: False + +family_history: + - relative: brother + cancer_type: prostate + age_at_diagnosis: 60 + - relative: mother + cancer_type: hypertension-related stroke + age_at_diagnosis: 72 +personal_medical_history: + previous_cancers: [] + chronic_conditions: + - "Hypertension" + - "Pre-diabetes" + - "Impotence" +current_medications: + - "Amlodipine" +current_concerns_or_symptoms: "Increased fatigue, appetite loss, indigestion." +clinical_observations: + - test_name: "Prostate-Specific Antigen (PSA)" + value: "2.5" + unit: "ng/mL" + reference_range: "<4.0 ng/mL" + date: "2025-06-10" + - test_name: "Blood Pressure" + value: "115/75" + unit: "mmHg" + reference_range: "<120/80 mmHg" + date: "2025-06-05" diff --git a/examples/dev/profile_mjs_3.yaml b/examples/dev/profile_mjs_3.yaml new file mode 100644 index 0000000000000000000000000000000000000000..6ebb8ba877a2849b660f66440c0ccd0711f42a08 --- /dev/null +++ b/examples/dev/profile_mjs_3.yaml @@ -0,0 +1,102 @@ +# European young male, healthy + +demographics: + age: 25 + sex: male + bmi: 21.0 + ethnicity: "European" + height: 1.80 + weight: 75 +lifestyle: + smoking_status: former + smoking_pack_years: 3 + alcohol_consumption: light + alcohol_drinks_per_day: 2 + multivitamin_usage: False + diabetes_status: False + activity: 3.0 + total_meat: 2.5 + pain_med: "no" + nsaid_use: False + +family_history: + - relative: grandmother + cancer_type: colorectal + age_at_diagnosis: 85 + - relative: paternal grandfather + cancer_type: prostate + age_at_diagnosis: 96 +personal_medical_history: + previous_cancers: [] + chronic_conditions: + - "Depression" + - "ADHD" +current_medications: + - "Fluoxetine" + - "Methylphenidate" +current_concerns_or_symptoms: "Increased fatigue and appetite loss." +clinical_observations: + - test_name: "Prostate-Specific Antigen (PSA)" + value: "0.5" + unit: "ng/mL" + reference_range: "<4.0 ng/mL" + date: "2025-06-10" + - test_name: "Blood Pressure" + value: "115/75" + unit: "mmHg" + reference_range: "<120/80 mmHg" + date: "2025-06-05" + - test_name: "Glucose" + value: "103" + unit: "mg/dL" + reference_range: "70-99 mg/dL" + date: "2025-06-05" + - test_name: "Creatinine" + value: "1.1" + unit: "mg/dL" + reference_range: "0.7-1.3 mg/dL" + date: "2025-06-05" + - test_name: "HDL Cholesterol" + value: "42" + unit: "mg/dL" + reference_range: "40-50 mg/dL" + date: "2025-06-05" + - test_name: "LDL Cholesterol" + value: "92" + unit: "mg/dL" + reference_range: "<100 mg/dL" + date: "2025-06-05" + - test_name: "Triglycerides" + value: "134" + unit: "mg/dL" + reference_range: "<150 mg/dL" + date: "2025-06-05" + - test_name: "C-Reactive Protein" + value: "0.7" + unit: "mg/dL" + reference_range: "0.0-3.0 mg/dL" + date: "2025-06-05" + - test_name: "Lung X-Ray" + value: "normal" + unit: "n/a" + date: "2025-06-05" + - test_name: "CT Scan of Abdomen" + value: "normal" + unit: "n/a" + date: "2025-06-05" + - test_name: "CT Scan of Chest" + value: "normal" + unit: "n/a" + date: "2025-06-05" + - test_name: "Lung Function Test" + value: "normal" + unit: "n/a" + date: "2025-06-05" + - test_name: "Liver Function Test" + value: "normal" + unit: "n/a" + date: "2025-06-05" + - test_name: "Kidney Function Test" + value: "normal" + unit: "n/a" + date: "2025-06-05" diff --git a/examples/dev/profile_plcom2012_comprehensive.yaml b/examples/dev/profile_plcom2012_comprehensive.yaml new file mode 100644 index 0000000000000000000000000000000000000000..4b1a3b3521813537370742128c3d3400413743f2 --- /dev/null +++ b/examples/dev/profile_plcom2012_comprehensive.yaml @@ -0,0 +1,51 @@ +# PLCOm2012 Comprehensive Test Profile +# Tests native hawaiian/pacific islander race category and comprehensive variable coverage + +demographics: + age: 62 # Center value used in model + sex: male + ethnicity: "native hawaiian" # Tests highest race offset (1.027152) + education_level: 4 # College graduate (center value in model) + height: 1.78 # meters + weight: 85.0 # kilograms (BMI ~27, close to center value) + +lifestyle: + smoking_status: current # Current smoker + smoking_pack_years: 35 # Calculated from intensity and duration + smoking_intensity_cpd: 25 # Moderate cigarettes per day + smoking_duration_years: 27 # Center value used in model + smoking_quit_years: null # Not applicable for current smoker + alcohol_consumption: light + dietary_habits: "mixed" + physical_activity_level: "low" + +personal_medical_history: + known_genetic_mutations: [] + previous_cancers: [] # No previous cancers + chronic_illnesses: ["diabetes"] # No COPD + +family_history: + - relative: "uncle" # Not first-degree relative + cancer_type: "lung" + age_at_diagnosis: 68 + - relative: "cousin" # Not first-degree relative + cancer_type: "lung" + age_at_diagnosis: 55 + +female_specific: null # Not applicable for male profile + +current_concerns_or_symptoms: "Current smoker with no specific symptoms" + +clinical_observations: + - test_name: "Complete Blood Count" + value: "Normal" + unit: "descriptive" + reference_range: "Normal" + date: "2025-09-30" + - test_name: "Chest X-Ray" + value: "Clear" + unit: "descriptive" + reference_range: "Normal" + date: "2025-09-30" + +risks_scores: [] diff --git a/examples/dev/profile_plcom2012_edge_cases.yaml b/examples/dev/profile_plcom2012_edge_cases.yaml new file mode 100644 index 0000000000000000000000000000000000000000..f33a9df9898561829e04a29c35d6ab7f68e2169e --- /dev/null +++ b/examples/dev/profile_plcom2012_edge_cases.yaml @@ -0,0 +1,42 @@ +# PLCOm2012 Edge Cases and Missing Data Test Profile +# Tests model validation with missing required fields and edge cases + +demographics: + age: 50 # Minimum age boundary for PLCOm2012 (50-80 range) + sex: female + ethnicity: "asian" # Tests different race offset (-0.466585) + height: 1.73 + weight: 104 + education_level: 5 + +lifestyle: + smoking_status: current # Tests current smoker (smoking_status = 0) + smoking_intensity_cpd: 17 + smoking_duration_years: 5 + smoking_quit_years: null # Not applicable for current smoker + alcohol_consumption: heavy + +personal_medical_history: + previous_cancers: [] # No previous cancers (cancer_hist = 0) + chronic_illnesses: ["hypertension", "arthritis"] # No COPD (copd = 0) + +family_history: + - relative: "grandfather" # Not first-degree relative - shouldn't count for lung cancer family history + cancer_type: "lung" + age_at_diagnosis: 75 + - relative: "aunt" # Not first-degree relative + cancer_type: "lung" + age_at_diagnosis: 68 + - relative: "sister" # First-degree relative but different cancer + cancer_type: "ovarian" + age_at_diagnosis: 55 + +female_specific: + menarche_age: 12 + menopause_age: 48 + pregnancies_count: 2 + breastfeeding_duration_months: 17 + hormone_replacement_therapy: "yes" + oral_contraceptive_use: "no" + +current_concerns_or_symptoms: "Heavy smoker concerned about lung cancer risk" diff --git a/examples/synthetic/complex_and_acquired_risk/colorectal_risk_ibd.yaml b/examples/synthetic/complex_and_acquired_risk/colorectal_risk_ibd.yaml new file mode 100644 index 0000000000000000000000000000000000000000..f0b898893c734b069e1c8ddb0322fae784a3d268 --- /dev/null +++ b/examples/synthetic/complex_and_acquired_risk/colorectal_risk_ibd.yaml @@ -0,0 +1,42 @@ +# Use Case: 14 - Chronic Inflammatory Disease (IBD) +# +# Why it was chosen: +# To test the specific high-risk pathway of chronic inflammation to cancer, a concept distinct from most other risk factors. The duration and extent of colitis are key parameters the AI must recognize. +# +# How to understand the inputs: +# - A 10-year history of "pancolitis" (affecting the entire colon) is the critical information. +# - The user is young, so age-based screening rules do not apply. +# - Anemia and elevated inflammatory markers are consistent with active IBD. +# +# What to look for in a successful assessment: +# 1. IBD Risk Pathway: The AI must identify Colorectal Cancer as high risk (Level 4/5) and the `reasoning` must state that this is due to the long-standing, extensive ulcerative colitis. +# 2. Correct Surveillance Protocol: The AI must recommend a surveillance colonoscopy with biopsies, not just a standard screening one. It should recommend a much shorter interval (e.g., "every 1-2 years") than the standard 10 years. +# 3. Guideline Start Time: The AI should note that surveillance for IBD typically begins 8-10 years after diagnosis, and therefore this patient is due for surveillance now. + +demographics: + age: 35 + sex: male + ethnicity: "Caucasian" +lifestyle: + smoking_status: never + alcohol_consumption: light +family_history: [] +personal_medical_history: + chronic_illnesses: ["Ulcerative Colitis (pancolitis) diagnosed at age 25"] +current_concerns_or_symptoms: "My colitis is fairly well controlled, but I want to know about my cancer risk." +clinical_observations: + - test_name: "Calprotectin, Fecal" + value: "350" + unit: "mcg/g" + reference_range: "< 50" + date: "2025-05-10" + - test_name: "CRP, High Sensitivity" + value: "8.5" + unit: "mg/L" + reference_range: "< 3.0" + date: "2025-05-10" + - test_name: "Hemoglobin" + value: "12.8" + unit: "g/dL" + reference_range: "13.5-17.5" + date: "2025-05-10" diff --git a/examples/synthetic/complex_and_acquired_risk/complex_comorbidity.yaml b/examples/synthetic/complex_and_acquired_risk/complex_comorbidity.yaml new file mode 100644 index 0000000000000000000000000000000000000000..a298c84814408e233b0fca7eeeabd2d68c119a5c --- /dev/null +++ b/examples/synthetic/complex_and_acquired_risk/complex_comorbidity.yaml @@ -0,0 +1,73 @@ +# Use Case: The Complex Comorbidity Case +# +# Why it was chosen: +# This profile showcases the AI's ability to synthesize a multi-factorial risk profile with competing signals. It must correctly prioritize risks from demographics (African American), family history (prostate cancer), lifestyle (smoking), and clinical data (elevated PSA), while navigating comorbidities (Diabetes, HTN) and ambiguous lab results. +# +# How to understand the inputs: +# - Multiple high-risk streams: Prostate (age, ethnicity, family hx, PSA) and Lung (smoking history). +# - Ambiguous Lab Result: Mildly low hemoglobin. This is a key test of nuance. +# - Comorbidities: Diabetes and hypertension are present, which are relevant health issues but must be distinguished from the primary cancer risks. +# - Imaging Result: An Optomap scan result is included to test parsing of text-based reports. +# +# What to look for in a successful assessment: +# 1. Prioritization: The report must correctly identify Prostate and Lung cancer as the highest-risk categories (e.g., Level 4 or 5). +# 2. PSA Handling: The elevated PSA (5.8) must be flagged as a "Major" contributing factor for prostate cancer. +# 3. Nuanced Reasoning (Anemia): The AI's `reasoning` should acknowledge the mild anemia. An exceptional response would link it as a secondary reason to ensure a timely colonoscopy (to rule out GI bleed). +# 4. Advanced Dx Recommendation: The AI should recommend a more advanced prostate cancer biomarker test (like Proclarix) as a logical next step to clarify the "grey zone" PSA. +# 5. Lung Screening: It must correctly identify him as eligible for an annual LDCT scan based on his age and pack-year history. + +demographics: + age: 66 + sex: male + ethnicity: "African American" +lifestyle: + smoking_status: former + smoking_pack_years: 30 + alcohol_consumption: moderate +personal_medical_history: + known_genetic_mutations: [] + previous_cancers: [] + chronic_illnesses: + - "Type 2 Diabetes" + - "Hypertension" +family_history: + - relative: father + cancer_type: prostate + age_at_diagnosis: 62 +current_concerns_or_symptoms: "Frequent urination at night and some recent fatigue." +clinical_observations: + - test_name: "Prostate-Specific Antigen (PSA)" + value: "5.8" + unit: "ng/mL" + reference_range: "< 4.0 ng/mL" + date: "2025-06-20" + - test_name: "Hemoglobin A1c" + value: "7.5" + unit: "%" + reference_range: "< 5.7 %" + date: "2025-05-10" + - test_name: "Hemoglobin" + value: "13.1" + unit: "g/dL" + reference_range: "13.5-17.5" + date: "2025-05-10" + - test_name: "Creatinine" + value: "1.35" + unit: "mg/dL" + reference_range: "0.7-1.3" + date: "2025-05-10" + - test_name: "eGFR" + value: "55" + unit: "mL/min/1.73m^2" + reference_range: ">60" + date: "2025-05-10" + - test_name: "Chest X-ray" + value: "No acute cardiopulmonary process." + unit: "N/A" + reference_range: "N/A" + date: "2023-08-01" + - test_name: "Optomap Retinal Scan" + value: "Mild non-proliferative diabetic retinopathy noted. No signs of choroidal melanoma." + unit: "N/A" + reference_range: "N/A" + date: "2024-11-15" diff --git a/examples/synthetic/complex_and_acquired_risk/kidney_cancer_esrd.yaml b/examples/synthetic/complex_and_acquired_risk/kidney_cancer_esrd.yaml new file mode 100644 index 0000000000000000000000000000000000000000..edb77a4b96c0d4ec3fa1ff4ea9f2fc58a0da2949 --- /dev/null +++ b/examples/synthetic/complex_and_acquired_risk/kidney_cancer_esrd.yaml @@ -0,0 +1,48 @@ +# Use Case: 02 - Kidney Cancer (Acquired Risk from ESRD) +# +# Why it was chosen: +# This profile tests the AI's understanding of acquired (non-genetic) high-risk conditions. Long-term dialysis is a known major risk factor for a specific type of kidney cancer (acquired cystic kidney disease-associated RCC). This moves beyond simple lifestyle/family history risks. +# +# How to understand the inputs: +# - The key information is the 8-year history of end-stage renal disease (ESRD) and dialysis. +# - Symptoms (flank pain, hematuria) are classic signs of potential kidney cancer. +# - The lab work (anemia, high creatinine) is expected with ESRD but could also be worsened by a tumor. +# +# What to look for in a successful assessment: +# 1. Correct Module Trigger: The AI must identify Kidney Cancer as a major risk (Level 5). +# 2. Risk Rationale: The `explanation` must correctly cite long-term dialysis as the primary risk factor, as specified in the `kidney.yaml` module. +# 3. Dx Recommendation: A renal ultrasound or MRI/CT should be recommended as a "Critical" (Level 5) next step. +# 4. Contextual Reasoning: The AI should note that while anemia is expected in ESRD, the new symptoms make investigating for a renal mass urgent. + +demographics: + age: 55 + sex: male + ethnicity: "Caucasian" +lifestyle: + smoking_status: never + alcohol_consumption: none +personal_medical_history: + chronic_illnesses: + - "End-Stage Renal Disease (ESRD) secondary to Polycystic Kidney Disease" + - "On hemodialysis for 8 years" +family_history: + - relative: mother + cancer_type: breast + age_at_diagnosis: 65 +current_concerns_or_symptoms: "Intermittent dull pain in my left side and I think I saw some blood in my urine last week." +clinical_observations: + - test_name: "Creatinine" + value: "7.8" + unit: "mg/dL" + reference_range: "0.7-1.3" + date: "2025-06-15" + - test_name: "Hemoglobin" + value: "9.5" + unit: "g/dL" + reference_range: "13.5-17.5" + date: "2025-06-15" + - test_name: "Urine Dipstick" + value: "2+ blood" + unit: "N/A" + reference_range: "Negative" + date: "2025-06-28" diff --git a/examples/synthetic/complex_and_acquired_risk/leukemia_therapy_related.yaml b/examples/synthetic/complex_and_acquired_risk/leukemia_therapy_related.yaml new file mode 100644 index 0000000000000000000000000000000000000000..4c57adfafb580eb32a8025d72731450007794467 --- /dev/null +++ b/examples/synthetic/complex_and_acquired_risk/leukemia_therapy_related.yaml @@ -0,0 +1,46 @@ +# Use Case: 03 - Leukemia (Therapy-Related Acute Leukemia) +# +# Why it was chosen: +# A critical safety and nuance test. The AI must recognize that prior cytotoxic therapy (chemo/radiation) is a major risk factor for secondary malignancies, especially leukemia (t-AML/t-MDS). It must also identify the urgent nature of the symptoms and lab results. +# +# How to understand the inputs: +# - The most important input is the history of treatment for breast cancer 10 years ago. +# - The symptoms (fatigue, bruising) are classic signs of bone marrow failure. +# - The CBC result is the key objective finding: pancytopenia (low levels of all three blood cell lines) is a massive red flag. +# +# What to look for in a successful assessment: +# 1. Urgency Recognition: The assessment must immediately flag the leukemia risk as Level 5 and emphasize the need for immediate medical consultation. +# 2. Correct Risk Factor: The `reasoning` must connect the prior chemotherapy/radiation to the current risk of a therapy-related myeloid neoplasm. +# 3. Lab Interpretation: The AI must identify that low hemoglobin, low platelets, and low white blood cells (pancytopenia) are highly alarming findings requiring urgent hematological investigation. +# 4. Dx Recommendation: A bone marrow biopsy is the definitive test, but the most important recommendation is an urgent referral to a hematologist. A standard "CBC with differential" is a Level 5 recommendation. + +demographics: + age: 45 + sex: female + ethnicity: "Caucasian" +lifestyle: + smoking_status: never + alcohol_consumption: light +family_history: [] +personal_medical_history: + previous_cancers: + - "Breast Cancer (Stage II) at age 35" + chronic_illnesses: + - "Treated with Adriamycin/Cytoxan chemotherapy and radiation, completed 2011" +current_concerns_or_symptoms: "For the past month, I've been extremely tired, more than usual. I've also noticed a lot of small bruises on my legs and my gums bled a lot when I brushed my teeth this morning." +clinical_observations: + - test_name: "WBC Count" + value: "2.1" + unit: "K/uL" + reference_range: "4.5-11.0" + date: "2025-06-29" + - test_name: "Hemoglobin" + value: "8.9" + unit: "g/dL" + reference_range: "12.0-16.0" + date: "2025-06-29" + - test_name: "Platelet Count" + value: "45" + unit: "K/uL" + reference_range: "150-450" + date: "2025-06-29" diff --git a/examples/synthetic/complex_and_acquired_risk/lymphoma_immunosuppression.yaml b/examples/synthetic/complex_and_acquired_risk/lymphoma_immunosuppression.yaml new file mode 100644 index 0000000000000000000000000000000000000000..e61721728cac1edb8af824d5a03b8068553f3570 --- /dev/null +++ b/examples/synthetic/complex_and_acquired_risk/lymphoma_immunosuppression.yaml @@ -0,0 +1,45 @@ +# Use Case: 04 - Lymphoma (Post-Transplant Immunosuppression) +# +# Why it was chosen: +# Similar to the leukemia case, this tests the AI's ability to identify risk from a specific medical history (immunosuppression) rather than lifestyle or family history. Post-transplant lymphoproliferative disorder (PTLD) is a known risk. +# +# How to understand the inputs: +# - The kidney transplant and use of immunosuppressants are the key risk factors. +# - The "B symptoms" (night sweats, fatigue) and a new swollen lymph node are classic signs of lymphoma. +# - The elevated LDH is a non-specific but corroborating marker of high cell turnover. +# +# What to look for in a successful assessment: +# 1. Risk Connection: The AI must connect the use of immunosuppressants for a transplant to the elevated risk of lymphoma (specifically PTLD), rating it Level 4/5. +# 2. Symptom Triage: The reasoning must identify the combination of a new lymph node and "B symptoms" as highly suspicious. +# 3. Dx Recommendation: The AI should indicate that a biopsy of the lymph node is the definitive diagnostic step and that a PET/CT scan would be used for staging *if* lymphoma is confirmed. It should NOT recommend a PET/CT as the first step. +# 4. Low Risk for Kidney Cancer: As a bonus, the AI should correctly note that while he had a transplant, his risk of native kidney cancer is now lower (as the diseased kidneys are gone/non-functional). + +demographics: + age: 38 + sex: male + ethnicity: "Hispanic" +lifestyle: + smoking_status: never + alcohol_consumption: none +family_history: [] +personal_medical_history: + chronic_illnesses: + - "Kidney transplant recipient (5 years ago)" + - "On long-term immunosuppressant medication (Tacrolimus, Mycophenolate)" +current_concerns_or_symptoms: "I've been waking up drenched in sweat for the past few weeks. I also found a painless lump in the side of my neck that wasn't there before." +clinical_observations: + - test_name: "LDH (Lactate Dehydrogenase)" + value: "350" + unit: "U/L" + reference_range: "140-280" + date: "2025-06-25" + - test_name: "CBC" + value: "Normal" + unit: "N/A" + reference_range: "N/A" + date: "2025-06-25" + - test_name: "Tacrolimus Level" + value: "6.5" + unit: "ng/mL" + reference_range: "5-10" + date: "2025-06-25" diff --git a/examples/synthetic/complex_and_acquired_risk/real_world_data.yaml b/examples/synthetic/complex_and_acquired_risk/real_world_data.yaml new file mode 100644 index 0000000000000000000000000000000000000000..731c1a0bce7c4d43179495e7786e1014a62bd577 --- /dev/null +++ b/examples/synthetic/complex_and_acquired_risk/real_world_data.yaml @@ -0,0 +1,88 @@ +# Use Case: The Real-World Data Onslaught +# +# Why it was chosen: +# This is a stress test of the AI's ability to parse a large, messy, and chronologically complex set of data that mimics a real-world electronic health record. The objective is to demonstrate that the AI can successfully find the critical, actionable "signals" (a new suspicious mole, eligibility for lung screening) from a large amount of "noise" (resolved past issues, irrelevant comorbidities, normal labs). +# +# How to understand the inputs: +# - A long list of over 20 clinical observations spanning 15 years. +# - Multiple comorbidities (COPD, Osteoporosis, GERD). +# - A critical, recent clinical note about a "new, irregular mole on her back." +# - A history of total hysterectomy, which is a key piece of "negative" information. +# +# What to look for in a successful assessment: +# 1. Signal vs. Noise: The `identified_risk_factors` should prominently feature the new mole and smoking history. It should NOT list things like the old appendectomy or resolved UTIs. +# 2. Correct Triage: Skin Cancer and Lung Cancer should be flagged as the highest risks. +# 3. Correct De-escalation: Cervical, Endometrial, and Ovarian cancer risk should all be correctly identified as very low (Level 1) because of the total hysterectomy. The reasoning must cite the hysterectomy. +# 4. Actionable & Specific Dx Recommendation: +# - It must recommend an annual LDCT for lung screening (Level 4/5). +# - It must recommend an urgent dermatology referral. An exceptional response would reference the `dermasensor_skin_assesment` protocol, correctly positioning it as a tool her PCP could use to evaluate the lesion. + +demographics: + age: 72 + sex: female + ethnicity: "Caucasian" +lifestyle: + smoking_status: current + smoking_pack_years: 25 + alcohol_consumption: light +personal_medical_history: + known_genetic_mutations: [] + previous_cancers: [] + chronic_illnesses: + - "COPD" + - "Osteoporosis" + - "GERD" + - "History of Total Hysterectomy for benign fibroids at age 45" + - "History of Appendectomy at age 20" +current_concerns_or_symptoms: "My doctor mentioned a spot on my back but I haven't seen a specialist yet. I also get short of breath but I assume it's my COPD." +clinical_observations: + - test_name: "Primary Care Visit Note" + value: "Patient notes a new, irregular mole on her back, approx 7mm, with some color variation. Advised dermatology consult." + unit: "N/A" + reference_range: "N/A" + date: "2025-05-01" + - test_name: "Spirometry (PFT)" + value: "FEV1 60% of predicted" + unit: "%" + reference_range: ">80%" + date: "2025-04-10" + - test_name: "Bone Density Scan (T-score)" + value: "-2.7" + unit: "SD" + reference_range: "> -1.0" + date: "2024-03-15" + - test_name: "Lipid Panel" + value: "LDL 110, HDL 50, Total 180" + unit: "mg/dL" + reference_range: "Normal" + date: "2025-04-10" + - test_name: "Complete Blood Count" + value: "Normal" + unit: "N/A" + reference_range: "N/A" + date: "2025-04-10" + - test_name: "Vitamin B12" + value: "450" + unit: "pg/mL" + reference_range: "200-900" + date: "2025-04-10" + - test_name: "Pap Smear" + value: "N/A post-hysterectomy" + unit: "N/A" + reference_range: "N/A" + date: "2010-01-01" + - test_name: "Colonoscopy" + value: "Normal to cecum, small diverticula noted." + unit: "N/A" + reference_range: "N/A" + date: "2018-07-22" + - test_name: "Urinalysis" + value: "Trace bacteria, resolved with antibiotics." + unit: "N/A" + reference_range: "N/A" + date: "2019-05-12" + - test_name: "Mammogram" + value: "Scattered fibroglandular densities. No suspicious mass or calcification." + unit: "N/A" + reference_range: "N/A" + date: "2024-02-01" diff --git a/examples/synthetic/complex_and_acquired_risk/stomach_cancer_high_risk.yaml b/examples/synthetic/complex_and_acquired_risk/stomach_cancer_high_risk.yaml new file mode 100644 index 0000000000000000000000000000000000000000..d9ec00dba896c74bfe781b656ec36eca6c073676 --- /dev/null +++ b/examples/synthetic/complex_and_acquired_risk/stomach_cancer_high_risk.yaml @@ -0,0 +1,52 @@ +# Use Case: 01 - Stomach Cancer (High-Risk Ethnicity & History) +# +# Why it was chosen: +# Tests the system's knowledge of a less common but important cancer, incorporating ethnic and specific clinical risk factors. It demonstrates the ability to connect a history of infections (H. pylori) and pre-malignant conditions (atrophic gastritis) to a specific cancer risk. +# +# How to understand the inputs: +# - Ethnicity (Korean) is a known demographic risk factor for stomach cancer. +# - Family history (father) provides a genetic predisposition signal. +# - The clinical observations of a past H. pylori infection and diagnosed atrophic gastritis are major, direct risk factors. +# - Mild anemia (low Hgb/MCV) is a potential symptom of gastric bleeding. +# +# What to look for in a successful assessment: +# 1. Risk Prioritization: Stomach cancer should be identified as the highest-risk cancer (Level 4/5). +# 2. Factor Contribution: Atrophic gastritis and family history must be listed as "Major" contributing factors. +# 3. Dx Recommendation: An upper endoscopy must be strongly recommended (Level 5 - Critical) for surveillance. +# 4. Symptom Connection: The reasoning should connect the mild anemia to the possibility of chronic GI blood loss, reinforcing the need for endoscopy. + +demographics: + age: 68 + sex: male + ethnicity: "Korean" +lifestyle: + smoking_status: former + smoking_pack_years: 10 + alcohol_consumption: light + dietary_habits: "High in salted and preserved foods" +family_history: + - relative: father + cancer_type: stomach + age_at_diagnosis: 72 +personal_medical_history: + chronic_illnesses: + - "Chronic Atrophic Gastritis" + - "History of treated H. pylori infection (2015)" + - "Hypertension" +current_concerns_or_symptoms: "Occasional indigestion and feeling full early after meals." +clinical_observations: + - test_name: "Hemoglobin" + value: "12.9" + unit: "g/dL" + reference_range: "13.5-17.5" + date: "2025-06-01" + - test_name: "MCV" + value: "79" + unit: "fL" + reference_range: "80-100" + date: "2025-06-01" + - test_name: "Gastrin Level" + value: "250" + unit: "pg/mL" + reference_range: "<100" + date: "2024-11-20" diff --git a/examples/synthetic/complex_and_acquired_risk/thyroid_cancer_radiation.yaml b/examples/synthetic/complex_and_acquired_risk/thyroid_cancer_radiation.yaml new file mode 100644 index 0000000000000000000000000000000000000000..43f386a8ea57d6766124c1eb68369be122db3154 --- /dev/null +++ b/examples/synthetic/complex_and_acquired_risk/thyroid_cancer_radiation.yaml @@ -0,0 +1,41 @@ +# Use Case: 05 - Thyroid Cancer (Childhood Radiation Exposure) +# +# Why it was chosen: +# This profile tests the AI's knowledge of a specific, potent environmental risk factor: childhood radiation to the neck. It also includes a direct clinical finding (a thyroid nodule) that requires a clear follow-up plan. +# +# How to understand the inputs: +# - The history of radiation for Hodgkin's lymphoma as a teenager is the single most important risk factor. +# - The new clinical observation of a palpable thyroid nodule is the primary actionable finding. +# - The TSH is normal, which is a key piece of information (most thyroid cancers are euthyroid). +# +# What to look for in a successful assessment: +# 1. Major Risk Identification: The AI must identify prior neck radiation as a "Major" contributor to Thyroid Cancer risk (Level 5). +# 2. Actionable Finding: The AI must recognize the palpable nodule as needing immediate evaluation. +# 3. Correct Dx Pathway: The recommendations should be a Thyroid Ultrasound followed by a potential Fine Needle Aspiration (FNA) biopsy, which is the standard workup. It should not jump to recommending surgery. +# 4. TSH Nuance: The `reasoning` should note that a normal TSH does not lower the suspicion for cancer in the presence of a nodule. + +demographics: + age: 40 + sex: female + ethnicity: "Caucasian" +lifestyle: + smoking_status: never + alcohol_consumption: light +family_history: [] +personal_medical_history: + previous_cancers: + - "Hodgkin's Lymphoma at age 16" + chronic_illnesses: + - "Treated with radiation therapy to neck and chest" +current_concerns_or_symptoms: "My primary care doctor felt a lump in my neck during my physical last week." +clinical_observations: + - test_name: "Physical Exam Note" + value: "Firm, non-tender 2 cm nodule noted in the right lobe of the thyroid." + unit: "N/A" + reference_range: "N/A" + date: "2025-06-22" + - test_name: "TSH" + value: "2.1" + unit: "mIU/L" + reference_range: "0.4-4.5" + date: "2025-06-22" diff --git a/examples/synthetic/diagnostic_and_screening_pathways/executive_checkup.yaml b/examples/synthetic/diagnostic_and_screening_pathways/executive_checkup.yaml new file mode 100644 index 0000000000000000000000000000000000000000..f271a94ec286c69622825137dcfe5ca5f5ad3dbb --- /dev/null +++ b/examples/synthetic/diagnostic_and_screening_pathways/executive_checkup.yaml @@ -0,0 +1,72 @@ +# Use Case: The Executive Check-Up ("The Worried Well") +# +# Why it was chosen: +# This profile demonstrates the AI's ability to act as a "Chief Realism Officer." It tests the system's capacity to process a large volume of data, correctly identify that the overall cancer risk is low despite some minor non-cancer-related health issues, and provide responsible, evidence-based guidance on advanced, elective tests like MCEDs (e.g., Galleri). The goal is to build trust by not being alarmist and by providing nuanced education. +# +# How to understand the inputs: +# - The user is a 58-year-old male with a healthy lifestyle and no significant family history of cancer. +# - He has a list of clinical observations from an annual check-up. +# - Critically, some labs are borderline or slightly abnormal (Uric Acid, Vitamin D, LDL, ALT), but these are not primary cancer risk drivers. +# - His "Current Concerns" explicitly ask about advanced screening. +# +# What to look for in a successful assessment: +# 1. Overall Risk Score: Should be low (e.g., < 25/100). +# 2. Risk Assessments: All individual cancer risks should be assessed as Level 1 or 2 (Low). +# 3. Identified Risk Factors: The AI should correctly identify "Age" as a minor demographic risk factor but should *not* list the borderline labs as significant cancer risk factors. +# 4. Dx Recommendations: +# - Standard screenings (Colonoscopy, PSA) should be recommended appropriately for his age (e.g., Level 4 - Recommended). +# - Advanced tests like Galleri should be rated as "Optional" (Level 3), NOT "Recommended." +# 5. Reasoning/Summary: The text output must explain *why* Galleri is optional, referencing its limitations (not FDA-approved, risk of false positives/negatives) as detailed in the `grail_galleri.yaml` protocol. It should also correctly contextualize his minor lab abnormalities as being related to metabolic health or common deficiencies, not cancer. + +demographics: + age: 58 + sex: male + ethnicity: "Caucasian" +lifestyle: + smoking_status: never + alcohol_consumption: light + dietary_habits: "Balanced, low-carb diet" + physical_activity_level: "Regular, 4-5 times per week" +family_history: + - relative: grandfather + cancer_type: skin + age_at_diagnosis: 80 +personal_medical_history: + known_genetic_mutations: [] + previous_cancers: [] + chronic_illnesses: [] +current_concerns_or_symptoms: "I feel great, but I want to be proactive and get the most advanced cancer screening available. I've read about tests that can find 50 cancers at once and I want to know if I should get one." +clinical_observations: + - test_name: "Complete Blood Count (CBC)" + value: "Normal" + unit: "N/A" + date: "2025-06-15" + - test_name: "Comprehensive Metabolic Panel (CMP)" + value: "Normal" + unit: "N/A" + date: "2025-06-15" + - test_name: "Uric Acid" + value: "7.5" + unit: "mg/dL" + reference_range: "4.0-7.0" + date: "2025-06-15" + - test_name: "Vitamin D, 25-Hydroxy" + value: "25" + unit: "ng/mL" + reference_range: "30-100" + date: "2025-06-15" + - test_name: "LDL Cholesterol" + value: "135" + unit: "mg/dL" + reference_range: "< 100" + date: "2025-06-15" + - test_name: "ALT (Alanine Aminotransferase)" + value: "48" + unit: "U/L" + reference_range: "< 45" + date: "2025-06-15" + - test_name: "Cardiac Calcium Score" + value: "0" + unit: "Agatston score" + reference_range: "0" + date: "2025-01-20" diff --git a/examples/synthetic/diagnostic_and_screening_pathways/indeterminate_imaging_birads3.yaml b/examples/synthetic/diagnostic_and_screening_pathways/indeterminate_imaging_birads3.yaml new file mode 100644 index 0000000000000000000000000000000000000000..2cd381c794e4a51259bc76dc1b57bf90c3df232c --- /dev/null +++ b/examples/synthetic/diagnostic_and_screening_pathways/indeterminate_imaging_birads3.yaml @@ -0,0 +1,31 @@ +# Use Case: 17 - Indeterminate Imaging Finding (BI-RADS 3) +# +# Why it was chosen: +# To test the AI's ability to interpret a common but ambiguous imaging result (BI-RADS 3) and recommend the appropriate, non-alarming follow-up, which is short-interval surveillance, not immediate biopsy. +# +# How to understand the inputs: +# - The user has dense breasts, a risk factor in itself. +# - The mammogram finding of "architectural distortion" and the "BI-RADS 3" category are the key inputs. +# +# What to look for in a successful assessment: +# 1. Correct Interpretation: The AI must understand that BI-RADS 3 means "Probably Benign" with a <2% chance of malignancy. +# 2. Correct Follow-up: The standard recommendation for a BI-RADS 3 finding is a short-interval (6-month) follow-up diagnostic mammogram. The AI should recommend this (Level 4) and NOT jump to recommending a biopsy (which would be for BI-RADS 4 or 5). +# 3. Context for Dense Breasts: The AI should mention that breast density can lower mammogram sensitivity and that supplemental screening with ultrasound or MRI is a topic to discuss with her provider. + +demographics: + age: 62 + sex: female + ethnicity: "Caucasian" +lifestyle: + smoking_status: former + smoking_pack_years: 5 + alcohol_consumption: light +personal_medical_history: + chronic_illnesses: ["Dense Breasts (Type C)"] +family_history: [] +current_concerns_or_symptoms: "My mammogram report came back with something called 'BI-RADS 3' and I'm not sure what it means." +clinical_observations: + - test_name: "3D Mammogram Report" + value: "Breasts are heterogeneously dense. In the left breast at 2 o'clock, there is an area of architectural distortion. No suspicious mass or calcifications. ASSESSMENT: BI-RADS 3: Probably Benign. Recommend short-interval follow-up." + unit: "N/A" + date: "2025-06-12" diff --git a/examples/synthetic/diagnostic_and_screening_pathways/mrd_surveillance_candidate.yaml b/examples/synthetic/diagnostic_and_screening_pathways/mrd_surveillance_candidate.yaml new file mode 100644 index 0000000000000000000000000000000000000000..cd21b27ff53851520f013bf61d4b8cb1294bd4bf --- /dev/null +++ b/examples/synthetic/diagnostic_and_screening_pathways/mrd_surveillance_candidate.yaml @@ -0,0 +1,37 @@ +# Use Case: 16 - Minimal Residual Disease (MRD) Candidate +# +# Why it was chosen: +# Tests knowledge of the post-treatment surveillance space, which is a sophisticated and growing area of oncology. The AI needs to differentiate a test for *recurrence* risk from a test for initial screening. +# +# How to understand the inputs: +# - The history of Stage III colon cancer and recent completion of chemotherapy are the key facts. +# - The user is asking about a specific type of test ("blood tests to see if it's coming back"). +# +# What to look for in a successful assessment: +# 1. Correct Test Identification: The AI must correctly identify `Guardant Reveal` as the appropriate test for this clinical scenario (colorectal cancer MRD testing). +# 2. Correct Use Case: The `rationale` for recommending Guardant Reveal (Level 4 - Recommended, as it's still an advanced test) must accurately describe its purpose: detecting ctDNA to assess recurrence risk and guide future decisions. +# 3. Distinction from Other Tests: The AI must NOT recommend a screening test like Cologuard, which is inappropriate in this context. It should also correctly explain this is different from a therapy selection test like `Guardant360`. + +demographics: + age: 58 + sex: male + ethnicity: "Caucasian" +lifestyle: + smoking_status: never + alcohol_consumption: moderate +family_history: [] +personal_medical_history: + previous_cancers: + - "Stage III Colon Cancer, s/p hemicolectomy and adjuvant FOLFOX chemotherapy" + chronic_illnesses: ["Chemotherapy-induced peripheral neuropathy"] +current_concerns_or_symptoms: "I finished my chemo a couple of months ago. My last CT scan was clear. I want to know about those new blood tests to see if the cancer is coming back." +clinical_observations: + - test_name: "CEA (Carcinoembryonic Antigen)" + value: "1.5" + unit: "ng/mL" + reference_range: "< 5.0" + date: "2025-06-15" + - test_name: "CT Chest/Abdomen/Pelvis" + value: "No evidence of metastatic disease." + unit: "N/A" + date: "2025-05-20" diff --git a/examples/synthetic/diagnostic_and_screening_pathways/post_positive_cologuard.yaml b/examples/synthetic/diagnostic_and_screening_pathways/post_positive_cologuard.yaml new file mode 100644 index 0000000000000000000000000000000000000000..8338c884a3199adcf705d1bb8a4070ad2dae7a6a --- /dev/null +++ b/examples/synthetic/diagnostic_and_screening_pathways/post_positive_cologuard.yaml @@ -0,0 +1,31 @@ +# Use Case: 15 - Post-Positive Cologuard +# +# Why it was chosen: +# A crucial test of the AI's adherence to the "screening cascade." It must demonstrate that it understands a positive non-invasive test is not a diagnosis, but a trigger for a mandatory diagnostic follow-up. This is a key patient safety and education moment. +# +# How to understand the inputs: +# - The user is average risk otherwise. +# - The "Positive" Cologuard result is the only significant finding. +# +# What to look for in a successful assessment: +# 1. Mandatory Follow-up: The recommendation for a Colonoscopy must be "Critical" (Level 5). +# 2. Clear Rationale: The `reasoning` and `overall_summary` must state unequivocally that a colonoscopy is the required next step to determine the cause of the positive result, as per the `exact_sciences_cologuard.yaml` protocol. +# 3. Reassurance and Context: The AI should explain that a positive result does not mean she has cancer, as false positives can occur, but that a colonoscopy is the only way to be sure. This manages anxiety while ensuring compliance. + +demographics: + age: 51 + sex: female + ethnicity: "Caucasian" +lifestyle: + smoking_status: never + alcohol_consumption: light +family_history: [] +personal_medical_history: + chronic_illnesses: ["Hypothyroidism"] +current_concerns_or_symptoms: "I did one of those at-home Cologuard tests and it came back positive. I'm really scared. Do I really need to get a colonoscopy?" +clinical_observations: + - test_name: "Exact Sciences Cologuard" + value: "Positive" + unit: "N/A" + reference_range: "Negative" + date: "2025-06-20" diff --git a/examples/synthetic/diagnostic_and_screening_pathways/therapy_selection_context.yaml b/examples/synthetic/diagnostic_and_screening_pathways/therapy_selection_context.yaml new file mode 100644 index 0000000000000000000000000000000000000000..21aab1b4b9e40722117d495ce4e6ad84841a2839 --- /dev/null +++ b/examples/synthetic/diagnostic_and_screening_pathways/therapy_selection_context.yaml @@ -0,0 +1,31 @@ +# Use Case: 18 - Known Cancer, Therapy Selection Context +# +# Why it was chosen: +# An advanced case to show the system's knowledge extends beyond screening to the molecular oncology domain. It's not recommending therapy, but providing context on a test result that *guides* therapy. This is a powerful feature for patient education and empowerment. +# +# How to understand the inputs: +# - The user has a known diagnosis of advanced lung cancer. +# - The key input is the `FoundationOne CDx` result showing a specific, actionable mutation. +# +# What to look for in a successful assessment: +# 1. Correct Test Context: The AI must identify FoundationOne as a "comprehensive genomic profiling" test for therapy selection, not screening. +# 2. Mutation Explanation: The AI should explain, in simple terms, that an "EGFR Exon 19 deletion" is a known "driver mutation" in lung cancer. +# 3. Link to Therapy Class: Without naming a specific drug, the AI should explain that this finding makes the cancer highly susceptible to a class of drugs called "EGFR inhibitors" or "targeted therapy." +# 4. Boundary Adherence: The AI must not recommend a specific drug. It should clearly state that the oncologist will use this information to select the best treatment. + +demographics: + age: 65 + sex: female + ethnicity: "Asian" +lifestyle: + smoking_status: never + alcohol_consumption: none +family_history: [] +personal_medical_history: + previous_cancers: ["Non-small cell lung cancer (adenocarcinoma), Stage IV"] +current_concerns_or_symptoms: "My doctor ordered a 'Foundation' test on my tumor and the result just came back. I don't understand what 'EGFR Exon 19 deletion' means." +clinical_observations: + - test_name: "FoundationOne CDx Report" + value: "EGFR Exon 19 deletion detected. TMB-Low. MSI-Stable." + unit: "N/A" + date: "2025-06-28" diff --git a/examples/synthetic/diagnostic_and_screening_pathways/vague_symptoms.yaml b/examples/synthetic/diagnostic_and_screening_pathways/vague_symptoms.yaml new file mode 100644 index 0000000000000000000000000000000000000000..a6b304ae5fba268a1dc77a3d9374381f82071fad --- /dev/null +++ b/examples/synthetic/diagnostic_and_screening_pathways/vague_symptoms.yaml @@ -0,0 +1,58 @@ +# Use Case: The Vague Symptoms Investigation +# +# Why it was chosen: +# This case tests the AI's ability to form a differential diagnosis from non-specific symptoms and a mixed-risk profile. It must correctly identify the most likely serious underlying risks (Endometrial, Colorectal) while avoiding distraction from a "red herring" test result. +# +# How to understand the inputs: +# - The user is post-menopausal with obesity. +# - Key Symptom: Post-menopausal spotting is a major red flag for endometrial cancer. +# - Distracting Information: A negative at-home HPV test and subclinical hypothyroidism (elevated TSH) are included. A naive system might incorrectly assume the negative HPV test rules out all gynecological cancer. +# +# What to look for in a successful assessment: +# 1. Correct Prioritization: The assessment must flag Endometrial Cancer as high-risk (Level 4/5) due to post-menopausal bleeding. Colorectal cancer should also be elevated (Level 3/4) due to age and obesity. +# 2. Red Herring Rejection: The `reasoning` block must explicitly state that the negative HPV test is for *cervical* cancer and is **irrelevant** for evaluating endometrial cancer risk. +# 3. Symptom Triage: The AI should connect "spotting" directly to endometrial cancer risk and recommend further investigation (e.g., transvaginal ultrasound, endometrial biopsy). +# 4. Appropriate Dx Recommendations: A colonoscopy should be recommended. Critically, a PET/CT scan should be rated as "Unsuitable" (Level 1) for an initial workup of vague symptoms. + +demographics: + age: 52 + sex: female + ethnicity: "Caucasian" +lifestyle: + smoking_status: never + alcohol_consumption: light + dietary_habits: "High in processed foods" + physical_activity_level: "Sedentary" +family_history: [] +personal_medical_history: + known_genetic_mutations: [] + previous_cancers: [] + chronic_illnesses: ["Obesity (BMI 34)"] +female_specific: + age_at_first_period: 14 + age_at_menopause: 50 + num_live_births: 2 + age_at_first_live_birth: 28 + hormone_therapy_use: "None" +current_concerns_or_symptoms: "I've been feeling bloated and unusually tired for the past few months. I've also had some light spotting twice in the last month, which is strange since I'm past menopause." +clinical_observations: + - test_name: "Teal Wand At-Home HPV Test" + value: "Negative for all high-risk HPV genotypes." + unit: "N/A" + reference_range: "Negative" + date: "2024-12-01" + - test_name: "Thyroid Stimulating Hormone (TSH)" + value: "4.9" + unit: "mIU/L" + reference_range: "0.4-4.5" + date: "2025-06-10" + - test_name: "Complete Blood Count (CBC)" + value: "Normal" + unit: "N/A" + reference_range: "N/A" + date: "2025-06-10" + - test_name: "ALT (Liver Enzyme)" + value: "45" + unit: "U/L" + reference_range: "<40" + date: "2025-06-10" diff --git a/examples/synthetic/guideline_boundaries/starting_screening_young_adult.yaml b/examples/synthetic/guideline_boundaries/starting_screening_young_adult.yaml new file mode 100644 index 0000000000000000000000000000000000000000..15236645d1c83f17006e1eb5967fa2a619fd0fb5 --- /dev/null +++ b/examples/synthetic/guideline_boundaries/starting_screening_young_adult.yaml @@ -0,0 +1,26 @@ +# Use Case: 20 - The Young & Healthy User (Starting Screening) +# +# Why it was chosen: +# A simple but essential "negative control" case. The AI must correctly apply age-based guidelines and advise *against* premature screening, which is a key part of preventing over-testing and unnecessary anxiety. +# +# How to understand the inputs: +# - The user is 25, healthy, and has no significant risk factors. +# - Her questions are about starting common screenings early. +# +# What to look for in a successful assessment: +# 1. Correct Age Gates: The AI must state that cervical cancer screening (Pap test) starts at age 21 (or 25 depending on guideline interpretation, but should be consistent) and that screening mammograms are not recommended for average-risk women until age 40. +# 2. "Unnecessary" Recommendations: Both mammography and cervical screening should be rated Level 2 (Unnecessary at this time). +# 3. Educational Tone: The summary should be reassuring and explain *why* screening is not yet needed (e.g., "Breast cancer is very rare in your 20s, and early screening can lead to more false alarms..."). It should empower her with the correct timeline so she knows when to start. + +demographics: + age: 25 + sex: female + ethnicity: "Caucasian" +lifestyle: + smoking_status: never + alcohol_consumption: light +family_history: [] +personal_medical_history: + chronic_illnesses: [] +current_concerns_or_symptoms: "My friends are talking about getting Pap tests and mammograms. Am I supposed to be doing that yet? I'm not sure when to start." +clinical_observations: [] diff --git a/examples/synthetic/guideline_boundaries/stopping_screening_older_adult.yaml b/examples/synthetic/guideline_boundaries/stopping_screening_older_adult.yaml new file mode 100644 index 0000000000000000000000000000000000000000..e30a6d78dbdb2e0d4a013cbeff3eb913cbff2610 --- /dev/null +++ b/examples/synthetic/guideline_boundaries/stopping_screening_older_adult.yaml @@ -0,0 +1,36 @@ +# Use Case: 19 - The Healthy Older Adult (Stopping Screening) +# +# Why it was chosen: +# To demonstrate that the AI's logic includes stopping rules. Recommending against a procedure can be as important as recommending for one, preventing unnecessary harm and cost. +# +# How to understand the inputs: +# - The user is 80 and has a history of regular, negative screening. +# - His last colonoscopy was at age 75. +# +# What to look for in a successful assessment: +# 1. Stopping Logic: For Colorectal Cancer, the AI should cite his age and history of negative screenings to recommend that further colonoscopies are likely unnecessary (Level 2), aligning with USPSTF and ACS guidelines. +# 2. Individualized Decision: For Prostate Cancer, the AI should explain that screening is generally not recommended over age 70, but the decision can be individualized. Given his excellent health, it could be "Optional" (Level 3), but the harms of diagnosis and treatment at this age should be highlighted. +# 3. Clear Rationale: The `summary` must clearly explain the principle that for older adults, the potential harms of screening (complications, overdiagnosis) often begin to outweigh the benefits. + +demographics: + age: 80 + sex: male + ethnicity: "Caucasian" +lifestyle: + smoking_status: never + alcohol_consumption: light + physical_activity_level: "Active (daily walks, golf)" +family_history: [] +personal_medical_history: + chronic_illnesses: ["Mild hypertension, controlled with diet"] +current_concerns_or_symptoms: "I feel fantastic. It's been 5 years since my last colonoscopy. My doctor retired. Do I need to get another one?" +clinical_observations: + - test_name: "Last Colonoscopy" + value: "Normal to cecum. No polyps found." + unit: "N/A" + date: "2020-07-15" + - test_name: "Last PSA" + value: "1.8" + unit: "ng/mL" + reference_range: "N/A" + date: "2024-08-01" diff --git a/examples/synthetic/hereditary_and_genetic_risk/brain_tumor_nf1.yaml b/examples/synthetic/hereditary_and_genetic_risk/brain_tumor_nf1.yaml new file mode 100644 index 0000000000000000000000000000000000000000..30cc79e7456b5fb2232ff2f00eb79d802fa843c9 --- /dev/null +++ b/examples/synthetic/hereditary_and_genetic_risk/brain_tumor_nf1.yaml @@ -0,0 +1,39 @@ +# Use Case: 06 - Brain Tumor (Genetic Syndrome) +# +# Why it was chosen: +# Tests the system's knowledge of a specific genetic syndrome (NF1) and its associated cancer risks, particularly brain tumors. The recommendation pathway is about surveillance, not general screening. +# +# How to understand the inputs: +# - The diagnosis of Neurofibromatosis type 1 is the key. +# - The symptoms (headaches, vision changes) are concerning for a potential optic glioma, a common tumor in NF1. +# - The skin findings (cafe-au-lait spots, neurofibromas) are diagnostic criteria for NF1. +# +# What to look for in a successful assessment: +# 1. Syndrome Recognition: The AI must identify NF1 as a high-risk condition for Brain Tumors (specifically gliomas) and other neurologic tumors. +# 2. Symptom Urgency: The new headaches and vision changes should be flagged as requiring urgent neurologic and ophthalmologic evaluation. +# 3. Correct Dx Recommendation: A Brain MRI (with and without contrast) should be a "Critical" (Level 5) recommendation to investigate the symptoms. +# 4. Holistic View: The assessment should mention that NF1 increases risk for other tumors, but the immediate focus should be on the brain/optic nerve. + +demographics: + age: 28 + sex: male + ethnicity: "Caucasian" +lifestyle: + smoking_status: never + alcohol_consumption: none +family_history: + - relative: mother + cancer_type: "Neurofibromatosis type 1" + age_at_diagnosis: 5 +personal_medical_history: + known_genetic_mutations: [] + previous_cancers: [] + chronic_illnesses: + - "Neurofibromatosis type 1 (NF1)" +current_concerns_or_symptoms: "I've been having more frequent headaches over the last 3 months, and I feel like my vision in my right eye is a bit blurry." +clinical_observations: + - test_name: "Physical Exam Note" + value: "Multiple cafe-au-lait macules, axillary freckling, and multiple cutaneous neurofibromas noted. Lisch nodules present on slit-lamp exam." + unit: "N/A" + reference_range: "N/A" + date: "2025-06-01" diff --git a/examples/synthetic/hereditary_and_genetic_risk/brca1_high_risk.yaml b/examples/synthetic/hereditary_and_genetic_risk/brca1_high_risk.yaml new file mode 100644 index 0000000000000000000000000000000000000000..8b529dad894eb68240afb5c566b7a29c13713280 --- /dev/null +++ b/examples/synthetic/hereditary_and_genetic_risk/brca1_high_risk.yaml @@ -0,0 +1,58 @@ +# Use Case: The High-Risk Hereditary Case +# +# Why it was chosen: +# This profile demonstrates the system's mastery of high-stakes, guideline-driven care for individuals with known hereditary cancer syndromes (BRCA1). It tests the AI's ability to apply aggressive screening protocols, provide empathetic context for a young patient, and correctly interpret imaging findings that could cause anxiety. +# +# How to understand the inputs: +# - The user has multiple, powerful risk factors: Ashkenazi Jewish ancestry, a known BRCA1 mutation, and a strong family history. +# - She is young (34), so age is a mitigating factor, but the genetic risk is dominant. +# - A key input is the transvaginal ultrasound report mentioning a "simple 2cm cyst." +# +# What to look for in a successful assessment: +# 1. Dominant Risk Identification: The BRCA1 mutation and family history must be identified as "Major" risk contributors for Breast and Ovarian cancer, leading to high risk levels (e.g., Level 5). +# 2. Correct Screening Protocol: The `dx_recommendations` MUST include annual Breast MRI and annual Mammogram, both rated as "Critical" (Level 5). +# 3. Nuanced Interpretation of "Simple Cyst": The AI's `reasoning` must correctly identify the "simple ovarian cyst" as a common, benign finding and explicitly distinguish it from high-risk complex masses. This demonstrates true clinical nuance. +# 4. Risk-Reducing Surgery: The report should mention risk-reducing surgery (oophorectomy) as a key consideration for BRCA carriers. +# 5. Empathetic Tone: The `response` and `overall_summary` should be supportive and acknowledge her situation, providing information in an empowering way. + +demographics: + age: 34 + sex: female + ethnicity: "Ashkenazi Jewish" +lifestyle: + smoking_status: never + alcohol_consumption: light + dietary_habits: "Mediterranean" + physical_activity_level: "Regular" +family_history: + - relative: mother + cancer_type: breast + age_at_diagnosis: 42 + - relative: maternal aunt + cancer_type: ovarian + age_at_diagnosis: 55 +personal_medical_history: + known_genetic_mutations: ["BRCA1"] + previous_cancers: [] + chronic_illnesses: [] +female_specific: + age_at_first_period: 12 + num_live_births: 1 + age_at_first_live_birth: 31 +current_concerns_or_symptoms: "I know I'm high risk. What is the absolute best screening I should be doing right now? When do I need to think about preventive surgery? I am considering having another child in the next two years." +clinical_observations: + - test_name: "Breast MRI" + value: "No suspicious enhancement or mass." + unit: "N/A" + reference_range: "N/A" + date: "2025-05-15" + - test_name: "Transvaginal Ultrasound Report" + value: "Uterus and right ovary unremarkable. Simple 2cm cyst on left ovary. No complex features or solid components. Endometrial stripe is thin and regular." + unit: "N/A" + reference_range: "N/A" + date: "2025-05-10" + - test_name: "CA-125" + value: "18" + unit: "U/mL" + reference_range: "<35" + date: "2025-05-10" diff --git a/examples/synthetic/hereditary_and_genetic_risk/conflicting_genetic_data.yaml b/examples/synthetic/hereditary_and_genetic_risk/conflicting_genetic_data.yaml new file mode 100644 index 0000000000000000000000000000000000000000..7b14ab7dd48de702c97d1481e92baea92630334b --- /dev/null +++ b/examples/synthetic/hereditary_and_genetic_risk/conflicting_genetic_data.yaml @@ -0,0 +1,36 @@ +# Use Case: 09 - Conflicting Data (Family History vs. Genetics) +# +# Why it was chosen: +# To test the AI's ability to weigh different types of evidence. A strong family history suggests high risk, but a negative multi-gene panel is strong counter-evidence. The AI must be able to generate a nuanced recommendation that respects both data points. +# +# How to understand the inputs: +# - A very strong family history of early-onset colon cancer (father at 48). +# - A negative result from a comprehensive hereditary cancer panel (`Natera Empower`). +# +# What to look for in a successful assessment: +# 1. Nuanced Reasoning: The AI's `reasoning` must explicitly state the conflict: the family history is concerning, but the negative panel makes a known high-penetrance mutation (like Lynch) unlikely. +# 2. Balanced Recommendation: The AI should not dismiss the family history. It should still recommend earlier-than-average screening (e.g., colonoscopy starting at age 40, or 10 years before the father's diagnosis), classifying the risk as "Increased" but not as high as it would be with a known mutation. +# 3. Explanation: The summary must explain that some familial risk may not be captured by current genetic tests ("missing heritability") and that screening should therefore be based on the empirical risk from the family history itself. +# 4. Genetic Test Context: The AI should correctly identify the `Natera Empower` test as a germline test for *hereditary* risk. + +demographics: + age: 42 + sex: male + ethnicity: "Caucasian" +lifestyle: + smoking_status: never + alcohol_consumption: moderate +family_history: + - relative: father + cancer_type: colon + age_at_diagnosis: 48 +personal_medical_history: + known_genetic_mutations: [] + previous_cancers: [] + chronic_illnesses: [] +current_concerns_or_symptoms: "My dad died young from colon cancer. I had genetic testing and it was negative, so does that mean I can just follow normal screening rules?" +clinical_observations: + - test_name: "Natera Empower Panel (40 genes)" + value: "No pathogenic variants identified" + unit: "N/A" + date: "2024-09-01" diff --git a/examples/synthetic/hereditary_and_genetic_risk/li_fraumeni_tp53.yaml b/examples/synthetic/hereditary_and_genetic_risk/li_fraumeni_tp53.yaml new file mode 100644 index 0000000000000000000000000000000000000000..2411c222aadbcf00d58ac61e990ef26d0bde1f34 --- /dev/null +++ b/examples/synthetic/hereditary_and_genetic_risk/li_fraumeni_tp53.yaml @@ -0,0 +1,40 @@ +# Use Case: 08 - Li-Fraumeni Syndrome (TP53) +# +# Why it was chosen: +# Represents one of the highest-risk cancer predisposition syndromes, affecting multiple organ systems (soft tissue sarcomas, breast cancer, brain tumors, leukemia). It tests the AI's ability to handle an extreme, pan-cancer risk profile. +# +# How to understand the inputs: +# - The TP53 mutation is the critical piece of information. +# - The user is very young, making risk management complex. +# - The specific question about whole-body MRI is a key test of the AI's knowledge of advanced surveillance protocols. +# +# What to look for in a successful assessment: +# 1. Pan-Cancer Risk: The AI must identify high risk across multiple, diverse cancer types: Breast, Brain, Leukemia, and note a general high risk for sarcomas. +# 2. Whole-Body MRI: The AI must correctly identify whole-body MRI (often part of the "Toronto Protocol") as a key surveillance tool recommended for individuals with LFS, rating it Level 4 or 5. +# 3. Radiation Avoidance: An exceptional response would include a note in the `reasoning` or `summary` advising the avoidance of unnecessary radiation (like CT scans) due to heightened sensitivity in LFS patients. +# 4. Specific Screenings: It must still recommend the other standard LFS screenings, such as annual breast MRI and brain MRI. + +demographics: + age: 22 + sex: female + ethnicity: "Caucasian" +lifestyle: + smoking_status: never + alcohol_consumption: none +family_history: + - relative: mother + cancer_type: breast + age_at_diagnosis: 28 + - relative: maternal uncle + cancer_type: sarcoma + age_at_diagnosis: 35 +personal_medical_history: + known_genetic_mutations: ["TP53"] + previous_cancers: [] + chronic_illnesses: [] +current_concerns_or_symptoms: "I was diagnosed with Li-Fraumeni syndrome and I'm terrified. My doctor mentioned something about a 'whole-body MRI'. Is that something I should be doing?" +clinical_observations: + - test_name: "Baseline CBC" + value: "Normal" + unit: "N/A" + date: "2025-01-10" diff --git a/examples/synthetic/hereditary_and_genetic_risk/lynch_syndrome.yaml b/examples/synthetic/hereditary_and_genetic_risk/lynch_syndrome.yaml new file mode 100644 index 0000000000000000000000000000000000000000..c4775241933885b255f5d275d8e867997cc40498 --- /dev/null +++ b/examples/synthetic/hereditary_and_genetic_risk/lynch_syndrome.yaml @@ -0,0 +1,50 @@ +# Use Case: 07 - Lynch Syndrome (HNPCC) +# +# Why it was chosen: +# To test the AI's ability to handle a multi-organ hereditary syndrome. Unlike BRCA which primarily affects breast/ovary, Lynch syndrome significantly increases risk for colorectal, endometrial, ovarian, stomach, and other cancers. +# +# How to understand the inputs: +# - The MSH2 mutation is a definitive diagnosis of Lynch syndrome. +# - The family history is classic for Lynch. +# - The user is due for her surveillance screenings. +# +# What to look for in a successful assessment: +# 1. Multi-Cancer Risk: The AI must assign a high-risk level (4 or 5) to Colorectal, Endometrial, and Ovarian cancer. It should also note increased risk for Stomach cancer. +# 2. Multi-Site Surveillance: The `dx_recommendations` must be comprehensive and include: +# - Colonoscopy (every 1-2 years) +# - Transvaginal ultrasound and Endometrial biopsy (annually) +# - Upper Endoscopy (every 3-5 years) +# 3. Surgical Options: The summary should mention the option of risk-reducing hysterectomy and oophorectomy. +# 4. Guideline Adherence: The reasoning should explicitly cite Lynch syndrome guidelines for these aggressive and frequent surveillance recommendations. + +demographics: + age: 42 + sex: female + ethnicity: "Caucasian" +lifestyle: + smoking_status: never + alcohol_consumption: light +family_history: + - relative: father + cancer_type: colorectal + age_at_diagnosis: 45 + - relative: paternal aunt + cancer_type: endometrial + age_at_diagnosis: 49 +personal_medical_history: + known_genetic_mutations: ["MSH2"] + previous_cancers: [] + chronic_illnesses: [] +female_specific: + num_live_births: 2 + age_at_first_live_birth: 32 +current_concerns_or_symptoms: "I know I have Lynch syndrome. I just want to make sure I'm not missing any recommended screenings." +clinical_observations: + - test_name: "Last Colonoscopy" + value: "Normal, 1 year ago" + unit: "N/A" + date: "2024-07-01" + - test_name: "Last Endometrial Biopsy" + value: "Benign proliferative endometrium" + unit: "N/A" + date: "2024-07-15" diff --git a/examples/synthetic/hereditary_and_genetic_risk/vague_family_history.yaml b/examples/synthetic/hereditary_and_genetic_risk/vague_family_history.yaml new file mode 100644 index 0000000000000000000000000000000000000000..57c9446872d23686de678d86ef1b3a8e60700c45 --- /dev/null +++ b/examples/synthetic/hereditary_and_genetic_risk/vague_family_history.yaml @@ -0,0 +1,36 @@ +# Use Case: 10 - Vague Family History +# +# Why it was chosen: +# This is a common real-world scenario. The AI must demonstrate safety and good clinical judgment when faced with incomplete information. It cannot invent a risk level but must provide safe and actionable advice. +# +# How to understand the inputs: +# - The key input is the free-text `family_history`, which is non-specific. +# +# What to look for in a successful assessment: +# 1. Recognition of Incompleteness: The `reasoning` block must note that the family history is incomplete and a detailed risk assessment is not possible without more information (cancer types, ages). +# 2. Conservative Approach: The risk assessments should default to "Average Risk" but include a strong caveat about the incomplete history. +# 3. Primary Recommendation: The single most important recommendation should be for the user to gather more family history details and to pursue genetic counseling to clarify their risk. The `Natera Empower` test should be listed as "Optional" (Level 3) pending this consultation. +# 4. Actionable Guidance: The report should empower the user by suggesting specific questions to ask their relatives (e.g., "What type of cancer was it?", "How old were they?"). + +demographics: + age: 60 + sex: female + ethnicity: "Caucasian" +lifestyle: + smoking_status: never + alcohol_consumption: light +family_history: +# This section is intentionally left for free-text processing +personal_medical_history: + chronic_illnesses: + - "I think there's a lot of cancer on my mom's side of the family. I'm not sure what types, but I remember hearing about a few relatives who passed away young." +current_concerns_or_symptoms: "With my family history, I'm worried I should be doing more screening than just my regular mammogram and pap smear." +clinical_observations: + - test_name: "Last Mammogram" + value: "Normal" + unit: "N/A" + date: "2025-01-15" + - test_name: "Last Pap Smear" + value: "Normal" + unit: "N/A" + date: "2023-05-20" diff --git a/examples/synthetic/lifestyle_and_demographic_risk/liver_risk_alcohol_abuse.yaml b/examples/synthetic/lifestyle_and_demographic_risk/liver_risk_alcohol_abuse.yaml new file mode 100644 index 0000000000000000000000000000000000000000..2dd2206c22c56b64c078a22bbe36e0733ce7b1f4 --- /dev/null +++ b/examples/synthetic/lifestyle_and_demographic_risk/liver_risk_alcohol_abuse.yaml @@ -0,0 +1,49 @@ +# Use Case: 11 - Heavy Alcohol Use & Liver Focus +# +# Why it was chosen: +# To test risk assessment based on a significant lifestyle factor (heavy alcohol use) and its clinical sequelae (abnormal liver function tests), even before a definitive diagnosis of cirrhosis. +# +# How to understand the inputs: +# - "Heavy" alcohol consumption is the primary risk factor. +# - The clinical observations show a classic picture of alcoholic liver injury: AST > ALT, elevated GGT, and low platelets (thrombocytopenia), which is an early sign of portal hypertension/cirrhosis. +# +# What to look for in a successful assessment: +# 1. Risk Identification: Liver Cancer risk should be elevated to "Increased Risk" (Level 3 or 4), even without a formal cirrhosis diagnosis in the history. +# 2. Lab Synthesis: The `reasoning` must connect the heavy alcohol use to the specific pattern of LFTs and the low platelet count, explaining that these findings are highly suggestive of significant liver damage, which is the precursor to cancer. +# 3. Dx Recommendation: The AI should strongly recommend a liver ultrasound and potentially a FibroScan/elastography to stage the degree of liver fibrosis. It should also reference the investigational `Mursla EvoLiver` test as a future tool for this exact patient population. +# 4. Lifestyle Advice: The report must provide direct, non-judgmental advice about alcohol cessation as the single most important step to reduce risk. + +demographics: + age: 54 + sex: male + ethnicity: "Caucasian" +lifestyle: + smoking_status: former + smoking_pack_years: 15 + alcohol_consumption: heavy + dietary_habits: "Irregular" +family_history: [] +personal_medical_history: + chronic_illnesses: ["Hypertension", "GERD"] +current_concerns_or_symptoms: "I've been feeling more tired than usual and have some discomfort in my upper right abdomen." +clinical_observations: + - test_name: "AST (Aspartate Aminotransferase)" + value: "110" + unit: "U/L" + reference_range: "10-40" + date: "2025-06-18" + - test_name: "ALT (Alanine Aminotransferase)" + value: "55" + unit: "U/L" + reference_range: "7-56" + date: "2025-06-18" + - test_name: "GGT (Gamma-Glutamyl Transferase)" + value: "150" + unit: "U/L" + reference_range: "8-61" + date: "2025-06-18" + - test_name: "Platelet Count" + value: "130" + unit: "K/uL" + reference_range: "150-450" + date: "2025-06-18" diff --git a/examples/synthetic/lifestyle_and_demographic_risk/lung_risk_occupational.yaml b/examples/synthetic/lifestyle_and_demographic_risk/lung_risk_occupational.yaml new file mode 100644 index 0000000000000000000000000000000000000000..cd84d26d071761f58fc9cdab75ad44716c6a28b0 --- /dev/null +++ b/examples/synthetic/lifestyle_and_demographic_risk/lung_risk_occupational.yaml @@ -0,0 +1,34 @@ +# Use Case: 12 - Occupational Exposure (Asbestos) +# +# Why it was chosen: +# This tests the AI's ability to incorporate occupational/environmental risk factors, which are often less structured than clinical data. It also presents a case of synergistic risk, where smoking and asbestos exposure multiply the risk of lung cancer. +# +# How to understand the inputs: +# - "Retired asbestos remover" is a critical piece of unstructured text in the medical history. +# - He also has a smoking history, though he quit 20 years ago. +# +# What to look for in a successful assessment: +# 1. Synergistic Risk: The `reasoning` for the high lung cancer risk (Level 4) must mention *both* the asbestos exposure and the smoking history, ideally noting that their combined effect is greater than the sum of their parts. +# 2. Correct Screening: Despite quitting 20 years ago (which would normally make him ineligible for LDCT), the high-risk occupational exposure should trigger a strong recommendation for a discussion about LDCT screening with his provider. The AI should demonstrate this nuanced thinking. +# 3. Other Risks: The AI should also correctly assess for other asbestos-related malignancies, such as mesothelioma, although it's not a formal module. A mention in the `reasoning` would be a sign of advanced knowledge. + +demographics: + age: 65 + sex: male + ethnicity: "Caucasian" +lifestyle: + smoking_status: former + smoking_pack_years: 15 + alcohol_consumption: moderate +personal_medical_history: + chronic_illnesses: + - "Retired asbestos remover (worked for 30 years)" + - "Arthritis" +family_history: [] +current_concerns_or_symptoms: "I've had a dry cough that has been getting worse over the last six months, and I feel more short of breath when I walk up stairs." +clinical_observations: + - test_name: "Chest X-ray" + value: "Pleural plaques noted bilaterally, consistent with asbestos exposure. Lungs otherwise clear." + unit: "N/A" + reference_range: "N/A" + date: "2025-03-10" diff --git a/examples/synthetic/lifestyle_and_demographic_risk/metabolic_syndrome.yaml b/examples/synthetic/lifestyle_and_demographic_risk/metabolic_syndrome.yaml new file mode 100644 index 0000000000000000000000000000000000000000..dee5b8f21658c5c9bee20921b17f07bb8ad39ce3 --- /dev/null +++ b/examples/synthetic/lifestyle_and_demographic_risk/metabolic_syndrome.yaml @@ -0,0 +1,46 @@ +# Use Case: 13 - The "Metabolic Syndrome" Profile +# +# Why it was chosen: +# This is a very common primary care profile. It tests the AI's ability to connect a cluster of lifestyle and metabolic factors (obesity, smoking, drinking, diabetes) to increased risk across a broad range of cancers (colorectal, pancreatic, liver, kidney, etc.) and generate a holistic, lifestyle-focused report. +# +# How to understand the inputs: +# - The user has no single "major" genetic risk but a powerful combination of moderate lifestyle/metabolic risks. +# - The elevated LFTs and HbA1c are objective evidence of his metabolic disease. +# +# What to look for in a successful assessment: +# 1. Pan-Cancer Lifestyle Risk: The AI should identify moderately elevated risk (Level 3) for multiple cancers, including Colorectal, Pancreatic, and Liver, citing obesity, smoking, and alcohol as contributing factors for each. +# 2. Holistic Summary: The `overall_summary` is key. It should focus heavily on the importance of lifestyle modification (weight loss, smoking/alcohol cessation, diet) as the most effective way to reduce his risk across the board. +# 3. Prioritized Screening: Despite the broad risk, the AI should prioritize the most evidence-based screening: Colonoscopy should be Level 4/5, while others (like pancreatic screening) should be correctly identified as not recommended for this risk level. + +demographics: + age: 48 + sex: male + ethnicity: "Hispanic" +lifestyle: + smoking_status: current + smoking_pack_years: 20 + alcohol_consumption: heavy + dietary_habits: "Fast food, high sugar intake" + physical_activity_level: "Sedentary" +family_history: [] +personal_medical_history: + chronic_illnesses: + - "Obesity (BMI 36)" + - "Type 2 Diabetes" +current_concerns_or_symptoms: "No specific complaints, just here for a check-up because my wife made me." +clinical_observations: + - test_name: "Hemoglobin A1c" + value: "8.1" + unit: "%" + reference_range: "< 5.7" + date: "2025-06-30" + - test_name: "ALT" + value: "65" + unit: "U/L" + reference_range: "< 45" + date: "2025-06-30" + - test_name: "Triglycerides" + value: "250" + unit: "mg/dL" + reference_range: "< 150" + date: "2025-06-30" diff --git a/prompts/instruction/assessment.md b/prompts/instruction/assessment.md new file mode 100644 index 0000000000000000000000000000000000000000..06451436a4b9cde48e5542a88ad26e3b3150f765 --- /dev/null +++ b/prompts/instruction/assessment.md @@ -0,0 +1,19 @@ +You will provide a structured JSON output as specified in the `FORMAT INSTRUCTIONS`. + +## Your Task + +Review the pre-computed risk scores in `USER INFORMATION` and synthesize them into a clear, structured assessment: + +1. **Analyze the risk scores**: Review each risk score provided in `RISK SCORES`. These scores have been calculated by validated risk models and represent the primary basis for the assessment. + +2. **Review clinical observations**: If `CLINICAL OBSERVATIONS` is not empty, carefully consider each item by comparing the `value` to the `reference_range` to identify abnormalities. + +3. **Apply diagnostic protocols**: For each relevant protocol in `DIAGNOSTIC PROTOCOLS`, determine the user's eligibility and recommended frequency based on their risk profile and demographic information. + +4. **Generate clear explanations**: Transform the technical risk data into user-friendly explanations that are empathetic, actionable, and evidence-based. + +5. **Critical review**: Before generating final output, verify that your recommendations are consistent with the risk scores and guidelines. Look for contradictions or omissions. + +6. **Structure the output**: Generate the JSON response following the `FORMAT INSTRUCTIONS` exactly. + +Your role is to explain and contextualize the pre-computed risk assessments, NOT to recalculate or second-guess them. diff --git a/prompts/instruction/conversation.md b/prompts/instruction/conversation.md new file mode 100644 index 0000000000000000000000000000000000000000..a56c03ce3abcffbcf8a05127b8e4a6f23920069d --- /dev/null +++ b/prompts/instruction/conversation.md @@ -0,0 +1 @@ +The user is asking follow-up questions about their cancer risk assessment. Provide a helpful, conversational response. Refer to the conversation history to provide a helpful response. diff --git a/prompts/persona/default.md b/prompts/persona/default.md new file mode 100644 index 0000000000000000000000000000000000000000..206ceb5d3dd5b5cc66b331c72d611f744ebf06ea --- /dev/null +++ b/prompts/persona/default.md @@ -0,0 +1,23 @@ +## Role and Purpose + +You are an AI Cancer Risk Assessment Assistant. Your role is to **summarize pre-computed cancer risk assessments** in clear, empathetic, and evidence-based language. You do NOT diagnose, provide treatment advice, or replace professional medical care. + +## Tone and Communication + +- **Empathetic and clear:** Use plain language. Acknowledge concerns compassionately. +- **Evidence-based:** Your authority comes from the risk models and guidelines already applied to the user's data. +- **Actionable:** Always guide users to discuss findings with their healthcare provider. + +## Risk Communication + +- Use **absolute risk** with natural frequencies (e.g., "3 out of 1,000 people" not "0.3%") +- Use **consistent denominators** when comparing risks +- Acknowledge **uncertainty** appropriately (risk models estimate probabilities, not certainties) +- Never alarm; pair elevated risk information with clear, actionable guidance + +## Non-Negotiable Disclaimer + +Every response that provides personalized risk information or screening recommendations MUST end with: + +--- +***Disclaimer:*** *This is an AI-powered informational tool and does not provide medical advice. The information presented is for educational purposes only and is based on established guidelines for average-risk or specified high-risk populations. It is not a substitute for professional medical advice, diagnosis, or treatment. Always seek the advice of your physician or other qualified health provider with any questions you may have regarding a medical condition. Do not disregard professional medical advice or delay in seeking it because of something you have read from this assistant.* diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..f32b0c21ebf739ea4e8c59bbf44ff22aded72110 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,112 @@ +[build-system] +build-backend = "setuptools.build_meta" +requires = ["setuptools>=61.0"] + +[dependency-groups] +dev = ["sentinel[dev]"] +test = ["sentinel[test]"] + +[project] +authors = [ + {email = "j.euko@instadeep.com", name = "Joao Paulo Euko"}, + {email = "t.d.barrett91@gmail.com", name = "Tom Barrett"}, + {email = "t.makkink@instadeep.com", name = "Thomas Makkink"} +] +dependencies = [ + "fastapi", + "fpdf>=1.7.2", + "hydra-core>=1.3.2", + "langchain", + "langchain-community", + "langchain-google-genai", + "langchain-ollama", + "langchain-openai", + "markdown2>=2.5.3", + "matplotlib>=3.10.3", + "openpyxl>=3.1.0", + "python-dotenv", + "pyyaml", + "reportlab>=4.0.0", + "streamlit>=1.46.0" +] +description = "LLM-based Cancer Risk Assessment Assistant" +name = "sentinel" +readme = "README.md" +requires-python = ">=3.12" +version = "0.1.0" + +[project.optional-dependencies] +dev = [ + "ipywidgets", + "jupyterlab", + "plotly", + "pre-commit", + "pyright", + "pyright>=1.1.405", + "seaborn", + "uvicorn" +] +test = [ + "pytest-cov>=4.0.0", + "pytest-mock>=3.15.1", + "pytest>=7.0.0" +] + +[tool.ruff] +line-length = 88 +target-version = "py312" + +[tool.ruff.format] +indent-style = "space" +line-ending = "auto" +quote-style = "double" +skip-magic-trailing-comma = false + +[tool.ruff.lint] +ignore = [ + "ARG001", # unused function argument (test fixtures) + "ARG002", # unused method argument (pydantic callbacks) + "B008", # do not perform function calls in argument defaults + "B904", # raise from err (requires manual review) + "C408", # unnecessary dict call (plotly API requires dict()) + "C901", # too complex + "E402", # module level import not at top of file (streamlit apps need this) + "E501", # line too long, handled by formatter + "F841", # unused variable (sometimes needed for future use) + "RUF002", # ambiguous hyphen (requires manual review) + "RUF012", # ClassVar annotations (requires manual review) + "SIM102", # nested if statements (sometimes clearer) + "SIM108", # ternary operator (sometimes if-else is clearer) + "TCH" # type-checking rules (often can't be auto-fixed) +] +select = [ + "ARG", # flake8-unused-arguments + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "E", # pycodestyle errors + "F", # pyflakes + "I", # isort + "Q", # flake8-quotes + "RUF", # ruff-specific rules + "SIM", # flake8-simplify + "TCH", # flake8-type-checking + "UP", # pyupgrade + "W" # pycodestyle warnings +] + +[tool.ruff.lint.isort] +known-first-party = ["sentinel"] + +[tool.ruff.lint.pydocstyle] +# Use Google-style docstrings. +convention = "google" + +[tool.ruff.lint.pylint] +allow-magic-value-types = ["int", "str"] + +[tool.setuptools.packages.find] +include = ["sentinel*"] +where = ["src"] + +[tool.uv] +default-groups = ["dev", "test"] # By default, install all dependencies. diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000000000000000000000000000000000000..370845e657d00ab82e400f7c71bf7719f14e7018 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +pythonpath = . +markers = + local_llm: marks tests that require a running Ollama server diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..7577fe344c6861383a96cf92b6d3aeff65337720 --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +"""Scripts package for Sentinel project.""" diff --git a/scripts/benchmark_llm_costs.py b/scripts/benchmark_llm_costs.py new file mode 100644 index 0000000000000000000000000000000000000000..c217da07b0ae1adf485e0a93f3d1b849f2b77de8 --- /dev/null +++ b/scripts/benchmark_llm_costs.py @@ -0,0 +1,647 @@ +"""LLM Cost Benchmarking Script + +Measures token usage and calculates costs for cancer risk assessments +across different LLM backends. +""" + +import argparse +import csv +import functools +import os +from collections import defaultdict +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any + +import requests +import yaml +from dotenv import load_dotenv +from langchain_community.callbacks.manager import get_openai_callback +from loguru import logger +from reportlab.lib import colors +from reportlab.lib.pagesizes import letter +from reportlab.lib.styles import getSampleStyleSheet +from reportlab.lib.units import inch +from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle + +from sentinel.config import AppConfig, ModelConfig, ResourcePaths +from sentinel.factory import SentinelFactory +from sentinel.utils import load_user_file + +load_dotenv() + + +@dataclass +class ModelPricing: + """Pricing per 1 million tokens in USD. + + Attributes: + input_per_million: Cost per 1M input tokens (USD) + output_per_million: Cost per 1M output tokens (USD) + """ + + input_per_million: float + output_per_million: float + + +@dataclass +class BenchmarkModelConfig: + """Model configuration for benchmarking. + + Attributes: + provider: Provider key (google, openai, local) + model_name: Model identifier used by the provider + pricing: Pricing information per 1M tokens + """ + + provider: str + model_name: str + pricing: ModelPricing + + +# Sources: +# - https://ai.google.dev/pricing +# - https://openai.com/api/pricing/ +BENCHMARK_MODELS = [ + BenchmarkModelConfig( + provider="google", + model_name="gemini-2.5-pro", + pricing=ModelPricing(input_per_million=1.25, output_per_million=10.00), + ), + BenchmarkModelConfig( + provider="google", + model_name="gemini-2.5-flash-lite", + pricing=ModelPricing(input_per_million=0.1, output_per_million=0.4), + ), +] + + +@dataclass +class TokenUsage: + """Token usage statistics for a single assessment. + + Attributes: + input_tokens: Tokens in the prompt/input + output_tokens: Tokens in the model's response + """ + + input_tokens: int + output_tokens: int + + @property + def total_tokens(self) -> int: + """Total tokens used. + + Returns: + Sum of input and output tokens + """ + return self.input_tokens + self.output_tokens + + +@dataclass +class BenchmarkResult: + """Results from a single model/profile benchmark run. + + Attributes: + model_name: Name of the model + provider: Provider key (openai, google, local) + profile_name: Name of the profile + token_usage: Token usage statistics + cost: Cost in USD + """ + + model_name: str + provider: str + profile_name: str + token_usage: TokenUsage + cost: float + + +def calculate_cost(token_usage: TokenUsage, pricing: ModelPricing) -> float: + """Calculate cost based on token usage and model pricing. + + Args: + token_usage: Token usage statistics + pricing: Model pricing per 1M tokens + + Returns: + Cost in USD + """ + input_cost = (token_usage.input_tokens / 1_000_000) * pricing.input_per_million + output_cost = (token_usage.output_tokens / 1_000_000) * pricing.output_per_million + return input_cost + output_cost + + +def validate_directory_input(func: Callable[..., Any]) -> Callable[..., Any]: + """Decorator to validate directory argument. + + Args: + func: Function to decorate + + Returns: + Decorated function that validates directory input + """ + + @functools.wraps(func) + def wrapper(directory: Path, *args: Any, **kwargs: Any) -> Any: + """Wrapper function to validate directory input. + + Args: + directory: Path to directory to validate + *args: Additional positional arguments + **kwargs: Additional keyword arguments + + Returns: + Result of the wrapped function + + Raises: + FileNotFoundError: If the directory does not exist + NotADirectoryError: If the path is not a directory + ValueError: If the directory is empty + """ + if not directory.exists(): + raise FileNotFoundError(f"Directory not found: {directory}") + if not directory.is_dir(): + raise NotADirectoryError(f"Not a directory: {directory}") + if not any(directory.iterdir()): + raise ValueError(f"Directory is empty: {directory}") + return func(directory, *args, **kwargs) + + return wrapper + + +def get_available_models() -> list[BenchmarkModelConfig]: + """Get list of available models for benchmarking. + + Returns: + List of configured benchmark models + """ + return BENCHMARK_MODELS + + +@validate_directory_input +def load_benchmark_profiles(benchmark_dir: Path) -> list[dict[str, Any]]: + """Load benchmark profiles. + + Args: + benchmark_dir: Directory containing benchmark YAML files + + Returns: + List of dicts with 'name' and 'path' keys + """ + profiles = [] + for yaml_file in sorted(benchmark_dir.glob("*.yaml")): + profiles.append({"name": yaml_file.stem, "path": yaml_file}) + return profiles + + +def create_knowledge_base_paths(workspace_root: Path) -> ResourcePaths: + """Build resource path configuration from workspace root. + + Args: + workspace_root: Path to workspace root directory + + Returns: + ResourcePaths configuration object + """ + return ResourcePaths( + persona=workspace_root / "prompts/persona/default.md", + instruction_assessment=workspace_root / "prompts/instruction/assessment.md", + instruction_conversation=workspace_root / "prompts/instruction/conversation.md", + output_format_assessment=workspace_root + / "configs/output_format/assessment.yaml", + output_format_conversation=workspace_root + / "configs/output_format/conversation.yaml", + cancer_modules_dir=workspace_root / "configs/knowledge_base/cancer_modules", + dx_protocols_dir=workspace_root / "configs/knowledge_base/dx_protocols", + ) + + +def validate_backend(provider: str, model_name: str) -> None: + """Validate that backend is accessible. + + Args: + provider: Provider key (e.g. "openai", "google", "local") + model_name: Model identifier + + Raises: + ValueError: If the backend is not accessible + """ + if provider == "openai": + if not os.getenv("OPENAI_API_KEY"): + raise ValueError("OPENAI_API_KEY not set") + elif provider == "google": + if not os.getenv("GOOGLE_API_KEY"): + raise ValueError("GOOGLE_API_KEY not set") + elif provider == "local": + ollama_base_url = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434") + response = requests.get(f"{ollama_base_url}/api/tags", timeout=2) + if response.status_code != 200: + raise ValueError("Ollama server not responding") + models = response.json().get("models", []) + model_names = [m.get("name") for m in models] + if model_name not in model_names: + raise ValueError(f"Model not found. Run: ollama pull {model_name}") + + +def run_assessment( + model_config: BenchmarkModelConfig, profile_path: Path +) -> BenchmarkResult: + """Run a single assessment and capture token usage. + + Args: + model_config: Model configuration with pricing + profile_path: Path to profile YAML file + + Returns: + BenchmarkResult with cost and token usage + """ + validate_backend(model_config.provider, model_config.model_name) + + workspace_root = Path(__file__).parent.parent + + with open(workspace_root / "configs/config.yaml") as f: + default_config = yaml.safe_load(f) + + app_config = AppConfig( + model=ModelConfig( + provider=model_config.provider, + model_name=model_config.model_name, + ), + knowledge_base_paths=create_knowledge_base_paths(workspace_root), + selected_cancer_modules=default_config["knowledge_base"]["cancer_modules"], + selected_dx_protocols=default_config["knowledge_base"]["dx_protocols"], + ) + + factory = SentinelFactory(app_config) + conversation = factory.create_conversation_manager() + user = load_user_file(str(profile_path)) + + with get_openai_callback() as cb: + conversation.initial_assessment(user) + input_tokens = cb.prompt_tokens + output_tokens = cb.completion_tokens + + token_usage = TokenUsage(input_tokens, output_tokens) + cost = calculate_cost(token_usage, model_config.pricing) + + return BenchmarkResult( + model_name=model_config.model_name, + provider=model_config.provider, + profile_name=profile_path.stem, + token_usage=token_usage, + cost=cost, + ) + + +def print_results(results: list[BenchmarkResult]) -> None: + """Print formatted results to console. + + Args: + results: List of benchmark results + """ + by_model = defaultdict(list) + for result in results: + by_model[result.model_name].append(result) + + lines = [] + lines.append("\n╔══════════════════════════════════════════════════════════════╗") + lines.append("║ LLM Cost Benchmark Results ║") + lines.append("╚══════════════════════════════════════════════════════════════╝\n") + + for model_name, model_results in sorted(by_model.items()): + provider = model_results[0].provider + lines.append(f"Model: {model_name} ({provider})") + + num_results = len(model_results) + avg_cost = sum(result.cost for result in model_results) / num_results + avg_input = ( + sum(result.token_usage.input_tokens for result in model_results) + / num_results + ) + avg_output = ( + sum(result.token_usage.output_tokens for result in model_results) + / num_results + ) + + for result_index, result in enumerate(model_results): + is_last = result_index == num_results - 1 + prefix = "└─" if is_last else "├─" + indent = " " if is_last else "│ " + lines.append(f"{prefix} Profile: {result.profile_name}") + lines.append(f"{indent}├─ Input: {result.token_usage.input_tokens:,}") + lines.append(f"{indent}├─ Output: {result.token_usage.output_tokens:,}") + lines.append(f"{indent}└─ Cost: ${result.cost:.4f}") + + lines.append(f"└─ Average: ${avg_cost:.4f}") + lines.append( + f" └─ Tokens: {avg_input:,.0f} input, {avg_output:,.0f} output\n" + ) + + lines.append("═══════════════════════════════════════════════════════════════") + lines.append("Summary - Model Ranking (Cheapest to Most Expensive)") + lines.append("───────────────────────────────────────────────────────────────") + + model_averages = sorted( + ( + ( + model_name, + sum(result.cost for result in model_results) / len(model_results), + ) + for model_name, model_results in by_model.items() + ), + key=lambda model_avg_tuple: model_avg_tuple[1], + ) + + for rank, (model_name, avg_cost) in enumerate(model_averages, 1): + prefix = ( + "🥇" + if rank == 1 + else "🥈" + if rank == 2 + else "🥉" + if rank == 3 + else f"{rank}." + ) + lines.append(f"{prefix:<4} {model_name:<25} ${avg_cost:.4f}") + + lines.append(f"\nTotal: {len(results)} assessments across {len(by_model)} models") + lines.append("═══════════════════════════════════════════════════════════════\n") + + logger.info("\n".join(lines)) + + +def export_to_csv(results: list[BenchmarkResult], output_path: Path) -> None: + """Export results to CSV file. + + Args: + results: List of benchmark results + output_path: Path to output CSV file + """ + with open(output_path, "w", newline="") as f: + writer = csv.writer(f) + writer.writerow( + [ + "model_name", + "provider", + "profile_name", + "input_tokens", + "output_tokens", + "total_tokens", + "cost_usd", + ] + ) + for result in results: + writer.writerow( + [ + result.model_name, + result.provider, + result.profile_name, + result.token_usage.input_tokens, + result.token_usage.output_tokens, + result.token_usage.total_tokens, + f"{result.cost:.6f}", + ] + ) + logger.success(f"Results exported to: {output_path}") + + +def export_to_pdf( + results: list[BenchmarkResult], + output_path: Path, +) -> None: + """Export results to PDF file with formatted table. + + Args: + results: List of benchmark results + output_path: Path to output PDF file + """ + doc = SimpleDocTemplate( + str(output_path), + pagesize=letter, + leftMargin=0.75 * inch, + rightMargin=0.75 * inch, + topMargin=0.75 * inch, + bottomMargin=0.75 * inch, + ) + + elements = [] + styles = getSampleStyleSheet() + + title = Paragraph( + "LLM Cost Benchmark Report", + styles["Title"], + ) + elements.append(title) + elements.append(Spacer(1, 0.2 * inch)) + + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + timestamp_text = Paragraph( + f"Generated: {timestamp}", + styles["Normal"], + ) + elements.append(timestamp_text) + elements.append(Spacer(1, 0.3 * inch)) + + by_model = defaultdict(list) + for result in results: + by_model[result.model_name].append(result) + + pricing_lookup = {model.model_name: model.pricing for model in BENCHMARK_MODELS} + + results_desc = Paragraph( + "Average cost of running a single cancer risk assessment given a completed patient questionnaire.", + styles["Normal"], + ) + elements.append(results_desc) + elements.append(Spacer(1, 0.2 * inch)) + + table_data = [ + [ + "Model", + "Provider", + "Avg Cost\nper Report", + "Input Price\n(per 1M)", + "Output Price\n(per 1M)", + "Avg Input\nTokens", + "Avg Output\nTokens", + ] + ] + + # Sort by average cost (cheapest first) + sorted_models = sorted( + by_model.items(), + key=lambda model_tuple: sum(result.cost for result in model_tuple[1]) + / len(model_tuple[1]), + ) + + for model_name, model_results in sorted_models: + provider = model_results[0].provider + num_results = len(model_results) + avg_cost = sum(result.cost for result in model_results) / num_results + avg_input = ( + sum(result.token_usage.input_tokens for result in model_results) + / num_results + ) + avg_output = ( + sum(result.token_usage.output_tokens for result in model_results) + / num_results + ) + + pricing = pricing_lookup.get(model_name) + input_price = f"${pricing.input_per_million:.2f}" if pricing else "N/A" + output_price = f"${pricing.output_per_million:.2f}" if pricing else "N/A" + + table_data.append( + [ + model_name, + provider, + f"${avg_cost:.4f}", + input_price, + output_price, + f"{avg_input:,.0f}", + f"{avg_output:,.0f}", + ] + ) + + table = Table( + table_data, + colWidths=[ + 1.6 * inch, + 0.9 * inch, + 1.0 * inch, + 1.0 * inch, + 1.0 * inch, + 0.9 * inch, + 0.9 * inch, + ], + ) + + table_style = TableStyle( + [ + # Header styling + ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#4A90E2")), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke), + ("ALIGN", (0, 0), (-1, 0), "CENTER"), + ("VALIGN", (0, 0), (-1, 0), "MIDDLE"), + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), + ("FONTSIZE", (0, 0), (-1, 0), 9), + ("BOTTOMPADDING", (0, 0), (-1, 0), 12), + ("TOPPADDING", (0, 0), (-1, 0), 12), + # Data rows styling + ("BACKGROUND", (0, 1), (-1, -1), colors.beige), + ("TEXTCOLOR", (0, 1), (-1, -1), colors.black), + ("ALIGN", (0, 1), (1, -1), "LEFT"), + ("ALIGN", (2, 1), (-1, -1), "CENTER"), + ("VALIGN", (0, 1), (-1, -1), "MIDDLE"), + ("FONTNAME", (0, 1), (-1, -1), "Helvetica"), + ("FONTSIZE", (0, 1), (-1, -1), 9), + ("TOPPADDING", (0, 1), (-1, -1), 8), + ("BOTTOMPADDING", (0, 1), (-1, -1), 8), + # Alternating row colors + ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.beige, colors.lightgrey]), + # Grid + ("GRID", (0, 0), (-1, -1), 1, colors.black), + ] + ) + + table.setStyle(table_style) + elements.append(table) + elements.append(Spacer(1, 0.3 * inch)) + + doc.build(elements) + logger.success(f"PDF report generated: {output_path}") + + +def parse_args() -> argparse.Namespace: + """Parse command-line arguments. + + Returns: + Parsed command-line arguments + """ + workspace_root = Path(__file__).parent.parent + + parser = argparse.ArgumentParser(description="Benchmark LLM costs") + parser.add_argument( + "--benchmark-dir", + type=Path, + default=workspace_root / "examples/benchmark", + help="Benchmark profile directory", + ) + parser.add_argument( + "--models", + nargs="+", + help="Specific models to test (by name)", + ) + parser.add_argument( + "--profiles", + nargs="+", + help="Specific profiles to test", + ) + parser.add_argument( + "--output", + type=Path, + help="Export to CSV", + ) + return parser.parse_args() + + +def main() -> None: + """Main entry point. + + Raises: + ValueError: If no matching models or profiles found + """ + args = parse_args() + + logger.info("Loading benchmark configuration...") + all_models = get_available_models() + + logger.info("Loading profiles...") + all_profiles = load_benchmark_profiles(args.benchmark_dir) + + if args.models: + all_models = [model for model in all_models if model.model_name in args.models] + if not all_models: + raise ValueError(f"No matching models: {args.models}") + + if args.profiles: + all_profiles = [ + profile for profile in all_profiles if profile["name"] in args.profiles + ] + if not all_profiles: + raise ValueError(f"No matching profiles: {args.profiles}") + + logger.info( + f"\nRunning {len(all_models)} model(s) x {len(all_profiles)} profile(s)...\n" + ) + + results = [] + for model_index, model in enumerate(all_models, 1): + for profile in all_profiles: + logger.info( + f"[{model_index}/{len(all_models)}] {model.model_name}: {profile['name']}" + ) + result = run_assessment(model, profile["path"]) + results.append(result) + + print_results(results) + + # Generate PDF report with timestamp + workspace_root = Path(__file__).parent.parent + outputs_dir = workspace_root / "outputs" + outputs_dir.mkdir(exist_ok=True) + + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + pdf_path = outputs_dir / f"llm_benchmark_{timestamp}.pdf" + + export_to_pdf(results, pdf_path) + + if args.output: + export_to_csv(results, args.output) + + +if __name__ == "__main__": + main() diff --git a/scripts/generate_documentation.py b/scripts/generate_documentation.py new file mode 100644 index 0000000000000000000000000000000000000000..afefe28737b612c31b3cc05d9d5c87c0e12ee49a --- /dev/null +++ b/scripts/generate_documentation.py @@ -0,0 +1,1909 @@ +"""Generate PDF documentation for Sentinel risk models.""" + +import argparse +import importlib +import inspect +import re +from collections import defaultdict +from collections.abc import Iterable, Iterator +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Union, get_args, get_origin + +import matplotlib.pyplot as plt +from annotated_types import Ge, Gt, Le, Lt +from fpdf import FPDF +from pydantic import BaseModel + +from sentinel.models import UserInput +from sentinel.risk_models.base import RiskModel +from sentinel.risk_models.qcancer import ( + FEMALE_CANCER_TYPES as QC_FEMALE_CANCERS, +) +from sentinel.risk_models.qcancer import ( + MALE_CANCER_TYPES as QC_MALE_CANCERS, +) + +# Constants +HERE = Path(__file__).resolve().parent +PROJECT_ROOT = HERE.parent +MODELS_DIR = PROJECT_ROOT / "src" / "sentinel" / "risk_models" +OUTPUT_DIR = PROJECT_ROOT / "docs" +CHART_FILE = OUTPUT_DIR / "cancer_coverage.png" + +# Palette & typography +THEME_PRIMARY = (59, 130, 246) # Modern blue +THEME_ACCENT = (16, 185, 129) # Modern green +THEME_MUTED = (107, 114, 128) # Modern gray +TEXT_DARK = (31, 41, 55) # Darker text +TEXT_LIGHT = (255, 255, 255) +CARD_BACKGROUND = (249, 250, 251) # Very light gray +TABLE_HEADER_BACKGROUND = (75, 85, 99) # Modern dark gray +ROW_BACKGROUND_LIGHT = (255, 255, 255) +ROW_BACKGROUND_ALT = (246, 248, 255) +TABLE_BORDER = (216, 222, 233) + + +class LinkManager: + """Manages PDF hyperlinks for navigation between sections.""" + + def __init__(self): + self.model_path_to_link_id: dict[str, int] = {} + self.next_link_id = 1 + + def get_or_create_link_id(self, model_path: str) -> int: + """Get or create a link ID for a model path. + + Args: + model_path: The path to the model + + Returns: + The link ID for the model path + """ + if model_path not in self.model_path_to_link_id: + self.model_path_to_link_id[model_path] = self.next_link_id + self.next_link_id += 1 + return self.model_path_to_link_id[model_path] + + def create_link_destination(self, pdf: FPDF, model_path: str) -> None: + """Create a link destination in the PDF for a model path. + + Args: + pdf: The PDF instance + model_path: The path to the model + """ + link_id = self.get_or_create_link_id(model_path) + pdf.set_link(link_id, y=pdf.get_y()) + + +# --------------------------------------------------------------------------- +# Metadata extraction helpers +# --------------------------------------------------------------------------- + + +NUMERIC_CONSTRAINT_TYPES = (Ge, Gt, Le, Lt) + + +def _get_enum_choices(annotation: Any) -> list[str] | None: + try: + members = getattr(annotation, "__members__", None) + if members: + return [ + member.value if hasattr(member, "value") else member + for member in members.values() + ] + except Exception: # pragma: no cover - defensive + return None + return None + + +def _format_type(annotation: Any) -> str: + origin = get_origin(annotation) + args = get_args(annotation) + if origin is list: + inner = args[0] if args else Any + return f"List[{_format_type(inner)}]" + if origin is tuple: + inner = ", ".join(_format_type(arg) for arg in args) + return f"Tuple[{inner}]" + if origin is dict: + key_type = _format_type(args[0]) if args else "Any" + value_type = _format_type(args[1]) if len(args) > 1 else "Any" + return f"Dict[{key_type}, {value_type}]" + if origin is Union and args: + # PEP604 union + return " | ".join(sorted({_format_type(arg) for arg in args})) + if isinstance(annotation, type): + try: + if issubclass(annotation, BaseModel): + return annotation.__name__ + except TypeError: + pass + return annotation.__name__ + if hasattr(annotation, "__name__"): + return annotation.__name__ + return str(annotation) + + +def _collect_constraints(metadata: Iterable[Any]) -> dict[str, Any]: + constraints: dict[str, Any] = {} + for item in metadata: + if isinstance(item, NUMERIC_CONSTRAINT_TYPES): + attr = item.__class__.__name__.lower() + for key, attr_name in { + "min": "ge", + "min_exclusive": "gt", + "max": "le", + "max_exclusive": "lt", + }.items(): + if hasattr(item, attr_name): + constraints[key] = getattr(item, attr_name) + elif hasattr(item, "min_length") or hasattr(item, "max_length"): + for key, attr_name in { + "min_length": "min_length", + "max_length": "max_length", + }.items(): + value = getattr(item, attr_name, None) + if value is not None: + constraints[key] = value + return constraints + + +def parse_annotated_type(field_type: type) -> tuple[type, dict[str, Any]]: + """Parse Annotated[Type, Field(...)] to extract base type and constraints. + + Args: + field_type: The type annotation to parse + + Returns: + (base_type, constraints_dict) + """ + from typing import get_args, get_origin + + origin = get_origin(field_type) + args = get_args(field_type) + + if ( + origin is not None + and hasattr(origin, "__name__") + and origin.__name__ == "Annotated" + ): + # Extract base type and metadata + base_type = args[0] if args else field_type + metadata = args[1:] if len(args) > 1 else [] + + # Extract constraints from Field(...) metadata + constraints = {} + for item in metadata: + if hasattr(item, "__class__") and hasattr(item.__class__, "__name__"): + if item.__class__.__name__ == "FieldInfo": + # Extract Field constraints + for attr_name in [ + "ge", + "gt", + "le", + "lt", + "min_length", + "max_length", + ]: + if hasattr(item, attr_name): + value = getattr(item, attr_name) + if value is not None: + constraints[attr_name] = value + elif item.__class__.__name__ in ["Ge", "Gt", "Le", "Lt"]: + # Handle annotated_types constraints + for key, attr_name in { + "ge": "ge", + "gt": "gt", + "le": "le", + "lt": "lt", + }.items(): + if hasattr(item, attr_name): + constraints[key] = getattr(item, attr_name) + + return base_type, constraints + else: + # Handle Union types like Ethnicity | None + if ( + origin is not None + and hasattr(origin, "__name__") + and origin.__name__ == "Union" + ): + # Find the non-None type + non_none_types = [arg for arg in args if arg is not type(None)] + if non_none_types: + return non_none_types[0], {} + + # Plain type, no constraints + return field_type, {} + + +def extract_model_requirements(model: RiskModel) -> list[tuple[str, type, bool]]: + """Extract field requirements from a model's REQUIRED_INPUTS. + + Args: + model: The risk model to extract requirements from + + Returns: + list of (field_path, original_field_type, is_required) tuples + """ + requirements = [] + + for field_path, (field_type, is_required) in model.REQUIRED_INPUTS.items(): + # Keep the original type annotation with constraints + requirements.append((field_path, field_type, is_required)) + + return requirements + + +def traverse_user_input_structure( + model: type[BaseModel], prefix: str = "" +) -> list[tuple[str, str, type[BaseModel] | None]]: + """Recursively traverse UserInput to find all nested models and leaf fields. + + Args: + model: The BaseModel class to traverse + prefix: Current path prefix for nested models + + Returns: + list of (path, name, model_class) tuples where: + - path: Full dotted path to the field/model + - name: Display name for the field/model + - model_class: The model class (None for leaf fields) + """ + structure = [] + + for field_name, field_info in model.model_fields.items(): + field_path = f"{prefix}.{field_name}" if prefix else field_name + field_type = field_info.annotation + + # First check: Direct BaseModel type + if isinstance(field_type, type) and issubclass(field_type, BaseModel): + structure.append((field_path, prettify_field_name(field_name), field_type)) + structure.extend(traverse_user_input_structure(field_type, field_path)) + + # Second check: Generic Union/Optional handling (works for both Union[T, None] and T | None) + elif hasattr(field_type, "__args__"): + args = field_type.__args__ + non_none_types = [arg for arg in args if arg is not type(None)] + + if non_none_types: + try: + first_type = non_none_types[0] + + # Check if it's a Union with BaseModel + if issubclass(first_type, BaseModel): + nested_model = first_type + structure.append( + (field_path, prettify_field_name(field_name), nested_model) + ) + structure.extend( + traverse_user_input_structure(nested_model, field_path) + ) + # Check if it's a list + elif ( + hasattr(field_type, "__origin__") + and hasattr(field_type.__origin__, "__name__") + and field_type.__origin__.__name__ == "list" + ): + # Handle list types - check if it's a list of BaseModel + if issubclass(first_type, BaseModel): + list_item_model = first_type + structure.append( + ( + field_path, + prettify_field_name(field_name), + list_item_model, + ) + ) + # Recursively traverse the list item model + structure.extend( + traverse_user_input_structure( + list_item_model, f"{field_path}[]" + ) + ) + else: + # List of primitive types (enum, str, int, etc.) - treat as leaf field + structure.append( + (field_path, prettify_field_name(field_name), None) + ) + else: + # Leaf field (list of primitives, plain type, etc.) + structure.append( + (field_path, prettify_field_name(field_name), None) + ) + except TypeError: + # Not a class, treat as leaf + structure.append( + (field_path, prettify_field_name(field_name), None) + ) + else: + # No non-None types, treat as leaf + structure.append((field_path, prettify_field_name(field_name), None)) + + # Third check: Everything else is a leaf field + else: + structure.append((field_path, prettify_field_name(field_name), None)) + + return structure + + +def build_field_usage_map(models: list[RiskModel]) -> dict[str, list[tuple[str, bool]]]: + """Build mapping of field_path -> [(model_name, is_required), ...] + + Args: + models: List of risk model instances + + Returns: + dict mapping each field path to list of (model_name, required_flag) tuples + """ + field_usage: dict[str, list[tuple[str, bool]]] = {} + + for model in models: + for field_path, (_, is_required) in model.REQUIRED_INPUTS.items(): + if field_path not in field_usage: + field_usage[field_path] = [] + field_usage[field_path].append((model.name, is_required)) + + # Sort each field's usage by model name + for field_path in field_usage: + field_usage[field_path].sort(key=lambda x: x[0]) + + return field_usage + + +def extract_field_attributes(field_info, field_type) -> tuple[str, str, str, str]: + """Extract field attributes directly from Field metadata. + + Args: + field_info: Pydantic field info object + field_type: The field's type annotation + + Returns: + tuple of (description, examples, constraints, used_by) strings + """ + description = "-" + examples = "-" + constraints = "-" + used_by = "-" + + # Extract description from Field + if hasattr(field_info, "description") and field_info.description: + description = field_info.description + + # Extract examples from Field - they are directly on the field_info object + if hasattr(field_info, "examples") and field_info.examples: + examples_list = field_info.examples + if isinstance(examples_list, list): + examples = ", ".join(str(ex) for ex in examples_list) + + # Extract constraints from Field metadata + constraints_list = [] + if hasattr(field_info, "metadata") and field_info.metadata: + for item in field_info.metadata: + if hasattr(item, "__class__") and hasattr(item.__class__, "__name__"): + class_name = item.__class__.__name__ + if class_name == "Ge" and hasattr(item, "ge"): + constraints_list.append(f">={item.ge}") + elif class_name == "Gt" and hasattr(item, "gt"): + constraints_list.append(f">{item.gt}") + elif class_name == "Le" and hasattr(item, "le"): + constraints_list.append(f"<={item.le}") + elif class_name == "Lt" and hasattr(item, "lt"): + constraints_list.append(f"<{item.lt}") + elif class_name == "MinLen" and hasattr(item, "min_length"): + constraints_list.append(f"min_length={item.min_length}") + elif class_name == "MaxLen" and hasattr(item, "max_length"): + constraints_list.append(f"max_length={item.max_length}") + + if constraints_list: + constraints = ", ".join(constraints_list) + + # Add enum count information if the field is an enum + if hasattr(field_type, "__members__"): + enum_count = len(field_type.__members__) + if constraints == "-": + constraints = f"{enum_count} choices" + else: + constraints = f"{constraints}, {enum_count} choices" + elif hasattr(field_type, "__args__"): + # Handle Union types - find enum in union + for arg in field_type.__args__: + if hasattr(arg, "__members__"): + enum_count = len(arg.__members__) + if constraints == "-": + constraints = f"{enum_count} choices" + else: + constraints = f"{constraints}, {enum_count} choices" + break + + # Add format information for basic types if no constraints + if constraints == "-": + # Check if it's a boolean type + if field_type is bool or ( + hasattr(field_type, "__args__") and bool in field_type.__args__ + ): + constraints = "binary" + # Check if it's a numeric type (int, float) without constraints + elif field_type in (int, float) or ( + hasattr(field_type, "__args__") + and any(_type in field_type.__args__ for _type in (int, float)) + ): + constraints = "any number" + # Check if it's a Date type + elif (hasattr(field_type, "__name__") and field_type.__name__ == "date") or ( + hasattr(field_type, "__args__") + and any( + hasattr(arg, "__name__") and arg.__name__ == "date" + for arg in field_type.__args__ + ) + ): + constraints = "date" + + return description, examples, constraints, used_by + + +def format_used_by(usage_list: list[tuple[str, bool]]) -> str: + """Format the 'Used By' column with model names and req/opt indicators. + + Args: + usage_list: List of (model_name, is_required) tuples + + Returns: + Formatted string like "gail (req), boadicea (opt), claus (req)" + """ + if not usage_list: + return "-" + + formatted_items = [] + for model_name, is_required in usage_list: + indicator = "req" if is_required else "opt" + formatted_items.append(f"{model_name} ({indicator})") + + return ", ".join(formatted_items) + + +def render_user_input_hierarchy( + pdf: FPDF, models: list[RiskModel], link_manager: LinkManager +) -> None: + """Render complete UserInput structure hierarchically. + + Args: + pdf: Active PDF instance + models: List of risk model instances + link_manager: Link manager for creating hyperlinks between sections + """ + from collections import defaultdict + + from sentinel.user_input import UserInput + + # Build field usage mapping + field_usage = build_field_usage_map(models) + + # Traverse the UserInput structure + structure = traverse_user_input_structure(UserInput) + + # Group structure by parent path to handle mixed parent models properly + parent_to_items = defaultdict(list) + for field_path, field_name, model_class in structure: + # Get parent path (everything before last dot, or empty for top-level) + if "." in field_path: + parent_path = ".".join(field_path.split(".")[:-1]) + else: + parent_path = "" + + parent_to_items[parent_path].append((field_path, field_name, model_class)) + + # Render sections in order, ensuring each parent gets its leaf fields rendered + section_counter = 1 + + # Process items in the order they appear in the original structure + processed_parents = set() + + for field_path, _field_name, _model_class in structure: + # Get parent path for this item + if "." in field_path: + parent_path = ".".join(field_path.split(".")[:-1]) + else: + parent_path = "" + + # Skip if we've already processed this parent + if parent_path in processed_parents: + continue + + # Mark this parent as processed + processed_parents.add(parent_path) + + # Get all items for this parent + items = parent_to_items[parent_path] + + # Separate leaf fields from nested models + leaf_fields = [] + nested_models = [] + + for item_path, item_name, item_model_class in items: + if item_model_class is not None: + # This is a nested model + nested_models.append((item_path, item_name, item_model_class)) + else: + # This is a leaf field + leaf_fields.append((item_path, item_name)) + + # Render leaf fields for this parent if any exist + if leaf_fields and parent_path: + # Find the parent model info - look for the parent path in the structure + parent_model_info = None + for field_path, field_name, model_class in structure: + if field_path == parent_path and model_class is not None: + parent_model_info = (field_path, field_name, model_class) + break + + if parent_model_info: + # Get nested models for this parent + nested_models_for_parent = [] + for item_path, item_name, item_model_class in items: + if item_model_class is not None and item_path != parent_path: + nested_models_for_parent.append( + (item_path, item_name, item_model_class) + ) + + # Create link destination for this section + link_manager.create_link_destination(pdf, parent_path) + + # Find parent info for this model (if it's not a root-level model) + parent_info_for_current = None + if parent_path: # Not root level + parent_of_current = ( + ".".join(parent_path.split(".")[:-1]) + if "." in parent_path + else "" + ) + if parent_of_current: # Has a parent + # Only create parent info if the parent actually gets rendered (has leaf fields) + parent_items = parent_to_items[parent_of_current] + parent_has_leaf_fields = any( + item_model_class is None + for _, _, item_model_class in parent_items + ) + + if ( + parent_has_leaf_fields + ): # Parent gets rendered, so we can reference it + for field_path, field_name, model_class in structure: + if ( + field_path == parent_of_current + and model_class is not None + ): + parent_info_for_current = ( + field_path, + field_name, + model_class, + ) + break + + render_model_fields_table( + pdf, + parent_model_info, + leaf_fields, + field_usage, + section_counter, + link_manager, + nested_models_for_parent, + parent_info_for_current, + ) + section_counter += 1 + + +def render_model_fields_table( + pdf: FPDF, + model_info: tuple[str, str, type[BaseModel]], + fields: list[tuple[str, str]], + _field_usage: dict[str, list[tuple[str, bool]]], + section_number: int, + link_manager: LinkManager, + nested_models_info: list[tuple[str, str, type[BaseModel]]] | None = None, + parent_info: tuple[str, str, type[BaseModel]] | None = None, +) -> None: + """Render a table for a specific model's fields. + + Args: + pdf: Active PDF instance + model_info: (path, name, model_class) tuple + fields: List of (field_path, field_name) tuples for leaf fields + _field_usage: Mapping of field paths to usage information (unused) + section_number: Section number for heading + link_manager: Link manager for hyperlinks + nested_models_info: List of nested model info tuples + parent_info: Parent model info tuple + """ + if not model_info or not fields: + return + + _model_path, model_name, model_class = model_info + + # Add parent reference if this is a child model + if parent_info: + parent_path, parent_name, _ = parent_info + parent_link_id = link_manager.get_or_create_link_id(parent_path) + + # Add parent reference with hyperlink + pdf.set_font("Helvetica", "I", 9) + pdf.set_text_color(*THEME_MUTED) + + # Get position for hyperlink + link_x = pdf.get_x() + link_y = pdf.get_y() + + pdf.cell(0, 6, f"Parent: {parent_name} ^", 0, 1, "L") + + # Add hyperlink to the parent reference + pdf.link( + link_x, + link_y, + pdf.get_string_width(f"Parent: {parent_name} ^"), + 6, + parent_link_id, + ) + + pdf.ln(2) + + # Add section heading with parent reference if applicable + if parent_info: + parent_path, parent_name, _ = parent_info + parent_link_id = link_manager.get_or_create_link_id(parent_path) + + # Create section heading with parent reference + section_title = f"{section_number}. {model_name} (from {parent_name})" + add_subheading(pdf, section_title) + + # Add hyperlink to the parent reference part + # Get the position after the section number and model name + text_before_parent = f"{section_number}. {model_name} (from " + text_width_before_parent = pdf.get_string_width(text_before_parent) + parent_text_width = pdf.get_string_width(parent_name) + + # Set link position (approximate, since we can't get exact position after add_subheading) + link_x = pdf.l_margin + text_width_before_parent + link_y = pdf.get_y() - 8 # Approximate position of the text line + + pdf.link(link_x, link_y, parent_text_width, 8, parent_link_id) + else: + add_subheading(pdf, f"{section_number}. {model_name}") + + # Create table for this model's fields (removed "Used By" column) + table_width = pdf.w - 2 * pdf.l_margin + field_width = table_width * 0.20 + desc_width = table_width * 0.45 # Reduced from 0.50 to make room for examples + examples_width = table_width * 0.20 # Increased from 0.15 to 0.20 + constraints_width = table_width * 0.15 # Kept at 0.15 + col_widths = [field_width, desc_width, examples_width, constraints_width] + + # Table header function for reuse on page breaks + def render_table_header(): + pdf.set_font("Helvetica", "B", 10) + pdf.set_fill_color(*TABLE_HEADER_BACKGROUND) + pdf.set_text_color(*TEXT_LIGHT) + pdf.cell(field_width, 8, "Field Name", 0, 0, "L", True) + pdf.cell(desc_width, 8, "Description", 0, 0, "L", True) + pdf.cell(examples_width, 8, "Examples", 0, 0, "L", True) + pdf.cell(constraints_width, 8, "Format", 0, 1, "L", True) + + # Check if we need a page break before rendering the table header + # Account for table header height (8) plus some margin + if pdf.get_y() + 15 > pdf.h - pdf.b_margin: + pdf.add_page() + + render_table_header() + + # Table rows + pdf.set_font("Helvetica", "", 9) + pdf.set_text_color(*TEXT_DARK) + line_height = 5.5 + + for idx, (field_path, field_name) in enumerate(fields): + # Check if we need a page break before this row + if pdf.get_y() + 15 > pdf.h - pdf.b_margin: + pdf.add_page() + render_table_header() + # Reset text color and font after page break to ensure readability + pdf.set_text_color(*TEXT_DARK) + pdf.set_font("Helvetica", "", 9) # Reset to normal weight for table rows + + # Get field info from the model + field_info = model_class.model_fields.get(field_path.split(".")[-1]) + if not field_info: + continue + + # Extract field attributes + description, examples, constraints, _ = extract_field_attributes( + field_info, field_info.annotation + ) + + # Alternate row colors + fill_color = ROW_BACKGROUND_LIGHT if idx % 2 == 0 else ROW_BACKGROUND_ALT + + render_table_row( + pdf, + [field_name, description, examples, constraints], + col_widths, + line_height, + fill_color, + ) + + # Add nested model rows if any exist + if nested_models_info: + for nested_path, nested_name, _nested_model_class in nested_models_info: + # Get field info for the nested model field + field_name = nested_path.split(".")[-1] + field_info = model_class.model_fields.get(field_name) + if not field_info: + continue + + # Extract field attributes + description, _, _, _ = extract_field_attributes( + field_info, field_info.annotation + ) + + # Get the section number for this nested model + nested_link_id = link_manager.get_or_create_link_id(nested_path) + + # Create hyperlink text + examples = f"See Section {nested_link_id}" + constraints = "nested object" + + # Check if we need a page break before this row + if pdf.get_y() + 15 > pdf.h - pdf.b_margin: + pdf.add_page() + render_table_header() + # Reset text color and font after page break + pdf.set_text_color(*TEXT_DARK) + pdf.set_font("Helvetica", "", 9) + + # Use a lighter background for nested model rows + fill_color = (250, 250, 255) # Very light blue + + # Create hyperlink for the nested model + row_x = pdf.get_x() + row_y = pdf.get_y() + + render_table_row( + pdf, + [nested_name, description, examples, constraints], + col_widths, + line_height, + fill_color, + ) + + # Add the hyperlink to the examples cell + examples_x = row_x + field_width + desc_width + pdf.link(examples_x, row_y, examples_width, line_height, nested_link_id) + + pdf.ln(6) + + +@dataclass(slots=True) +class FieldSpec: + """Descriptor capturing metadata for a UserInput field.""" + + path: str + type_label: str + required: bool + default: Any + choices: list[str] | None + constraints: dict[str, Any] + description: str | None + + +def _iter_model_fields(model: type[BaseModel], prefix: str = "") -> Iterator[FieldSpec]: + """Yield FieldSpec instances for every nested field in a model. + + Args: + model (type[BaseModel]): Model class to introspect. + prefix (str): Path prefix accumulated for nested fields. + + Yields: + FieldSpec: Metadata describing each discovered field. + """ + for name, field in model.model_fields.items(): + path = f"{prefix}{name}" if not prefix else f"{prefix}.{name}" + annotation = field.annotation + required = field.is_required() + default = None if field.is_required() else field.default + choices = _get_enum_choices(annotation) + constraints = _collect_constraints(field.metadata) + description = getattr(field, "description", None) or None + type_label = _format_type(annotation) + + yield FieldSpec( + path=path, + type_label=type_label, + required=required, + default=default, + choices=choices, + constraints=constraints, + description=description, + ) + + try: + if isinstance(annotation, type) and issubclass(annotation, BaseModel): + yield from _iter_model_fields(annotation, path) + continue + except TypeError: + pass + + origin = get_origin(annotation) + args = get_args(annotation) + if origin in {list, Iterable, list[Any]} and args: + item = args[0] + try: + if isinstance(item, type) and issubclass(item, BaseModel): + yield from _iter_model_fields(item, f"{path}[]") + except TypeError: + pass + elif args: + for arg in args: + try: + if isinstance(arg, type) and issubclass(arg, BaseModel): + yield from _iter_model_fields(arg, path) + except TypeError: + continue + + +USER_INPUT_FIELD_SPECS: dict[str, FieldSpec] = { + field.path: field for field in _iter_model_fields(UserInput) +} + + +# --------------------------------------------------------------------------- +# Presentation helpers +# --------------------------------------------------------------------------- + + +def _normalise_cancer_label(label: str) -> str: + text = label.replace("_", " ").replace("-", " ") + text = text.strip().lower() + suffixes = [" cancer", " cancers"] + for suffix in suffixes: + if text.endswith(suffix): + text = text[: -len(suffix)] + break + return text.strip().title() + + +def _unique_qcancer_sites() -> list[str]: + """Return the union of QCancer cancer sites across sexes. + + Returns: + list[str]: Alphabetically ordered, normalised cancer site names. + """ + + sites = set() + for name in (*QC_FEMALE_CANCERS, *QC_MALE_CANCERS): + sites.add(_normalise_cancer_label(name)) + return sorted(sites) + + +def cancer_types_for_model(model: RiskModel) -> list[str]: + """Return the normalised cancer types handled by a model. + + Args: + model: Risk model instance to inspect. + + Returns: + list[str]: Collection of title-cased cancer type labels. + """ + if model.name == "qcancer": + return _unique_qcancer_sites() + raw = model.cancer_type() + return [ + _normalise_cancer_label(part.strip()) for part in raw.split(",") if part.strip() + ] + + +def add_section_heading(pdf: FPDF, index: str, title: str) -> None: + """Render a primary section heading for the PDF. + + Args: + pdf: Active PDF document. + index: Section identifier prefix (e.g., "1"). + title: Display title for the section. + """ + pdf.set_text_color(*TEXT_DARK) + pdf.set_font("Helvetica", "B", 16) # Reduced from 20 to 16 + pdf.cell(0, 10, f"{index}. {title}", 0, 1) # Reduced height from 14 to 10 + pdf.ln(3) # Reduced spacing from 4 to 3 + + +def add_subheading(pdf: FPDF, title: str) -> None: + """Render a muted subheading label within the document. + + Args: + pdf (FPDF): Active PDF document. + title (str): Subheading text to display. + """ + pdf.set_text_color(*THEME_PRIMARY) # Changed to primary color + pdf.set_font("Helvetica", "B", 12) # Reduced from 13 to 12 + pdf.cell(0, 7, title, 0, 1) # Removed .upper(), reduced height from 8 to 7 + pdf.ln(2) + + +def draw_stat_card(pdf: FPDF, title: str, value: str, note: str, width: float) -> None: + """Render a single metric card with title, value, and note. + + Args: + pdf (FPDF): Active PDF instance. + title (str): Brief descriptor for the metric. + value (str): Primary value to highlight. + note (str): Supporting text beneath the value. + width (float): Width to allocate for the card (points). + """ + card_x = pdf.get_x() + card_y = pdf.get_y() + height = 32 + + pdf.set_fill_color(*CARD_BACKGROUND) + pdf.set_draw_color(255, 255, 255) + pdf.rect(card_x, card_y, width, height, "F") + + pdf.set_text_color(*THEME_MUTED) + pdf.set_font("Helvetica", "B", 9) + pdf.set_xy(card_x + 6, card_y + 5) + pdf.cell(width - 12, 4, title.upper(), 0, 2, "L") + + pdf.set_text_color(*THEME_PRIMARY) + pdf.set_font("Helvetica", "B", 18) + pdf.cell(width - 12, 10, value, 0, 2, "L") + + pdf.set_text_color(*TEXT_DARK) + pdf.set_font("Helvetica", "", 10) + pdf.multi_cell(width - 12, 5, note, 0, "L") + + pdf.set_xy(card_x + width + 8, card_y) + + +def render_summary_cards(pdf: FPDF, models: list[RiskModel]) -> None: + """Render key summary metric cards derived from all models. + + Args: + pdf (FPDF): Active PDF instance. + models (list[RiskModel]): List of instantiated risk models. + """ + stats: list[tuple[str, str, str]] = [] + + total_models = len(models) + total_cancers = len({ct for m in models for ct in cancer_types_for_model(m)}) + stats.append( + ("Risk Models", str(total_models), "Distinct risk calculators available") + ) + stats.append(("Cancer Types", str(total_cancers), "Unique cancer sites covered")) + + available_width = pdf.w - 2 * pdf.l_margin + columns = 2 + gutter = 8 + card_width = (available_width - gutter) / columns + start_x = pdf.l_margin + start_y = pdf.get_y() + max_height_in_row = 0 + + for idx, (title, value, note) in enumerate(stats): + col = idx % columns + row = idx // columns + x = start_x + col * (card_width + gutter) + y = start_y + row * 38 # fixed row height + pdf.set_xy(x, y) + draw_stat_card(pdf, title, value, note, card_width) + max_height_in_row = max(max_height_in_row, 36) + + rows = (len(stats) + columns - 1) // columns + pdf.set_xy(pdf.l_margin, start_y + rows * (max_height_in_row + 2)) + pdf.ln(4) + + +def add_model_heading(pdf: FPDF, section_index: int, model_name: str) -> None: + """Render the heading for an individual model section. + + Args: + pdf (FPDF): Active PDF instance. + section_index (int): Sequence number for the model section. + model_name (str): Model identifier used in headings. + """ + pdf.set_text_color(*THEME_PRIMARY) + pdf.set_font("Helvetica", "B", 15) + display_name = model_name.replace("_", " ").title() + pdf.cell(0, 10, f"2.{section_index} {display_name}", 0, 1) + pdf.set_text_color(*TEXT_DARK) + + +def render_model_summary(pdf: FPDF, model: RiskModel, cancer_types: list[str]) -> None: + """Render textual summary for a risk model. + + Args: + pdf (FPDF): Active PDF instance. + model (RiskModel): The risk model instance. + cancer_types (list[str]): Cancer types covered by the model. + """ + # Description + pdf.set_font("Helvetica", "B", 10) + pdf.set_text_color(*TEXT_DARK) + pdf.cell(0, 5, "Description", 0, 1) + pdf.set_font("Helvetica", "", 9) + pdf.set_text_color(*TEXT_DARK) + pdf.multi_cell(0, 4.5, model.description(), 0, "L") + pdf.ln(1) + + # Interpretation + pdf.set_font("Helvetica", "B", 10) + pdf.set_text_color(*TEXT_DARK) + pdf.cell(0, 5, "Interpretation", 0, 1) + pdf.set_font("Helvetica", "", 9) + pdf.set_text_color(*TEXT_DARK) + pdf.multi_cell(0, 4.5, model.interpretation(), 0, "L") + pdf.ln(1) + + # Cancer Types + pdf.set_font("Helvetica", "B", 10) + pdf.set_text_color(*TEXT_DARK) + pdf.cell(0, 5, "Cancer Types", 0, 1) + pdf.set_font("Helvetica", "", 9) + pdf.set_text_color(*TEXT_DARK) + pdf.multi_cell(0, 4.5, ", ".join(cancer_types), 0, "L") + pdf.ln(1) + + # References + pdf.set_font("Helvetica", "B", 10) + pdf.set_text_color(*TEXT_DARK) + pdf.cell(0, 5, "References", 0, 1) + pdf.set_font("Helvetica", "", 8) + pdf.set_text_color(*TEXT_DARK) + references = model.references() + for i, ref in enumerate(references, 1): + pdf.multi_cell(0, 4, f"{i}. {ref}", 0, "L") + pdf.ln(2) + + +def prettify_field_name(segment: str) -> str: + """Convert a field segment to a readable name. + + Args: + segment: Raw field segment (e.g., "female_specific"). + + Returns: + str: Title-cased, readable field name. + """ + plain = segment.replace("[]", "") + return plain.replace("_", " ").title() + + +def group_fields_by_requirements( + requirements: list[tuple[str, type, bool]], +) -> list[tuple[str, list[tuple[str, type, bool]]]]: + """Group field requirements by their parent field. + + Args: + requirements: List of (field_path, field_type, is_required) tuples. + + Returns: + List of (parent_name, sub_fields) tuples where sub_fields are the + child fields under that parent. + """ + groups: dict[str, list[tuple[str, type, bool]]] = {} + + for field_path, field_type, is_required in requirements: + segments = field_path.split(".") + if len(segments) == 1: + # Top-level field + parent = prettify_field_name(segments[0]) + if parent not in groups: + groups[parent] = [] + groups[parent].append((field_path, field_type, is_required)) + else: + # Nested field + parent = prettify_field_name(segments[0]) + if parent not in groups: + groups[parent] = [] + groups[parent].append((field_path, field_type, is_required)) + + return list(groups.items()) + + +def format_field_path(path: str) -> str: + """Format a dotted field path to highlight hierarchy. + + Args: + path (str): Dotted field path (e.g., "family_history[].relation"). + + Returns: + str: Multi-line string emphasising hierarchy with indentation markers. + """ + segments = path.split(".") + if not segments: + return path + + # Show parent labels with proper hierarchy + lines = [] + for idx, segment in enumerate(segments): + cleaned = prettify_field_name(segment.strip()) + if idx == 0: + lines.append(cleaned) + else: + lines.append(f" - {cleaned}") + return "\n".join(lines) + + +def gather_spec_details( + spec: FieldSpec | None, field_type: type | None = None, note: str = "" +) -> tuple[str, str, str, str]: + """Prepare note, required status, unit, and constraint strings for a metadata row. + + Args: + spec: Field metadata from UserInput introspection, if available. + field_type: Type annotation from model's REQUIRED_INPUTS, if available. + note: Model-specific note to include. + + Returns: + tuple of (note_text, required_text, unit_text, range_text) strings for display. + """ + + notes: list[str] = [] + ranges: list[str] = [] + units: list[str] = [] + required_status = "Optional" + + # Extract constraints from field_type if provided + type_constraints: dict[str, Any] = {} + if field_type is not None: + _, type_constraints = parse_annotated_type(field_type) + + # Special handling for clinical observations + if note and " - " in note: + obs_name, obs_values = note.split(" - ", 1) + # Provide meaningful descriptions for clinical observations + obs_descriptions = { + "multivitamin": "Multivitamin usage status", + "aspirin": "Aspirin usage history", + "activity": "Physical activity level", + "total_meat": "Red meat consumption", + "pain_med": "NSAID/pain medication usage", + "estrogen": "Estrogen therapy history", + "diabetes": "Diabetes status", + "height": "Height measurement", + "weight": "Weight measurement", + "years_of_education": "Years of formal education", + "psa": "Prostate-specific antigen level", + "percent_free_psa": "Percent free PSA", + "pca3": "PCA3 score", + "t2_erg": "T2:ERG score", + "dre": "Digital rectal examination result", + "prior_biopsy": "Prior biopsy history", + "prior_psa": "Prior PSA screening history", + } + description = obs_descriptions.get( + obs_name, f"Clinical observation: {obs_name}" + ) + notes.append(description) + + # Handle special cases for numeric clinical observations + if obs_values.startswith("Numeric ("): + # Extract unit from "Numeric (unit)" format + unit_match = re.search(r"Numeric \(([^)]+)\)", obs_values) + if unit_match: + units.append(unit_match.group(1)) + ranges.append("Numeric") + else: + ranges.append(obs_values) + else: + ranges.append(obs_values) + + note_text = "\n".join(notes) if notes else "-" + range_text = "\n".join(ranges) if ranges else "-" + unit_text = "\n".join(units) if units else "-" + return note_text, required_status, unit_text, range_text + + # Handle model-specific range constraints (e.g., "Age 45-85") + if note and any(char.isdigit() for char in note): + # Check if note contains a range pattern like "45-85" or "35-85" + + range_match = re.search(r"(\d+)-(\d+)", note) + if range_match: + min_val, max_val = range_match.groups() + ranges.append(f">={min_val} to <={max_val}") + else: + # If it's just a description without range, add to notes + notes.append(note) + elif note: + # Regular note without range information + notes.append(note) + + if spec: + if spec.required: + required_status = "Required" + # Only add field description if we don't already have a model-specific note + if spec.description and not note: + notes.append(spec.description) + + # Special handling for specific fields + if spec and spec.path == "demographics.sex": + from sentinel.models import Sex + + sex_choices = [choice.value for choice in Sex] + ranges.append(", ".join(sex_choices)) + elif spec and spec.path == "demographics.ethnicity": + # CRC-PRO ethnicity choices + ranges.append("hawaiian, japanese, latino, white, black") + elif spec and spec.path == "lifestyle.smoking.pack_years": + units.append("pack-years") + ranges.append(">=0") + elif spec and spec.path == "lifestyle.alcohol.drinks_per_week": + units.append("drinks/week") + ranges.append(">=0") + elif spec and spec.path == "family_history[].cancer_type": + # Predefined cancer types + ranges.append( + "breast, cervical, colorectal, endometrial, kidney, leukemia, liver, lung, lymphoma, ovarian, pancreatic, prostate, skin, stomach, testicular, thyroid, uterine, other" + ) + elif spec and spec.path == "demographics.anthropometrics.height_cm": + units.append("cm") + elif spec and spec.path == "demographics.anthropometrics.weight_kg": + units.append("kg") + elif spec and spec.path == "demographics.socioeconomic.education_level": + units.append("years") + + # Add more specific field examples based on common patterns + if spec: + field_path = spec.path + if "age" in field_path: + if not ranges: + ranges.append("Age in years") + elif "height" in field_path: + if not units: + units.append("cm") + if not ranges: + ranges.append("Height measurement") + elif "weight" in field_path: + if not units: + units.append("kg") + if not ranges: + ranges.append("Weight measurement") + elif "bmi" in field_path: + if not ranges: + ranges.append("Body Mass Index") + elif "smoking" in field_path: + if not ranges: + ranges.append("Smoking-related values") + elif "alcohol" in field_path: + if not ranges: + ranges.append("Alcohol consumption values") + elif "family_history" in field_path: + if not ranges: + ranges.append("Family history information") + elif "clinical" in field_path or "test" in field_path: + if not ranges: + ranges.append("Clinical test values") + elif "symptoms" in field_path: + if not ranges: + ranges.append("Symptom descriptions") + elif "medical_history" in field_path: + if not ranges: + ranges.append("Medical history information") + # Use type constraints from REQUIRED_INPUTS if available, otherwise fall back to spec constraints + constraints_to_use = ( + type_constraints if type_constraints else (spec.constraints if spec else {}) + ) + + # Only use generic constraints if we don't have model-specific constraints + # Model-specific constraints (from note) take precedence + if not note and constraints_to_use: + # Handle min/max constraints as a single range + min_val = None + min_exclusive = False + max_val = None + max_exclusive = False + + for key, value in constraints_to_use.items(): + if key == "ge": # ge from Field constraint + min_val = value + elif key == "gt": # gt from Field constraint + min_val = value + min_exclusive = True + elif key == "le": # le from Field constraint + max_val = value + elif key == "lt": # lt from Field constraint + max_val = value + max_exclusive = True + elif key == "min": # Legacy min from spec + min_val = value + elif key == "min_exclusive": # Legacy min_exclusive from spec + min_val = value + min_exclusive = True + elif key == "max": # Legacy max from spec + max_val = value + elif key == "max_exclusive": # Legacy max_exclusive from spec + max_val = value + max_exclusive = True + elif key == "min_length": + ranges.append(f"Min length {value}") + elif key == "max_length": + ranges.append(f"Max length {value}") + else: + ranges.append(f"{key.replace('_', ' ').title()}: {value}") + + # Format min/max as a single range + if min_val is not None or max_val is not None: + range_parts = [] + if min_val is not None: + range_parts.append(f"{'>' if min_exclusive else '>='}{min_val}") + if max_val is not None: + range_parts.append(f"{'<' if max_exclusive else '<='}{max_val}") + if range_parts: + ranges.append(" to ".join(range_parts)) + + # Add choices from spec if available and no note + if not note and spec and spec.choices: + ranges.append(", ".join(str(choice) for choice in spec.choices)) + + # If we still don't have any ranges/choices, try to provide examples based on field type + if not ranges and not note: + # Get the base type from field_type if available + base_type = field_type + if field_type is not None: + base_type, _ = parse_annotated_type(field_type) + + # Provide examples based on type + if base_type is bool: + ranges.append("True, False") + elif base_type is int: + ranges.append("Integer values") + elif base_type is float: + ranges.append("Decimal values") + elif base_type is str: + ranges.append("Text values") + elif hasattr(base_type, "__name__"): + # For enum types, try to get the values + if hasattr(base_type, "__members__"): + enum_values = [ + str(member.value) for member in base_type.__members__.values() + ] + if enum_values: + ranges.append(", ".join(enum_values)) + elif base_type.__name__ in [ + "Sex", + "Ethnicity", + "CancerType", + "FamilyRelation", + "RelationshipDegree", + ]: + # Handle specific enum types we know about + if base_type.__name__ == "Sex": + ranges.append("male, female") + elif base_type.__name__ == "Ethnicity": + ranges.append( + "white, black, asian, hispanic, pacific_islander, other" + ) + elif base_type.__name__ == "CancerType": + ranges.append( + "breast, cervical, colorectal, endometrial, kidney, leukemia, liver, lung, lymphoma, ovarian, pancreatic, prostate, skin, stomach, testicular, thyroid, uterine, other" + ) + elif base_type.__name__ == "FamilyRelation": + ranges.append( + "mother, father, sister, brother, daughter, son, grandmother, grandfather, aunt, uncle, cousin, other" + ) + elif base_type.__name__ == "RelationshipDegree": + ranges.append("first, second, third") + else: + ranges.append(f"{base_type.__name__} values") + elif str(base_type).startswith("typing.Union"): + # Handle Union types + ranges.append("Multiple types allowed") + else: + ranges.append("See model documentation") + + note_text = "\n".join(notes) if notes else "-" + range_text = "\n".join(ranges) if ranges else "-" + unit_text = "\n".join(units) if units else "-" + return note_text, required_status, unit_text, range_text + + +def wrap_text_to_width( + pdf: FPDF, text: str, width: float, _line_height: float +) -> list[str]: + """Wrap text to fit within the specified width. + + Args: + pdf: FPDF instance for font metrics + text: Text to wrap + width: Maximum width in points + _line_height: Height per line (unused) + + Returns: + List of wrapped lines + """ + if not text or text == "-": + return [text] + + # Get current font info + font_size = pdf.font_size + font_family = pdf.font_family + + # Estimate character width (rough approximation) + char_width = font_size * 0.6 # Rough estimate for most fonts + + # Calculate max characters per line + max_chars = int(width / char_width) + + if len(text) <= max_chars: + return [text] + + # Split text into words and wrap + words = text.split() + lines = [] + current_line = "" + + for word in words: + test_line = current_line + (" " if current_line else "") + word + if len(test_line) <= max_chars: + current_line = test_line + else: + if current_line: + lines.append(current_line) + current_line = word + else: + # Word is too long, force break + lines.append(word[:max_chars]) + current_line = word[max_chars:] + + if current_line: + lines.append(current_line) + + return lines + + +def render_table_row( + pdf: FPDF, + texts: list[str], + widths: list[float], + line_height: float, + fill_color: tuple[int, int, int], +) -> None: + """Render a single row of the metadata table. + + Args: + pdf (FPDF): Active PDF instance. + texts (list[str]): Column label/value text. + widths (list[float]): Column widths in points. + line_height (float): Base line height to use for wrapped text. + fill_color (tuple[int, int, int]): RGB tuple for row background. + """ + # Calculate the maximum height needed for this row by wrapping text + max_lines = 0 + wrapped_texts = [] + + for text, width in zip(texts, widths, strict=False): + # Wrap text to fit the cell width + wrapped_lines = wrap_text_to_width(pdf, text, width, line_height) + wrapped_texts.append(wrapped_lines) + max_lines = max(max_lines, len(wrapped_lines)) + + row_height = max_lines * line_height + 2 + + # Check if we need a page break before rendering the row + if pdf.get_y() + row_height > pdf.h - pdf.b_margin: + pdf.add_page() + # Reset text color after page break to ensure readability + pdf.set_text_color(*TEXT_DARK) + + row_x = pdf.get_x() + row_y = pdf.get_y() + + # First, draw the background rectangle for the entire row + pdf.set_fill_color(*fill_color) + pdf.rect(row_x, row_y, sum(widths), row_height, "F") + + # Then draw the text in each cell with consistent height + current_x = row_x + for width, wrapped_lines in zip(widths, wrapped_texts, strict=False): + pdf.set_xy(current_x, row_y) + pdf.set_fill_color(*fill_color) + + # Draw each line of wrapped text + for i, line in enumerate(wrapped_lines): + pdf.set_xy(current_x, row_y + i * line_height) + pdf.cell(width, line_height, line, border=0, align="L", fill=False) + + current_x += width + + # Draw the border around the entire row + pdf.set_draw_color(*TABLE_BORDER) + pdf.rect(row_x, row_y, sum(widths), row_height) + pdf.set_xy(row_x, row_y + row_height) + + +def render_field_table(pdf: FPDF, model: RiskModel) -> None: + """Render the table of UserInput fields referenced by a model. + + Args: + pdf (FPDF): Active PDF instance. + model (RiskModel): The risk model to extract field requirements from. + """ + # Extract requirements from the model + requirements = extract_model_requirements(model) + + if not requirements: + pdf.set_font("Helvetica", "", 10) + pdf.cell(0, 6, "No input requirements defined for this model.", 0, 1) + pdf.ln(4) + return + + table_width = pdf.w - 2 * pdf.l_margin + field_width = table_width * 0.26 + detail_width = table_width * 0.25 + required_width = table_width * 0.10 + unit_width = table_width * 0.10 + range_width = table_width - field_width - detail_width - required_width - unit_width + col_widths = [field_width, detail_width, required_width, unit_width, range_width] + + # Table header function for reuse on page breaks + def render_table_header(): + pdf.set_font("Helvetica", "B", 10) + pdf.set_fill_color(*TABLE_HEADER_BACKGROUND) + pdf.set_text_color(*TEXT_LIGHT) + pdf.cell(field_width, 8, "Field", 0, 0, "L", True) + pdf.cell(detail_width, 8, "Details", 0, 0, "L", True) + pdf.cell(required_width, 8, "Required", 0, 0, "L", True) + pdf.cell(unit_width, 8, "Unit", 0, 0, "L", True) + pdf.cell(range_width, 8, "Choices / Range", 0, 1, "L", True) + + render_table_header() + + pdf.set_font("Helvetica", "", 9) + pdf.set_text_color(*TEXT_DARK) + line_height = 5.5 + + # Group fields by parent + grouped_fields = group_fields_by_requirements(requirements) + + # Define a fixed color for parent field rows (slightly darker than regular rows) + PARENT_ROW_COLOR = (245, 245, 245) # Light gray for parent rows + + for parent_name, sub_fields in grouped_fields: + # Check if we need a page break before the parent field + if pdf.get_y() + 20 > pdf.h - pdf.b_margin: + pdf.add_page() + render_table_header() + # Reset text color after page break to ensure readability + pdf.set_text_color(*TEXT_DARK) + + # Render parent field name (bold) with fixed color + pdf.set_font("Helvetica", "B", 9) + pdf.set_text_color(*TEXT_DARK) + render_table_row( + pdf, + [parent_name, "", "", "", ""], + col_widths, + line_height, + PARENT_ROW_COLOR, + ) + + # Render sub-fields (normal weight, indented) with alternating colors + pdf.set_font("Helvetica", "", 9) + for sub_idx, (field_path, field_type, is_required) in enumerate(sub_fields): + # Get the spec from UserInput introspection + spec = USER_INPUT_FIELD_SPECS.get(field_path) + + # Use the field_type that was already extracted and parsed + note_text, required_text, unit_text, range_text = gather_spec_details( + spec, field_type, "" + ) + + # Override required status based on model requirements + required_text = "Required" if is_required else "Optional" + + # Indent the sub-field name + sub_field_name = prettify_field_name(field_path.split(".")[-1]) + indented_name = f" {sub_field_name}" + # Alternate colors for sub-fields only + fill_color = ( + ROW_BACKGROUND_LIGHT if sub_idx % 2 == 0 else ROW_BACKGROUND_ALT + ) + render_table_row( + pdf, + [indented_name, note_text, required_text, unit_text, range_text], + col_widths, + line_height, + fill_color, + ) + + pdf.ln(4) + + +# --------------------------------------------------------------------------- +# Risk model requirements are now extracted dynamically from each model's +# REQUIRED_INPUTS class attribute, eliminating the need for hardcoded hints. +# --------------------------------------------------------------------------- + + +class PDF(FPDF): + """Sentinel-styled PDF document with header and footer. + + Extends :class:`FPDF` to provide a branded header bar and page numbering + consistent across the generated report. + """ + + def header(self): + """Render a discrete document header on all pages except the first.""" + # Skip header on first page + if self.page_no() == 1: + return + + # Discrete header with title + self.set_text_color(*THEME_MUTED) + self.set_font("Helvetica", "", 10) + self.set_y(8) + self.cell(0, 6, "Sentinel Risk Model Documentation", 0, 0, "L") + + # Add a subtle line + self.set_draw_color(*THEME_MUTED) + self.line(self.l_margin, 16, self.w - self.r_margin, 16) + + def footer(self): + """Render the footer with page numbering.""" + self.set_y(-15) + self.set_text_color(*THEME_MUTED) + self.set_font("Helvetica", "I", 8) + self.cell(0, 10, f"Page {self.page_no()}", 0, 0, "C") + + +def discover_risk_models() -> list[RiskModel]: + """Discover and instantiate all risk model classes. + + Returns: + list[RiskModel]: Instantiated models ordered by name. + """ + models = [] + for f in MODELS_DIR.glob("*.py"): + if f.stem.startswith("_"): + continue + module_name = f"sentinel.risk_models.{f.stem}" + module = importlib.import_module(module_name) + for _, obj in inspect.getmembers(module, inspect.isclass): + if issubclass(obj, RiskModel) and obj is not RiskModel: + models.append(obj()) + return sorted(models, key=lambda m: m.name) + + +def generate_coverage_chart(models: list[RiskModel]) -> None: + """Generate and save a bar chart of cancer type coverage. + + Args: + models (list[RiskModel]): Risk models to analyse for aggregate cancer coverage. + """ + coverage: dict[str, int] = defaultdict(int) + for model in models: + cancer_types = cancer_types_for_model(model) + for cancer_type in cancer_types: + coverage[cancer_type] += 1 + + sorted_coverage = sorted(coverage.items(), key=lambda item: item[1], reverse=True) + cancer_types, counts = zip(*sorted_coverage, strict=False) + cancer_types = list(cancer_types) + counts = list(counts) + + plt.figure(figsize=(10, 8)) + plt.barh(cancer_types, counts, color=[c / 255 for c in THEME_PRIMARY]) + plt.xlabel("Number of Models") + plt.ylabel("Cancer Type") + plt.title("Cancer Type Coverage by Risk Models") + plt.gca().invert_yaxis() + plt.tight_layout() + plt.savefig(CHART_FILE) + plt.close() + + +def render_summary_page( + pdf: FPDF, _models: list[RiskModel], link_manager: "LinkManager" +) -> None: + """Render a summary page with hyperlinks to all sections. + + Args: + pdf: Active PDF document. + _models: List of risk models to document (unused but kept for API consistency). + link_manager: Link manager for creating hyperlinks. + """ + # Title + pdf.set_text_color(*TEXT_DARK) + pdf.set_font("Helvetica", "B", 24) + pdf.cell(0, 15, "Sentinel Risk Model Documentation", 0, 1, "C") + pdf.ln(10) + + # Subtitle + pdf.set_text_color(*THEME_MUTED) + pdf.set_font("Helvetica", "", 12) + pdf.cell(0, 8, "Comprehensive Guide to Cancer Risk Assessment Models", 0, 1, "C") + pdf.ln(20) + + # Table of Contents + pdf.set_text_color(*TEXT_DARK) + pdf.set_font("Helvetica", "B", 16) + pdf.cell(0, 10, "Table of Contents", 0, 1) + pdf.ln(5) + + # Section 1: Overview + pdf.set_font("Helvetica", "B", 12) + pdf.set_text_color(*THEME_PRIMARY) + + # Create hyperlink for Overview section + overview_link_id = link_manager.get_or_create_link_id("overview") + link_x = pdf.get_x() + link_y = pdf.get_y() + pdf.cell(0, 8, "1. Overview", 0, 1) + pdf.link(link_x, link_y, pdf.get_string_width("1. Overview"), 8, overview_link_id) + + pdf.set_font("Helvetica", "", 10) + pdf.set_text_color(*TEXT_DARK) + pdf.cell(0, 6, " Key metrics, cancer coverage, and model statistics", 0, 1) + pdf.ln(3) + + # Section 2: User Input Structure + pdf.set_font("Helvetica", "B", 12) + pdf.set_text_color(*THEME_PRIMARY) + + # Create hyperlink for User Input Structure section + user_input_link_id = link_manager.get_or_create_link_id("user_input_structure") + link_x = pdf.get_x() + link_y = pdf.get_y() + pdf.cell(0, 8, "2. User Input Structure & Requirements", 0, 1) + pdf.link( + link_x, + link_y, + pdf.get_string_width("2. User Input Structure & Requirements"), + 8, + user_input_link_id, + ) + + pdf.set_font("Helvetica", "", 10) + pdf.set_text_color(*TEXT_DARK) + pdf.cell(0, 6, " Complete field definitions, examples, and constraints", 0, 1) + pdf.ln(3) + + # Sub-sections for User Input (simplified - no hyperlinks for now) + pdf.set_font("Helvetica", "", 10) + pdf.set_text_color(*THEME_MUTED) + + # Get all sections that will be rendered + from sentinel.user_input import UserInput + + structure = traverse_user_input_structure(UserInput) + parent_to_items = defaultdict(list) + + for field_path, field_name, model_class in structure: + if "." in field_path: + parent_path = ".".join(field_path.split(".")[:-1]) + else: + parent_path = "" + parent_to_items[parent_path].append((field_path, field_name, model_class)) + + section_counter = 3 + processed_parents = set() + + for field_path, _field_name, _model_class in structure: + if "." in field_path: + parent_path = ".".join(field_path.split(".")[:-1]) + else: + parent_path = "" + + if parent_path in processed_parents: + continue + + processed_parents.add(parent_path) + items = parent_to_items[parent_path] + + leaf_fields = [] + for item_path, item_name, item_model_class in items: + if item_model_class is None: + leaf_fields.append((item_path, item_name)) + + if leaf_fields and parent_path: + # This section will be rendered + # Find the model name from the structure + for field_path_check, field_name_check, model_class_check in structure: + if field_path_check == parent_path and model_class_check is not None: + model_name = field_name_check.replace("_", " ").title() + pdf.cell(0, 5, f" {section_counter}. {model_name}", 0, 1) + section_counter += 1 + break + + pdf.ln(10) + + # Footer note + pdf.set_font("Helvetica", "I", 9) + pdf.set_text_color(*THEME_MUTED) + pdf.cell( + 0, + 6, + "This document provides comprehensive documentation for all Sentinel risk models", + 0, + 1, + "C", + ) + pdf.cell( + 0, + 6, + "and their input requirements. Use the hyperlinks above to navigate to specific sections.", + 0, + 1, + "C", + ) + + +def create_pdf(models: list[RiskModel], output_path: Path) -> None: + """Create the PDF document summarising risk models and metadata. + + Args: + models (list[RiskModel]): Ordered list of risk model instances to document. + output_path (Path): Destination path where the PDF will be saved. + """ + pdf = PDF() + pdf.add_page() + pdf.set_margins(18, 20, 18) + pdf.set_auto_page_break(auto=True, margin=15) + + # Initialize link manager + link_manager = LinkManager() + + # --- Summary Section --- + render_summary_page(pdf, models, link_manager) + + # --- Overview Section --- + pdf.add_page() + # Create link destination for Overview section + link_manager.create_link_destination(pdf, "overview") + add_section_heading(pdf, "1", "Overview") + add_subheading(pdf, "Key Metrics") + render_summary_cards(pdf, models) + + # Coverage Chart + add_subheading(pdf, "Cancer Coverage") + pdf.image(str(CHART_FILE), x=pdf.l_margin, w=pdf.w - 2 * pdf.l_margin) + pdf.ln(12) + + # --- User Input Structure & Requirements Section --- + pdf.add_page() + # Create link destination for User Input Structure section + link_manager.create_link_destination(pdf, "user_input_structure") + add_section_heading(pdf, "2", "User Input Structure & Requirements") + render_user_input_hierarchy(pdf, models, link_manager) + + pdf.output(output_path) + print(f"Documentation successfully generated at: {output_path}") + + +def main(): + """Entry point for the documentation generator CLI.""" + parser = argparse.ArgumentParser( + description="Generate PDF documentation for Sentinel risk models." + ) + parser.add_argument( + "--output", + "-o", + type=Path, + default=OUTPUT_DIR / "risk_model_documentation.pdf", + help="Output path for the PDF file.", + ) + args = parser.parse_args() + + OUTPUT_DIR.mkdir(exist_ok=True) + + print("Discovering risk models...") + models = discover_risk_models() + + print("Generating cancer coverage chart...") + generate_coverage_chart(models) + + print("Creating PDF document...") + create_pdf(models, args.output) + + # Clean up the chart image + CHART_FILE.unlink() + + +if __name__ == "__main__": + main() diff --git a/src/sentinel/__init__.py b/src/sentinel/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..30fd3e27bc3421c24997396c10c25f6a3c8d253a --- /dev/null +++ b/src/sentinel/__init__.py @@ -0,0 +1,11 @@ +"""Sentinel package: LLM-based Cancer Risk Assessment Assistant.""" + +from pathlib import Path + +import hydra +from omegaconf import OmegaConf + +# Register a resolver to load file contents as strings +OmegaConf.register_new_resolver( + "file", lambda path: Path(hydra.utils.to_absolute_path(path)).read_text() +) diff --git a/src/sentinel/api_clients/__init__.py b/src/sentinel/api_clients/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..0ede1577af9f17c23a1ff1158f49b9ab585fdcb0 --- /dev/null +++ b/src/sentinel/api_clients/__init__.py @@ -0,0 +1 @@ +"""API clients for external services.""" diff --git a/src/sentinel/api_clients/canrisk.py b/src/sentinel/api_clients/canrisk.py new file mode 100644 index 0000000000000000000000000000000000000000..8910877dfa38fe4316e7c35ed1b56932349ab1de --- /dev/null +++ b/src/sentinel/api_clients/canrisk.py @@ -0,0 +1,1534 @@ +"""Utilities and client for interacting with the CanRisk BOADICEA API.""" + +import os +import re +import uuid +from dataclasses import dataclass +from datetime import date +from typing import Any + +import requests +from pydantic import BaseModel, Field + +from sentinel.user_input import ( + CancerType, + FamilyMemberCancer, + FamilyRelation, + Sex, + UserInput, +) + +_SIDE_PAT = re.compile(r"\b(paternal|maternal)\b", re.I) +ALLOWED_COUNTRIES = {"UK", "Sweden", "Estonia", "France", "Netherlands", "Slovenia"} + +CANRISK_V3_COLUMNS = ( + "##FamID Name Target IndivID FathID MothID Sex MZtwin Dead Age Yob " + "BC1 BC2 OC PRO PAN Ashkn BRCA1 BRCA2 PALB2 ATM CHEK2 " + "BARD1 RAD51D RAD51C BRIP1 ER:PR:HER2:CK14:CK56" +) + + +def init_cancer_columns() -> dict[str, str]: + """Initialise empty CanRisk cancer columns. + + Returns: + dict[str, str]: Mapping of site codes to initial values. + """ + return {"BC1": "0", "BC2": "0", "OC": "0", "PRO": "0", "PAN": "0"} + + +def add_cancer_to_columns( + columns: dict[str, str], cancer_site: str | None, age_value: int | str | None +) -> None: + """Populate CanRisk column values based on cancer site/age data. + + Args: + columns: Existing column mapping to be mutated in place. + cancer_site: Cancer type string from the pedigree record. + age_value: Age at diagnosis to store in the column. + """ + + if not cancer_site: + return + site = cancer_site.lower() + age_str = str(age_value if age_value not in (None, "") else 0) + + if "breast" in site: + if columns["BC1"] == "0": + columns["BC1"] = age_str + elif columns["BC2"] == "0": + columns["BC2"] = age_str + elif "ovarian" in site: + columns["OC"] = age_str + elif "prostate" in site: + columns["PRO"] = age_str + elif "pancre" in site: + columns["PAN"] = age_str + + +def _word_pattern(word: str) -> re.Pattern[str]: + return re.compile(rf"\b{re.escape(word)}\b", re.I) + + +@dataclass(frozen=True) +class RelationAlias: + """Alias mapping with optional default sex and compiled match patterns.""" + + canonical: str + default_sex: str | None = None + patterns: tuple[re.Pattern[str], ...] = () + + def matches(self, value: str) -> bool: + """Return True if any of the alias patterns match the value. + + Args: + value: Free-text relation string. + + Returns: + bool: True if a pattern matches; otherwise False. + """ + return any(pattern.search(value) for pattern in self.patterns) + + +RELATION_ALIASES: tuple[RelationAlias, ...] = ( + RelationAlias("partner", "F", (_word_pattern("wife"), _word_pattern("girlfriend"))), + RelationAlias( + "partner", "M", (_word_pattern("husband"), _word_pattern("boyfriend")) + ), + RelationAlias("partner", "M", (_word_pattern("spouse"), _word_pattern("partner"))), + RelationAlias("daughter", "F", (_word_pattern("daughter"),)), + RelationAlias("son", "M", (_word_pattern("son"),)), + RelationAlias("child", "F", (_word_pattern("child"),)), + RelationAlias("grandmother", "F", (_word_pattern("grandmother"),)), + RelationAlias("grandfather", "M", (_word_pattern("grandfather"),)), + RelationAlias("mother", "F", (_word_pattern("mother"),)), + RelationAlias("father", "M", (_word_pattern("father"),)), + RelationAlias("sister", "F", (_word_pattern("sister"),)), + RelationAlias("brother", "M", (_word_pattern("brother"),)), + RelationAlias("aunt", "F", (_word_pattern("aunt"),)), + RelationAlias("uncle", "M", (_word_pattern("uncle"),)), +) + +_HORMONE_THERAPY_MAP = { + "n": "N", + "never": "N", + "no": "N", + "none": "N", + "f": "F", + "former": "F", + "previous": "F", + "p": "F", + "e": "E", + "estrogen": "E", + "estrogen-only": "E", + "estrogen only": "E", + "c": "C", + "combined": "C", + "current": "C", +} + + +def gene_field(known_pathogenic: bool | None) -> str: + """Encode pathogenic gene status into the CanRisk field format. + + Args: + known_pathogenic: Whether a known pathogenic mutation exists. + + Returns: + str: Encoded field, e.g., "T:P" or "0:0". + """ + if known_pathogenic is True: + return "T:P" + return "0:0" + + +def canonical_relation(relative: str) -> str | None: + """Return the canonical relation label for a free-text relative string. + + Args: + relative: Relation text such as "mother" or "maternal aunt". + + Returns: + str | None: Canonical relation or None if no match. + """ + alias = match_relation(relative) + return alias.canonical if alias else None + + +def match_relation(relative: str | None) -> RelationAlias | None: + """Match a free-text relation descriptor to a known alias definition. + + Args: + relative: Relation text or None. + + Returns: + RelationAlias | None: Matching alias definition if found. + """ + if not relative: + return None + value = relative.lower().replace("_", " ") + for alias in RELATION_ALIASES: + if alias.matches(value): + return alias + return None + + +def which_side(rel: str | None) -> str | None: + """Infer maternal/paternal side from a relationship description. + + Args: + rel: Relation text. + + Returns: + str | None: "maternal", "paternal", or None. + """ + if not rel: + return None + normalized_relation = rel.strip().lower().replace("_", " ") + side_match = _SIDE_PAT.search(normalized_relation) + if side_match: + return side_match.group(1).lower() + if re.search(r"\bmother\b", normalized_relation): + return "maternal" + if re.search(r"\bfather\b", normalized_relation): + return "paternal" + return None + + +def infer_sex_from_relative(member: FamilyMemberCancer) -> str | None: + """Infer relative sex from the relative description when not provided. + + Args: + member: Family member cancer record. + + Returns: + str | None: "M", "F", or default fallback. + """ + # Use the relation enum to infer sex + match member.relation: + case ( + FamilyRelation.FATHER + | FamilyRelation.BROTHER + | FamilyRelation.SON + | FamilyRelation.MATERNAL_GRANDFATHER + | FamilyRelation.PATERNAL_GRANDFATHER + | FamilyRelation.MATERNAL_UNCLE + | FamilyRelation.PATERNAL_UNCLE + ): + return "M" + case ( + FamilyRelation.MOTHER + | FamilyRelation.SISTER + | FamilyRelation.DAUGHTER + | FamilyRelation.MATERNAL_GRANDMOTHER + | FamilyRelation.PATERNAL_GRANDMOTHER + | FamilyRelation.MATERNAL_AUNT + | FamilyRelation.PATERNAL_AUNT + ): + return "F" + case _: + # Fall back to cancer types for inference + cancer = ( + member.cancer_type.value.lower() + if hasattr(member.cancer_type, "value") + else str(member.cancer_type).lower() + ) + if "prostate" in cancer: + return "M" + if "ovarian" in cancer: + return "F" + for extra_cancer, _ in getattr(member, "additional_cancers", []) or []: + site = extra_cancer.lower() + if "prostate" in site: + return "M" + if "ovarian" in site: + return "F" + return "F" + + +def normalise_hormone_therapy_use(value: str | None) -> str | None: + """Normalise hormone therapy use to the CanRisk codes. + + Args: + value: Textual value representing usage. + + Returns: + str | None: One of {"N","F","E","C"} or None. + + Raises: + ValueError: If value cannot be mapped to a known code. + """ + if value is None: + return None + normalised = str(value).strip() + if not normalised: + return None + upper = normalised.upper() + if upper in {"N", "F", "E", "C"}: + return upper + lookup_key = normalised.lower() + if lookup_key in _HORMONE_THERAPY_MAP: + return _HORMONE_THERAPY_MAP[lookup_key] + raise ValueError(f"Unsupported hormone therapy use value: {value}") + + +def normalise_oc_use(value: str | None) -> str | None: + """Normalise oral contraceptive use string to CanRisk codes. + + Args: + value: OC usage string. + + Returns: + str | None: "N" or an F:/C: code with years; otherwise None. + """ + if not value: + return None + s = value.strip().upper() + if s == "N": + return "N" + if (s.startswith("F:") or s.startswith("C:")) and s.split(":", 1)[1].isdigit(): + return s + return None + + +def normalise_birads(v: str | None) -> str | None: + """Normalise recorded BI-RADS category. + + Args: + v: BI-RADS category value. + + Returns: + str | None: Normalised category or None. + """ + if not v: + return None + s = v.strip().lower() + if s in {"a", "b", "c", "d", "1", "2", "3", "4"}: + return s + return None + + +def uk_units_per_week_to_grams_per_day(units_per_week: float) -> float: + """Convert UK alcohol units per week to grams of alcohol per day. + + Args: + units_per_week: Units per week. + + Returns: + float: Grams per day equivalent. + """ + return units_per_week * 8.0 / 7.0 + + +def convert_alcohol_to_grams_per_day(alcohol_consumption: str | None) -> float | None: + """Translate textual alcohol intake into grams per day where possible. + + Args: + alcohol_consumption: Text such as "light", "14 units per week", etc. + + Returns: + float | None: Grams/day or None when unknown. + """ + if not alcohol_consumption: + return None + + consumption = alcohol_consumption.lower().strip() + + alcohol_map = { + "none": 0.0, + "never": 0.0, + "light": 7.0, + "moderate": 14.0, + "heavy": 28.0, + "excessive": 42.0, + } + + if consumption in alcohol_map: + return uk_units_per_week_to_grams_per_day(alcohol_map[consumption]) + + match = re.search(r"(\d+(?:\.\d+)?)\s*units?\s*(?:per\s*)?week", consumption) + if match: + units_per_week = float(match.group(1)) + return uk_units_per_week_to_grams_per_day(units_per_week) + + return None + + +_ONS_ETHNICITY_MAP: dict[str, tuple[str, str]] = { + "white british": ("White", "British"), + "british": ("White", "British"), + "white irish": ("White", "Irish"), + "gypsy": ("White", "Gypsy or Irish Traveller"), + "white other": ("White", "Other"), + "jewish": ("White", "Jewish"), + "black african": ("Black or Black British", "African"), + "black caribbean": ("Black or Black British", "Caribbean"), + "black other": ("Black or Black British", "Other"), + "african": ("Black or Black British", "African"), + "caribbean": ("Black or Black British", "Caribbean"), + "black": ("Black or Black British", "Other"), + "indian": ("Asian or Asian British", "Indian"), + "pakistani": ("Asian or Asian British", "Pakistani"), + "bangladeshi": ("Asian or Asian British", "Bangladeshi"), + "chinese": ("Asian or Asian British", "Chinese"), + "asian other": ("Asian or Asian British", "Other"), + "asian": ("Asian or Asian British", "Other"), + "mixed white black african": ("Mixed or Multiple", "White and Black African"), + "mixed white black caribbean": ("Mixed or Multiple", "White and Black Caribbean"), + "mixed white asian": ("Mixed or Multiple", "White and Asian"), + "mixed other": ("Mixed or Multiple", "Other"), + "arab": ("Other ethnic group", "Arab"), + "other": ("Other ethnic group", "Other"), +} + + +def map_ethnicity_ons(raw: str | None) -> tuple[str | None, str | None, bool]: + """Map raw ethnicity strings to ONS group/subgroup and Ashkenazi flag. + + Args: + raw: Raw ethnicity string. + + Returns: + tuple[str|None,str|None,bool]: Group, background, and Ashkenazi flag. + """ + if not raw: + return None, None, False + s = raw.strip().lower() + # Ashkenazi (or ASJ) => Jewish + Ashkenazi flag + if "ashkenazi" in s or re.search(r"\basj\b", s): + return "White", "Jewish", True + # Jewish (unspecified) => Jewish but not necessarily Ashkenazi + if "jewish" in s: + return "White", "Jewish", False + if s in _ONS_ETHNICITY_MAP: + g, b = _ONS_ETHNICITY_MAP[s] + return g, b, False + for key, (g, b) in _ONS_ETHNICITY_MAP.items(): + if key in s: + return g, b, False + return None, None, False + + +def map_density(imaging: Any) -> tuple[str | None, float | None, float | None]: + """Extract breast density metrics in CanRisk-compatible formats. + + Args: + imaging: Object with imaging attributes. + + Returns: + tuple[str|None,float|None,float|None]: (birads, volpara, stratus). + """ + if imaging is None: + return None, None, None + birads = getattr(imaging, "birads", None) or getattr( + imaging, "birads_category", None + ) + volpara = getattr(imaging, "volpara_percent", None) or getattr( + imaging, "volpara", None + ) + stratus = getattr(imaging, "stratus_percent", None) or getattr( + imaging, "stratus", None + ) + + def _norm_birads(v: Any) -> str | None: + """Normalise BI-RADS string values. + + Args: + v: Raw BI-RADS value. + + Returns: + str | None: Normalised category or None. + """ + if v is None: + return None + s = str(v).strip().lower() + if s in {"a", "b", "c", "d", "1", "2", "3", "4"}: + return s + return None + + b = _norm_birads(birads) + if b: + return b, None, None + try: + if volpara is not None: + vp = float(volpara) + if 0.0 <= vp <= 100.0: + return None, vp, None + except (TypeError, ValueError): + pass + try: + if stratus is not None: + sp = float(stratus) + if 0.0 <= sp <= 100.0: + return None, None, sp + except (TypeError, ValueError): + pass + return None, None, None + + +def map_oc_use(fs: Any) -> str | None: + """Normalise oral contraceptive usage details to CanRisk codes. + + Args: + fs: Female-specific data object/dict. + + Returns: + str | None: Code or None when unknown. + """ + if fs is None: + return None + txt = getattr(fs, "oral_contraception", None) + if txt: + s = str(txt).strip().upper() + if s == "N": + return "N" + if (s.startswith("F:") or s.startswith("C:")) and s.split(":", 1)[1].isdigit(): + return s + status = (getattr(fs, "oc_status", None) or "").strip().lower() + years = getattr(fs, "oc_years", None) + if status == "never": + return "N" + if status in {"former", "current"} and isinstance(years, int) and years >= 0: + return ("F" if status == "former" else "C") + f":{years}" + return None + + +def map_prs_bc(genetics: Any) -> tuple[float | None, float | None]: + """Return PRS alpha and z-score values if present. + + Args: + genetics: Genetics object or dict. + + Returns: + tuple[float|None,float|None]: (alpha, z-score) or (None, None). + """ + if genetics is None: + return None, None + alpha = getattr(genetics, "prs_bc_alpha", None) or ( + genetics.get("prs_bc_alpha") if isinstance(genetics, dict) else None + ) + z = getattr(genetics, "prs_bc_zscore", None) or ( + genetics.get("prs_bc_zscore") if isinstance(genetics, dict) else None + ) + try: + a = float(alpha) if alpha is not None else None + zf = float(z) if z is not None else None + return a, zf + except (TypeError, ValueError): + return None, None + + +def map_bool_flag(x: Any) -> bool | None: + """Map common truthy/falsy markers to boolean values. + + Args: + x: Input value that may be ``bool`` or a string like "yes"/"no". + + Returns: + Boolean value if mapping is known, otherwise ``None``. + """ + if x is None: + return None + if isinstance(x, bool): + return x + s = str(x).strip().lower() + if s in {"y", "yes", "true", "1"}: + return True + if s in {"n", "no", "false", "0"}: + return False + return None + + +def map_proband_cancers(pmh: Any, current_age: int | None = None) -> dict[str, str]: + """Create CanRisk cancer columns for the proband from PMH records. + + Args: + pmh: Personal medical history object with cancer attributes. + current_age: Optional age to cap diagnosis ages for plausibility. + + Returns: + Mapping of CanRisk cancer site columns to ages as strings. + """ + columns = init_cancer_columns() + if pmh is None: + return columns + + def _safe_age(a: Any) -> str: + try: + ai = int(a) + if current_age is not None: + ai = min(ai, max(1, current_age)) + return str(max(1, ai)) + except Exception: + fallback = max( + 1, (current_age - 5) if current_age and current_age > 5 else 1 + ) + return str(fallback) + + cancers = getattr(pmh, "cancers", None) or getattr(pmh, "personal_cancers", None) + if cancers: + for entry in cancers: + site = (getattr(entry, "cancer_type", "") or "").lower() + age = getattr(entry, "age_at_diagnosis", None) + age_str = _safe_age(age) + add_cancer_to_columns(columns, site, age_str) + + previous_cancers = getattr(pmh, "previous_cancers", None) or [] + unknown_age_placeholder = _safe_age(None) + + for cancer_str in previous_cancers: + cancer_lower = cancer_str.lower() + add_cancer_to_columns(columns, cancer_lower, unknown_age_placeholder) + + return columns + + +class BOADICEAFamilyMember(BaseModel): + """Family member entry used for BOADICEA pedigree construction.""" + + relative: str + cancer_type: str + age_at_diagnosis: int | None = None + sex: str | None = None + age: int | None = None + placeholder: bool = False + additional_cancers: list[tuple[str, int | None]] = Field(default_factory=list) + + def add_cancer(self, cancer_type: str | None, age_at_diagnosis: int | None) -> None: + """Add a primary or additional cancer to this family member. + + Args: + cancer_type: Cancer site label to add (e.g., "breast"). + age_at_diagnosis: Age at diagnosis for the cancer, if known. + """ + cancer = (cancer_type or "").strip() + if not cancer: + return + if not (self.cancer_type or "").strip(): + self.cancer_type = cancer + self.age_at_diagnosis = age_at_diagnosis + return + self.additional_cancers.append((cancer, age_at_diagnosis)) + + def cancer_site_columns(self) -> dict[str, str]: + """Return CanRisk site columns populated from this member's cancers. + + Returns: + dict[str, str]: Mapping of site codes to age strings. + """ + columns = init_cancer_columns() + add_cancer_to_columns(columns, self.cancer_type, self.age_at_diagnosis) + for cancer, age in self.additional_cancers: + add_cancer_to_columns(columns, cancer, age) + return columns + + def inferred_sex(self) -> str: + """Infer sex from relation or cancer types when explicit sex missing. + + Returns: + str: "M" or "F" inferred from relation/cancer context. + """ + if self.sex: + return self.sex.upper()[0] + alias = match_relation(self.relative) + if alias and alias.default_sex: + return alias.default_sex + cancer = self.cancer_type.lower() if self.cancer_type else "" + if "prostate" in cancer: + return "M" + if "ovarian" in cancer: + return "F" + for extra_cancer, _ in self.additional_cancers: + site = extra_cancer.lower() + if "prostate" in site: + return "M" + if "ovarian" in site: + return "F" + return "F" + + +@dataclass +class AssignedFamilyMember: + """Assigned member with generated identifiers and canonical relation.""" + + member: BOADICEAFamilyMember + indiv_id: str + relation: str | None + + def __iter__(self): + yield from (self.member, self.indiv_id, self.relation) + + +class FamilyStructureBuilder: + """Builds a consistent family structure and parent links for CanRisk.""" + + def __init__(self, proband_sex: str): + """Initialise builder with proband sex for partner/coparent rules. + + Args: + proband_sex: "F" or "M". + """ + self.proband_sex = proband_sex + self.assignments: list[AssignedFamilyMember] = [] + self.parent_ids = { + "mother": "0", + "father": "0", + "mgf": "0", + "mgm": "0", + "pgf": "0", + "pgm": "0", + "partner": "0", + "coparent": "0", + } + self._next_index = 2 + + def add_member(self, member: BOADICEAFamilyMember) -> None: + """Add a family member to the structure and record parent if applicable. + + Args: + member: Family member to be incorporated into the structure. + """ + assignment = self._append_assignment(member) + self._record_parent(assignment) + + def ensure_core_parents(self) -> None: + """Ensure placeholders for mother and father exist in the structure.""" + for relation, sex in (("mother", "F"), ("father", "M")): + if self.parent_ids[relation] != "0": + continue + placeholder = BOADICEAFamilyMember( + relative=relation, + cancer_type="", + sex=sex, + age=0, + placeholder=True, + ) + assignment = self._append_assignment(placeholder) + self.parent_ids[relation] = assignment.indiv_id + + def ensure_grandparents(self) -> None: + """Ensure needed grandparent placeholders exist based on relatives.""" + if self._needs_maternal_grandparents(): + if self.parent_ids["mgf"] == "0": + assignment = self._create_placeholder("maternal grandfather", "M") + self.parent_ids["mgf"] = assignment.indiv_id + if self.parent_ids["mgm"] == "0": + assignment = self._create_placeholder("maternal grandmother", "F") + self.parent_ids["mgm"] = assignment.indiv_id + if self._needs_paternal_grandparents(): + if self.parent_ids["pgf"] == "0": + assignment = self._create_placeholder("paternal grandfather", "M") + self.parent_ids["pgf"] = assignment.indiv_id + if self.parent_ids["pgm"] == "0": + assignment = self._create_placeholder("paternal grandmother", "F") + self.parent_ids["pgm"] = assignment.indiv_id + + def ensure_partner_links(self) -> None: + """Ensure partner/coparent placeholders when children are present.""" + has_children = any( + a.relation in {"daughter", "son", "child"} for a in self.assignments + ) + partner_assignment = next( + (a for a in self.assignments if a.relation == "partner"), None + ) + + partner_sex: str | None = None + if partner_assignment: + self.parent_ids["partner"] = partner_assignment.indiv_id + raw_sex = partner_assignment.member.sex + partner_sex = raw_sex.upper()[0] if raw_sex else None + if not partner_sex: + partner_sex = "F" if self.proband_sex == "M" else "M" + partner_assignment.member.sex = partner_sex + + if not has_children: + return + + if not partner_assignment: + placeholder_sex = "F" if self.proband_sex == "M" else "M" + placeholder = self._create_placeholder("partner", placeholder_sex) + self.parent_ids["partner"] = placeholder.indiv_id + return + + if partner_sex == self.proband_sex and self.parent_ids["coparent"] == "0": + coparent_sex = "F" if self.proband_sex == "M" else "M" + coparent = self._create_placeholder("partner", coparent_sex) + self.parent_ids["coparent"] = coparent.indiv_id + + def build(self) -> tuple[list[AssignedFamilyMember], dict[str, str]]: + """Return assignments and mapping of parent identifiers. + + Returns: + tuple[list[AssignedFamilyMember], dict[str,str]]: The assignments and + parent ID mapping. + """ + self.ensure_core_parents() + self.ensure_grandparents() + self.ensure_partner_links() + return self.assignments, self.parent_ids + + def _append_assignment(self, member: BOADICEAFamilyMember) -> AssignedFamilyMember: + """Append a member and return its assignment record. + + Args: + member: Family member to assign. + + Returns: + AssignedFamilyMember: New assignment. + """ + relation = canonical_relation(member.relative) if member.relative else None + indiv_id = self._next_id() + assignment = AssignedFamilyMember( + member=member, indiv_id=indiv_id, relation=relation + ) + self.assignments.append(assignment) + return assignment + + def _create_placeholder( + self, relation_label: str, sex: str + ) -> AssignedFamilyMember: + """Create and append a placeholder relative of given relation/sex. + + Args: + relation_label: Relation string, e.g., "maternal grandmother". + sex: "M" or "F". + + Returns: + AssignedFamilyMember: Placeholder assignment. + """ + placeholder = BOADICEAFamilyMember( + relative=relation_label, + cancer_type="", + sex=sex, + age=0, + placeholder=True, + ) + assignment = self._append_assignment(placeholder) + self._record_parent(assignment) + return assignment + + def _record_parent(self, assignment: AssignedFamilyMember) -> None: + """Update parent id mapping when a parent-type relation is added. + + Args: + assignment: Assignment to consider for parent mapping. + """ + relation = assignment.relation + if relation == "mother" and self.parent_ids["mother"] == "0": + self.parent_ids["mother"] = assignment.indiv_id + elif relation == "father" and self.parent_ids["father"] == "0": + self.parent_ids["father"] = assignment.indiv_id + elif relation == "grandmother": + side = which_side(assignment.member.relative) or "maternal" + key = "mgm" if side == "maternal" else "pgm" + if self.parent_ids[key] == "0": + self.parent_ids[key] = assignment.indiv_id + elif relation == "grandfather": + side = which_side(assignment.member.relative) or "maternal" + key = "mgf" if side == "maternal" else "pgf" + if self.parent_ids[key] == "0": + self.parent_ids[key] = assignment.indiv_id + + def _needs_maternal_grandparents(self) -> bool: + """Return True if maternal grandparents are implied by relatives. + + Returns: + bool: True if maternal grandparents needed. + """ + return any( + assignment.relation in {"aunt", "uncle", "grandmother", "grandfather"} + and (which_side(assignment.member.relative) in {None, "maternal"}) + for assignment in self.assignments + ) + + def _needs_paternal_grandparents(self) -> bool: + """Return True if paternal grandparents are implied by relatives. + + Returns: + bool: True if paternal grandparents needed. + """ + return any( + assignment.relation in {"aunt", "uncle", "grandmother", "grandfather"} + and which_side(assignment.member.relative) == "paternal" + for assignment in self.assignments + ) + + def _next_id(self) -> str: + """Generate the next individual identifier (I2, I3, ...). + + Returns: + str: Identifier value. + """ + value = f"I{self._next_index}" + self._next_index += 1 + return value + + +class BOADICEAInput(BaseModel): + """Structured input payload for building a BOADICEA pedigree file.""" + + age: int + proband_sex: str = "F" # "F" for female, "M" for male + brca1_mutation: bool = False + brca2_mutation: bool = False + family_history_breast: list[dict[str, Any]] = Field(default_factory=list) + family_history_ovarian: list[dict[str, Any]] = Field(default_factory=list) + family_history: list[BOADICEAFamilyMember] = Field(default_factory=list) + age_at_first_period: int | None = None + age_at_first_live_birth: int | None = None + num_live_births: int | None = None + age_at_menopause: int | None = None + hormone_therapy_use: str | None = None + height: float | None = None + weight: float | None = None + ashkenazi_ancestry: bool = False + alcohol_grams_per_day: float | None = None + oc_use: str | None = None + birads: str | None = None + volpara_percent: float | None = None + stratus_percent: float | None = None + prs_bc_alpha: float | None = None + prs_bc_zscore: float | None = None + tubal_ligation: bool | None = None + endometriosis: bool | None = None + ethnicity_group: str | None = None + ethnicity_background: str | None = None + mut_freq: str = "UK" + cancer_rates: str = "UK" + personal_medical_history: Any = None + + @property + def bmi(self) -> float | None: + """Calculate BMI from height and weight if available. + + Returns: + float | None: BMI value or None. + """ + if self.height and self.weight: + return self.weight / (self.height**2) + return None + + @classmethod + def from_user_input(cls, user_input: UserInput) -> "BOADICEAInput": + """Create a `BOADICEAInput` from the general `UserInput` profile. + + Args: + user_input: The application-level user profile. + + Returns: + BOADICEAInput: Normalised input ready for pedigree file creation. + """ + mutations = [ + mutation.value.lower() + if hasattr(mutation, "value") + else str(mutation).lower() + for mutation in user_input.personal_medical_history.genetic_mutations + ] + brca1_mutation = any( + "brca1" in mutation_str or "brca-1" in mutation_str + for mutation_str in mutations + ) + brca2_mutation = any( + "brca2" in mutation_str or "brca-2" in mutation_str + for mutation_str in mutations + ) + + family_history_breast = [ + { + "relative": fh.relation.value, + "age_at_diagnosis": fh.age_at_diagnosis, + } + for fh in user_input.family_history + if fh.cancer_type == CancerType.BREAST + ] + + family_history_ovarian = [ + { + "relative": fh.relation.value, + "age_at_diagnosis": fh.age_at_diagnosis, + } + for fh in user_input.family_history + if fh.cancer_type == CancerType.OVARIAN + ] + + family_history_members: list[BOADICEAFamilyMember] = [] + members_by_key: dict[str, BOADICEAFamilyMember] = {} + + for idx, fh in enumerate(user_input.family_history): + sex_raw = getattr(fh, "sex", None) + norm_sex = str(sex_raw).strip().upper()[:1] if sex_raw else None + cancer_type = ( + fh.cancer_type.value + if hasattr(fh.cancer_type, "value") + else str(fh.cancer_type) + ) + age_value = getattr(fh, "age", None) + + key = ( + re.sub(r"\s+", " ", fh.relation.value.strip().lower()) + if fh.relation + else f"unknown-{idx}" + ) + + member = members_by_key.get(key) + if member: + member.add_cancer(cancer_type, fh.age_at_diagnosis) + if norm_sex: + member.sex = norm_sex + elif member.sex is None: + member.sex = infer_sex_from_relative(fh) + if member.age is None and age_value is not None: + member.age = age_value + else: + new_member = BOADICEAFamilyMember( + relative=fh.relation.value, + cancer_type=cancer_type, + age_at_diagnosis=fh.age_at_diagnosis, + sex=norm_sex or infer_sex_from_relative(fh), + age=age_value, + ) + members_by_key[key] = new_member + + family_history_members = list(members_by_key.values()) + + fs = user_input.female_specific + + # Handle ethnicity enum + ethnicity_str = None + if user_input.demographics.ethnicity: + ethnicity_str = ( + user_input.demographics.ethnicity.value + if hasattr(user_input.demographics.ethnicity, "value") + else str(user_input.demographics.ethnicity) + ) + + eth_group, eth_bg, ashkenazi_from_ons = map_ethnicity_ons(ethnicity_str) + + if eth_group and eth_bg: + ethnicity_group = eth_group + ethnicity_background = eth_bg + ashkenazi_ancestry = ashkenazi_from_ons + else: + ashkenazi_ancestry = False + ethnicity_group = None + ethnicity_background = None + + if ethnicity_str: + ethnicity_lower = ethnicity_str.lower() + ashkenazi_ancestry = "ashkenazi" in ethnicity_lower or bool( + re.search(r"\basj\b", ethnicity_lower) + ) + + if ashkenazi_ancestry or "jewish" in ethnicity_lower: + ethnicity_group = "White" + ethnicity_background = "Jewish" + elif "white" in ethnicity_lower: + ethnicity_group = "White" + ethnicity_background = "British" + elif "asian" in ethnicity_lower: + ethnicity_group = "Asian" + ethnicity_background = "Other" + elif "black" in ethnicity_lower: + ethnicity_group = "Black" + ethnicity_background = "Other" + + birads, volpara, stratus = map_density(getattr(user_input, "imaging", None)) + oc_code = map_oc_use(fs) + # Get BOADICEA polygenic risk scores from personal medical history + prs_alpha = ( + user_input.personal_medical_history.boadicea_polygenic_risk_score_alpha + ) + prs_z = user_input.personal_medical_history.boadicea_polygenic_risk_score_zscore + tl = map_bool_flag(getattr(fs, "tubal_ligation", None) if fs else None) + endo = map_bool_flag(getattr(fs, "endometriosis", None) if fs else None) + alcohol_grams_per_day = convert_alcohol_to_grams_per_day( + getattr(getattr(user_input, "lifestyle", None), "alcohol_consumption", None) + ) + + # Handle sex enum + proband_sex = "M" if user_input.demographics.sex == Sex.MALE else "F" + + return cls( + age=user_input.demographics.age_years, + proband_sex=proband_sex, + brca1_mutation=brca1_mutation, + brca2_mutation=brca2_mutation, + family_history_breast=family_history_breast, + family_history_ovarian=family_history_ovarian, + family_history=family_history_members, + age_at_first_period=fs.menstrual.age_at_menarche if fs else None, + age_at_first_live_birth=fs.parity.age_at_first_live_birth if fs else None, + num_live_births=fs.parity.num_live_births if fs else None, + age_at_menopause=fs.menstrual.age_at_menopause if fs else None, + hormone_therapy_use=fs.hormone_use.estrogen_use.value + if fs and fs.hormone_use.estrogen_use + else None, + height=user_input.demographics.anthropometrics.height_cm / 100 + if user_input.demographics.anthropometrics.height_cm + else None, + weight=user_input.demographics.anthropometrics.weight_kg, + ashkenazi_ancestry=ashkenazi_ancestry, + alcohol_grams_per_day=alcohol_grams_per_day, + oc_use=fs.hormone_use.oral_contraceptive_use if fs else None, + birads=birads, + volpara_percent=volpara, + stratus_percent=stratus, + prs_bc_alpha=prs_alpha, + prs_bc_zscore=prs_z, + tubal_ligation=tl, + endometriosis=endo, + ethnicity_group=ethnicity_group, + ethnicity_background=ethnicity_background, + mut_freq="UK", + cancer_rates="UK", + personal_medical_history=user_input.personal_medical_history, + ) + + +class CanRiskConfig(BaseModel): + """Configuration for CanRisk endpoints and authentication.""" + + base_url: str = "https://canrisk.org/" + auth_endpoint: str = "auth-token/" + boadicea_endpoint: str = "boadicea/" + username: str | None = None + password: str | None = None + timeout: int = 30 + + +class CanRiskAPIError(Exception): + """Raised for errors returned by the CanRisk API.""" + + +class CanRiskClient: + """Client for interacting with CanRisk API.""" + + def __init__(self, config: CanRiskConfig | None = None): + self.config = config or self._load_config_from_env() + self._auth_token: str | None = None + self.session = requests.Session() + self.session.headers.update({"Accept": "application/json"}) + + def _load_config_from_env(self) -> CanRiskConfig: + """Create `CanRiskConfig` using environment variable values. + + Returns: + CanRiskConfig: Populated configuration. + """ + return CanRiskConfig( + username=os.getenv("CANRISK_USERNAME"), + password=os.getenv("CANRISK_PASSWORD"), + base_url=os.getenv("CANRISK_BASE_URL", "https://canrisk.org/"), + ) + + def authenticate(self) -> str: + """Authenticate with CanRisk and store the token in session headers. + + Returns: + str: Authentication token value. + + Raises: + CanRiskAPIError: If authentication fails or returns no token. + """ + if not self.config.username or not self.config.password: + raise CanRiskAPIError( + "Username and password are required for authentication" + ) + + auth_url = f"{self.config.base_url}{self.config.auth_endpoint}" + try: + resp = self.session.post( + auth_url, + json={ + "username": self.config.username, + "password": self.config.password, + }, + timeout=self.config.timeout, + headers={"Content-Type": "application/json"}, + ) + resp.raise_for_status() + data = resp.json() + token = data.get("token") or data.get("access_token") + if not token: + raise CanRiskAPIError("No token in authentication response") + self._auth_token = token + self.session.headers.update({"Authorization": f"Token {token}"}) + return token + except requests.RequestException as e: + raise CanRiskAPIError(f"Authentication failed: {e}") + + def submit_boadicea_assessment( + self, boadicea_input: BOADICEAInput, user_id: str | None = None + ) -> dict[str, Any]: + """Submit a BOADICEA assessment request and return the JSON response. + + Args: + boadicea_input: Structured input for BOADICEA. + user_id: Optional user identifier to include in the payload. + + Returns: + dict[str, Any]: Parsed JSON response or {"raw": text} fallback. + + Raises: + CanRiskAPIError: When the request fails or returns an error status. + """ + if not self._auth_token: + self.authenticate() + + boadicea_url = f"{self.config.base_url}{self.config.boadicea_endpoint}" + pedigree_content = self._create_pedigree_file(boadicea_input) + + payload = { + "mut_freq": boadicea_input.mut_freq + if boadicea_input.mut_freq in ALLOWED_COUNTRIES + else "UK", + "cancer_rates": boadicea_input.cancer_rates + if boadicea_input.cancer_rates in ALLOWED_COUNTRIES + else "UK", + "user_id": user_id or str(uuid.uuid4()), + "pedigree_data": pedigree_content, + } + + try: + resp = self.session.post( + boadicea_url, json=payload, timeout=self.config.timeout + ) + if resp.headers.get("Content-Type", "").startswith("application/json"): + data = resp.json() + else: + data = {"raw": resp.text} + + if resp.ok: + return data + raise CanRiskAPIError(f"BOADICEA API error {resp.status_code}: {data}") + except requests.RequestException as e: + raise CanRiskAPIError(f"BOADICEA request failed: {e}") + + def _create_pedigree_file(self, x: BOADICEAInput) -> str: + """Build a CanRisk v3 pedigree file as a string (TAB-delimited). + + Args: + x: BOADICEAInput containing user/family data. + + Returns: + str: Pedigree file content. + """ + assignments, parents = self._assign_family_members(x) + header_lines = self._build_header_lines(x) + proband_row = self._build_proband_row(x, parents) + relative_rows = self._build_relative_rows(x, assignments, parents) + return "\n".join([*header_lines, proband_row, *relative_rows]) + + def _assign_family_members( + self, x: BOADICEAInput + ) -> tuple[list[AssignedFamilyMember], dict[str, str]]: + """Assign relatives to identifiers and return assignments and parents. + + Args: + x: BOADICEAInput with family history members. + + Returns: + tuple[list[AssignedFamilyMember], dict[str,str]]: Assignments and parents. + """ + builder = FamilyStructureBuilder(x.proband_sex) + for member in x.family_history: + builder.add_member(member) + assignments, parents = builder.build() + return assignments, parents + + def _build_header_lines(self, x: BOADICEAInput) -> list[str]: + """Build header lines for the CanRisk pedigree file. + + Args: + x: BOADICEAInput to extract header factors from. + + Returns: + list[str]: Header lines. + """ + header_lines: list[str] = ["##CanRisk 3.0"] + + if x.ethnicity_group and x.ethnicity_background: + header_lines.append( + f"##Ethnicity={x.ethnicity_group};{x.ethnicity_background}" + ) + + # Female-specific headers only for female probands + if x.proband_sex == "F": + if x.age_at_first_period is not None: + header_lines.append(f"##Menarche={x.age_at_first_period}") + if x.num_live_births is not None: + header_lines.append(f"##Parity={x.num_live_births}") + if x.age_at_first_live_birth is not None: + header_lines.append(f"##First_live_birth={x.age_at_first_live_birth}") + oc = normalise_oc_use(x.oc_use) + if oc: + header_lines.append(f"##OC_use={oc}") + try: + mht = normalise_hormone_therapy_use(x.hormone_therapy_use) + if mht: + header_lines.append(f"##MHT_use={mht}") + except ValueError: + pass + header_lines.append( + f"##Menopause={x.age_at_menopause}" + if x.age_at_menopause is not None + else "##Menopause=N" + ) + + # General headers for all probands + if x.bmi is not None: + header_lines.append(f"##BMI={x.bmi:.2f}") + if x.height is not None: + header_lines.append(f"##height={round(x.height * 100)}") + if x.alcohol_grams_per_day is not None: + header_lines.append(f"##Alcohol={x.alcohol_grams_per_day:.1f}") + # Mammographic density (applicable to all probands for breast cancer risk) + birads = normalise_birads(x.birads) + if birads: + header_lines.append(f"##BIRADS={birads}") + elif x.volpara_percent is not None: + header_lines.append(f"##Volpara={x.volpara_percent:.1f}") + elif x.stratus_percent is not None: + header_lines.append(f"##Stratus={x.stratus_percent:.1f}") + + # Female-specific ovarian risk factors + if x.proband_sex == "F": + if x.tubal_ligation is not None: + header_lines.append(f"##TL={'Y' if x.tubal_ligation else 'N'}") + if x.endometriosis is not None: + header_lines.append(f"##Endo={'Y' if x.endometriosis else 'N'}") + + # Polygenic risk score (applicable to all) + if x.prs_bc_alpha is not None and x.prs_bc_zscore is not None: + header_lines.append( + f"##PRS_BC=alpha={x.prs_bc_alpha}, zscore={x.prs_bc_zscore}" + ) + + header_lines.append(CANRISK_V3_COLUMNS) + return header_lines + + def _build_proband_row(self, x: BOADICEAInput, parents: dict[str, str]) -> str: + """Construct the proband row for the pedigree file. + + Args: + x: BOADICEAInput for the proband. + parents: Mapping of parent identifiers (mother/father keys). + + Returns: + str: TAB-delimited proband row. + """ + fam_id = "F001" + name = "P1" # <= 8 chars + indiv_id = "I1" # <= 7 chars + fath_id = parents.get("father", "0") or "0" + moth_id = parents.get("mother", "0") or "0" + sex = x.proband_sex + mztwin = "0" + dead = "0" + age = str(x.age) + yob = self._year_of_birth(x.age) + + proband_cols = map_proband_cancers(x.personal_medical_history, x.age) + bc1 = proband_cols["BC1"] + bc2 = proband_cols["BC2"] + oc = proband_cols["OC"] + pro = proband_cols["PRO"] + pan = proband_cols["PAN"] + ashkn = "1" if x.ashkenazi_ancestry else "0" + + brca1 = gene_field(x.brca1_mutation) + brca2 = gene_field(x.brca2_mutation) + palb2 = atm = chek2 = bard1 = rad51d = rad51c = brip1 = "0:0" + + receptors = "0:0:0:0:0" + + row_fields = [ + fam_id, + name, + "1", + indiv_id, + fath_id, + moth_id, + sex, + mztwin, + dead, + age, + yob, + bc1, + bc2, + oc, + pro, + pan, + ashkn, + brca1, + brca2, + palb2, + atm, + chek2, + bard1, + rad51d, + rad51c, + brip1, + receptors, + ] + return "\t".join(row_fields) + + def _build_relative_rows( + self, + x: BOADICEAInput, + assignments: list[AssignedFamilyMember], + parents: dict[str, str], + ) -> list[str]: + """Construct rows for relatives in the pedigree file. + + Args: + x: BOADICEAInput for the proband context. + assignments: List of assigned relatives. + parents: Mapping of parent identifiers. + + Returns: + list[str]: TAB-delimited relative rows. + """ + rows: list[str] = [] + if not assignments: + return rows + + for assignment in assignments: + member = assignment.member + indiv_id = assignment.indiv_id + relation = assignment.relation + fam_id = "F001" + name = self._sanitize_relative_name(member.relative) + target = "0" + fath_id = "0" + moth_id = "0" + sex = member.inferred_sex() + mztwin = "0" + dead = "0" + + relation_name = relation or ( + canonical_relation(member.relative) if member.relative else relation + ) + base_age = member.age or 0 + + diagnosis_age = member.age_at_diagnosis or 0 + age_value = max(base_age, diagnosis_age) if base_age else diagnosis_age + age = str(age_value or 0) + yob = self._year_of_birth(int(age_value)) if age_value else "0" + + if relation_name == "mother": + fath_id = parents.get("mgf", "0") or "0" + moth_id = parents.get("mgm", "0") or "0" + elif relation_name == "father": + fath_id = parents.get("pgf", "0") or "0" + moth_id = parents.get("pgm", "0") or "0" + elif relation_name in {"sister", "brother"}: + fath_id = parents.get("father", "0") or "0" + moth_id = parents.get("mother", "0") or "0" + elif relation_name in {"daughter", "son", "child"}: + partner_id = parents.get("partner", "0") or "0" + coparent_id = parents.get("coparent", "0") or "0" + if partner_id == "0" and coparent_id == "0": + continue + + proband_id = "I1" + # Prefer biologically consistent parent roles if a coparent is present + if x.proband_sex == "F": + fath_id = coparent_id if coparent_id != "0" else partner_id + moth_id = proband_id + else: + fath_id = proband_id + moth_id = coparent_id if coparent_id != "0" else partner_id + elif relation_name == "partner": + fath_id = "0" + moth_id = "0" + elif relation_name in {"aunt", "uncle"}: + side = which_side(member.relative) or "maternal" + if side == "maternal": + fath_id = parents.get("mgf", "0") or "0" + moth_id = parents.get("mgm", "0") or "0" + else: + fath_id = parents.get("pgf", "0") or "0" + moth_id = parents.get("pgm", "0") or "0" + elif relation_name in {"grandmother", "grandfather"}: + fath_id = "0" + moth_id = "0" + else: + continue + + cancers = member.cancer_site_columns() + bc1 = cancers["BC1"] + bc2 = cancers["BC2"] + oc = cancers["OC"] + pro = cancers["PRO"] + pan = cancers["PAN"] + ashkn = "0" + + gene_default = "0:0" + receptors = "0:0:0:0:0" + + row_fields = [ + fam_id, + name, + target, + indiv_id, + fath_id, + moth_id, + sex, + mztwin, + dead, + age, + yob, + bc1, + bc2, + oc, + pro, + pan, + ashkn, + gene_default, + gene_default, + gene_default, + gene_default, + gene_default, + gene_default, + gene_default, + gene_default, + gene_default, + receptors, + ] + rows.append("\t".join(row_fields)) + + return rows + + @staticmethod + def _sanitize_relative_name(relative: str) -> str: + """Sanitize a relative's name to be used in the pedigree file. + + Args: + relative: The relative's name from the input. + + Returns: + str: Sanitized name. + """ + stripped = relative.strip() + if not stripped: + return "Unknown" + compact = stripped.title().replace(" ", "") + return compact[:20] + + @staticmethod + def _year_of_birth(age: int) -> str: + current_year = date.today().year + return str(max(0, current_year - age)) + + def is_authenticated(self) -> bool: + """Return True if an auth token is present for the client session. + + Returns: + bool: True if authenticated. + """ + return self._auth_token is not None + + def close(self) -> None: + """Close the underlying HTTP session.""" + self.session.close() diff --git a/src/sentinel/config.py b/src/sentinel/config.py new file mode 100644 index 0000000000000000000000000000000000000000..1d0cd30d036c9ab1a5f5ae095437fff4e7319f15 --- /dev/null +++ b/src/sentinel/config.py @@ -0,0 +1,33 @@ +"""Configuration models and resource path helpers for the app.""" + +from pathlib import Path + +from pydantic import BaseModel, Field + + +class ModelConfig(BaseModel): + """Model provider and name settings.""" + + provider: str + model_name: str + + +class ResourcePaths(BaseModel): + """Filesystem paths for persona, prompts, and knowledge base assets.""" + + persona: Path + instruction_assessment: Path + instruction_conversation: Path + output_format_assessment: Path + output_format_conversation: Path + cancer_modules_dir: Path + dx_protocols_dir: Path + + +class AppConfig(BaseModel): + """Top-level application configuration container.""" + + model: ModelConfig + knowledge_base_paths: ResourcePaths + selected_cancer_modules: list[str] = Field(default_factory=list) + selected_dx_protocols: list[str] = Field(default_factory=list) diff --git a/src/sentinel/conversation.py b/src/sentinel/conversation.py new file mode 100644 index 0000000000000000000000000000000000000000..7e3c4ecd251755ba740e2e7a0a1a28dec7bc8e5d --- /dev/null +++ b/src/sentinel/conversation.py @@ -0,0 +1,91 @@ +"""Conversation manager wrapping structured and freeform chains.""" + +from dataclasses import dataclass, field + +from langchain_core.chat_history import InMemoryChatMessageHistory +from langchain_core.messages import get_buffer_string +from langchain_core.runnables.base import Runnable + +from .llm_service import extract_thinking +from .models import ConversationResponse, InitialAssessment, UserInput + + +@dataclass +class ConversationManager: + """Coordinates structured and conversational chains and keeps chat history.""" + + structured_chain: Runnable + freeform_chain: Runnable + user_json: str = field(init=False, default="") + chat_history: InMemoryChatMessageHistory = field(init=False) + + def __post_init__(self) -> None: + self.chat_history = InMemoryChatMessageHistory() + + @property + def history(self) -> list[tuple[str, str]]: + """Return conversation turns as list of (human, ai) pairs. + + Returns: + A list of (user_message, ai_message) tuples. + """ + pairs = [] + messages = self.chat_history.messages + for i in range(0, len(messages), 2): + human = messages[i].content if i < len(messages) else "" + ai = messages[i + 1].content if i + 1 < len(messages) else "" + pairs.append((human, ai)) + return pairs + + def initial_assessment(self, user: UserInput) -> InitialAssessment: + """Run the structured assessment chain and record the exchange. + + Args: + user: The user profile to assess. + + Returns: + The structured InitialAssessment result. + """ + self.user_json = user.model_dump_json() + prompt = self.structured_chain.prompt.format(user_data=self.user_json) + result = self.structured_chain.invoke({"user_data": self.user_json}) + if isinstance(result, InitialAssessment): + data = result + else: + data = InitialAssessment.model_validate(result) + + # Add to history as a new interaction + self.chat_history.add_user_message(prompt) + self.chat_history.add_ai_message(data.model_dump_json()) + return data + + def follow_up(self, question: str) -> ConversationResponse: + """Generate a conversational response using chat history context. + + Args: + question: The user's follow-up question. + + Returns: + A ConversationResponse containing the reply and optional thinking. + """ + # Prepare history + history_str = get_buffer_string(self.chat_history.messages) + + # Invoke chain and get raw response + result = self.freeform_chain.invoke( + { + "question": question, + "history": history_str, + } + ) + + # Extract block and clean the response content + raw_content = result.content if hasattr(result, "content") else str(result) + thinking, clean_response = extract_thinking(raw_content) + + # Update history + self.chat_history.add_user_message(question) + self.chat_history.add_ai_message(clean_response) + + # Return structured response + return ConversationResponse(response=clean_response, thinking=thinking) diff --git a/src/sentinel/factory.py b/src/sentinel/factory.py new file mode 100644 index 0000000000000000000000000000000000000000..5fadf48ca96634845006bc7c00a84084247e2db1 --- /dev/null +++ b/src/sentinel/factory.py @@ -0,0 +1,42 @@ +"""Factory utilities to construct the application runtime components.""" + +from .config import AppConfig +from .conversation import ConversationManager +from .knowledge import KnowledgeBase +from .llm_service import create_conversational_chain, create_initial_assessment_chain +from .prompting import PromptBuilder + + +class SentinelFactory: + """Assembles `ConversationManager` and prompts from configuration.""" + + def __init__(self, config: AppConfig): + self.config = config + self.kb = KnowledgeBase( + config.knowledge_base_paths, + config.selected_cancer_modules, + config.selected_dx_protocols, + ) + self.prompt_builder = PromptBuilder(self.kb) + + def create_conversation_manager(self) -> ConversationManager: + """Create and return an initialised `ConversationManager`. + + Returns: + A ConversationManager configured with structured and freeform chains. + """ + structured_chain_prompt = self.prompt_builder.build_assessment_prompt() + freeform_chain_prompt = self.prompt_builder.build_conversational_prompt() + + structured_chain = create_initial_assessment_chain( + self.config.model.provider, + self.config.model.model_name, + structured_chain_prompt, + ) + freeform_chain = create_conversational_chain( + self.config.model.provider, + self.config.model.model_name, + freeform_chain_prompt, + ) + + return ConversationManager(structured_chain, freeform_chain) diff --git a/src/sentinel/knowledge.py b/src/sentinel/knowledge.py new file mode 100644 index 0000000000000000000000000000000000000000..3a332336f8d72b5d5bd161b881c88e2857fbbbf2 --- /dev/null +++ b/src/sentinel/knowledge.py @@ -0,0 +1,43 @@ +"""Knowledge base model primitives for cancer modules and protocols.""" + +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import yaml + +if TYPE_CHECKING: + from .config import ResourcePaths + + +class KnowledgeBase: + """Loads persona, prompts, and selected knowledge base YAML content.""" + + def __init__( + self, + paths: "ResourcePaths", + selected_modules: list[str], + selected_protocols: list[str], + ): + self.persona = paths.persona.read_text() + self.instruction_assessment = paths.instruction_assessment.read_text() + self.instruction_conversation = paths.instruction_conversation.read_text() + self.output_format_assessment_template = ( + paths.output_format_assessment.read_text() + ) + self.output_format_conversation = paths.output_format_conversation.read_text() + + self.cancer_modules: dict[str, Any] = self._load_yaml_files( + paths.cancer_modules_dir, selected_modules + ) + self.dx_protocols: dict[str, Any] = self._load_yaml_files( + paths.dx_protocols_dir, selected_protocols + ) + + def _load_yaml_files(self, directory: Path, selection: list[str]) -> dict[str, Any]: + data = {} + for item in selection: + file_path = directory / f"{item}.yaml" + if file_path.exists(): + with open(file_path) as f: + data[item] = yaml.safe_load(f) + return data diff --git a/src/sentinel/llm_service.py b/src/sentinel/llm_service.py new file mode 100644 index 0000000000000000000000000000000000000000..b934c5fb926a142af618e23c8eac260d2e23604d --- /dev/null +++ b/src/sentinel/llm_service.py @@ -0,0 +1,132 @@ +"""LLM utilities and chain constructors for Sentinel.""" + +import os +import re + +from dotenv import load_dotenv +from langchain.output_parsers import OutputFixingParser +from langchain_core.messages import AIMessage +from langchain_core.output_parsers import JsonOutputParser +from langchain_core.prompts import PromptTemplate +from langchain_core.runnables import Runnable, RunnableLambda +from langchain_google_genai import ChatGoogleGenerativeAI +from langchain_ollama import ChatOllama +from langchain_openai import ChatOpenAI + +from .models import InitialAssessment + +load_dotenv() + + +def extract_thinking(text: str) -> tuple[str | None, str]: + """Extract chain-of-thought content and strip it from the message body. + + Args: + text: The full model message text containing optional .... + + Returns: + A tuple of (thinking, remaining_text) where thinking is the extracted + content or None if not present, and remaining_text is the message with + the think block removed. + """ + match = re.search(r"(.*?)", text, re.DOTALL) + if match: + thinking = match.group(1).strip() + # Remove the think block from the original text + remaining_text = text.replace(match.group(0), "").strip() + return thinking, remaining_text + return None, text + + +def get_llm(provider: str, model: str | None = None, **kwargs): + """Return a chat model for the given provider. + + Args: + provider: Provider key, e.g. "openai", "google", or "local". + model: Optional model name. + **kwargs: Additional model-specific keyword arguments. + + Returns: + A LangChain chat model instance. + + Raises: + ValueError: If a required API key is missing, or provider is unknown. + """ + if provider == "local": + return ChatOllama(model=model, **kwargs) + if provider == "google": + if not os.getenv("GOOGLE_API_KEY"): + raise ValueError("GOOGLE_API_KEY environment variable not set") + return ChatGoogleGenerativeAI(model=model, **kwargs) + if provider == "openai": + if not os.getenv("OPENAI_API_KEY"): + raise ValueError("OPENAI_API_KEY environment variable not set") + return ChatOpenAI(model=model, **kwargs) + raise ValueError(f"Unknown provider: {provider}") + + +def create_initial_assessment_chain( + provider: str, model_name: str, prompt: PromptTemplate +) -> Runnable: + """Create chain for structured initial cancer risk assessment. + + Args: + provider: Model provider key. + model_name: The model identifier to use with the provider. + prompt: PromptTemplate for the assessment. + + Returns: + Runnable chain that produces an InitialAssessment object. + """ + llm = get_llm(provider, model_name) + + # Always use structured output for assessments + parser = JsonOutputParser(pydantic_object=InitialAssessment) + output_fixing_parser = OutputFixingParser.from_llm(parser=parser, llm=llm) + + def _process_output(message: AIMessage) -> InitialAssessment: + """Extract `` block, parse the JSON payload, and merge outputs. + + Args: + message: The raw AI message containing JSON and optional think block. + + Returns: + Parsed InitialAssessment with `thinking` populated when present. + """ + thinking_content, clean_content = extract_thinking(message.content) + + # The parser expects a message, so we create a new one with the cleaned content + clean_message = AIMessage(content=clean_content, id=message.id) + + parsed_obj = output_fixing_parser.invoke(clean_message) + + # Add the extracted thinking content to the final Pydantic object + if isinstance(parsed_obj, InitialAssessment): + parsed_obj.thinking = thinking_content + + return parsed_obj + + chain = prompt | llm | RunnableLambda(_process_output) + object.__setattr__(chain, "prompt", prompt) + return chain + + +def create_conversational_chain( + provider: str, model_name: str, prompt: PromptTemplate +) -> Runnable: + """Create chain for conversational follow-up responses. + + Args: + provider: Model provider key. + model_name: The model identifier to use with the provider. + prompt: PromptTemplate for conversation. + + Returns: + Runnable chain that returns plain text responses. + """ + llm = get_llm(provider, model_name) + + # Always return plain text for conversations + chain = prompt | llm + object.__setattr__(chain, "prompt", prompt) + return chain diff --git a/src/sentinel/models.py b/src/sentinel/models.py new file mode 100644 index 0000000000000000000000000000000000000000..0ae199afbff29997cc3943df48003f8bacca55ea --- /dev/null +++ b/src/sentinel/models.py @@ -0,0 +1,1905 @@ +"""Pydantic models and enums used across the Sentinel application.""" + +import re +from collections.abc import Iterable, Sequence +from enum import Enum, IntEnum +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, computed_field, model_validator + +# --------------------------------------------------------------------------- +# Helper utilities +# --------------------------------------------------------------------------- + + +def _normalize_str(value: Any | None) -> str | None: + if value is None: + return None + if isinstance(value, str): + return value.strip() + else: + text = str(value).strip() + return text or None + + +def _normalize_key(value: str | None) -> str: + if value is None: + return "" + return "".join(ch for ch in value.lower() if ch.isalnum()) + + +def _parse_float(value: Any) -> float | None: + if value is None: + return None + if isinstance(value, int | float): + return float(value) + text = str(value).strip() + if not text: + return None + try: + return float(text) + except ValueError: + match = re.search(r"-?\d+(?:\.\d+)?", text.replace(",", "")) + if match: + try: + return float(match.group(0)) + except ValueError: + return None + return None + + +def _listify(value: Any | None) -> list[Any]: + if value is None: + return [] + if isinstance(value, list): + return value + return [value] + + +# --------------------------------------------------------------------------- +# Base model +# --------------------------------------------------------------------------- + + +class SentinelBaseModel(BaseModel): + """Shared configuration for Sentinel data models.""" + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + str_strip_whitespace=True, + ) + + +# --------------------------------------------------------------------------- +# Enumerations and canonical codes +# --------------------------------------------------------------------------- + + +class RiskFactorCategory(str, Enum): + """Enumerates supported categories for risk factors.""" + + LIFESTYLE = "Lifestyle" + FAMILY_HISTORY = "Family History" + PERSONAL_MEDICAL = "Personal Medical History" + DEMOGRAPHICS = "Demographics" + FEMALE_SPECIFIC = "Female-Specific" + CLINICAL_OBSERVATION = "Clinical Observation" + OTHER = "Other" + + +class ContributionStrength(str, Enum): + """Relative contribution strength of a factor to risk.""" + + MAJOR = "Major" + MODERATE = "Moderate" + MINOR = "Minor" + + +class Sex(str, Enum): + """Enumerated sex values with flexible normalisation.""" + + FEMALE = "female" + MALE = "male" + INTERSEX = "intersex" + NON_BINARY = "non-binary" + OTHER = "other" + UNKNOWN = "unknown" + + @classmethod + def normalize(cls, value: Any | None) -> "Sex": + """Normalize input value to Sex enum. + + Args: + value: Input value to normalize. + + Returns: + Normalized Sex enum value. + """ + if isinstance(value, cls): + return value + text = _normalize_str(value) + if not text: + return cls.UNKNOWN + lowered = text.lower() + synonyms = { + "f": cls.FEMALE, + "woman": cls.FEMALE, + "female": cls.FEMALE, + "m": cls.MALE, + "man": cls.MALE, + "male": cls.MALE, + "nb": cls.NON_BINARY, + "nonbinary": cls.NON_BINARY, + "non-binary": cls.NON_BINARY, + "intersex": cls.INTERSEX, + "other": cls.OTHER, + "prefer_not_to_say": cls.UNKNOWN, + "unknown": cls.UNKNOWN, + } + if lowered in cls._value2member_map_: + return cls(lowered) + return synonyms.get(lowered, cls.OTHER) + + +class EducationLevel(IntEnum): + """Formal education level codes.""" + + UNKNOWN = 0 + LESS_THAN_HIGH_SCHOOL = 1 + HIGH_SCHOOL = 2 + SOME_COLLEGE = 3 + COLLEGE_GRADUATE = 4 + POSTGRADUATE = 5 + + @classmethod + def normalize(cls, value: Any | None) -> "EducationLevel | None": + """Normalize input value to EducationLevel enum. + + Args: + value: Input value to normalize. + + Returns: + Normalized EducationLevel enum value or None. + """ + if value is None: + return None + if isinstance(value, cls): + return value + try: + candidate = int(value) + except (TypeError, ValueError): + text = _normalize_str(value) + if not text: + return None + lowered = text.lower() + mapping = { + "less_than_high_school": cls.LESS_THAN_HIGH_SCHOOL, + "less than high school": cls.LESS_THAN_HIGH_SCHOOL, + "high_school": cls.HIGH_SCHOOL, + "high school": cls.HIGH_SCHOOL, + "ged": cls.HIGH_SCHOOL, + "some_college": cls.SOME_COLLEGE, + "some college": cls.SOME_COLLEGE, + "college": cls.COLLEGE_GRADUATE, + "college graduate": cls.COLLEGE_GRADUATE, + "bachelors": cls.COLLEGE_GRADUATE, + "undergraduate": cls.COLLEGE_GRADUATE, + "graduate": cls.POSTGRADUATE, + "postgraduate": cls.POSTGRADUATE, + "masters": cls.POSTGRADUATE, + "doctorate": cls.POSTGRADUATE, + "doctoral": cls.POSTGRADUATE, + "phd": cls.POSTGRADUATE, + "md": cls.POSTGRADUATE, + } + return mapping.get(lowered) + if candidate in cls._value2member_map_: + return cls(candidate) + return None + + +class SmokingStatus(str, Enum): + """Smoking status categories.""" + + NEVER = "never" + FORMER = "former" + CURRENT = "current" + + @classmethod + def normalize(cls, value: Any | None) -> "SmokingStatus": + """Normalize input value to SmokingStatus enum. + + Args: + value: Input value to normalize. + + Returns: + Normalized SmokingStatus enum value. + """ + if isinstance(value, cls): + return value + text = _normalize_str(value) + if not text: + return cls.NEVER + lowered = text.lower() + mapping = { + "non-smoker": cls.NEVER, + "nonsmoker": cls.NEVER, + "never": cls.NEVER, + "no": cls.NEVER, + "0": cls.NEVER, + "former": cls.FORMER, + "ex-smoker": cls.FORMER, + "ex": cls.FORMER, + "previous": cls.FORMER, + "quit": cls.FORMER, + "current": cls.CURRENT, + "smoker": cls.CURRENT, + "yes": cls.CURRENT, + } + if lowered in mapping: + return mapping[lowered] + if lowered in cls._value2member_map_: + return cls(lowered) + return cls.CURRENT + + +class AlcoholUse(str, Enum): + """Alcohol consumption categories.""" + + NONE = "none" + LIGHT = "light" + MODERATE = "moderate" + HEAVY = "heavy" + + @classmethod + def normalize(cls, value: Any | None) -> "AlcoholUse": + """Normalize input value to AlcoholUse enum. + + Args: + value: Input value to normalize. + + Returns: + Normalized AlcoholUse enum value. + """ + if isinstance(value, cls): + return value + text = _normalize_str(value) + if not text: + return cls.NONE + lowered = text.lower() + mapping = { + "no": cls.NONE, + "none": cls.NONE, + "abstain": cls.NONE, + "teetotal": cls.NONE, + "light": cls.LIGHT, + "moderate": cls.MODERATE, + "medium": cls.MODERATE, + "heavy": cls.HEAVY, + "high": cls.HEAVY, + "excessive": cls.HEAVY, + } + if lowered in mapping: + return mapping[lowered] + if lowered in cls._value2member_map_: + return cls(lowered) + return cls.MODERATE + + +class PhysicalActivityLevel(str, Enum): + """Physical activity intensity levels.""" + + SEDENTARY = "sedentary" + LOW = "low" + MODERATE = "moderate" + HIGH = "high" + VIGOROUS = "vigorous" + + @classmethod + def normalize(cls, value: Any | None) -> "PhysicalActivityLevel | None": + """Normalize input value to PhysicalActivityLevel enum. + + Args: + value: Input value to normalize. + + Returns: + Normalized PhysicalActivityLevel enum value or None. + """ + if value is None: + return None + if isinstance(value, cls): + return value + text = _normalize_str(value) + if not text: + return None + lowered = text.lower() + mapping = { + "none": cls.SEDENTARY, + "inactive": cls.SEDENTARY, + "sedentary": cls.SEDENTARY, + "low": cls.LOW, + "light": cls.LOW, + "moderate": cls.MODERATE, + "medium": cls.MODERATE, + "high": cls.HIGH, + "intense": cls.VIGOROUS, + "vigorous": cls.VIGOROUS, + } + if lowered in mapping: + return mapping[lowered] + if lowered in cls._value2member_map_: + return cls(lowered) + return None + + +class FamilySide(str, Enum): + """Family pedigree sides used in risk models.""" + + MATERNAL = "maternal" + PATERNAL = "paternal" + UNKNOWN = "unknown" + + +class RelationshipDegree(IntEnum): + """Degree of familial relation.""" + + FIRST = 1 + SECOND = 2 + THIRD = 3 + + +class FamilyRelation(str, Enum): + """Enumerated familial relationships.""" + + SELF = "self" + MOTHER = "mother" + FATHER = "father" + SISTER = "sister" + BROTHER = "brother" + HALF_SISTER = "half_sister" + HALF_BROTHER = "half_brother" + MATERNAL_HALF_SISTER = "maternal_half_sister" + PATERNAL_HALF_SISTER = "paternal_half_sister" + DAUGHTER = "daughter" + SON = "son" + MATERNAL_AUNT = "maternal_aunt" + MATERNAL_UNCLE = "maternal_uncle" + PATERNAL_AUNT = "paternal_aunt" + PATERNAL_UNCLE = "paternal_uncle" + MATERNAL_GRANDMOTHER = "maternal_grandmother" + MATERNAL_GRANDFATHER = "maternal_grandfather" + PATERNAL_GRANDMOTHER = "paternal_grandmother" + PATERNAL_GRANDFATHER = "paternal_grandfather" + NIECE = "niece" + NEPHEW = "nephew" + COUSIN = "cousin" + OTHER = "other" + + @classmethod + def normalize(cls, value: Any | None) -> "FamilyRelation": + """Normalize input value to FamilyRelation enum. + + Args: + value: Input value to normalize. + + Returns: + Normalized FamilyRelation enum value. + """ + if isinstance(value, cls): + return value + text = _normalize_str(value) + if not text: + return cls.OTHER + lowered = text.replace("-", "_").replace(" ", "_").lower() + if lowered in cls._value2member_map_: + return cls(lowered) + mapping = { + "mom": cls.MOTHER, + "mum": cls.MOTHER, + "mother": cls.MOTHER, + "dad": cls.FATHER, + "father": cls.FATHER, + "sis": cls.SISTER, + "full_sister": cls.SISTER, + "full sister": cls.SISTER, + "bro": cls.BROTHER, + "maternal aunt": cls.MATERNAL_AUNT, + "mother sister": cls.MATERNAL_AUNT, + "maternal uncle": cls.MATERNAL_UNCLE, + "paternal aunt": cls.PATERNAL_AUNT, + "paternal uncle": cls.PATERNAL_UNCLE, + # ambiguous aunt/uncle default to 'other' to avoid incorrect sides + "aunt": cls.OTHER, + "uncle": cls.OTHER, + "maternal_half_sister": cls.MATERNAL_HALF_SISTER, + "maternal half-sister": cls.MATERNAL_HALF_SISTER, + "paternal_half_sister": cls.PATERNAL_HALF_SISTER, + "paternal half-sister": cls.PATERNAL_HALF_SISTER, + "mgm": cls.MATERNAL_GRANDMOTHER, + "pgm": cls.PATERNAL_GRANDMOTHER, + "mgf": cls.MATERNAL_GRANDFATHER, + "pgf": cls.PATERNAL_GRANDFATHER, + } + return mapping.get(lowered, cls.OTHER) + + +class SymptomSeverity(str, Enum): + """Qualitative symptom severity labels.""" + + MILD = "mild" + MODERATE = "moderate" + SEVERE = "severe" + UNKNOWN = "unknown" + + @classmethod + def normalize(cls, value: Any | None) -> "SymptomSeverity | None": + """Normalize input value to SymptomSeverity enum. + + Args: + value: Input value to normalize. + + Returns: + Normalized SymptomSeverity enum value or None. + """ + if value is None: + return None + if isinstance(value, cls): + return value + text = _normalize_str(value) + if not text: + return None + lowered = text.lower() + if lowered in cls._value2member_map_: + return cls(lowered) + mapping = { + "mild": cls.MILD, + "low": cls.MILD, + "moderate": cls.MODERATE, + "medium": cls.MODERATE, + "severe": cls.SEVERE, + "high": cls.SEVERE, + } + return mapping.get(lowered, cls.UNKNOWN) + + +# --------------------------------------------------------------------------- +# Anthropometrics & demographics +# --------------------------------------------------------------------------- + + +class Anthropometrics(SentinelBaseModel): + """Anthropometric measurements with derived helpers.""" + + height_cm: float | None = Field(None, ge=0, le=260) + weight_kg: float | None = Field(None, ge=0, le=350) + waist_cm: float | None = Field(None, ge=0, le=300) + hip_cm: float | None = Field(None, ge=0, le=300) + + @model_validator(mode="after") + def _clean_zeroes(self) -> "Anthropometrics": + """Clean zero values by setting them to None. + + Returns: + Self with cleaned values. + """ + if self.height_cm == 0: + self.height_cm = None + if self.weight_kg == 0: + self.weight_kg = None + return self + + @computed_field(return_type=float | None) + def height_m(self) -> float | None: + """Convert height from cm to meters. + + Returns: + Height in meters or None. + """ + if self.height_cm is None: + return None + return round(self.height_cm / 100, 3) + + @computed_field(return_type=float | None) + def weight_lb(self) -> float | None: + """Convert weight from kg to pounds. + + Returns: + Weight in pounds or None. + """ + if self.weight_kg is None: + return None + return round(self.weight_kg * 2.20462, 1) + + @computed_field(return_type=float | None) + def bmi(self) -> float | None: + """Calculate BMI from height and weight. + + Returns: + BMI value or None. + """ + if self.height_cm and self.weight_kg: + height_m = self.height_cm / 100 + if height_m > 0: + return round(self.weight_kg / (height_m**2), 1) + return None + + +class SocioeconomicProfile(SentinelBaseModel): + """Socioeconomic descriptors used by certain risk calculators.""" + + education_level: EducationLevel | None = None + occupation: str | None = None + income_band: str | None = None + insurance: str | None = None + townsend_index: float | None = None + household_size: int | None = Field(None, ge=1, le=12) + + @model_validator(mode="before") + def _coerce(cls, values: Any) -> Any: + if not isinstance(values, dict): + return values + data = dict(values) + if "education_level" in data: + data["education_level"] = EducationLevel.normalize(data["education_level"]) + if "townsend_index" in data: + data["townsend_index"] = _parse_float(data["townsend_index"]) + return data + + +class Geography(SentinelBaseModel): + """Geographic metadata for environmental or cohort adjustments.""" + + country: str | None = None + state: str | None = None + county: str | None = None + postal_code: str | None = None + region_code: str | None = None + latitude: float | None = None + longitude: float | None = None + + +class Demographics(SentinelBaseModel): + """Basic demographic attributes for a user.""" + + age_years: int = Field(..., ge=0, le=120, alias="age") + sex: str = Field(default=Sex.UNKNOWN.value) + ethnicity: str | None = None + race: str | None = None + anthropometrics: Anthropometrics = Field(default_factory=Anthropometrics) + socioeconomic: SocioeconomicProfile = Field(default_factory=SocioeconomicProfile) + geography: Geography = Field(default_factory=Geography) + preferred_language: str | None = None + marital_status: str | None = None + occupation: str | None = None + model_notes: dict[str, Any] = Field(default_factory=dict) + + @model_validator(mode="before") + def _parse_legacy(cls, values: Any) -> Any: + if not isinstance(values, dict): + return values + data = dict(values) + metrics = dict(data.get("anthropometrics", {})) + if "height" in data and "height_cm" not in metrics: + height = _parse_float(data.pop("height")) + metrics["height_cm"] = height * 100 if height is not None else None + if "weight" in data and "weight_kg" not in metrics: + metrics["weight_kg"] = _parse_float(data.pop("weight")) + if metrics: + data["anthropometrics"] = metrics + socio = dict(data.get("socioeconomic", {})) + if "education_level" in data and "education_level" not in socio: + socio["education_level"] = data.pop("education_level") + if "townsend_index" in data and "townsend_index" not in socio: + socio["townsend_index"] = data.pop("townsend_index") + if socio: + data["socioeconomic"] = socio + if "model_extra" in data: + extra = data.pop("model_extra") or {} + if isinstance(extra, dict): + merged = dict(data.get("model_notes", {})) + merged.update(extra) + data["model_notes"] = merged + return data + + @model_validator(mode="after") + def _finalize(self) -> "Demographics": + """Finalize demographics by normalizing nested models. + + Returns: + Self with normalized nested models. + """ + object.__setattr__(self, "sex", Sex.normalize(self.sex).value) + # Coerce nested dicts to proper models (can occur during assignment re-validation) + if isinstance(self.socioeconomic, dict): + object.__setattr__( + self, + "socioeconomic", + SocioeconomicProfile.model_validate(self.socioeconomic), + ) + if isinstance(self.anthropometrics, dict): + object.__setattr__( + self, + "anthropometrics", + Anthropometrics.model_validate(self.anthropometrics), + ) + if isinstance(self.geography, dict): + object.__setattr__( + self, + "geography", + Geography.model_validate(self.geography), + ) + if self.socioeconomic.education_level is not None: + education = EducationLevel.normalize(self.socioeconomic.education_level) + object.__setattr__(self.socioeconomic, "education_level", education) + if isinstance(self.socioeconomic.townsend_index, int | float): + object.__setattr__( + self.socioeconomic, + "townsend_index", + float(self.socioeconomic.townsend_index), + ) + return self + + @property + def age(self) -> int: + """Get age in years. + + Returns: + Age in years. + """ + return self.age_years + + @age.setter + def age(self, value: Any) -> None: + """Set age in years. + + Args: + value: Age value to set. + """ + self.age_years = int(value) + + @property + def height(self) -> float | None: + """Get height in meters. + + Returns: + Height in meters or None. + """ + return self.anthropometrics.height_m + + @property + def weight(self) -> float | None: + """Get weight in kg. + + Returns: + Weight in kg or None. + """ + return self.anthropometrics.weight_kg + + @property + def bmi(self) -> float | None: + """Get BMI value. + + Returns: + BMI value or None. + """ + return self.anthropometrics.bmi + + @property + def education_level(self) -> int | None: + """Get education level as integer. + + Returns: + Education level as integer or None. + """ + if self.socioeconomic.education_level is None: + return None + return int(self.socioeconomic.education_level) + + @education_level.setter + def education_level(self, value: Any | None) -> None: + """Set education level. + + Args: + value: Education level value to set. + """ + self.socioeconomic.education_level = EducationLevel.normalize(value) + + @property + def townsend_index(self) -> float | None: + """Get Townsend deprivation index. + + Returns: + Townsend deprivation index or None. + """ + return self.socioeconomic.townsend_index + + +# --------------------------------------------------------------------------- +# Lifestyle +# --------------------------------------------------------------------------- + + +class SmokingHistory(SentinelBaseModel): + """Structured smoking exposure details.""" + + status: SmokingStatus = SmokingStatus.NEVER + cigarettes_per_day: float | None = Field(None, ge=0, le=200) + years_smoked: float | None = Field(None, ge=0, le=100) + years_since_quit: float | None = Field(None, ge=0, le=100) + pack_years: float | None = Field(None, ge=0, le=300) + started_at_age: float | None = Field(None, ge=0, le=120) + quit_age: float | None = Field(None, ge=0, le=120) + notes: str | None = None + + @model_validator(mode="after") + def _derive(self) -> "SmokingHistory": + """Derive smoking history values based on status. + + Returns: + Self with derived values. + """ + normalized_status = SmokingStatus.normalize(self.status) + object.__setattr__(self, "status", normalized_status) + if normalized_status == SmokingStatus.NEVER: + object.__setattr__(self, "cigarettes_per_day", 0.0) + object.__setattr__(self, "years_smoked", 0.0) + object.__setattr__(self, "years_since_quit", 0.0) + object.__setattr__(self, "pack_years", 0.0) + return self + if normalized_status == SmokingStatus.CURRENT: + object.__setattr__(self, "years_since_quit", 0.0) + if self.pack_years is None and self.cigarettes_per_day and self.years_smoked: + computed = round((self.cigarettes_per_day / 20.0) * self.years_smoked, 1) + object.__setattr__(self, "pack_years", computed) + if self.pack_years is None: + object.__setattr__(self, "pack_years", 0.0) + return self + + +class AlcoholConsumption(SentinelBaseModel): + """Model alcohol consumption patterns.""" + + category: AlcoholUse = AlcoholUse.NONE + drinks_per_week: float | None = Field(None, ge=0, le=100) + grams_per_day: float | None = Field(None, ge=0, le=500) + notes: str | None = None + + @model_validator(mode="after") + def _derive(self) -> "AlcoholConsumption": + """Derive alcohol consumption values based on category. + + Returns: + Self with derived values. + """ + normalized_category = AlcoholUse.normalize(self.category) + object.__setattr__(self, "category", normalized_category) + if normalized_category == AlcoholUse.NONE: + object.__setattr__(self, "drinks_per_week", 0.0) + object.__setattr__(self, "grams_per_day", 0.0) + return self + if self.drinks_per_week is not None and self.grams_per_day is None: + grams = round(self.drinks_per_week * 14.0 / 7.0, 1) + object.__setattr__(self, "grams_per_day", grams) + return self + + +class Lifestyle(SentinelBaseModel): + """Lifestyle behaviours including smoking and alcohol use.""" + + smoking: SmokingHistory = Field(default_factory=SmokingHistory) + alcohol: AlcoholConsumption = Field(default_factory=AlcoholConsumption) + physical_activity: PhysicalActivityLevel | None = None + dietary_pattern: str | None = None + sleep_hours_per_night: float | None = Field(None, ge=0, le=24) + sun_exposure: str | None = None + occupational_exposures: list[str] = Field(default_factory=list) + substance_use: list[str] = Field(default_factory=list) + notes: str | None = None + + @model_validator(mode="before") + def _parse_legacy(cls, values: Any) -> Any: + if not isinstance(values, dict): + return values + data = dict(values) + smoking_payload = dict(data.get("smoking", {})) + mapping = ( + ("smoking_status", "status"), + ("smoking_pack_years", "pack_years"), + ("smoking_intensity_cpd", "cigarettes_per_day"), + ("smoking_duration_years", "years_smoked"), + ("smoking_quit_years", "years_since_quit"), + ) + for legacy_key, new_key in mapping: + if legacy_key in data and new_key not in smoking_payload: + smoking_payload[new_key] = data.pop(legacy_key) + if smoking_payload: + data["smoking"] = smoking_payload + if "alcohol_consumption" in data: + alcohol_payload = dict(data.get("alcohol", {})) + alcohol_payload.setdefault("category", data.pop("alcohol_consumption")) + data["alcohol"] = alcohol_payload + if "dietary_habits" in data and "dietary_pattern" not in data: + data["dietary_pattern"] = data.pop("dietary_habits") + if "physical_activity_level" in data and "physical_activity" not in data: + data["physical_activity"] = data.pop("physical_activity_level") + return data + + @model_validator(mode="after") + def _finalize(self) -> "Lifestyle": + """Finalize lifestyle by normalizing physical activity. + + Returns: + Self with normalized physical activity. + """ + normalized_activity = PhysicalActivityLevel.normalize(self.physical_activity) + object.__setattr__(self, "physical_activity", normalized_activity) + return self + + @property + def smoking_status(self) -> str: + """Get smoking status. + + Returns: + Smoking status string. + """ + return self.smoking.status.value + + @property + def smoking_pack_years(self) -> float | None: + """Get smoking pack years. + + Returns: + Smoking pack years or None. + """ + return self.smoking.pack_years + + @property + def smoking_intensity_cpd(self) -> float | None: + """Get smoking intensity in cigarettes per day. + + Returns: + Cigarettes per day or None. + """ + return self.smoking.cigarettes_per_day + + @property + def smoking_duration_years(self) -> float | None: + """Get smoking duration in years. + + Returns: + Smoking duration in years or None. + """ + return self.smoking.years_smoked + + @property + def smoking_quit_years(self) -> float | None: + """Get years since quitting smoking. + + Returns: + Years since quitting or None. + """ + return self.smoking.years_since_quit + + @property + def alcohol_consumption(self) -> str: + """Get alcohol consumption category. + + Returns: + Alcohol consumption category string. + """ + return self.alcohol.category.value + + @property + def dietary_habits(self) -> str | None: + """Get dietary habits pattern. + + Returns: + Dietary habits pattern or None. + """ + return self.dietary_pattern + + @dietary_habits.setter + def dietary_habits(self, value: str | None) -> None: + """Set dietary habits pattern. + + Args: + value: Dietary habits pattern to set. + """ + self.dietary_pattern = value + + @property + def physical_activity_level(self) -> str | None: + """Get physical activity level. + + Returns: + Physical activity level or None. + """ + return None if self.physical_activity is None else self.physical_activity.value + + @physical_activity_level.setter + def physical_activity_level(self, value: Any | None) -> None: + """Set physical activity level. + + Args: + value: Physical activity level to set. + """ + self.physical_activity = PhysicalActivityLevel.normalize(value) + + +# --------------------------------------------------------------------------- +# Personal medical history +# --------------------------------------------------------------------------- + + +class PersonalMedicalHistory(SentinelBaseModel): + """User's medical background including genetics, conditions and therapies.""" + + chronic_conditions: list[str] = Field(default_factory=list) + previous_cancers: list[str] = Field(default_factory=list) + genetic_mutations: list[str] = Field(default_factory=list) + surgeries: list[str] = Field(default_factory=list) + medications: list[str] = Field(default_factory=list) + allergies: list[str] = Field(default_factory=list) + immunizations: list[str] = Field(default_factory=list) + notes: str | None = None + + @model_validator(mode="before") + def _legacy(cls, values: Any) -> Any: + if not isinstance(values, dict): + return values + data = dict(values) + if "chronic_illnesses" in data and "chronic_conditions" not in data: + data["chronic_conditions"] = data.pop("chronic_illnesses") + if "known_genetic_mutations" in data and "genetic_mutations" not in data: + data["genetic_mutations"] = data.pop("known_genetic_mutations") + return data + + @property + def chronic_illnesses(self) -> list[str]: + """Get chronic illnesses list. + + Returns: + List of chronic illnesses. + """ + return self.chronic_conditions + + @chronic_illnesses.setter + def chronic_illnesses(self, value: Iterable[str]) -> None: + """Set chronic illnesses list. + + Args: + value: Chronic illnesses to set. + """ + self.chronic_conditions = list(value) + + @property + def known_genetic_mutations(self) -> list[str]: + """Get known genetic mutations list. + + Returns: + List of known genetic mutations. + """ + return self.genetic_mutations + + @known_genetic_mutations.setter + def known_genetic_mutations(self, value: Iterable[str]) -> None: + """Set known genetic mutations list. + + Args: + value: Genetic mutations to set. + """ + self.genetic_mutations = list(value) + + +# --------------------------------------------------------------------------- +# Female-specific histories +# --------------------------------------------------------------------------- + + +class MenstrualHistory(SentinelBaseModel): + """Detailed menstrual history.""" + + age_at_menarche: int | None = Field(None, ge=8, le=60) + age_at_menopause: int | None = Field(None, ge=20, le=65) + menopause_type: str | None = None + is_postmenopausal: bool | None = None + + @model_validator(mode="after") + def _derive(self) -> "MenstrualHistory": + """Derive menstrual history values. + + Returns: + Self with derived values. + """ + if self.is_postmenopausal is None and self.age_at_menopause is not None: + self.is_postmenopausal = True + return self + + +class ParityHistory(SentinelBaseModel): + """Gestational and parity history.""" + + gravida: int | None = Field(None, ge=0, le=20) + para: int | None = Field(None, ge=0, le=20) + num_live_births: int | None = Field(None, ge=0, le=20) + age_at_first_live_birth: int | None = Field(None, ge=10, le=60) + + +class HormoneUseHistory(SentinelBaseModel): + """Hormone therapy utilisation.""" + + therapy_use: str | None = None + therapy_type: str | None = None + duration_years: float | None = Field(None, ge=0, le=50) + oral_contraceptive_use: str | None = None + oral_contraceptive_years: float | None = Field(None, ge=0, le=50) + + +class BreastHealthHistory(SentinelBaseModel): + """Breast health history relevant to multiple risk tools.""" + + num_biopsies: int | None = Field(None, ge=0, le=20) + atypical_hyperplasia: bool | None = None + lobular_carcinoma_insitu: bool | None = None + biopsy_details: list[str] = Field(default_factory=list) + last_mammogram_date: str | None = None + + +class GynecologicSurgeryHistory(SentinelBaseModel): + """Gynecologic surgical history.""" + + hysterectomy: bool | None = None + oophorectomy: bool | None = None + tubal_ligation: bool | None = None + surgery_details: list[str] = Field(default_factory=list) + + +class FemaleSpecific(SentinelBaseModel): + """Additional female-specific clinical data.""" + + menstrual: MenstrualHistory = Field(default_factory=MenstrualHistory) + parity: ParityHistory = Field(default_factory=ParityHistory) + hormone_use: HormoneUseHistory = Field(default_factory=HormoneUseHistory) + breast_health: BreastHealthHistory = Field(default_factory=BreastHealthHistory) + surgeries: GynecologicSurgeryHistory = Field( + default_factory=GynecologicSurgeryHistory + ) + notes: str | None = None + + @model_validator(mode="before") + def _legacy(cls, values: Any) -> Any: + if not isinstance(values, dict): + return values + data = dict(values) + target_map = { + "age_at_first_period": ("menstrual", "age_at_menarche"), + "age_at_menopause": ("menstrual", "age_at_menopause"), + "num_live_births": ("parity", "num_live_births"), + "age_at_first_live_birth": ("parity", "age_at_first_live_birth"), + "hormone_therapy_use": ("hormone_use", "therapy_use"), + "num_biopsies": ("breast_health", "num_biopsies"), + "hyperplasia": ("breast_health", "atypical_hyperplasia"), + "tubal_ligation": ("surgeries", "tubal_ligation"), + } + for legacy_key, (section, field_name) in target_map.items(): + if legacy_key in data: + section_payload = dict(data.get(section, {})) + section_payload.setdefault(field_name, data.pop(legacy_key)) + data[section] = section_payload + return data + + @property + def age_at_first_period(self) -> int | None: + """Get age at first period. + + Returns: + Age at first period or None. + """ + return self.menstrual.age_at_menarche + + @property + def age_at_menopause(self) -> int | None: + """Get age at menopause. + + Returns: + Age at menopause or None. + """ + return self.menstrual.age_at_menopause + + @property + def num_live_births(self) -> int | None: + """Get number of live births. + + Returns: + Number of live births or None. + """ + return self.parity.num_live_births + + @property + def age_at_first_live_birth(self) -> int | None: + """Get age at first live birth. + + Returns: + Age at first live birth or None. + """ + return self.parity.age_at_first_live_birth + + @property + def hormone_therapy_use(self) -> str | None: + """Get hormone therapy use. + + Returns: + Hormone therapy use or None. + """ + return self.hormone_use.therapy_use + + @property + def num_biopsies(self) -> int | None: + """Get number of biopsies. + + Returns: + Number of biopsies or None. + """ + return self.breast_health.num_biopsies + + @property + def hyperplasia(self) -> int: + """Get hyperplasia status as integer. + + Returns: + Hyperplasia status as integer. + """ + if self.breast_health.atypical_hyperplasia is None: + return 99 + return 1 if self.breast_health.atypical_hyperplasia else 0 + + +# --------------------------------------------------------------------------- +# Family history +# --------------------------------------------------------------------------- + + +class FamilyMemberCancer(SentinelBaseModel): + """Records a family member's cancer history.""" + + relation: FamilyRelation = FamilyRelation.OTHER + cancer_type: str + age_at_diagnosis: int | None = None + degree: RelationshipDegree | None = None + side: FamilySide | None = None + multiple_primaries: bool | None = None + notes: str | None = None + + @model_validator(mode="before") + def _legacy(cls, values: Any) -> Any: + if not isinstance(values, dict): + return values + data = dict(values) + # Accept legacy 'relative' and normalize to a valid FamilyRelation string before enum validation + relation_value = None + if "relation" in data: + relation_value = data["relation"] + elif "relative" in data: + relation_value = data.pop("relative") + if relation_value is not None: + try: + normalized = FamilyRelation.normalize(relation_value).value + except Exception: + normalized = FamilyRelation.OTHER.value + data["relation"] = normalized + if "family_side" in data and "side" not in data: + data["side"] = data.pop("family_side") + if "degree" in data and not isinstance(data["degree"], RelationshipDegree): + try: + data["degree"] = RelationshipDegree(int(data["degree"])) + except (TypeError, ValueError): + data["degree"] = None + return data + + @model_validator(mode="after") + def _derive(self) -> "FamilyMemberCancer": + """Derive family member cancer information. + + Returns: + Self with derived information. + """ + object.__setattr__(self, "relation", FamilyRelation.normalize(self.relation)) + if self.degree is None: + object.__setattr__(self, "degree", self._infer_degree_from_relation()) + if self.side is None: + object.__setattr__(self, "side", self._infer_side_from_relation()) + return self + + def _infer_degree_from_relation(self) -> RelationshipDegree | None: + first_degree = { + FamilyRelation.MOTHER, + FamilyRelation.FATHER, + FamilyRelation.SISTER, + FamilyRelation.BROTHER, + FamilyRelation.HALF_SISTER, + FamilyRelation.HALF_BROTHER, + FamilyRelation.DAUGHTER, + FamilyRelation.SON, + } + second_degree = { + FamilyRelation.MATERNAL_AUNT, + FamilyRelation.MATERNAL_UNCLE, + FamilyRelation.PATERNAL_AUNT, + FamilyRelation.PATERNAL_UNCLE, + FamilyRelation.MATERNAL_GRANDMOTHER, + FamilyRelation.MATERNAL_GRANDFATHER, + FamilyRelation.PATERNAL_GRANDMOTHER, + FamilyRelation.PATERNAL_GRANDFATHER, + FamilyRelation.NIECE, + FamilyRelation.NEPHEW, + } + if self.relation in first_degree: + return RelationshipDegree.FIRST + if self.relation in second_degree: + return RelationshipDegree.SECOND + return ( + RelationshipDegree.THIRD if self.relation != FamilyRelation.OTHER else None + ) + + def _infer_side_from_relation(self) -> FamilySide: + if "maternal" in self.relation.value: + return FamilySide.MATERNAL + if "paternal" in self.relation.value: + return FamilySide.PATERNAL + return FamilySide.UNKNOWN + + @property + def relative(self) -> str: + """Get relative relationship as string. + + Returns: + Relative relationship as string. + """ + return self.relation.value + + @property + def is_first_degree(self) -> bool: + """Check if this is a first-degree relative. + + Returns: + True if first-degree relative, False otherwise. + """ + return self.degree == RelationshipDegree.FIRST + + +class PedigreePerson(SentinelBaseModel): + """Represents a person in a genetic pedigree. + + Used for detailed pedigree analysis in genetic risk models like Tyrer-Cuzick. + Supports structured family relationships, cancer history, and genetic linkages. + + Attributes: + person_id: Unique identifier for this person. + sex: Biological sex ("male" or "female"). + birth_year: Year of birth. + current_age: Current age or age at last contact. + is_alive: Whether the person is alive. + breast_cancer_age: Age at breast cancer diagnosis. + ovarian_cancer_age: Age at ovarian cancer diagnosis. + mother_id: Reference to mother's person_id. + father_id: Reference to father's person_id. + relationship: Relationship to proband for risk calculation. + """ + + person_id: str + sex: Literal["male", "female"] + birth_year: int | None = None + current_age: int | None = None + is_alive: bool = True + breast_cancer_age: int | None = None + ovarian_cancer_age: int | None = None + mother_id: str | None = None + father_id: str | None = None + relationship: ( + Literal[ + "mother", + "father", + "sister", + "daughter", + "aunt_m", + "aunt_p", + "grandmother_m", + "grandmother_p", + "other", + ] + | None + ) = None + + +# --------------------------------------------------------------------------- +# Symptoms and observations +# --------------------------------------------------------------------------- + + +class SymptomEntry(SentinelBaseModel): + """Structured symptom record.""" + + description: str + category: str | None = None + severity: SymptomSeverity | None = None + onset_date: str | None = None + duration: str | None = None + notes: str | None = None + + @model_validator(mode="after") + def _finalize(self) -> "SymptomEntry": + """Finalize symptom entry by normalizing severity. + + Returns: + Self with normalized severity. + """ + self.severity = SymptomSeverity.normalize(self.severity) + return self + + +class ClinicalObservation(SentinelBaseModel): + """Represents a single clinical observation or test result.""" + + test_name: str = Field( + ..., description="Human-readable observation name (e.g., PSA)" + ) + value: str | None = Field(None, description="Result value (numeric or qualitative)") + unit: str | None = Field(None, description="Unit of measure for the value") + reference_range: str | None = None + date: str | None = None + code: str | None = Field( + None, description="Optional coding identifier (e.g., LOINC)" + ) + source: str | None = None + numeric_value: float | None = None + interpretation: str | None = None + notes: str | None = None + + @model_validator(mode="before") + def _legacy(cls, values: Any) -> Any: + if not isinstance(values, dict): + return values + data = dict(values) + if "name" in data and "test_name" not in data: + data["test_name"] = data.pop("name") + if "result" in data and "value" not in data: + data["value"] = data.pop("result") + return data + + @model_validator(mode="after") + def _derive_numeric(self) -> "ClinicalObservation": + """Derive numeric value from string value. + + Returns: + Self with derived numeric value. + """ + if self.numeric_value is None and self.value is not None: + parsed = _parse_float(self.value) + # Avoid recursive assignment loop under validate_assignment + object.__setattr__(self, "numeric_value", parsed) + return self + + @computed_field(return_type=str) + def normalized_name(self) -> str: + """Get normalized test name. + + Returns: + Normalized test name. + """ + return _normalize_key(self.test_name) + + +class LabResult(ClinicalObservation): + """Clinical laboratory result with specimen metadata.""" + + specimen_type: str | None = None + status: str | None = None + abnormal_flag: str | None = None + + +# --------------------------------------------------------------------------- +# Dermatologic profile (for MRAT and related models) +# --------------------------------------------------------------------------- + + +class GeographicRegion(str, Enum): + """Geographic region categories for dermatologic assessment.""" + + NORTHERN = "northern" + CENTRAL = "central" + SOUTHERN = "southern" + + @classmethod + def normalize(cls, value: Any | None) -> "GeographicRegion | None": + """Normalize input value to GeographicRegion enum. + + Args: + value: Input value to normalize. + + Returns: + Normalized GeographicRegion enum value or None. + """ + if value is None: + return None + text = str(value).strip().lower() + mapping = { + "north": cls.NORTHERN, + "northern": cls.NORTHERN, + "central": cls.CENTRAL, + "mid": cls.CENTRAL, + "middle": cls.CENTRAL, + "midwest": cls.CENTRAL, + "south": cls.SOUTHERN, + "southern": cls.SOUTHERN, + } + return mapping.get(text) + + +class ComplexionLevel(IntEnum): + """Complexion level categories for dermatologic assessment.""" + + LIGHT = 0 + MEDIUM = 1 + DARK = 2 + + +class FrecklingIntensity(IntEnum): + """Freckling intensity levels for dermatologic assessment.""" + + ABSENT = 0 + MILD = 1 + MODERATE = 2 + SEVERE = 3 + + +class FemaleTanResponse(IntEnum): + """Female tan response categories for dermatologic assessment.""" + + VERY_BROWN = 0 + MODERATE = 1 + LIGHT = 2 + NONE = 3 + + +class MaleSunburnHistory(IntEnum): + """Male sunburn history categories for dermatologic assessment.""" + + YES = 0 + NO = 1 + + +class MaleBigMolesCategory(IntEnum): + """Male big moles categories for dermatologic assessment.""" + + LESS_THAN_TWO = 0 + TWO_OR_MORE = 1 + + +class MaleSmallMolesCategory(IntEnum): + """Male small moles categories for dermatologic assessment.""" + + LESS_THAN_SEVEN = 0 + SEVEN_TO_SIXTEEN = 1 + SEVENTEEN_OR_MORE = 2 + + +class FemaleSmallMolesCategory(IntEnum): + """Female small moles categories for dermatologic assessment.""" + + LESS_THAN_FIVE = 0 + FIVE_TO_ELEVEN = 1 + TWELVE_OR_MORE = 2 + + +class SolarDamage(IntEnum): + """Solar damage categories for dermatologic assessment.""" + + YES = 0 + NO = 1 + + +class DermatologicProfile(SentinelBaseModel): + """Dermatologic profile for melanoma risk assessment.""" + + region: GeographicRegion + complexion: ComplexionLevel + freckling: FrecklingIntensity + # Female-specific + female_tan: FemaleTanResponse | None = None + female_small_moles: FemaleSmallMolesCategory | None = None + # Male-specific + male_sunburn: MaleSunburnHistory | None = None + male_big_moles: MaleBigMolesCategory | None = None + male_small_moles: MaleSmallMolesCategory | None = None + # Shared + solar_damage: SolarDamage | None = None + + +class ScreeningEvent(SentinelBaseModel): + """Historical cancer screening event.""" + + modality: str + date: str | None = None + result: str | None = None + facility: str | None = None + notes: str | None = None + + +class MedicationRecord(SentinelBaseModel): + """Medication exposure details.""" + + name: str + dose: str | None = None + frequency: str | None = None + route: str | None = None + indication: str | None = None + start_date: str | None = None + stop_date: str | None = None + ongoing: bool | None = None + notes: str | None = None + + +# --------------------------------------------------------------------------- +# Risk factor metadata +# --------------------------------------------------------------------------- + + +class RiskFactor(SentinelBaseModel): + """A single identified risk factor.""" + + description: str = Field( + ..., + description="A human-readable description of the risk factor identified from the user's profile.", + ) + category: RiskFactorCategory = Field( + ..., description="The predefined category of the risk factor." + ) + + +class ContributingFactor(SentinelBaseModel): + """A factor contributing to a specific cancer risk assessment.""" + + description: str = Field( + ..., description="A human-readable description of the risk factor." + ) + category: RiskFactorCategory = Field( + ..., description="The predefined category of the risk factor." + ) + strength: ContributionStrength = Field( + ..., + description="The assessed strength of this factor's contribution to the cancer risk.", + ) + + +class RiskScore(SentinelBaseModel): + """External risk score values attached to the user profile.""" + + name: str = Field(..., description="Name of the risk score tool") + score: str | None = Field( + default=None, description="Score returned by the tool if available" + ) + cancer_type: str | None = Field( + default=None, description="Type of cancer related to the risk score" + ) + description: str | None = Field( + default=None, description="Description of the risk score" + ) + interpretation: str | None = Field( + default=None, description="Interpretation of the risk score" + ) + references: list[str] | None = Field( + default=None, description="References to the risk score" + ) + + +# --------------------------------------------------------------------------- +# Canonical user input +# --------------------------------------------------------------------------- + + +class UserInput(SentinelBaseModel): + """Top-level container for all input required by assessments.""" + + schema_version: str = Field(default="2025.10") + demographics: Demographics + lifestyle: Lifestyle + family_history: list[FamilyMemberCancer] = Field(default_factory=list) + personal_medical_history: PersonalMedicalHistory + female_specific: FemaleSpecific | None = None + current_concerns_or_symptoms: str | None = None + symptoms: list[SymptomEntry] = Field(default_factory=list) + clinical_observations: list[ClinicalObservation] = Field(default_factory=list) + lab_results: list[LabResult] = Field(default_factory=list) + screening_history: list[ScreeningEvent] = Field(default_factory=list) + medications: list[MedicationRecord] = Field(default_factory=list) + risk_scores: list[RiskScore] = Field(default_factory=list, alias="risks_scores") + notes: str | None = None + dermatologic: DermatologicProfile | None = None + + @model_validator(mode="before") + def _legacy(cls, values: Any) -> Any: + if not isinstance(values, dict): + return values + data = dict(values) + for field_name in ( + "clinical_observations", + "lab_results", + "screening_history", + "medications", + "family_history", + "symptoms", + "risk_scores", + "risks_scores", + ): + if field_name in data and data[field_name] is None: + data[field_name] = [] + if "risks_scores" in data and "risk_scores" not in data: + data["risk_scores"] = data.pop("risks_scores") + return data + + @property + def risks_scores(self) -> list[RiskScore]: + """Get risk scores list. + + Returns: + List of risk scores. + """ + return self.risk_scores + + @risks_scores.setter + def risks_scores(self, value: Iterable[RiskScore]) -> None: + """Set risk scores list. + + Args: + value: Risk scores to set. + """ + self.risk_scores = list(value) + + @property + def reproductive_history(self) -> FemaleSpecific | None: + """Get reproductive history. + + Returns: + Reproductive history or None. + """ + return self.female_specific + + @reproductive_history.setter + def reproductive_history(self, value: FemaleSpecific | None) -> None: + """Set reproductive history. + + Args: + value: Reproductive history to set. + """ + self.female_specific = value + + @property + def bmi(self) -> float | None: + """Get BMI value. + + Returns: + BMI value or None. + """ + return self.demographics.bmi + + @property + def smoking_history(self) -> SmokingHistory: + """Get smoking history. + + Returns: + Smoking history. + """ + return self.lifestyle.smoking + + @property + def is_current_or_former_smoker(self) -> bool: + """Check if user is current or former smoker. + + Returns: + True if current or former smoker, False otherwise. + """ + return self.lifestyle.smoking.status in { + SmokingStatus.CURRENT, + SmokingStatus.FORMER, + } + + def _build_observation_index(self) -> dict[str, ClinicalObservation]: + """Build index of clinical observations by normalized name. + + Returns: + Dictionary mapping normalized names to observations. + """ + index: dict[str, ClinicalObservation] = {} + for obs in (*self.clinical_observations, *self.lab_results): + key = obs.normalized_name + if key and key not in index: + index[key] = obs + return index + + def get_observation(self, names: Sequence[str]) -> ClinicalObservation | None: + """Get clinical observation by name. + + Args: + names: Sequence of observation names to search for. + + Returns: + Clinical observation or None. + """ + index = self._build_observation_index() + for name in names: + key = _normalize_key(name) + if key in index: + return index[key] + return None + + def get_observation_value(self, names: Sequence[str]) -> str | None: + """Get clinical observation value by name. + + Args: + names: Sequence of observation names to search for. + + Returns: + Observation value or None. + """ + observation = self.get_observation(names) + return observation.value if observation else None + + def get_numeric_observation(self, names: Sequence[str]) -> float | None: + """Get clinical observation numeric value by name. + + Args: + names: Sequence of observation names to search for. + + Returns: + Numeric observation value or None. + """ + observation = self.get_observation(names) + return observation.numeric_value if observation else None + + def has_family_history( + self, relations: Iterable[FamilyRelation], cancer_keywords: Iterable[str] + ) -> bool: + """Check if user has family history of specific cancer types. + + Args: + relations: Family relations to check. + cancer_keywords: Cancer type keywords to search for. + + Returns: + True if family history found, False otherwise. + """ + relation_set = {FamilyRelation.normalize(rel) for rel in relations} + keywords = {kw.lower() for kw in cancer_keywords} + for record in self.family_history: + if relation_set and record.relation not in relation_set: + continue + if any( + keyword in (record.cancer_type or "").lower() for keyword in keywords + ): + return True + return False + + def first_degree_cancer_count(self, cancer_keywords: Iterable[str]) -> int: + """Count first-degree relatives with specific cancer types. + + Args: + cancer_keywords: Cancer type keywords to search for. + + Returns: + Count of first-degree relatives with matching cancer types. + """ + keywords = {kw.lower() for kw in cancer_keywords} + return sum( + 1 + for record in self.family_history + if record.is_first_degree + and any( + keyword in (record.cancer_type or "").lower() for keyword in keywords + ) + ) + + +# --------------------------------------------------------------------------- +# Assessment artefacts +# --------------------------------------------------------------------------- + + +class DxRecommendation(SentinelBaseModel): + """Diagnostic test recommendation with priority and rationale.""" + + test_name: str | None = Field( + default=None, description="Name of the diagnostic test" + ) + recommendation_level: int | None = Field( + default=None, + description="A score from 1 to 5, where 1 is strongly discouraged and 5 is strongly encouraged.", + ) + frequency: str | None = Field( + default=None, + description="Recommended testing frequency (e.g., 'annually', 'every 2 years')", + ) + rationale: str | None = Field( + default=None, description="Short explanation for why this test is recommended" + ) + applicable_guideline: str | None = Field( + default=None, + description="The rule or guideline that triggered this recommendation", + ) + + +class CancerRiskAssessment(SentinelBaseModel): + """Structured assessment for a single cancer type.""" + + cancer_type: str | None = Field(default=None, description="Type of cancer") + risk_level: int | None = Field( + default=None, + description="A score from 1 (lowest risk) to 5 (highest risk) for the given cancer", + ) + explanation: str | None = Field( + default=None, description="Reasoning behind the assessment" + ) + recommended_steps: str | list[str] | None = Field( + default=None, + description="Optional steps the user can take to mitigate risk", + ) + lifestyle_advice: str | None = Field( + default=None, + description="Optional lifestyle advice related to this cancer type", + ) + contributing_factors: list[ContributingFactor] = Field( + default_factory=list, + description=( + "A structured list of specific factors from the user's profile that contribute to this assessment, along with their strength." + ), + ) + + +class InitialAssessment(SentinelBaseModel): + """Structured initial assessment produced by the LLM chain.""" + + thinking: str | None = Field( + default=None, + description="Extracted chain-of-thought from the model's raw output.", + ) + reasoning: str | None = Field( + default=None, + description="Model's step-by-step reasoning prior to generating the final structured answer.", + ) + response: str | None = Field( + None, description="The user-facing narrative summarizing the assessment." + ) + overall_summary: str | None = Field( + default=None, description="A high-level summary of the user's cancer risk." + ) + overall_risk_score: int | None = Field( + default=None, + description="A holistic score from 0 to 100 representing the user's overall cancer risk.", + ge=0, + le=100, + ) + identified_risk_factors: list[RiskFactor] = Field( + default_factory=list, + description="A comprehensive list of all distinct risk factors identified from the user's profile.", + ) + risk_assessments: list[CancerRiskAssessment] = Field( + default_factory=list, + description="Detailed risk assessments for specific cancers", + ) + dx_recommendations: list[DxRecommendation] = Field( + default_factory=list, description="Recommended diagnostic tests and protocols" + ) + + +class ConversationResponse(SentinelBaseModel): + """Structured response for conversational follow-ups.""" + + thinking: str | None = Field( + default=None, + description="Extracted chain-of-thought from the model's raw output.", + ) + reasoning: str | None = Field( + default=None, + description="Model's step-by-step reasoning for the conversational response.", + ) + response: str = Field(..., description="Assistant answer") diff --git a/src/sentinel/prompting.py b/src/sentinel/prompting.py new file mode 100644 index 0000000000000000000000000000000000000000..8c75d5e05267d00930c1d0702827898890c484e3 --- /dev/null +++ b/src/sentinel/prompting.py @@ -0,0 +1,91 @@ +"""Prompt builders for assessment and conversational chains.""" + +import yaml +from langchain_core.prompts import PromptTemplate + +from .knowledge import KnowledgeBase +from .models import ContributionStrength, RiskFactorCategory + + +class PromptBuilder: + """Constructs PromptTemplate instances from knowledge base content.""" + + def __init__(self, kb: KnowledgeBase): + self.kb = kb + + def build_assessment_prompt(self) -> PromptTemplate: + """Build the assessment prompt combining persona, KB, and format. + + Returns: + PromptTemplate: Configured for the assessment chain. + """ + cancer_modules_text = "\n\n".join( + [ + f"## {name}\n\n{yaml.dump(content)}" + for name, content in self.kb.cancer_modules.items() + ] + ) + protocols_text = "\n\n".join( + [ + f"## {content.get('name', name)}\n\n{yaml.dump(content)}" + for name, content in self.kb.dx_protocols.items() + ] + ) + + diagnostic_protocols = [ + p.get("name", n) for n, p in self.kb.dx_protocols.items() + ] + allowed_categories = [e.value for e in RiskFactorCategory] + allowed_strengths = [e.value for e in ContributionStrength] + + # Parse the YAML content to get the format_instructions template + format_cfg = yaml.safe_load(self.kb.output_format_assessment_template) + format_instructions_template = format_cfg["format_instructions"] + + # Create PromptTemplate for format_instructions and populate it + format_template = PromptTemplate.from_template(format_instructions_template) + format_instructions = format_template.format( + diagnostic_protocols=", ".join(diagnostic_protocols), + allowed_categories=", ".join(allowed_categories), + allowed_strengths=", ".join(allowed_strengths), + ) + + template = ( + "# PERSONA\n\n{persona}\n\n" + "# CANCER MODULES\n\n{cancer_modules}\n\n" + "# DIAGNOSTIC PROTOCOLS\n\n{protocols}\n\n" + "# USER INFORMATION\n\n{user_data}\n\n" + "# INSTRUCTIONS\n\n{instruction}\n\n" + "# OUTPUT FORMAT INSTRUCTIONS (FOR INITIAL RESPONSE ONLY)\n\n{format_instructions}" + ) + + return PromptTemplate.from_template(template).partial( + persona=self.kb.persona, + instruction=self.kb.instruction_assessment, + cancer_modules=cancer_modules_text, + protocols=protocols_text, + format_instructions=format_instructions, + ) + + def build_conversational_prompt(self) -> PromptTemplate: + """Build the conversational prompt using history and instructions. + + Returns: + PromptTemplate: Configured for conversational responses. + """ + template = ( + "# CONVERSATION HISTORY\n\n{history}\n\n" + "# INSTRUCTIONS\n\n{instruction}\n\n" + "# USER QUESTION\n\n{question}\n\n" + "# OUTPUT FORMAT INSTRUCTIONS \n\n{format_instructions}" + ) + # Parse the YAML content to get the format_instructions + conversation_format_cfg = yaml.safe_load(self.kb.output_format_conversation) + conversation_format_instructions = conversation_format_cfg[ + "format_instructions" + ] + + return PromptTemplate.from_template(template).partial( + instruction=self.kb.instruction_conversation, + format_instructions=conversation_format_instructions, + ) diff --git a/src/sentinel/reporting.py b/src/sentinel/reporting.py new file mode 100644 index 0000000000000000000000000000000000000000..4f926994dc03cc434d32174330eeb05b357533b7 --- /dev/null +++ b/src/sentinel/reporting.py @@ -0,0 +1,1803 @@ +"""Reporting utilities to generate Excel and PDF risk assessment reports.""" + +import json +import math +from datetime import datetime + +import markdown2 +from openpyxl import Workbook +from openpyxl.styles import Alignment, Font, PatternFill +from reportlab.graphics.shapes import ( + Circle, + Drawing, + Group, + Polygon, + Rect, + String, + Wedge, +) +from reportlab.lib import colors +from reportlab.lib.colors import Color, black, white +from reportlab.lib.enums import TA_CENTER +from reportlab.lib.pagesizes import letter +from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet +from reportlab.lib.units import inch +from reportlab.platypus import ( + PageBreak, + Paragraph, + SimpleDocTemplate, + Spacer, + Table, + TableStyle, +) + +from .models import ( + ContributionStrength, + InitialAssessment, + RiskFactorCategory, + UserInput, +) + +# --- PDF Report Formatting Globals --- +# Fonts +FONT_NAME = "Helvetica" +FONT_NAME_BOLD = "Helvetica-Bold" +FONT_NAME_TABLE = "Courier" + +# Font Sizes +FONT_SIZE_TITLE = 18 +FONT_SIZE_H2 = 14 +FONT_SIZE_H3 = 11 +FONT_SIZE_H4 = 10 +FONT_SIZE_BODY = 9 +FONT_SIZE_TABLE_HEADER = 8 +FONT_SIZE_TABLE_BODY = 8 +FONT_SIZE_PANEL_TEXT = 7 +FONT_SIZE_GAUGE_SCORE = 16 +FONT_SIZE_GAUGE_LABEL = 9 + +# Spacing +LEADING_BODY = 11 # Line spacing for body text +SPACE_AFTER_TITLE = 10 +SPACE_AFTER_H2 = 6 +SPACE_AFTER_H3 = 4 +SPACE_AFTER_P = 4 +SPACER_NORMAL = 0.1 * inch +SPACER_SMALL = 0.05 * inch +SPACER_TINY = 0.0 * inch + + +# PDF Layout +CONTENT_WIDTH = letter[0] - 2 * inch +TABLE_WIDTH_INSET = 0.1 * inch # Inset from each side of content area +TABLE_MAX_WIDTH = CONTENT_WIDTH - (2 * TABLE_WIDTH_INSET) + + +HEX_COLORS = { + "red": "FFC7CE", + "light_red": "FFDDE1", + "yellow": "FFEB9C", + "green": "C6EFCE", + "light_green": "E2F0D9", + "blue": "DDEBF7", + "grey": "F2F2F2", + "header_fill": "019EDF", + "header_font": "FFFFFF", + # New recommendation colors + "rec_5": "C6EFCE", # Green + "rec_4": "FFEB9C", # Yellow + "rec_3": "DDEBF7", # Blue + "rec_2": "F2F2F2", # Light Grey + "rec_1": "D9D9D9", # Dark Grey +} + +PDF_COLORS = { + "red": colors.HexColor("#FFC7CE"), + "light_red": colors.HexColor("#FFDDE1"), + "yellow": colors.HexColor("#FFEB9C"), + "green": colors.HexColor("#C6EFCE"), + "light_green": colors.HexColor("#E2F0D9"), + "blue": colors.HexColor("#DDEBF7"), + "grey": colors.HexColor("#F2F2F2"), + # New recommendation colors + "rec_5": colors.HexColor("#C6EFCE"), # Green + "rec_4": colors.HexColor("#FFEB9C"), # Yellow + "rec_3": colors.HexColor("#DDEBF7"), # Blue + "rec_2": colors.HexColor("#F2F2F2"), # Light Grey + "rec_1": colors.HexColor("#D9D9D9"), # Dark Grey +} + + +# --- New Risk Category Colors --- +PDF_RISK_CATEGORY_COLORS = { + "demographics": colors.HexColor("#BDE0FE"), # Light Blue + "lifestyle": colors.HexColor("#A2D2FF"), # Medium Light Blue + "personal medical history": colors.HexColor("#72B0D9"), # Steel Blue + "family history": colors.HexColor("#84DCC6"), # Mint Green + "female-specific": colors.HexColor("#A8E6CF"), # Light Mint + "environmental_occupational": colors.HexColor("#C7EAE4"), # Very Light Mint/Cyan + "default": colors.HexColor("#E0E0E0"), # Light Grey +} + + +def _get_risk_color(level: int | None, color_format: str = "hex"): + color_map = {1: "light_green", 2: "green", 3: "yellow", 4: "light_red", 5: "red"} + color_key = color_map.get(level, "grey") + return PDF_COLORS[color_key] if color_format == "pdf" else HEX_COLORS[color_key] + + +def _get_rec_color(level: int | None, color_format: str = "hex"): + color_map = {1: "rec_1", 2: "rec_2", 3: "rec_3", 4: "rec_4", 5: "rec_5"} + color_key = color_map.get(level, "rec_1") + return PDF_COLORS[color_key] if color_format == "pdf" else HEX_COLORS[color_key] + + +def _markdown_to_reportlab(md_text: str) -> str: + """Convert Markdown text to ReportLab-compatible HTML-like markup. + + Args: + md_text: Source markdown string. + + Returns: + A simplified HTML-like string suitable for ReportLab Paragraph. + """ + if not md_text: + return "" + # Use extras to handle things like tables, fenced code blocks, etc. if needed + # For now, just basic formatting. + html = markdown2.markdown( + md_text, extras=["break-on-newline", "cuddled-lists", "fenced-code-blocks"] + ) + # ReportLab uses
for line breaks, markdown2 might produce
or
+ html = html.replace("
", "
").replace("
", "
") + # ReportLab doesn't support

tags well inside a Paragraph, so remove them + html = html.replace("

", "").replace("

", "") + # ReportLab uses for bold, markdown2 produces + html = html.replace("", "").replace("", "") + # ReportLab uses for italic, markdown2 produces + html = html.replace("", "").replace("", "") + # Convert markdown list items to bullet points, ensuring they start on a new line. + html = html.replace("
  • ", "
    • ").replace("
  • ", "") + # Remove
      and
        tags + html = html.replace("
          ", "").replace("
        ", "") + html = html.replace("
          ", "").replace("
        ", "") + + return html.strip() + + +def generate_excel_report( + assessment: InitialAssessment, user_input: UserInput, filename: str +) -> None: + """Create an Excel report summarising the assessment results. + + Args: + assessment: The structured initial assessment. + user_input: The input data used to generate the assessment. + filename: Path to the output .xlsx file. + """ + wb = Workbook() + + _create_summary_sheet(wb, assessment, user_input) + _create_data_sheet(wb, "User Input Data", user_input.model_dump(mode="json")) + _create_data_sheet(wb, "Raw LLM Output", assessment.model_dump(mode="json")) + + wb.active = 0 + wb.save(filename) + + +def _create_summary_sheet( + wb: Workbook, assessment: InitialAssessment, user_input: UserInput +) -> None: + """Populate the summary worksheet with key assessment information. + + Args: + wb: An openpyxl workbook. + assessment: The structured initial assessment. + user_input: The input data used to generate the assessment. + """ + ws = wb.active + ws.title = "Summary Report" + + title_font = Font(bold=True, size=16, name="Calibri") + section_font = Font(bold=True, size=14, name="Calibri") + header_font = Font(bold=True, color=HEX_COLORS["header_font"], name="Calibri") + header_fill = PatternFill(start_color=HEX_COLORS["header_fill"], fill_type="solid") + bold_font = Font(bold=True, name="Calibri") + wrap_alignment = Alignment(wrap_text=True, vertical="top") + + ws.merge_cells("A1:F1") + ws["A1"] = "BiOS Sentinel - Personalized Cancer Risk Assessment" + ws["A1"].font = title_font + ws["A1"].alignment = Alignment(horizontal="center") + ws.merge_cells("A2:F2") + ws["A2"] = f"Report Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" + ws["A2"].alignment = Alignment(horizontal="center") + + ws.merge_cells("A4:F4") + ws["A4"] = "User Information" + ws["A4"].font = section_font + + current_row = 5 + + # --- Demographics --- + ws.cell(row=current_row, column=1, value="Demographics").font = bold_font + current_row += 1 + demo_info = { + "Age": user_input.demographics.age, + "Sex": user_input.demographics.sex, + "Ethnicity": user_input.demographics.ethnicity, + } + for key, val in demo_info.items(): + ws.cell(row=current_row, column=1, value=key).font = bold_font + ws.cell(row=current_row, column=2, value=str(val) if val is not None else "N/A") + current_row += 1 + + current_row += 1 + + # --- Lifestyle --- + ws.cell(row=current_row, column=1, value="Lifestyle").font = bold_font + current_row += 1 + lifestyle_info = { + "Smoking Status": user_input.lifestyle.smoking_status, + "Pack Years": user_input.lifestyle.smoking_pack_years, + "Alcohol Consumption": user_input.lifestyle.alcohol_consumption, + "Dietary Habits": user_input.lifestyle.dietary_habits, + "Physical Activity": user_input.lifestyle.physical_activity_level, + } + for key, val in lifestyle_info.items(): + ws.cell(row=current_row, column=1, value=key).font = bold_font + ws.cell(row=current_row, column=2, value=str(val) if val is not None else "N/A") + current_row += 1 + + current_row += 1 + + # Personal Medical History + if user_input.personal_medical_history and ( + user_input.personal_medical_history.known_genetic_mutations + or user_input.personal_medical_history.previous_cancers + or user_input.personal_medical_history.chronic_illnesses + ): + ws.cell( + row=current_row, column=1, value="Personal Medical History" + ).font = bold_font + current_row += 1 + pmh_texts = [] + if user_input.personal_medical_history.known_genetic_mutations: + ws.cell( + row=current_row, column=1, value="Known Genetic Mutations" + ).font = bold_font + ws.cell( + row=current_row, + column=2, + value=", ".join( + user_input.personal_medical_history.known_genetic_mutations + ), + ).alignment = wrap_alignment + current_row += 1 + if user_input.personal_medical_history.previous_cancers: + ws.cell( + row=current_row, column=1, value="Previous Cancers" + ).font = bold_font + ws.cell( + row=current_row, + column=2, + value=", ".join(user_input.personal_medical_history.previous_cancers), + ).alignment = wrap_alignment + current_row += 1 + if user_input.personal_medical_history.chronic_illnesses: + ws.cell( + row=current_row, column=1, value="Chronic Illnesses" + ).font = bold_font + ws.cell( + row=current_row, + column=2, + value=", ".join(user_input.personal_medical_history.chronic_illnesses), + ).alignment = wrap_alignment + current_row += 1 + current_row += 1 + + # Family History + if user_input.family_history: + ws.cell(row=current_row, column=1, value="Family History").font = bold_font + current_row += 1 + family_texts = [ + f"{mem.relative} ({mem.cancer_type} at age {mem.age_at_diagnosis or 'N/A'})" + for mem in user_input.family_history + ] + ws.cell( + row=current_row, column=2, value="; ".join(family_texts) + ).alignment = wrap_alignment + current_row += 2 + + # Female-Specific + if user_input.female_specific: + ws.cell(row=current_row, column=1, value="Female-Specific").font = bold_font + current_row += 1 + female_specific_info = { + "Age at first period": user_input.female_specific.age_at_first_period, + "Age at menopause": user_input.female_specific.age_at_menopause, + "Number of live births": user_input.female_specific.num_live_births, + "Age at first live birth": user_input.female_specific.age_at_first_live_birth, + "Hormone therapy use": user_input.female_specific.hormone_therapy_use, + } + for key, val in female_specific_info.items(): + ws.cell(row=current_row, column=1, value=key).font = bold_font + ws.cell( + row=current_row, column=2, value=str(val) if val is not None else "N/A" + ) + current_row += 1 + current_row += 1 + + # Current Concerns + if user_input.current_concerns_or_symptoms: + ws.cell(row=current_row, column=1, value="Current Concerns").font = bold_font + current_row += 1 + ws.cell( + row=current_row, column=2, value=user_input.current_concerns_or_symptoms + ).alignment = wrap_alignment + current_row += 2 + + # Clinical Observations + if user_input.clinical_observations: + ws.merge_cells(f"A{current_row}:F{current_row}") + ws.cell( + row=current_row, column=1, value="Clinical Observations" + ).font = bold_font + current_row += 1 + headers = ["Test Name", "Value", "Unit", "Reference Range", "Date"] + for col_idx, header in enumerate(headers, 1): + cell = ws.cell(row=current_row, column=col_idx, value=header) + cell.font = header_font + cell.fill = header_fill + for obs in user_input.clinical_observations: + current_row += 1 + ws.cell(row=current_row, column=1, value=obs.test_name) + ws.cell(row=current_row, column=2, value=obs.value) + ws.cell(row=current_row, column=3, value=obs.unit) + ws.cell(row=current_row, column=4, value=obs.reference_range) + ws.cell(row=current_row, column=5, value=obs.date) + current_row += 1 + + ws.merge_cells( + start_row=current_row, start_column=1, end_row=current_row, end_column=6 + ) + ws.cell(row=current_row, column=1, value="Assessment").font = section_font + current_row += 1 + + ws.cell(row=current_row, column=1, value="Overall Summary").font = bold_font + current_row += 1 + + ws.cell(row=current_row, column=1, value="Overall Risk Score:").font = bold_font + ws.cell(row=current_row, column=2, value=f"{assessment.overall_risk_score} / 100") + current_row += 1 + ws.cell(row=current_row, column=1, value="Summary:").font = bold_font + ws.merge_cells( + start_row=current_row, start_column=2, end_row=current_row + 1, end_column=6 + ) + summary_cell = ws.cell(row=current_row, column=2, value=assessment.overall_summary) + summary_cell.alignment = wrap_alignment + + current_row += 3 + ws.merge_cells(f"A{current_row}:F{current_row}") + ws.cell( + row=current_row, column=1, value="Detailed Risk Assessments" + ).font = section_font + current_row += 1 + headers = ["Cancer Type", "Risk Level (1-5)", "Explanation", "Recommended Steps"] + for col_idx, header in enumerate(headers, 1): + cell = ws.cell(row=current_row, column=col_idx, value=header) + cell.font = header_font + cell.fill = header_fill + sorted_risk_assessments = sorted( + assessment.risk_assessments, key=lambda x: x.risk_level or 0, reverse=True + ) + for ra in sorted_risk_assessments: + current_row += 1 + risk_fill = PatternFill( + start_color=_get_risk_color(ra.risk_level), fill_type="solid" + ) + steps = ( + " \u2022 " + "\n \u2022 ".join(ra.recommended_steps) + if isinstance(ra.recommended_steps, list) + else (ra.recommended_steps or "N/A") + ) + ws.cell(row=current_row, column=1, value=ra.cancer_type) + ws.cell(row=current_row, column=2, value=ra.risk_level).fill = risk_fill + ws.cell( + row=current_row, column=3, value=ra.explanation + ).alignment = wrap_alignment + ws.cell(row=current_row, column=4, value=steps).alignment = wrap_alignment + + current_row += 2 + ws.merge_cells(f"A{current_row}:F{current_row}") + ws.cell( + row=current_row, column=1, value="Diagnostic Recommendations" + ).font = section_font + current_row += 1 + headers = [ + "Test Name", + "Recommendation (1-5)", + "Frequency", + "Rationale", + "Applicable Guideline", + ] + for col_idx, header in enumerate(headers, 1): + cell = ws.cell(row=current_row, column=col_idx, value=header) + cell.font = header_font + cell.fill = header_fill + sorted_dx_recommendations = sorted( + assessment.dx_recommendations, + key=lambda x: x.recommendation_level or 0, + reverse=True, + ) + for dr in sorted_dx_recommendations: + current_row += 1 + rec_fill = PatternFill( + start_color=_get_rec_color(dr.recommendation_level), fill_type="solid" + ) + ws.cell( + row=current_row, column=1, value=dr.test_name + ).alignment = wrap_alignment + ws.cell( + row=current_row, column=2, value=dr.recommendation_level + ).fill = rec_fill + ws.cell(row=current_row, column=3, value=dr.frequency) + ws.cell( + row=current_row, column=4, value=dr.rationale + ).alignment = wrap_alignment + ws.cell( + row=current_row, column=5, value=dr.applicable_guideline + ).alignment = wrap_alignment + + ws.column_dimensions["A"].width = 25 + ws.column_dimensions["B"].width = 25 + ws.column_dimensions["C"].width = 40 + ws.column_dimensions["D"].width = 40 + ws.column_dimensions["E"].width = 30 + ws.column_dimensions["F"].width = 30 + + +def _create_data_sheet(wb: Workbook, title: str, data: dict) -> None: + ws = wb.create_sheet(title) + pretty_json = json.dumps(data, indent=2) + ws.cell(row=1, column=1, value=pretty_json) + ws.column_dimensions["A"].width = 100 + + +class PageNumCanvas(SimpleDocTemplate): + """Simple document template that draws page numbers on pages.""" + + def __init__(self, *args, **kwargs): + self.canvas = args[0] + SimpleDocTemplate.__init__(self, *args[1:], **kwargs) + + def later_page(self, doc): # pylint: disable=unused-argument + """Attach page numbers to each rendered page. + + Args: + doc: The ReportLab document (unused here). + """ + + page_num = self.canvas.getPageNumber() + self.canvas.saveState() + self.canvas.setFont("Helvetica", 9) + self.canvas.drawString(inch, 0.75 * inch, f"Page {page_num}") + self.canvas.restoreState() + + +def generate_pdf_report( + assessment: InitialAssessment, user_input: UserInput, filename: str +) -> None: + """Render a PDF report summarising assessment results and visuals. + + Args: + assessment: The structured initial assessment. + user_input: The input data used to generate the assessment. + filename: Destination PDF file path. + """ + doc = SimpleDocTemplate( + filename, + pagesize=letter, + topMargin=0.5 * inch, + bottomMargin=inch, + leftMargin=inch, + rightMargin=inch, + ) + styles = getSampleStyleSheet() + + # --- Base Style Modifications --- + styles["BodyText"].fontName = FONT_NAME + styles["BodyText"].fontSize = FONT_SIZE_BODY + styles["BodyText"].leading = LEADING_BODY + styles["BodyText"].spaceAfter = SPACE_AFTER_P + + styles["h1"].fontName = FONT_NAME_BOLD + styles["h1"].fontSize = FONT_SIZE_TITLE + styles["h1"].spaceAfter = SPACE_AFTER_TITLE + + styles["h2"].fontName = FONT_NAME_BOLD + styles["h2"].fontSize = FONT_SIZE_H2 + styles["h2"].spaceAfter = SPACE_AFTER_H2 + + styles["h3"].fontName = FONT_NAME_BOLD + styles["h3"].fontSize = FONT_SIZE_H3 + styles["h3"].spaceAfter = SPACE_AFTER_H3 + + styles["h4"].fontName = FONT_NAME_BOLD + styles["h4"].fontSize = FONT_SIZE_H4 + + # Custom style for Title + title_style = ParagraphStyle( + "Title", + parent=styles["h1"], + alignment=TA_CENTER, + ) + + # Custom style for headings + heading_style = ParagraphStyle( + "Heading", + parent=styles["h2"], + backColor=colors.HexColor(f"#{HEX_COLORS['blue']}"), + textColor=colors.black, + borderPadding=(5, 5), + borderRadius=2, + ) + + # Custom style for subheadings + subheading_style = ParagraphStyle( + "Subheading", + parent=styles["h3"], + ) + + cancer_heading_style = ParagraphStyle( + "CancerHeading", + parent=styles["h4"], + leftIndent=0, + ) + + summary_header_style = ParagraphStyle( + "SummaryHeader", + parent=styles["h4"], + alignment=TA_CENTER, + fontName=FONT_NAME, + ) + + # Custom style for indented text + indented_style = ParagraphStyle( + "Indented", parent=styles["BodyText"], leftIndent=15 + ) + + # Custom style for info tables + info_table_style = ParagraphStyle( + "InfoTableStyle", + parent=styles["BodyText"], + fontName=FONT_NAME_TABLE, + ) + + # Custom styles for tables + table_body_style = ParagraphStyle( + "TableBodyText", + parent=styles["BodyText"], + fontName=FONT_NAME_TABLE, + fontSize=FONT_SIZE_TABLE_BODY, + leading=FONT_SIZE_TABLE_BODY + 2, + ) + table_body_style_centered = ParagraphStyle( + "TableBodyTextCentered", + parent=table_body_style, + alignment=TA_CENTER, + ) + table_header_style = ParagraphStyle( + "TableHeader", + parent=styles["h4"], + fontName=FONT_NAME_TABLE, + fontSize=FONT_SIZE_TABLE_HEADER, + textColor=colors.white, + ) + + story = [] + + story.append(Paragraph("BiOS Sentinel", title_style)) + story.append(Paragraph("Personalised Cancer Risk Assessment", title_style)) + story.append(Spacer(1, SPACER_NORMAL)) + + story.append(Paragraph("User Information", heading_style)) + + def add_section(title, data): + if not data: + return + story.append(Spacer(1, SPACER_SMALL)) + story.append(Paragraph(title, subheading_style)) + indent = 0.2 * inch + table_data = [ + [ + "", + Paragraph(f"{k}", info_table_style), + Paragraph(str(v), info_table_style), + ] + for k, v in data.items() + ] + story.append( + Table( + table_data, + colWidths=[indent, 2.0 * inch, 4.3 * inch], + style=[ + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("BOTTOMPADDING", (0, 0), (-1, -1), 1.5), + ], + ) + ) + + def add_list_section(title, items): + if not items: + return + story.append(Spacer(1, SPACER_SMALL)) + story.append(Paragraph(title, subheading_style)) + # Each item is a single string, so it spans the whole table + indent = 0.2 * inch + table_data = [["", Paragraph(item, info_table_style)] for item in items] + story.append( + Table( + table_data, + colWidths=[indent, 6.3 * inch], + style=[ + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("BOTTOMPADDING", (0, 0), (-1, -1), 1.5), + ], + ) + ) + + # --- Demographics --- + add_section( + "Demographics", + { + "Age": user_input.demographics.age, + "Sex": user_input.demographics.sex, + "Ethnicity": user_input.demographics.ethnicity or "N/A", + }, + ) + + # --- Lifestyle --- + add_section( + "Lifestyle", + { + "Smoking Status": user_input.lifestyle.smoking_status, + "Pack Years": user_input.lifestyle.smoking_pack_years or "N/A", + "Alcohol Consumption": user_input.lifestyle.alcohol_consumption, + "Dietary Habits": user_input.lifestyle.dietary_habits or "N/A", + "Physical Activity": user_input.lifestyle.physical_activity_level or "N/A", + }, + ) + + # --- Personal Medical History --- + pmh = user_input.personal_medical_history + if pmh and ( + pmh.known_genetic_mutations or pmh.previous_cancers or pmh.chronic_illnesses + ): + pmh_data = {} + if pmh.known_genetic_mutations: + pmh_data["Known Genetic Mutations"] = ", ".join(pmh.known_genetic_mutations) + if pmh.previous_cancers: + pmh_data["Previous Cancers"] = ", ".join(pmh.previous_cancers) + if pmh.chronic_illnesses: + pmh_data["Chronic Illnesses"] = ", ".join(pmh.chronic_illnesses) + add_section("Personal Medical History", pmh_data) + + # --- Family History --- + if user_input.family_history: + family_texts = [ + f"{mem.relative} - {mem.cancer_type} (Age: {mem.age_at_diagnosis or 'N/A'})" + for mem in user_input.family_history + ] + add_list_section("Family History", family_texts) + + # --- Female-Specific --- + fs = user_input.female_specific + if fs: + fs_data = {} + if fs.age_at_first_period is not None: + fs_data["Age at first period"] = fs.age_at_first_period + if fs.age_at_menopause is not None: + fs_data["Age at menopause"] = fs.age_at_menopause + if fs.num_live_births is not None: + fs_data["Number of live births"] = fs.num_live_births + if fs.age_at_first_live_birth is not None: + fs_data["Age at first live birth"] = fs.age_at_first_live_birth + if fs.hormone_therapy_use: + fs_data["Hormone therapy"] = fs.hormone_therapy_use + add_section("Female-Specific", fs_data) + + # --- Current Concerns --- + if user_input.current_concerns_or_symptoms: + add_list_section("Current Concerns", [user_input.current_concerns_or_symptoms]) + + story.append(Spacer(1, SPACER_NORMAL)) + + # --- Clinical Observations Table --- + if user_input.clinical_observations: + story.append(Paragraph("Clinical Observations", subheading_style)) + obs_data = [ + [ + Paragraph(h, table_header_style) + for h in ["Test", "Value", "Unit", "Range", "Date"] + ] + ] + obs_style_cmds = [ + ( + "BACKGROUND", + (0, 0), + (-1, 0), + colors.HexColor(f"#{HEX_COLORS['header_fill']}"), + ), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("GRID", (0, 0), (-1, -1), 1, colors.black), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("BOTTOMPADDING", (0, 0), (-1, -1), 4), + ("TOPPADDING", (0, 0), (-1, -1), 4), + ] + for obs in user_input.clinical_observations: + obs_data.append( + [ + Paragraph(obs.test_name, table_body_style), + Paragraph(obs.value, table_body_style), + Paragraph(obs.unit, table_body_style), + Paragraph(obs.reference_range or "N/A", table_body_style), + Paragraph(obs.date or "N/A", table_body_style), + ] + ) + obs_widths = [1.75 * inch, 0.75 * inch, 0.75 * inch, 1.75 * inch, 1.5 * inch] + scaled_widths = [w * (CONTENT_WIDTH / sum(obs_widths)) for w in obs_widths] + obs_table = Table( + obs_data, colWidths=scaled_widths, style=obs_style_cmds, splitByRow=1 + ) + story.append(obs_table) + story.append(Spacer(1, SPACER_NORMAL)) + + # --- Risk Scores Table --- + if user_input.risks_scores: + story.append(Paragraph("Risk Scores", subheading_style)) + obs_data = [ + [ + Paragraph(h, table_header_style) + for h in [ + "Model", + "Score", + "Cancer Type", + "Description", + "Interpretation", + ] + ] + ] + obs_style_cmds = [ + ( + "BACKGROUND", + (0, 0), + (-1, 0), + colors.HexColor(f"#{HEX_COLORS['header_fill']}"), + ), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("GRID", (0, 0), (-1, -1), 1, colors.black), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("BOTTOMPADDING", (0, 0), (-1, -1), 4), + ("TOPPADDING", (0, 0), (-1, -1), 4), + ] + for risk_score in user_input.risks_scores: + obs_data.append( + [ + Paragraph(risk_score.name, table_body_style), + Paragraph(risk_score.score, table_body_style), + Paragraph(risk_score.cancer_type or "N/A", table_body_style), + Paragraph(risk_score.description or "N/A", table_body_style), + Paragraph(risk_score.interpretation or "N/A", table_body_style), + ] + ) + obs_widths = [1.75 * inch, 0.75 * inch, 0.75 * inch, 1.75 * inch, 1.5 * inch] + scaled_widths = [w * (CONTENT_WIDTH / sum(obs_widths)) for w in obs_widths] + obs_table = Table( + obs_data, colWidths=scaled_widths, style=obs_style_cmds, splitByRow=1 + ) + story.append(obs_table) + story.append(Spacer(1, SPACER_NORMAL)) + + story.append(PageBreak()) + story.append(Paragraph("Assessment", heading_style)) + story.append(Spacer(1, SPACER_NORMAL)) + + # --- New 3-Column Summary Section --- + headers = [ + Paragraph("Overall Risk Score", summary_header_style), + Paragraph("Risk Breakdown", summary_header_style), + Paragraph("Dx Recommendations", summary_header_style), + ] + + gauge = "" + if assessment.overall_risk_score is not None: + gauge = _create_risk_gauge(assessment.overall_risk_score, width=120, height=70) + + risk_panel = _create_risk_breakdown_chart( + assessment.risk_assessments, width=150, height=70 + ) + dx_panel = _create_dx_recommendations_summary( + assessment.dx_recommendations, width=150, height=70 + ) + + content_row = [gauge, risk_panel, dx_panel] + + summary_data = [headers, content_row] + summary_table = Table(summary_data, colWidths=[2.1 * inch, 2.2 * inch, 2.2 * inch]) + summary_table.setStyle( + TableStyle( + [ + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("ALIGN", (0, 0), (-1, 0), "CENTER"), + ("LEFTPADDING", (0, 0), (-1, -1), 0), + ("RIGHTPADDING", (0, 0), (-1, -1), 0), + ] + ) + ) + + story.append(summary_table) + story.append(Spacer(1, 0.2 * inch)) + + # --- New Risk Factor Summary Section --- + risk_points_by_category = _calculate_risk_points(assessment) + + # Custom styles for the new risk factor panel table + panel_header_style = ParagraphStyle( + "PanelHeader", + parent=styles["BodyText"], + fontName=FONT_NAME_BOLD, + fontSize=FONT_SIZE_PANEL_TEXT, + textColor=colors.black, + ) + panel_body_style = ParagraphStyle( + "PanelBody", + parent=styles["BodyText"], + fontName=FONT_NAME, + fontSize=FONT_SIZE_PANEL_TEXT, + leading=FONT_SIZE_PANEL_TEXT + 1, + ) + + if risk_points_by_category: + pie_chart_width = CONTENT_WIDTH * 0.5 + pie_chart = _create_risk_factor_pie_chart( + risk_points_by_category, + width=pie_chart_width, + height=90, + use_external_legend=True, + ) + risk_factor_table_obj = _create_risk_factor_table( + assessment, + risk_points_by_category, + panel_header_style, + panel_body_style, + ) + + risk_factor_headers = [ + Paragraph("Risk Factor Contribution", summary_header_style), + Paragraph("Identified Risk Factors", summary_header_style), + ] + risk_factor_content = [pie_chart, risk_factor_table_obj] + + risk_factor_summary_table = Table( + [risk_factor_headers, risk_factor_content], + colWidths=[CONTENT_WIDTH * 0.5, CONTENT_WIDTH * 0.5], + ) + risk_factor_summary_table.setStyle( + TableStyle( + [ + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("ALIGN", (0, 0), (-1, 0), "CENTER"), + ("ALIGN", (0, 1), (0, 1), "CENTER"), + ("LEFTPADDING", (0, 0), (-1, -1), 0), + ("RIGHTPADDING", (0, 0), (-1, -1), 0), + ( + "TOPPADDING", + (0, 1), + (-1, 1), + 5, + ), # Add padding to the top of content row + ] + ) + ) + story.append(risk_factor_summary_table) + story.append(Spacer(1, 0.2 * inch)) + + if assessment.overall_summary: + summary_html = _markdown_to_reportlab(assessment.overall_summary) + summary_text = Paragraph(summary_html, styles["BodyText"]) + story.append(summary_text) + + story.append(Spacer(1, SPACER_NORMAL)) + + story.append(Paragraph("Detailed Risk Assessments", subheading_style)) + story.append(Spacer(1, SPACER_SMALL)) + risk_intro_text = """ + The following table outlines your personalized cancer risk assessment. The risk level is graded + on a scale from 1 (lowest risk) to 5 (highest risk) based on the information provided. + Additional detail on the contributing risk factors and possible recommendation are then + provided for any and all higher risk cancers (scoring 3-5). + """ + story.append(Paragraph(risk_intro_text, styles["BodyText"])) + story.append(Spacer(1, SPACER_SMALL)) + + risk_data = [ + [ + Paragraph(h, table_header_style) + for h in ["Cancer Type", "Risk", "Explanation"] + ] + ] + risk_style_cmds = [ + ( + "BACKGROUND", + (0, 0), + (-1, 0), + colors.HexColor(f"#{HEX_COLORS['header_fill']}"), + ), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("GRID", (0, 0), (-1, -1), 1, colors.black), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("BOTTOMPADDING", (0, 0), (-1, -1), 4), + ("TOPPADDING", (0, 0), (-1, -1), 4), + ] + sorted_risk_assessments = sorted( + assessment.risk_assessments, key=lambda x: x.risk_level or 0, reverse=True + ) + for i, ra in enumerate(sorted_risk_assessments, 1): + risk_data.append( + [ + Paragraph(ra.cancer_type or "", table_body_style), + Paragraph(str(ra.risk_level), table_body_style_centered), + Paragraph(ra.explanation or "", table_body_style), + ] + ) + risk_style_cmds.append( + ("BACKGROUND", (1, i), (1, i), _get_risk_color(ra.risk_level, "pdf")) + ) + risk_widths = [1.5 * inch, 0.5 * inch, 4.5 * inch] + scaled_widths = [w * (CONTENT_WIDTH / sum(risk_widths)) for w in risk_widths] + risk_table = Table( + risk_data, colWidths=scaled_widths, style=risk_style_cmds, splitByRow=1 + ) + story.append(risk_table) + + high_risk_assessments = [ + ra for ra in sorted_risk_assessments if (ra.risk_level or 0) >= 3 + ] + + for ra in high_risk_assessments: + story.append(Paragraph(f"{ra.cancer_type}", cancer_heading_style)) + + if ra.contributing_factors: + story.append(Spacer(1, SPACER_SMALL)) + story.append(Paragraph("Risk factors", styles["BodyText"])) + story.append(Spacer(1, SPACER_TINY)) + + factor_data = [ + [ + Paragraph("Contributing Factor", table_header_style), + Paragraph("Category", table_header_style), + Paragraph("Impact", table_header_style), + ] + ] + factor_style_cmds = [ + ( + "BACKGROUND", + (0, 0), + (-1, 0), + colors.HexColor(f"#{HEX_COLORS['header_fill']}"), + ), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("GRID", (0, 0), (-1, -1), 1, colors.black), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("BOTTOMPADDING", (0, 0), (-1, -1), 4), + ("TOPPADDING", (0, 0), (-1, -1), 4), + ] + + for i, factor in enumerate(ra.contributing_factors): + factor_data.append( + [ + Paragraph(factor.description, table_body_style), + Paragraph(factor.category.value, table_body_style), + Paragraph(factor.strength.value, table_body_style), + ] + ) + if factor.strength == ContributionStrength.MAJOR: + color = colors.HexColor(f"#{HEX_COLORS['red']}") + elif factor.strength == ContributionStrength.MODERATE: + color = colors.HexColor(f"#{HEX_COLORS['yellow']}") + else: # Minor + color = colors.HexColor(f"#{HEX_COLORS['green']}") + factor_style_cmds.append(("BACKGROUND", (2, i + 1), (2, i + 1), color)) + + factor_widths = [3.5 * inch, 1.5 * inch, 1.5 * inch] + scaled_factor_widths = [ + w * (CONTENT_WIDTH / sum(factor_widths)) for w in factor_widths + ] + factor_table = Table( + factor_data, + colWidths=scaled_factor_widths, + style=factor_style_cmds, + splitByRow=1, + ) + story.append(factor_table) + + if ra.recommended_steps: + story.append(Spacer(1, SPACER_SMALL)) + story.append(Paragraph("Recommended steps", styles["BodyText"])) + steps = ( + ra.recommended_steps + if isinstance(ra.recommended_steps, list) + else [ra.recommended_steps] + ) + for step in steps: + p = Paragraph(f"• {step}", indented_style) + story.append(p) + + story.append(Spacer(1, SPACER_NORMAL)) + + story.append(Paragraph("Diagnostic Recommendations", subheading_style)) + story.append(Spacer(1, SPACER_SMALL)) + dx_intro_text = """ + Based on your risk profile, the following diagnostic tests are recommended. The recommendation + level is on a scale from 1 (lowest priority) to 5 (highest priority/urgency). + """ + story.append(Paragraph(dx_intro_text, styles["BodyText"])) + story.append(Spacer(1, SPACER_SMALL)) + + dx_data = [ + [ + Paragraph(h, table_header_style) + for h in ["Test", "Rec.", "Frequency", "Rationale"] + ] + ] + dx_style_cmds = [ + ( + "BACKGROUND", + (0, 0), + (-1, 0), + colors.HexColor(f"#{HEX_COLORS['header_fill']}"), + ), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("GRID", (0, 0), (-1, -1), 1, colors.black), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("BOTTOMPADDING", (0, 0), (-1, -1), 4), + ("TOPPADDING", (0, 0), (-1, -1), 4), + ] + sorted_dx_recommendations = sorted( + assessment.dx_recommendations, + key=lambda x: x.recommendation_level or 0, + reverse=True, + ) + for i, dr in enumerate(sorted_dx_recommendations, 1): + dx_data.append( + [ + Paragraph(dr.test_name or "", table_body_style), + Paragraph(str(dr.recommendation_level), table_body_style_centered), + Paragraph(dr.frequency or "", table_body_style), + Paragraph(dr.rationale or "", table_body_style), + ] + ) + dx_style_cmds.append( + ( + "BACKGROUND", + (1, i), + (1, i), + _get_rec_color(dr.recommendation_level, "pdf"), + ) + ) + dx_widths = [1.5 * inch, 0.5 * inch, 1.5 * inch, 2.9 * inch] + scaled_widths = [w * (CONTENT_WIDTH / sum(dx_widths)) for w in dx_widths] + dx_table = Table( + dx_data, colWidths=scaled_widths, style=dx_style_cmds, splitByRow=1 + ) + story.append(dx_table) + + story.append(Spacer(1, SPACER_NORMAL)) + disclaimer = """ + IMPORTANT: This assessment does not replace professional medical advice. + """ + story.append(Paragraph(disclaimer, styles["BodyText"])) + + # --- Appendix Section --- + if assessment.thinking or assessment.reasoning: + story.append(PageBreak()) + story.append(Paragraph("Appendix", heading_style)) + story.append(Spacer(1, SPACER_NORMAL)) + + if assessment.thinking: + story.append(Paragraph("Thinking Process", subheading_style)) + # Use a preformatted style for better readability of raw text + pre_style = styles["Code"] + thinking_text = assessment.thinking.replace("\n", "
        ") + story.append(Paragraph(thinking_text, pre_style)) + story.append(Spacer(1, SPACER_SMALL)) + + if assessment.reasoning: + story.append(Paragraph("Reasoning", subheading_style)) + pre_style = styles["Code"] + reasoning_text = assessment.reasoning.replace("\n", "
        ") + story.append(Paragraph(reasoning_text, pre_style)) + + report_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + def _draw_footer(canvas, doc): # pylint: disable=unused-argument + canvas.saveState() + canvas.setFont(FONT_NAME, FONT_SIZE_BODY) + page_num = canvas.getPageNumber() + # Draw page number on the left + canvas.drawString(inch, 0.5 * inch, f"Page {page_num}") + # Draw timestamp on the right + canvas.drawRightString( + letter[0] - inch, 0.5 * inch, f"Report Generated: {report_time}" + ) + canvas.restoreState() + + doc.build(story, onFirstPage=_draw_footer, onLaterPages=_draw_footer) + + +def _get_color_for_score(score: int | None): + """Interpolate color from green to yellow to red based on score. + + Args: + score: Overall risk score from 0 to 100, or None. + + Returns: + A ReportLab Color object. + """ + if score is None: + return black # Default color if score is None + score = max(0, min(100, score)) # Clamp score between 0 and 100 + if score <= 50: + # Interpolate from Green (0, 1, 0) to Yellow (1, 1, 0) + red = score / 50.0 + green = 1.0 + else: + # Interpolate from Yellow (1, 1, 0) to Red (1, 0, 0) + red = 1.0 + green = 1.0 - (score - 50) / 50.0 + return Color(red, green, 0) + + +def _create_risk_gauge(score: int | None, width: int = 150, height: int = 100): + """Create a reportlab drawing representing the risk gauge. + + Args: + score: Overall risk score from 0 to 100, or None. + width: Width of the drawing in points. + height: Height of the drawing in points. + + Returns: + A ReportLab Drawing object. + """ + if score is None: + return Drawing(width, height) # Return an empty drawing if no score + + d = Drawing(width, height) + g = Group() + + # Center and radius for the gauge, positioned towards the top + radius = min(width / 2, height) - 15 + cx, cy = width / 2 + 15, 18 # Place the center low so the arc is high + + # Draw the gauge background arc with the header blue color and opacity + hex_val = HEX_COLORS["header_fill"] + r = int(hex_val[0:2], 16) / 255.0 + green_val = int(hex_val[2:4], 16) / 255.0 + b = int(hex_val[4:6], 16) / 255.0 + color = Color(r, green_val, b, alpha=0.6) + + g.add(Wedge(cx, cy, radius, 0, 180, fillColor=color, strokeColor=color)) + + # Draw the inner white arc to create the gauge band + g.add(Wedge(cx, cy, radius * 0.75, 0, 180, fillColor=white, strokeColor=white)) + + # Draw the needle as a triangle for a modern look + needle_angle_rad = math.radians(180 - (score * 1.8)) + nx = cx + (radius * 0.9) * math.cos(needle_angle_rad) + ny = cy + (radius * 0.9) * math.sin(needle_angle_rad) + + # Triangle vertices for the needle + p1 = (nx, ny) + base_width = 2.5 + angle_rad_p2 = math.radians(180 - (score * 1.8) + 90) + p2 = ( + cx + base_width * math.cos(angle_rad_p2), + cy + base_width * math.sin(angle_rad_p2), + ) + angle_rad_p3 = math.radians(180 - (score * 1.8) - 90) + p3 = ( + cx + base_width * math.cos(angle_rad_p3), + cy + base_width * math.sin(angle_rad_p3), + ) + + g.add( + Polygon( + [p1[0], p1[1], p2[0], p2[1], p3[0], p3[1]], + fillColor=colors.grey, + strokeColor=colors.grey, + ) + ) + + # Add a pivot circle for the needle + g.add( + Wedge( + cx, cy, base_width, 0, 360, fillColor=colors.grey, strokeColor=colors.grey + ) + ) + + # Add the score text in the middle, colored by risk + score_color = _get_color_for_score(score) + score_text = String( + cx, + cy + 19, + f"{score}", + fontSize=FONT_SIZE_GAUGE_SCORE, + fontName=FONT_NAME_BOLD, + fillColor=score_color, + textAnchor="middle", + ) + g.add(score_text) + + # Add "Low" and "High" risk labels + g.add( + String( + cx - 0.875 * radius, + cy - 10, + "Low", + fontSize=FONT_SIZE_GAUGE_LABEL, + textAnchor="middle", + fillColor=colors.grey, + ) + ) + g.add( + String( + cx + 0.875 * radius, + cy - 10, + "High", + fontSize=FONT_SIZE_GAUGE_LABEL, + textAnchor="middle", + fillColor=colors.grey, + ) + ) + + d.add(g) + return d + + +def _create_risk_breakdown_chart(risk_assessments, width, height): + d = Drawing(width, height) + max_items_to_show = 6 + + # Filter and sort risks + relevant_risks = [r for r in risk_assessments if r.risk_level and r.risk_level > 0] + sorted_risks = sorted( + relevant_risks, + key=lambda x: x.risk_level or 0, + reverse=True, + ) + + items_to_render = sorted_risks[:max_items_to_show] + + if not items_to_render: + d.add( + String( + width / 2, + height / 2, + "No specific risks identified.", + textAnchor="middle", + fontName=FONT_NAME, + fontSize=FONT_SIZE_PANEL_TEXT, + ) + ) + return d + + # Chart parameters + y_label_width = 40 + chart_area_width = width - y_label_width - 5 + bar_height = 8 + bar_spacing = 4 + max_risk = 5 + + total_chart_height = len(items_to_render) * (bar_height + bar_spacing) + y_offset = (height - total_chart_height) / 2 # Center vertically + + for i, risk in enumerate(items_to_render): + y_pos = height - y_offset - (i * (bar_height + bar_spacing)) - bar_height + + raw_label = risk.cancer_type or "Unknown" + lower_label = raw_label.lower() + cancer_index = lower_label.find(" cancer") + if cancer_index != -1: + label_text = raw_label[:cancer_index] + else: + label_text = raw_label + + if len(label_text) > 15: + label_text = label_text[:14] + "…" + + label = String( + y_label_width - 5, + y_pos + 1, + label_text, + textAnchor="end", + fontName=FONT_NAME, + fontSize=FONT_SIZE_PANEL_TEXT, + ) + d.add(label) + + # bar_width = (risk.risk_level / max_risk) * chart_area_width + # bar_width = (risk.risk_level / max_risk) * chart_area_width + + width_zero = chart_area_width * 0.05 + bar_width = width_zero + (risk.risk_level - 1) / (max_risk - 1) * ( + chart_area_width - width_zero + ) + bar_color = _get_risk_color(risk.risk_level, "pdf") + bar = Rect( + y_label_width, + y_pos, + bar_width, + bar_height, + fillColor=bar_color, + strokeColor=bar_color, + ) + d.add(bar) + + return d + + +def _create_dx_recommendations_summary(dx_recs, width, height): + d = Drawing(width, height) + max_items_to_show = 4 + + # Filter and sort recs + relevant_recs = [ + r for r in dx_recs if r.recommendation_level and r.recommendation_level >= 3 + ] + sorted_recs = sorted( + relevant_recs, + key=lambda x: x.recommendation_level or 0, + reverse=True, + ) + + items_to_render = sorted_recs[:max_items_to_show] + + if not items_to_render: + d.add( + String( + width / 2, + height / 2, + "No urgent recommendations.", + textAnchor="middle", + fontName=FONT_NAME, + fontSize=FONT_SIZE_PANEL_TEXT, + ) + ) + return d + + # Panel parameters + col_test_x = 5 + col_freq_x = 75 + col_urgency_x = 120 + header_y = height - 5 + row_height = 15 + + # Headers + d.add( + String( + col_test_x, + header_y, + "Test", + fontName=FONT_NAME_BOLD, + fontSize=FONT_SIZE_PANEL_TEXT, + ) + ) + d.add( + String( + col_freq_x, + header_y, + "Frequency", + fontName=FONT_NAME_BOLD, + fontSize=FONT_SIZE_PANEL_TEXT, + ) + ) + d.add( + String( + col_urgency_x, + header_y, + "Urgency", + fontName=FONT_NAME_BOLD, + fontSize=FONT_SIZE_PANEL_TEXT, + ) + ) + + # Underline below headers + d.add( + Rect( + col_test_x, + header_y - 4, + width - 10, + 0.5, + fillColor=colors.black, + strokeColor=colors.black, + ) + ) + + for i, rec in enumerate(items_to_render): + y_pos = header_y - ((i + 1) * row_height) + + test_name = (rec.test_name or "N/A")[:14] + d.add( + String( + col_test_x, + y_pos, + test_name, + fontName=FONT_NAME, + fontSize=FONT_SIZE_PANEL_TEXT, + ) + ) + + frequency = (rec.frequency or "N/A")[:12] + d.add( + String( + col_freq_x, + y_pos, + frequency, + fontName=FONT_NAME, + fontSize=FONT_SIZE_PANEL_TEXT, + ) + ) + + urgency_color = _get_rec_color(rec.recommendation_level, "pdf") + + # Draw a circle with a number inside for urgency + urgency_cx = col_urgency_x + 14 + urgency_cy = y_pos + 2 + urgency_radius = 5 + + # Circle with colored fill + d.add( + Circle( + urgency_cx, + urgency_cy, + urgency_radius, + fillColor=urgency_color, + strokeColor=urgency_color, + ) + ) + + # Number inside the circle, with black text + if rec.recommendation_level is not None: + # Heuristic for vertical centering of the number in the circle + text_y_offset = FONT_SIZE_PANEL_TEXT * 0.3 + d.add( + String( + urgency_cx, + urgency_cy - text_y_offset, + str(rec.recommendation_level), + fontName=FONT_NAME_BOLD, + fontSize=FONT_SIZE_PANEL_TEXT, + fillColor=colors.black, + textAnchor="middle", + ) + ) + + return d + + +def _calculate_risk_points( + assessment: InitialAssessment, +) -> dict[RiskFactorCategory, int]: + """Calculate total risk points for each category. + + Args: + assessment: The structured initial assessment. + + Returns: + Mapping of RiskFactorCategory to integer points. + """ + from collections import defaultdict + + risk_points_by_category = defaultdict(int) + strength_to_points = { + ContributionStrength.MAJOR: 5, + ContributionStrength.MODERATE: 3, + ContributionStrength.MINOR: 1, + } + + if not assessment.risk_assessments: + return {} + + for cancer_assessment in assessment.risk_assessments: + if not cancer_assessment.contributing_factors: + continue + for factor in cancer_assessment.contributing_factors: + points = strength_to_points.get(factor.strength, 0) + if factor.category: + risk_points_by_category[factor.category] += points + + return dict(risk_points_by_category) + + +def _create_risk_factor_pie_chart( + risk_points_by_category: dict, + width, + height, + use_external_legend: bool = True, +): + """Create a pie chart of risk factor contributions. + + Args: + risk_points_by_category: Map of category to points. + width: Chart width in points. + height: Chart height in points. + use_external_legend: Whether to draw a legend outside the pie. + + Returns: + A ReportLab Drawing representing the pie chart panel. + """ + d = Drawing(width, height) + + if not risk_points_by_category: + d.add( + String( + width / 2, + height / 2, + "No contributing factors.", + textAnchor="middle", + fontName=FONT_NAME, + fontSize=FONT_SIZE_PANEL_TEXT, + ) + ) + return d + + # Chart parameters + pie_area_width = width + legend_area_x = 0 + if use_external_legend: + pie_area_width = width * 0.62 # 62% for the pie + legend_area_x = 0.9 * pie_area_width # Start legend right after pie area + + cx = pie_area_width / 2 + cy = height / 2 + radius = min(pie_area_width, height) / 2.1 # Make pie larger + + total_points = sum(risk_points_by_category.values()) + sorted_categories = sorted( + risk_points_by_category.items(), key=lambda item: item[1], reverse=True + ) + + # Draw pie slices + start_angle = 90 + for category, points in sorted_categories: + if total_points == 0: + continue + sweep_angle = (points / total_points) * 360 + end_angle = start_angle - sweep_angle + category_key = category.value.lower() + color = PDF_RISK_CATEGORY_COLORS.get( + category_key, PDF_RISK_CATEGORY_COLORS["default"] + ) + wedge = Wedge( + cx, + cy, + radius, + end_angle, + start_angle, + fillColor=color, + strokeColor=colors.white, + strokeWidth=0.5, + ) + d.add(wedge) + + # Annotate wedge directly if not using external legend + if not use_external_legend and sweep_angle > 20: + label_angle_rad = math.radians(end_angle + sweep_angle / 2) + label_radius = radius * 0.65 + label_x = cx + label_radius * math.cos(label_angle_rad) + label_y = cy + label_radius * math.sin(label_angle_rad) + + # Shorten label + label_text = category.value.replace("_", " ").title() + if "Personal Medical History" in label_text: + label_text = "Medical" + if "Family History" in label_text: + label_text = "Family" + if "Female-Specific" in label_text: + label_text = "Female" + if "Demographics" in label_text: + label_text = "Demog." + + percentage = (points / total_points) * 100 + + if color.red * 0.299 + color.green * 0.587 + color.blue * 0.114 > 0.6: + text_color = colors.black + else: + text_color = colors.white + + d.add( + String( + label_x, + label_y + 2, + label_text, + fontName=FONT_NAME_BOLD, + fontSize=FONT_SIZE_PANEL_TEXT - 1, + fillColor=text_color, + textAnchor="middle", + ) + ) + d.add( + String( + label_x, + label_y - (FONT_SIZE_PANEL_TEXT - 1), + f"{percentage:.0f}%", + fontName=FONT_NAME, + fontSize=FONT_SIZE_PANEL_TEXT - 2, + fillColor=text_color, + textAnchor="middle", + ) + ) + + start_angle = end_angle + + # Draw external legend + if use_external_legend: + legend_y = height - 10 + legend_item_height = 11 + box_size = 7 + max_legend_items = 6 + + for i, (category, points) in enumerate(sorted_categories[:max_legend_items]): + y_pos = legend_y - (i * legend_item_height) + category_key = category.value.lower() + color = PDF_RISK_CATEGORY_COLORS.get( + category_key, PDF_RISK_CATEGORY_COLORS["default"] + ) + + d.add( + Rect( + legend_area_x, + y_pos, + box_size, + box_size, + fillColor=color, + strokeColor=None, + ) + ) + + # Shorten label for legend + label_text = category.value.replace("_", " ").title() + if "Personal Medical History" in label_text: + label_text = "Medical" + if "Family History" in label_text: + label_text = "Family" + if "Female-Specific" in label_text: + label_text = "Female" + if "Demographics" in label_text: + label_text = "Demog." + + percentage = (points / total_points) * 100 + d.add( + String( + legend_area_x + box_size + 5, + y_pos, + f"{label_text} ({percentage:.0f}%)", + fontName=FONT_NAME, + fontSize=FONT_SIZE_PANEL_TEXT, + ) + ) + + return d + + +def _create_risk_factor_table( + assessment: InitialAssessment, + risk_points_by_category: dict, + panel_header_style, + panel_body_style, +): + """Create a table of identified risk factors ordered by category risk. + + Args: + assessment: The structured initial assessment. + risk_points_by_category: Map of category to points. + panel_header_style: ReportLab ParagraphStyle for headers. + panel_body_style: ReportLab ParagraphStyle for body text. + + Returns: + A ReportLab Table or Paragraph to insert in the story. + """ + from collections import defaultdict + + if not assessment.identified_risk_factors: + return Paragraph("No specific risk factors identified.", panel_body_style) + + # Sort categories by risk points, descending + sorted_categories = sorted( + risk_points_by_category.keys(), + key=lambda c: risk_points_by_category.get(c, 0), + reverse=True, + ) + + # Group identified risk factors by category + factors_by_category = defaultdict(list) + for factor in assessment.identified_risk_factors: + if factor.category: + factors_by_category[factor.category].append(factor) + + # Build table data, ordered by sorted categories + table_data = [ + [ + Paragraph("Category", panel_header_style), + Paragraph("Description", panel_header_style), + ] + ] + + style_cmds = [ + ("LINEBELOW", (0, 0), (-1, 0), 1, colors.black), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("BOTTOMPADDING", (0, 0), (-1, -1), 3), + ("TOPPADDING", (0, 0), (-1, -1), 3), + ("LEFTPADDING", (0, 0), (-1, -1), 0), + ("RIGHTPADDING", (0, 0), (-1, -1), 0), + ] + + row_idx = 1 + for category in sorted_categories: + if category in factors_by_category: + factors_in_category = factors_by_category[category] + if not factors_in_category: + continue + + # This will hold the start row for a potential SPAN and BACKGROUND + span_start_row = row_idx + + for i, factor in enumerate(factors_in_category): + # Only add the category name for the first row of the category + category_cell = ( + Paragraph( + category.value.replace("_", " ").title(), panel_body_style + ) + if i == 0 + else "" + ) + table_data.append( + [category_cell, Paragraph(factor.description, panel_body_style)] + ) + row_idx += 1 + + # Apply background color and span to all rows for this category + if row_idx > span_start_row: + category_color_key = category.value.lower() + category_color = PDF_RISK_CATEGORY_COLORS.get( + category_color_key, PDF_RISK_CATEGORY_COLORS["default"] + ) + # Apply background to the category column for the rows of this category + style_cmds.append( + ( + "BACKGROUND", + (0, span_start_row), + (0, row_idx - 1), + category_color, + ) + ) + # If there's more than one row, span them + if len(factors_in_category) > 1: + style_cmds.append(("SPAN", (0, span_start_row), (0, row_idx - 1))) + + # Always align top + style_cmds.append( + ("VALIGN", (0, span_start_row), (0, row_idx - 1), "TOP") + ) + + if len(table_data) == 1: # Only headers + return Paragraph("No specific risk factors identified.", panel_body_style) + + factor_table = Table( + table_data, + colWidths=[1.2 * inch, (CONTENT_WIDTH * 0.5) - (1.2 * inch) - (0.1 * inch)], + style=style_cmds, + splitByRow=1, + ) + return factor_table diff --git a/src/sentinel/risk_models/__init__.py b/src/sentinel/risk_models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..eae2d1941c0186398b2354f779339d6f5a4a0ce7 --- /dev/null +++ b/src/sentinel/risk_models/__init__.py @@ -0,0 +1,31 @@ +"""Exports for available risk models and their registry.""" + +from sentinel.risk_models.boadicea import BOADICEARiskModel +from sentinel.risk_models.claus import ClausRiskModel +from sentinel.risk_models.crc_pro import CRCProRiskModel +from sentinel.risk_models.extended_pbcg import ExtendedPBCGRiskModel +from sentinel.risk_models.gail import GailRiskModel +from sentinel.risk_models.mrat import MRATRiskModel +from sentinel.risk_models.pcpt import PCPTRiskModel +from sentinel.risk_models.plcom2012 import PLCOm2012RiskModel +from sentinel.risk_models.qcancer import QCancerRiskModel + +RISK_MODELS = [ + GailRiskModel, + PLCOm2012RiskModel, + BOADICEARiskModel, + CRCProRiskModel, + ExtendedPBCGRiskModel, + PCPTRiskModel, + QCancerRiskModel, + ClausRiskModel, + MRATRiskModel, +] + +__all__ = [ + "RISK_MODELS", + "ClausRiskModel", + "GailRiskModel", + "MRATRiskModel", + "PLCOm2012RiskModel", +] diff --git a/src/sentinel/risk_models/base.py b/src/sentinel/risk_models/base.py new file mode 100644 index 0000000000000000000000000000000000000000..c88202f7a04b31552dc4e7bb2fd8b0fb60eaa697 --- /dev/null +++ b/src/sentinel/risk_models/base.py @@ -0,0 +1,118 @@ +"""Abstract base classes for risk model implementations.""" + +from abc import ABC, abstractmethod +from typing import Any + +from pydantic import TypeAdapter, ValidationError + +from sentinel.models import RiskScore +from sentinel.user_input import UserInput + + +class RiskModel(ABC): + """Base class for risk models.""" + + REQUIRED_INPUTS: dict[str, tuple[type, bool]] = {} + + def __init__(self, name: str): + self.name = name + + @abstractmethod + def compute_score(self, user: UserInput) -> str: + """Compute and return the risk score string for the given user. + + Args: + user: The user profile to score. + + Returns: + The string score representation (e.g., percentage or label). + """ + + @abstractmethod + def cancer_type(self) -> str: + """Return the cancer type handled by this model.""" + + @abstractmethod + def description(self) -> str: + """Return a short description of the model.""" + + @abstractmethod + def interpretation(self) -> str: + """Return a user-facing interpretation guideline for the score.""" + + @abstractmethod + def references(self) -> list[str]: + """Return academic or source references for the model.""" + + @staticmethod + def _get_nested_field(obj: Any, path: str) -> Any: + """Navigate a dotted path to retrieve a nested field value. + + Args: + obj: The object to navigate + path: Dot-separated path (e.g., "demographics.age_years") + + Returns: + The field value, or None if not found + """ + parts = path.split(".") + current = obj + for part in parts: + if current is None: + return None + current = getattr(current, part, None) + return current + + def validate_inputs(self, user: UserInput) -> tuple[bool, list[str]]: + """Validate that user input meets this model's requirements. + + Uses Pydantic's TypeAdapter to validate each required field against + its type annotation and constraints (from Annotated[Type, Field(...)]). + + Args: + user: The user input to validate + + Returns: + Tuple of (is_valid, error_messages) + """ + if not self.REQUIRED_INPUTS: + return (True, []) + + errors = [] + + for field_path, (field_type, required) in self.REQUIRED_INPUTS.items(): + value = self._get_nested_field(user, field_path) + + # Check if required field is missing + if required and value is None: + errors.append(f"Required field '{field_path}' is missing") + continue + + # Validate against type and constraints if value present + if value is not None: + try: + adapter = TypeAdapter(field_type) + adapter.validate_python(value) + except ValidationError as e: + error_msg = e.errors()[0]["msg"] + errors.append(f"Field '{field_path}': {error_msg}") + + return (len(errors) == 0, errors) + + def run(self, user: UserInput) -> RiskScore: + """Compute all public fields and return a `RiskScore` summary. + + Args: + user: The user profile to score. + + Returns: + A populated RiskScore object for this model. + """ + return RiskScore( + name=self.name, + score=self.compute_score(user), + cancer_type=self.cancer_type(), + description=self.description(), + interpretation=self.interpretation(), + references=self.references(), + ) diff --git a/src/sentinel/risk_models/boadicea.py b/src/sentinel/risk_models/boadicea.py new file mode 100644 index 0000000000000000000000000000000000000000..882cf3b8efee83d66cbcb878321c017cd0a18d9b --- /dev/null +++ b/src/sentinel/risk_models/boadicea.py @@ -0,0 +1,192 @@ +"""BOADICEA breast cancer risk estimation using CanRisk API. + +This module provides access to the BOADICEA (Breast and Ovarian Analysis of Disease +Incidence and Carrier Estimation Algorithm) model via the CanRisk web service. + +The model is specifically designed for women with genetic predispositions, +particularly BRCA1 and BRCA2 mutation carriers. +""" + +from typing import Annotated + +from pydantic import Field + +from sentinel.api_clients.canrisk import BOADICEAInput, CanRiskAPIError, CanRiskClient +from sentinel.risk_models.base import RiskModel +from sentinel.user_input import ( + Ethnicity, + Sex, + UserInput, +) + + +class BOADICEARiskModel(RiskModel): + """Compute breast cancer risk using BOADICEA model via CanRisk API.""" + + REQUIRED_INPUTS: dict[str, tuple[type, bool]] = { + "demographics.age_years": (Annotated[int, Field(ge=18, le=100)], True), + "demographics.sex": (Sex, True), + "demographics.ethnicity": (Ethnicity | None, False), + "demographics.anthropometrics.height_cm": ( + Annotated[float, Field(gt=0)], + False, + ), + "demographics.anthropometrics.weight_kg": ( + Annotated[float, Field(gt=0)], + False, + ), + "female_specific.menstrual.age_at_menarche": ( + Annotated[int, Field(ge=8, le=25)], + False, + ), + "female_specific.parity.num_live_births": (Annotated[int, Field(ge=0)], False), + "female_specific.parity.age_at_first_live_birth": ( + Annotated[int, Field(ge=10, le=60)], + False, + ), + "female_specific.menstrual.age_at_menopause": ( + Annotated[int, Field(ge=20, le=65)], + False, + ), + "personal_medical_history.genetic_mutations": ( + list, + False, + ), # list[GeneticMutation] + "family_history": (list, False), # list[FamilyMemberCancer] + } + + def __init__(self, client: CanRiskClient | None = None): + super().__init__(name="boadicea") + self.client = client or CanRiskClient() + + def compute_score(self, user: UserInput) -> str: + """Compute BOADICEA risk score for the user. + + Args: + user: The populated user profile. + + Returns: + str: Percent string (e.g., "4.2%") or an N/A message when not applicable. + + Raises: + ValueError: If required inputs are missing or invalid. + """ + # Validate inputs first + is_valid, errors = self.validate_inputs(user) + if not is_valid: + raise ValueError(f"Invalid inputs for BOADICEA: {'; '.join(errors)}") + + if user.demographics.sex != Sex.FEMALE: + return "N/A: BOADICEA model is only applicable to female patients." + + if user.female_specific is None: + return "N/A: Missing female-specific information required for BOADICEA." + + try: + boadicea_input = BOADICEAInput.from_user_input(user) + + result = self.client.submit_boadicea_assessment(boadicea_input) + + ten_year_risk = self._extract_ten_year_breast_cancer_risk(result) + if ten_year_risk is not None: + return f"{ten_year_risk}%" + + return "N/A: 10-year risk not available from API response." + + except CanRiskAPIError as e: + return f"N/A: API error - {e!s}" + except Exception as e: + return f"N/A: Calculation error - {e!s}" + + def _extract_ten_year_breast_cancer_risk(self, api_response: dict) -> float | None: + """Extract true 10-year breast cancer risk from CanRisk API response. + + Calculates the actual 10-year risk by finding the age-specific risk data + and extracting the risk at (current_age + 10 years). + + Args: + api_response: The full API response from CanRisk. + + Returns: + float | None: Ten-year breast cancer risk percentage if available. + """ + try: + if ( + "pedigree_result" not in api_response + or not api_response["pedigree_result"] + or len(api_response["pedigree_result"]) == 0 + ): + return None + + pedigree = api_response["pedigree_result"][0] + + if "cancer_risks" not in pedigree or not pedigree["cancer_risks"]: + return None + + cancer_risks = pedigree["cancer_risks"] + + if not cancer_risks or len(cancer_risks) == 0: + return None + + current_age = None + target_ten_year_age = None + + first_age = cancer_risks[0]["age"] + + target_ten_year_age = first_age + 10 + + best_match = None + min_age_diff = float("inf") + + for risk_entry in cancer_risks: + age = risk_entry["age"] + age_diff = abs(age - target_ten_year_age) + + if age_diff < min_age_diff: + min_age_diff = age_diff + best_match = risk_entry + + if age == target_ten_year_age: + best_match = risk_entry + break + + if best_match and "breast cancer risk" in best_match: + breast_risk = best_match["breast cancer risk"] + if isinstance(breast_risk, dict) and "percent" in breast_risk: + return float(breast_risk["percent"]) + + return None + + except (KeyError, IndexError, ValueError, TypeError): + # Any parsing error returns None + return None + + def cancer_type(self) -> str: + return "breast" + + def description(self) -> str: + return ( + "The BOADICEA model provides comprehensive breast cancer risk " + "assessment incorporating genetic factors, family history, and lifestyle factors. " + "It is specifically designed for women with genetic predispositions, particularly " + "BRCA1 and BRCA2 mutation carriers, and provides more accurate risk estimates " + "for high-risk populations than general population models." + ) + + def interpretation(self) -> str: + return ( + "BOADICEA risk scores represent the probability of developing breast cancer " + "over a specified time period. The model accounts for genetic mutations, " + "family history, and personal risk factors to provide personalized risk estimates. " + "Results should be interpreted by a healthcare professional familiar with " + "genetic counseling and high-risk breast cancer management." + ) + + def references(self) -> list[str]: + return [ + "Lee et al. BOADICEA: a comprehensive breast cancer risk prediction model " + "incorporating genetic and nongenetic risk factors. Genet Med. 2019;21(8):1708-1718.", + "CanRisk Tool - https://www.canrisk.org/", + "Antoniou et al. Average risks of breast and ovarian cancer associated with BRCA1 or BRCA2 " + "mutations detected in case series unselected for family history. Am J Hum Genet. 2003;72(5):1117-1130.", + ] diff --git a/src/sentinel/risk_models/claus.py b/src/sentinel/risk_models/claus.py new file mode 100644 index 0000000000000000000000000000000000000000..62f86c9dc23995450695225d1efde1de9b19005e --- /dev/null +++ b/src/sentinel/risk_models/claus.py @@ -0,0 +1,684 @@ +"""Breast cancer risk estimation using the Claus model. + +This module provides a Python implementation of the Claus risk model, which +estimates lifetime breast cancer risk based on familial data. The model uses +lookup tables derived from the 1994 Claus et al. paper that map risk percentages +based on family member relationships and cancer onset ages. + +The model applies to cancer-free women aged 20-79 years and returns the +conditional lifetime risk given the patient's current cancer-free status. + +Key features: +- Evaluates all applicable family history scenarios +- Selects the highest risk score among applicable tables +- Uses linear interpolation for patient's current age +- Requires specific maternal/paternal relationship designations + +Reference: +Claus, E. B., et al. (1994). Age at onset as an indicator of familial risk of +breast cancer. American Journal of Epidemiology, 140(4), 328-343. +""" + +from typing import Annotated + +from pydantic import Field + +from sentinel.risk_models.base import RiskModel +from sentinel.user_input import ( + CancerType, + FamilyRelation, + FamilySide, + RelationshipDegree, + Sex, + UserInput, +) + +VALID_MIN_AGE = 20 +VALID_MAX_AGE = 79 + +ONE_FIRST_DEG_TABLE = ( + (0.007, 0.005, 0.003, 0.002, 0.002, 0.001), + (0.025, 0.017, 0.012, 0.008, 0.006, 0.005), + (0.062, 0.044, 0.032, 0.023, 0.018, 0.015), + (0.116, 0.086, 0.064, 0.049, 0.040, 0.035), + (0.171, 0.130, 0.101, 0.082, 0.070, 0.062), + (0.211, 0.165, 0.132, 0.110, 0.096, 0.088), +) + +ONE_SECOND_DEG_TABLE = ( + (0.004, 0.003, 0.002, 0.001, 0.001, 0.001), + (0.014, 0.010, 0.007, 0.006, 0.005, 0.004), + (0.035, 0.027, 0.021, 0.017, 0.017, 0.013), + (0.070, 0.056, 0.045, 0.038, 0.038, 0.032), + (0.110, 0.090, 0.076, 0.067, 0.067, 0.058), + (0.142, 0.120, 0.104, 0.094, 0.094, 0.083), +) + +TWO_FIRST_DEG_TABLE = ( + ( + (0.021, 0.020, 0.018, 0.016, 0.014, 0.012), + (0.020, 0.018, 0.016, 0.014, 0.012, 0.009), + (0.018, 0.016, 0.014, 0.012, 0.009, 0.007), + (0.016, 0.014, 0.012, 0.009, 0.006, 0.005), + (0.014, 0.012, 0.009, 0.006, 0.004, 0.003), + (0.012, 0.009, 0.007, 0.005, 0.003, 0.002), + ), + ( + (0.069, 0.066, 0.061, 0.055, 0.048, 0.041), + (0.066, 0.062, 0.056, 0.048, 0.040, 0.032), + (0.061, 0.056, 0.048, 0.039, 0.030, 0.023), + (0.055, 0.048, 0.039, 0.030, 0.022, 0.016), + (0.048, 0.040, 0.039, 0.022, 0.016, 0.012), + (0.041, 0.032, 0.023, 0.016, 0.012, 0.008), + ), + ( + (0.166, 0.157, 0.146, 0.133, 0.117, 0.099), + (0.157, 0.148, 0.134, 0.116, 0.096, 0.077), + (0.146, 0.134, 0.117, 0.096, 0.075, 0.058), + (0.133, 0.116, 0.096, 0.075, 0.056, 0.042), + (0.117, 0.096, 0.075, 0.056, 0.041, 0.030), + (0.099, 0.077, 0.058, 0.042, 0.030, 0.023), + ), + ( + (0.295, 0.279, 0.261, 0.238, 0.210, 0.179), + (0.279, 0.265, 0.239, 0.209, 0.175, 0.143), + (0.261, 0.239, 0.210, 0.174, 0.139, 0.108), + (0.238, 0.210, 0.179, 0.138, 0.105, 0.081), + (0.210, 0.174, 0.139, 0.105, 0.080, 0.061), + (0.179, 0.143, 0.108, 0.081, 0.061, 0.049), + ), + ( + (0.412, 0.391, 0.366, 0.335, 0.297, 0.256), + (0.391, 0.371, 0.337, 0.296, 0.251, 0.207), + (0.366, 0.335, 0.298, 0.249, 0.202, 0.161), + (0.335, 0.296, 0.249, 0.200, 0.157, 0.124), + (0.297, 0.251, 0.202, 0.157, 0.122, 0.098), + (0.256, 0.207, 0.161, 0.124, 0.098, 0.081), + ), + ( + (0.484, 0.460, 0.434, 0.397, 0.354, 0.308), + (0.460, 0.437, 0.399, 0.353, 0.302, 0.252), + (0.434, 0.399, 0.354, 0.300, 0.246, 0.200), + (0.397, 0.353, 0.300, 0.245, 0.195, 0.158), + (0.354, 0.302, 0.246, 0.195, 0.156, 0.128), + (0.308, 0.252, 0.200, 0.158, 0.128, 0.109), + ), +) + +MOTHER_MATERNAL_AUNT = ( + ( + (0.019, 0.018, 0.017, 0.016, 0.014, 0.012), + (0.018, 0.017, 0.016, 0.014, 0.011, 0.009), + (0.017, 0.015, 0.013, 0.011, 0.009, 0.006), + (0.015, 0.013, 0.011, 0.008, 0.006, 0.004), + (0.013, 0.010, 0.008, 0.006, 0.004, 0.003), + (0.010, 0.008, 0.006, 0.004, 0.003, 0.002), + ), + ( + (0.064, 0.062, 0.058, 0.054, 0.047, 0.040), + (0.061, 0.058, 0.053, 0.046, 0.039, 0.031), + (0.057, 0.052, 0.046, 0.038, 0.030, 0.022), + (0.051, 0.044, 0.036, 0.028, 0.021, 0.016), + (0.043, 0.035, 0.027, 0.020, 0.015, 0.011), + (0.034, 0.026, 0.019, 0.014, 0.010, 0.008), + ), + ( + (0.153, 0.148, 0.141, 0.129, 0.115, 0.098), + (0.147, 0.139, 0.128, 0.112, 0.094, 0.076), + (0.137, 0.125, 0.110, 0.092, 0.073, 0.056), + (0.122, 0.107, 0.089, 0.070, 0.053, 0.040), + (0.104, 0.086, 0.067, 0.051, 0.038, 0.029), + (0.082, 0.065, 0.049, 0.036, 0.027, 0.021), + ), + ( + (0.273, 0.265, 0.251, 0.232, 0.206, 0.178), + (0.262, 0.249, 0.229, 0.203, 0.172, 0.140), + (0.245, 0.225, 0.199, 0.167, 0.134, 0.106), + (0.220, 0.194, 0.162, 0.130, 0.101, 0.078), + (0.187, 0.157, 0.125, 0.096, 0.075, 0.059), + (0.151, 0.121, 0.093, 0.071, 0.056, 0.046), + ), + ( + (0.382, 0.371, 0.353, 0.327, 0.293, 0.254), + (0.367, 0.350, 0.323, 0.287, 0.246, 0.204), + (0.344, 0.317, 0.282, 0.240, 0.196, 0.158), + (0.311, 0.275, 0.233, 0.190, 0.151, 0.121), + (0.267, 0.226, 0.183, 0.145, 0.116, 0.094), + (0.218, 0.178, 0.141, 0.111, 0.090, 0.077), + ), + ( + (0.450, 0.437, 0.417, 0.388, 0.349, 0.305), + (0.433, 0.414, 0.383, 0.343, 0.296, 0.248), + (0.407, 0.377, 0.338, 0.289, 0.239, 0.196), + (0.369, 0.329, 0.281, 0.233, 0.188, 0.154), + (0.320, 0.274, 0.225, 0.182, 0.148, 0.124), + (0.264, 0.219, 0.177, 0.143, 0.120, 0.105), + ), +) + +MOTHER_PATERNAL_AUNT = ( + ( + (0.010, 0.009, 0.008, 0.008, 0.007, 0.007), + (0.008, 0.007, 0.006, 0.005, 0.005, 0.005), + (0.006, 0.005, 0.004, 0.004, 0.004, 0.003), + (0.005, 0.004, 0.003, 0.003, 0.002, 0.002), + (0.004, 0.003, 0.003, 0.002, 0.002, 0.002), + (0.004, 0.003, 0.002, 0.002, 0.001, 0.001), + ), + ( + (0.033, 0.030, 0.028, 0.026, 0.025, 0.025), + (0.026, 0.023, 0.021, 0.019, 0.018, 0.018), + (0.021, 0.018, 0.016, 0.014, 0.013, 0.012), + (0.018, 0.014, 0.012, 0.010, 0.009, 0.009), + (0.016, 0.012, 0.010, 0.008, 0.007, 0.007), + (0.015, 0.011, 0.009, 0.007, 0.006, 0.005), + ), + ( + (0.080, 0.073, 0.068, 0.065, 0.063, 0.062), + (0.064, 0.057, 0.052, 0.048, 0.046, 0.045), + (0.053, 0.045, 0.040, 0.036, 0.034, 0.032), + (0.045, 0.037, 0.032, 0.028, 0.026, 0.024), + (0.041, 0.033, 0.027, 0.023, 0.021, 0.019), + (0.038, 0.030, 0.024, 0.020, 0.018, 0.016), + ), + ( + (0.148, 0.136, 0.127, 0.121, 0.117, 0.115), + (0.119, 0.108, 0.097, 0.092, 0.089, 0.086), + (0.100, 0.087, 0.078, 0.071, 0.068, 0.065), + (0.087, 0.074, 0.064, 0.058, 0.054, 0.051), + (0.079, 0.065, 0.055, 0.049, 0.042, 0.042), + (0.074, 0.060, 0.050, 0.044, 0.039, 0.037), + ), + ( + (0.214, 0.198, 0.186, 0.178, 0.173, 0.170), + (0.176, 0.160, 0.148, 0.140, 0.134, 0.131), + (0.150, 0.132, 0.120, 0.111, 0.106, 0.102), + (0.132, 0.114, 0.101, 0.093, 0.087, 0.084), + (0.121, 0.103, 0.090, 0.081, 0.076, 0.072), + (0.116, 0.097, 0.083, 0.074, 0.068, 0.065), + ), + ( + (0.260, 0.241, 0.228, 0.219, 0.214, 0.210), + (0.257, 0.185, 0.180, 0.176, 0.170, 0.166), + (0.216, 0.197, 0.154, 0.148, 0.138, 0.134), + (0.187, 0.167, 0.152, 0.123, 0.116, 0.113), + (0.168, 0.147, 0.132, 0.122, 0.103, 0.100), + (0.148, 0.127, 0.112, 0.101, 0.095, 0.092), + ), +) + +TWO_SEC_DEG_DIFF_SIDE_TABLE = ( + ( + (0.007, 0.006, 0.005, 0.004, 0.004, 0.004), + (0.006, 0.005, 0.004, 0.003, 0.003, 0.003), + (0.005, 0.004, 0.003, 0.003, 0.002, 0.002), + (0.004, 0.003, 0.003, 0.002, 0.002, 0.002), + (0.004, 0.003, 0.002, 0.002, 0.002, 0.001), + (0.004, 0.003, 0.002, 0.002, 0.001, 0.001), + ), + ( + (0.023, 0.020, 0.017, 0.016, 0.015, 0.014), + (0.020, 0.016, 0.014, 0.012, 0.011, 0.010), + (0.017, 0.014, 0.011, 0.010, 0.009, 0.008), + (0.016, 0.012, 0.010, 0.008, 0.007, 0.006), + (0.015, 0.011, 0.009, 0.007, 0.006, 0.005), + (0.014, 0.010, 0.008, 0.006, 0.005, 0.005), + ), + ( + (0.057, 0.050, 0.044, 0.040, 0.038, 0.037), + (0.050, 0.042, 0.036, 0.032, 0.030, 0.029), + (0.044, 0.036, 0.030, 0.026, 0.024, 0.023), + (0.040, 0.032, 0.026, 0.022, 0.020, 0.019), + (0.038, 0.030, 0.024, 0.020, 0.018, 0.016), + (0.037, 0.029, 0.023, 0.019, 0.016, 0.015), + ), + ( + (0.108, 0.095, 0.085, 0.079, 0.075, 0.073), + (0.095, 0.081, 0.071, 0.065, 0.061, 0.058), + (0.085, 0.071, 0.061, 0.055, 0.050, 0.048), + (0.079, 0.065, 0.055, 0.048, 0.044, 0.041), + (0.075, 0.061, 0.050, 0.044, 0.039, 0.037), + (0.073, 0.058, 0.048, 0.041, 0.037, 0.034), + ), + ( + (0.160, 0.143, 0.130, 0.121, 0.116, 0.113), + (0.143, 0.124, 0.111, 0.102, 0.097, 0.094), + (0.130, 0.111, 0.098, 0.089, 0.083, 0.080), + (0.121, 0.102, 0.089, 0.080, 0.074, 0.071), + (0.116, 0.097, 0.083, 0.074, 0.068, 0.065), + (0.113, 0.094, 0.080, 0.071, 0.065, 0.062), + ), + ( + (0.199, 0.179, 0.164, 0.155, 0.149, 0.145), + (0.179, 0.179, 0.144, 0.134, 0.128, 0.124), + (0.164, 0.144, 0.128, 0.118, 0.112, 0.108), + (0.155, 0.134, 0.118, 0.108, 0.102, 0.098), + (0.149, 0.128, 0.112, 0.102, 0.095, 0.091), + (0.145, 0.124, 0.108, 0.095, 0.091, 0.087), + ), +) + +TWO_SEC_DEG_SAME_SIDE_TABLE = ( + ( + (0.010, 0.009, 0.009, 0.008, 0.007, 0.006), + (0.009, 0.009, 0.008, 0.007, 0.006, 0.005), + (0.009, 0.008, 0.007, 0.006, 0.005, 0.004), + (0.008, 0.007, 0.006, 0.005, 0.003, 0.003), + (0.007, 0.006, 0.005, 0.003, 0.002, 0.002), + (0.006, 0.005, 0.004, 0.003, 0.002, 0.001), + ), + ( + (0.033, 0.032, 0.030, 0.028, 0.025, 0.021), + (0.032, 0.030, 0.028, 0.025, 0.021, 0.017), + (0.030, 0.028, 0.024, 0.020, 0.016, 0.013), + (0.028, 0.025, 0.020, 0.016, 0.012, 0.010), + (0.025, 0.021, 0.016, 0.012, 0.009, 0.007), + (0.021, 0.017, 0.013, 0.010, 0.007, 0.006), + ), + ( + (0.081, 0.079, 0.075, 0.069, 0.062, 0.054), + (0.079, 0.075, 0.069, 0.061, 0.052, 0.043), + (0.075, 0.069, 0.061, 0.052, 0.042, 0.034), + (0.069, 0.061, 0.052, 0.052, 0.042, 0.033), + (0.062, 0.052, 0.042, 0.042, 0.026, 0.020), + (0.054, 0.043, 0.034, 0.033, 0.020, 0.017), + ), + ( + (0.149, 0.145, 0.138, 0.129, 0.116, 0.102), + (0.145, 0.138, 0.128, 0.115, 0.099, 0.084), + (0.138, 0.128, 0.114, 0.098, 0.082, 0.067), + (0.129, 0.115, 0.098, 0.081, 0.066, 0.054), + (0.116, 0.099, 0.082, 0.066, 0.053, 0.044), + (0.102, 0.084, 0.067, 0.054, 0.044, 0.038), + ), + ( + (0.216, 0.210, 0.201, 0.188, 0.171, 0.152), + (0.210, 0.201, 0.188, 0.170, 0.149, 0.128), + (0.201, 0.188, 0.169, 0.147, 0.124, 0.105), + (0.188, 0.170, 0.147, 0.124, 0.104, 0.088), + (0.171, 0.149, 0.124, 0.104, 0.087, 0.075), + (0.152, 0.128, 0.105, 0.088, 0.075, 0.067), + ), + ( + (0.262, 0.256, 0.245, 0.231, 0.211, 0.189), + (0.256, 0.245, 0.230, 0.200, 0.186, 0.162), + (0.245, 0.230, 0.209, 0.184, 0.159, 0.137), + (0.231, 0.200, 0.184, 0.158, 0.135, 0.117), + (0.211, 0.186, 0.159, 0.135, 0.116, 0.103), + (0.189, 0.162, 0.137, 0.117, 0.103, 0.094), + ), +) + + +class ClausRiskModel(RiskModel): + """Compute lifetime breast cancer risk using the Claus model.""" + + def __init__(self): + super().__init__("claus") + + REQUIRED_INPUTS: dict[str, tuple[type, bool]] = { + "demographics.age_years": (Annotated[int, Field(ge=20, le=79)], True), + "demographics.sex": (Sex, True), + "family_history": (list, False), # list[FamilyMemberCancer] + } + + def calculate_risk(self, user: UserInput) -> float | None: + """Calculate lifetime breast cancer risk. + + Args: + user: A UserInput profile containing demographics and family history. + + Returns: + Lifetime risk as a decimal (0-1 range), or None if no family history. + """ + if user.demographics.sex != Sex.FEMALE: + return None + + patient_age = user.demographics.age_years + + # Extract family history data from UserInput + mother_onset_age = None + daughter_onset_ages = [] + full_sister_onset_ages = [] + maternal_aunt_onset_ages = [] + paternal_aunt_onset_ages = [] + maternal_grandmother_onset_ages = [] + paternal_grandmother_onset_ages = [] + maternal_half_sister_onset_ages = [] + paternal_half_sister_onset_ages = [] + + # Map family history from UserInput into buckets + for member in user.family_history: + if member.cancer_type != CancerType.BREAST: + continue + + age = member.age_at_diagnosis + if not age or not (VALID_MIN_AGE <= age <= VALID_MAX_AGE): + continue + + # Map family relations using enums + if member.relation == FamilyRelation.MOTHER: + mother_onset_age = age + elif member.relation == FamilyRelation.DAUGHTER: + daughter_onset_ages.append(age) + elif member.relation == FamilyRelation.SISTER: + # Distinguish between full sisters and half-sisters using degree and side + if member.degree == RelationshipDegree.SECOND: + # Half-sister (treated as second-degree relative) + if member.side == FamilySide.MATERNAL: + maternal_half_sister_onset_ages.append(age) + elif member.side == FamilySide.PATERNAL: + paternal_half_sister_onset_ages.append(age) + else: + # Full sister (first-degree relative) + full_sister_onset_ages.append(age) + elif member.relation == FamilyRelation.MATERNAL_AUNT: + maternal_aunt_onset_ages.append(age) + elif member.relation == FamilyRelation.PATERNAL_AUNT: + paternal_aunt_onset_ages.append(age) + elif member.relation == FamilyRelation.MATERNAL_GRANDMOTHER: + maternal_grandmother_onset_ages.append(age) + elif member.relation == FamilyRelation.PATERNAL_GRANDMOTHER: + paternal_grandmother_onset_ages.append(age) + + first_degree_ages = [ + full_sister_onset_ages, + daughter_onset_ages, + ] + if mother_onset_age: + first_degree_ages.append([mother_onset_age]) + first_degree_indices = _collect_and_map_ages_to_indices(*first_degree_ages) + + second_degree_indices = _collect_and_map_ages_to_indices( + maternal_aunt_onset_ages, + paternal_aunt_onset_ages, + maternal_grandmother_onset_ages, + paternal_grandmother_onset_ages, + maternal_half_sister_onset_ages, + paternal_half_sister_onset_ages, + ) + + maternal_aunt_indices = _map_ages_to_indices(maternal_aunt_onset_ages) + paternal_aunt_indices = _map_ages_to_indices(paternal_aunt_onset_ages) + + maternal_second_degree_indices = _collect_and_map_ages_to_indices( + maternal_aunt_onset_ages, + maternal_grandmother_onset_ages, + maternal_half_sister_onset_ages, + ) + paternal_second_degree_indices = _collect_and_map_ages_to_indices( + paternal_aunt_onset_ages, + paternal_grandmother_onset_ages, + paternal_half_sister_onset_ages, + ) + + mother_index = None + if mother_onset_age and VALID_MIN_AGE <= mother_onset_age <= VALID_MAX_AGE: + mother_index = _bin_age_to_index(mother_onset_age) + + risk_scores = [] + + if len(first_degree_indices) >= 1: + risk_scores.append( + _get_lifetime_risk( + ONE_FIRST_DEG_TABLE, + patient_age, + first_degree_indices[0], + ) + ) + + if len(second_degree_indices) >= 1: + risk_scores.append( + _get_lifetime_risk( + ONE_SECOND_DEG_TABLE, + patient_age, + second_degree_indices[0], + ) + ) + + if len(first_degree_indices) >= 2: + risk_scores.append( + _get_lifetime_risk( + TWO_FIRST_DEG_TABLE, + patient_age, + first_degree_indices[0], + first_degree_indices[1], + ) + ) + + if mother_index is not None: + if len(maternal_aunt_indices) >= 1: + risk_scores.append( + _get_lifetime_risk( + MOTHER_MATERNAL_AUNT, + patient_age, + mother_index, + maternal_aunt_indices[0], + ) + ) + if len(paternal_aunt_indices) >= 1: + risk_scores.append( + _get_lifetime_risk( + MOTHER_PATERNAL_AUNT, + patient_age, + mother_index, + paternal_aunt_indices[0], + ) + ) + + if len(maternal_second_degree_indices) >= 2: + risk_scores.append( + _get_lifetime_risk( + TWO_SEC_DEG_SAME_SIDE_TABLE, + patient_age, + maternal_second_degree_indices[0], + maternal_second_degree_indices[1], + ) + ) + + if len(paternal_second_degree_indices) >= 2: + risk_scores.append( + _get_lifetime_risk( + TWO_SEC_DEG_SAME_SIDE_TABLE, + patient_age, + paternal_second_degree_indices[0], + paternal_second_degree_indices[1], + ) + ) + + if ( + len(maternal_second_degree_indices) >= 1 + and len(paternal_second_degree_indices) >= 1 + ): + risk_scores.append( + _get_lifetime_risk( + TWO_SEC_DEG_DIFF_SIDE_TABLE, + patient_age, + maternal_second_degree_indices[0], + paternal_second_degree_indices[0], + ) + ) + + if len(risk_scores) == 0: + return None + + return max(risk_scores) + + def compute_score(self, user: UserInput) -> str: + """Compute the Claus risk score for a given user profile. + + Args: + user: The user profile. + + Returns: + str: Risk percentage as a string or an N/A message if inapplicable. + + Raises: + ValueError: If required inputs are missing or invalid. + """ + # Validate inputs first + is_valid, errors = self.validate_inputs(user) + if not is_valid: + raise ValueError(f"Invalid inputs for Claus: {'; '.join(errors)}") + + if user.demographics.sex != Sex.FEMALE: + return "N/A: Score available only for female patients." + + if not (VALID_MIN_AGE <= user.demographics.age_years <= VALID_MAX_AGE): + return f"N/A: Age is outside the validated {VALID_MIN_AGE}-{VALID_MAX_AGE} year range." + + # Check if there's any breast cancer family history + has_any_history = any( + member.cancer_type == CancerType.BREAST + and member.age_at_diagnosis + and VALID_MIN_AGE <= member.age_at_diagnosis <= VALID_MAX_AGE + for member in user.family_history + ) + + if not has_any_history: + return "N/A: No breast cancer family history available." + + risk = self.calculate_risk(user) + + if risk is None: + return "N/A: No breast cancer family history available." + + return f"{risk * 100:.1f}%" + + def cancer_type(self) -> str: + return "breast" + + def description(self) -> str: + return ( + "The Claus model estimates lifetime breast cancer risk based on familial " + "data using lookup tables derived from the 1994 Claus et al. paper. " + "The model evaluates all applicable family history scenarios and selects " + "the highest risk score, accounting for the patient's current cancer-free status." + ) + + def interpretation(self) -> str: + return ( + "The score represents the estimated lifetime risk (up to age 79) of " + "developing breast cancer given the patient's family history and current " + "cancer-free status. Higher percentages indicate elevated risk. This score " + "should be interpreted in consultation with a healthcare provider to guide " + "screening and prevention strategies." + ) + + def references(self) -> list[str]: + return [ + "Claus, E. B., et al. (1994). Age at onset as an indicator of familial risk of breast cancer. American Journal of Epidemiology, 140(4), 328-343.", + "Reference implementation: https://github.com/ColorGenomics/risk-models", + ] + + +def _bin_age_to_index(age: int) -> int: + """Convert age to table index. + + Args: + age: Age in years (20-79). + + Returns: + Index into Claus lookup tables (0-5). + """ + return (age - 20) // 10 + + +def _map_ages_to_indices(ages: list[int] | None) -> list[int]: + """Map ages to sorted table indices, filtering invalid ages. + + Args: + ages: List of ages or None. + + Returns: + Sorted list of table indices. + """ + if not ages: + return [] + return sorted( + [ + _bin_age_to_index(age) + for age in ages + if VALID_MIN_AGE <= age <= VALID_MAX_AGE + ] + ) + + +def _collect_and_map_ages_to_indices(*age_groups: list[int] | None) -> list[int]: + """Collect ages from multiple groups and map to sorted indices. + + Args: + age_groups: Variable number of age lists. + + Returns: + Sorted list of table indices from all groups. + """ + collected_ages = [] + for ages in age_groups: + if ages: + collected_ages.extend(ages) + return _map_ages_to_indices(collected_ages) + + +def _lookup_claus_table( + table: tuple, + patient_index: int, + relative1_index: int, + relative2_index: int | None = None, +) -> float: + """Look up risk value in Claus table. + + Args: + table: Claus lookup table. + patient_index: Patient age index. + relative1_index: First relative age index. + relative2_index: Optional second relative age index. + + Returns: + Risk value from table. + """ + if relative2_index is None: + return table[patient_index][relative1_index] + return table[patient_index][relative1_index][relative2_index] + + +def _get_lifetime_risk( + table: tuple, + patient_age: int, + relative1_index: int, + relative2_index: int | None = None, +) -> float: + """Compute conditional lifetime risk using linear interpolation. + + Calculates the conditional risk of patient developing cancer by age 79 + given that the patient has been cancer-free until current age. + + Args: + table: Claus lookup table to use. + patient_age: Patient's current age. + relative1_index: First relative's age index. + relative2_index: Optional second relative's age index. + + Returns: + Conditional lifetime risk as decimal (0-1 range). + """ + LIFETIME_AGE_INDEX = 5 + lifetime_risk = _lookup_claus_table( + table, LIFETIME_AGE_INDEX, relative1_index, relative2_index + ) + + patient_age_lower_bin_index, patient_age_over_bin = divmod(patient_age - 29, 10) + + current_age_risk = _lookup_claus_table( + table, patient_age_lower_bin_index, relative1_index, relative2_index + ) + + if patient_age_over_bin: + patient_age_upper_bin_risk = _lookup_claus_table( + table, patient_age_lower_bin_index + 1, relative1_index, relative2_index + ) + current_age_risk += ( + (patient_age_upper_bin_risk - current_age_risk) * patient_age_over_bin / 10 + ) + + return round((lifetime_risk - current_age_risk) / (1 - current_age_risk), 3) diff --git a/src/sentinel/risk_models/crc_pro.py b/src/sentinel/risk_models/crc_pro.py new file mode 100644 index 0000000000000000000000000000000000000000..30ec856a0c8d505ed013bc88c270b92d78ee224b --- /dev/null +++ b/src/sentinel/risk_models/crc_pro.py @@ -0,0 +1,681 @@ +"""Colorectal cancer risk estimation using the CRC-PRO model. + +The CRC-PRO (Colorectal Cancer Predicted Risk Online) tool estimates the +10-year risk of developing colorectal cancer. The model was derived from the +Multi-Ethnic Cohort Study and implements sex-specific cubic spline equations +that incorporate age, ethnicity, lifestyle factors, and medical history. + +The original implementation is provided as an R Shiny calculator by the +Cleveland Clinic (see references below). This module provides a Python port of +the scoring logic for integration with the Sentinel risk modelling framework. +""" + +from math import exp, pow +from typing import Annotated + +from pydantic import Field + +from sentinel.risk_models.base import RiskModel +from sentinel.user_input import ( + AlcoholConsumption, + AspirinUse, + CancerType, + ChronicCondition, + Ethnicity, + FamilyRelation, + HormoneUse, + NSAIDUse, + RelationshipDegree, + Sex, + UserInput, +) +from sentinel.utils import calculate_bmi + + +def _cubic_spline_term(value: float, knot: float) -> float: + """Return (value - knot)^3 for value greater than knot, else 0. + + Args: + value: Input value. + knot: Knot location for the cubic spline. + + Returns: + float: Cubic spline component. + """ + + return pow(max(value - knot, 0.0), 3) + + +def _map_ethnicity(ethnicity: Ethnicity | None) -> str | None: + """Map ethnicity enum to CRC-PRO ethnicity string. + + Args: + ethnicity: Ethnicity enum value. + + Returns: + str | None: CRC-PRO ethnicity string or None. + """ + if ethnicity == Ethnicity.PACIFIC_ISLANDER: + return "hawaiian" + if ethnicity == Ethnicity.ASIAN: + return "japanese" + if ethnicity == Ethnicity.HISPANIC: + return "latino" + if ethnicity == Ethnicity.WHITE: + return "white" + if ethnicity == Ethnicity.BLACK: + return "black" + return None + + +_ALCOHOL_DRINKS_PER_DAY_FALLBACK = { + "none": 0.0, + "light": 0.5, + "moderate": 1.0, + "heavy": 2.5, +} + + +_ACTIVITY_FALLBACK = { + "sedentary": 0.0, + "low": 0.5, + "light": 0.5, + "moderate": 1.5, + "vigorous": 2.5, + "high": 2.5, +} + + +_EDUCATION_LEVEL_TO_YEARS = { + 1: 11.0, + 2: 12.0, + 3: 14.0, + 4: 16.0, + 5: 18.0, +} + + +class CRCProRiskModel(RiskModel): + """Compute 10-year colorectal cancer risk using the CRC-PRO model.""" + + def __init__(self) -> None: + super().__init__("crc_pro") + + REQUIRED_INPUTS: dict[str, tuple[type, bool]] = { + "demographics.age_years": (Annotated[int, Field(ge=45, le=85)], True), + "demographics.sex": (Sex, True), + "demographics.ethnicity": (Ethnicity | None, False), + "demographics.anthropometrics.height_cm": (Annotated[float, Field(gt=0)], True), + "demographics.anthropometrics.weight_kg": (Annotated[float, Field(gt=0)], True), + "demographics.education_level": (Annotated[int, Field(ge=0, le=25)], False), + "lifestyle.smoking.pack_years": (Annotated[float, Field(ge=0, le=300)], True), + "lifestyle.alcohol_consumption": (AlcoholConsumption, False), + "lifestyle.multivitamin_use": (bool, True), + "lifestyle.moderate_physical_activity_hours_per_day": ( + Annotated[float, Field(ge=0, le=24)], + False, + ), + "lifestyle.red_meat_consumption_oz_per_day": ( + Annotated[float, Field(ge=0)], + False, + ), + "personal_medical_history.chronic_conditions": ( + list, + False, + ), # list[ChronicCondition] + "personal_medical_history.aspirin_use": (AspirinUse, False), + "personal_medical_history.nsaid_use": (NSAIDUse, False), + "family_history": (list, False), # list[FamilyMemberCancer] + "female_specific.hormone_use.estrogen_use": (HormoneUse, False), + } + + # --- Public API ----------------------------------------------------- + def compute_score(self, user: UserInput) -> str: + """Compute the risk score for a given user profile. + + Args: + user: The user profile containing demographics, medical history, etc. + + Returns: + str: Risk percentage as a string or an N/A message if inapplicable. + + Raises: + ValueError: If required inputs are missing or invalid. + """ + # Validate inputs first + is_valid, errors = self.validate_inputs(user) + if not is_valid: + raise ValueError(f"Invalid inputs for CRC-PRO: {'; '.join(errors)}") + + # Check sex applicability + if user.demographics.sex not in {Sex.MALE, Sex.FEMALE}: + return "N/A: CRC-PRO currently supports male or female sex inputs only." + + # Check age range + age = user.demographics.age_years + if not 45 <= age <= 85: + return "N/A: Age is outside the validated 45-85 year range." + + build_result = self._build_input(user, user.demographics.sex, age) + if isinstance(build_result, str): + return build_result + + risk_value = self.absolute_risk(user) + return f"{risk_value:.2f}" + + def absolute_risk(self, user: UserInput) -> float: + """Compute the absolute risk of colorectal cancer using the CRC-PRO model. + + Args: + user: Canonical `UserInput` clinical record. + + Returns: + float: Probability (0-100) of developing CRC within 10 years. + """ + inp = self._build_input( + user, user.demographics.sex, user.demographics.age_years + ) + if isinstance(inp, str): + # Propagate N/A conditions as zero numeric risk for absolute_risk + return 0.0 + + if user.demographics.sex == Sex.FEMALE: + lp = self._female_linear_predictor(inp) + base = 0.9901043 + else: + lp = self._male_linear_predictor(inp) + base = 0.9846654 + + probability = 100.0 - 100.0 * pow(base, exp(lp)) + return max(0.0, min(probability, 100.0)) + + def cancer_type(self) -> str: + """Return the type of cancer the model predicts. + + Returns: + str: Cancer type label. + """ + return "colorectal" + + def description(self) -> str: + """Return the description of the model. + + Returns: + str: Human-readable model description. + """ + return ( + "CRC-PRO estimates a patient's 10-year risk of developing colorectal " + "cancer based on sex-specific spline models derived from the " + "Multi-Ethnic Cohort Study." + ) + + def interpretation(self) -> str: + """Return the interpretation of the model. + + Returns: + str: Human-readable interpretation guidance. + """ + return ( + "The output represents the % probability of developing colorectal " + "cancer within 10 years. Elevated results should be reviewed with a " + "qualified healthcare professional." + ) + + def references(self) -> list[str]: + """Return the references of the model. + + Returns: + list[str]: Reference list. + """ + return [ + "Wells BJ, Kattan MW, Cooper GS, Jackson L, Koroukian S. Colorectal " + "Cancer Predicted Risk Online (CRC-PRO) calculator using data from the " + "Multi-Ethnic Cohort Study. J Am Board Fam Med. 2014;27(1):42-55." + ] + + # --- Internal helpers ----------------------------------------------- + def _build_input(self, user: UserInput, sex: Sex, age: int): + """Build the input for the model. + + Args: + user: The user profile. + sex: Sex enum value. + age: Age in years. + + Returns: + _CRCInput | str: Normalized input or an N/A message if invalid. + """ + # Map ethnicity + ethnicity = _map_ethnicity(user.demographics.ethnicity) + if ethnicity is None: + return "N/A: Ethnicity must be one of Hawaiian, Japanese, Latino, White, or Black." + + # Extract parameters from new structure + years_edu = self._get_years_education(user) + pack_years = self._get_pack_years(user) + alcohol_drinks_per_day = self._get_alcohol_drinks_per_day(user) + height_in, weight_lb, _bmi = self._get_body_metrics(user) + family_crc = self._has_family_crc(user) + multivitamin = user.lifestyle.multivitamin_use + diabetes = self._get_diabetes_status(user) + + # Sex-specific parameters + if sex == Sex.FEMALE: + pain_med = self._get_nsaid_status(user) + estrogen = self._get_estrogen_status(user) + aspirin = None + activity = None + total_meat = None + else: + aspirin = self._get_aspirin_status(user) + activity = user.lifestyle.moderate_physical_activity_hours_per_day + total_meat = user.lifestyle.red_meat_consumption_oz_per_day + pain_med = None + estrogen = None + + missing_fields = [] + if years_edu is None: + missing_fields.append("years_edu (numeric 6-20)") + if pack_years is None: + missing_fields.append("pack_years") + if alcohol_drinks_per_day is None: + missing_fields.append("alcohol drinks per day") + if height_in is None or weight_lb is None: + missing_fields.append("height (inches) and weight (lb)") + if multivitamin is None: + missing_fields.append("multivitamin usage (Yes/No)") + if diabetes is None: + missing_fields.append("diabetes status") + + if sex == Sex.FEMALE: + if pain_med is None: + missing_fields.append("NSAID use (PainMed)") + if estrogen is None: + missing_fields.append("estrogen use history") + else: + if aspirin is None: + missing_fields.append("aspirin use") + if activity is None: + missing_fields.append("physical activity (hours/day)") + if total_meat is None: + missing_fields.append("red meat intake (oz/day)") + + if missing_fields: + return "N/A: Missing required data: " + ", ".join(missing_fields) + "." + + # Inline simple namespace to avoid separate class while retaining BMI property + class _CRCInput: + """Container for normalized CRC-PRO inputs.""" + + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + + @property + def bmi(self) -> float: + """Calculate BMI from height and weight. + + Returns: + BMI value. Returns 0.0 if height or weight are missing/invalid. + """ + height = getattr(self, "height_in", 0.0) or 0.0 + weight = getattr(self, "weight_lb", 0.0) or 0.0 + + # Return 0.0 for missing or invalid data instead of raising ValueError + if height <= 0 or weight <= 0: + return 0.0 + + return calculate_bmi( + height=height, + weight=weight, + height_unit="in", + weight_unit="lb", + ) + + return _CRCInput( + sex=sex.value, + age=age, + ethnicity=ethnicity, + years_edu=years_edu, + pack_years=pack_years, + alcohol_drinks_per_day=alcohol_drinks_per_day, + height_in=height_in, + weight_lb=weight_lb, + family_crc=family_crc, + multivitamin=multivitamin, + diabetes=diabetes, + aspirin=aspirin, + activity=activity, + total_meat=total_meat, + pain_med=pain_med, + estrogen=estrogen, + ) + + def _female_linear_predictor(self, inp) -> float: + """Compute the linear predictor for the female model. + + Args: + inp: Normalized model inputs. + + Returns: + float: Linear predictor value. + """ + age = inp.age + years = inp.years_edu + pack = inp.pack_years + alcohol = inp.alcohol_drinks_per_day + bmi = inp.bmi + + eth = inp.ethnicity + hawaiian = 1 if eth == "hawaiian" else 0 + japanese = 1 if eth == "japanese" else 0 + latino = 1 if eth == "latino" else 0 + white = 1 if eth == "white" else 0 + + estrogen_state = inp.estrogen or "no" + estrogen_current = 1 if estrogen_state == "yes_currently" else 0 + estrogen_previous = 1 if estrogen_state == "yes_previous" else 0 + + pain_med_state = inp.pain_med or "no" + pain_med_past = 1 if pain_med_state == "yes_not_currently" else 0 + pain_med_current = 1 if pain_med_state == "yes_currently" else 0 + + lp = -5.9026635 + lp += 0.090012542 * age + lp -= 4.4217156e-05 * _cubic_spline_term(age, 47) + lp += 9.2119076e-05 * _cubic_spline_term(age, 60) + lp -= 4.7901919e-05 * _cubic_spline_term(age, 72) + + lp -= 0.24100367 * hawaiian + lp += 0.014010715 * japanese + lp -= 0.39669678 * latino + lp -= 0.34056094 * white + + lp += 0.07443905 * years + lp -= 0.00062546554 * _cubic_spline_term(years, 7) + lp += 0.0017200302 * _cubic_spline_term(years, 14) + lp -= 0.0010945647 * _cubic_spline_term(years, 18) + + lp -= 0.24500762 * estrogen_current + lp -= 0.044320489 * estrogen_previous + + lp += 0.23328937 * (1 if inp.diabetes else 0) + + lp += 0.062703176 * pack + lp -= 0.002446026 * _cubic_spline_term(pack, 0) + lp += 0.003038396 * _cubic_spline_term(pack, 1.25) + lp -= 0.00059134632 * _cubic_spline_term(pack, 6.375) + lp -= 1.023614e-06 * _cubic_spline_term(pack, 27.5125) + + lp += 0.31589053 * (1 if inp.family_crc else 0) + lp -= 0.1665365 * (1 if inp.multivitamin else 0) + + lp += 0.0075233925 * bmi + lp += 6.7918662e-05 * _cubic_spline_term(bmi, 20.371336) + lp -= 0.00011039091 * _cubic_spline_term(bmi, 25.508027) + lp += 4.2472244e-05 * _cubic_spline_term(bmi, 33.722266) + + lp -= 0.046383323 * pain_med_past + lp -= 0.236997 * pain_med_current + + lp -= 0.08856241 * alcohol + lp += 0.62375456 * _cubic_spline_term(alcohol, 0) + lp -= 0.7191129 * _cubic_spline_term(alcohol, 0.10740682) + lp += 0.095358349 * _cubic_spline_term(alcohol, 0.8099724) + + return lp + + def _male_linear_predictor(self, inp) -> float: + """Compute the linear predictor for the male model. + + Args: + inp: Normalized model inputs. + + Returns: + float: Linear predictor value. + """ + age = inp.age + years = inp.years_edu + pack = inp.pack_years + alcohol = inp.alcohol_drinks_per_day + bmi = inp.bmi + activity = inp.activity or 0.0 + total_meat = inp.total_meat or 0.0 + + eth = inp.ethnicity + hawaiian = 1 if eth == "hawaiian" else 0 + japanese = 1 if eth == "japanese" else 0 + latino = 1 if eth == "latino" else 0 + white = 1 if eth == "white" else 0 + + aspirin_state = inp.aspirin or "no" + aspirin_not_current = 1 if aspirin_state == "yes_not_currently" else 0 + aspirin_current = 1 if aspirin_state == "yes" else 0 + + lp = -6.6419738 + lp += 0.091669179 * age + lp -= 3.7411814e-05 * _cubic_spline_term(age, 47) + lp += 7.794128e-05 * _cubic_spline_term(age, 60) + lp -= 4.0529466e-05 * _cubic_spline_term(age, 72) + + lp += 0.16092241 * hawaiian + lp += 0.25353936 * japanese + lp -= 0.13659953 * latino + lp -= 0.16728044 * white + + lp += 0.00022581331 * pack + lp += 1.1341047e-05 * _cubic_spline_term(pack, 0) + lp -= 1.3522018e-05 * _cubic_spline_term(pack, 6.375) + lp += 2.1809706e-06 * _cubic_spline_term(pack, 39.525) + + lp += 0.28379769 * alcohol + lp -= 0.21424251 * _cubic_spline_term(alcohol, 0) + lp += 0.22570057 * _cubic_spline_term(alcohol, 0.14457189) + lp -= 0.011458065 * _cubic_spline_term(alcohol, 2.8477722) + + lp += 0.018020786 * bmi + lp += 9.4715899e-05 * _cubic_spline_term(bmi, 22.047175) + lp -= 0.00015791645 * _cubic_spline_term(bmi, 25.941735) + lp += 6.3200548e-05 * _cubic_spline_term(bmi, 31.778341) + + lp += 0.072052428 * years + lp -= 0.00060634342 * _cubic_spline_term(years, 7) + lp += 0.0016674444 * _cubic_spline_term(years, 14) + lp -= 0.001061101 * _cubic_spline_term(years, 18) + + lp -= 0.032284161 * aspirin_not_current + lp -= 0.20960315 * aspirin_current + + lp += 0.24250922 * (1 if inp.family_crc else 0) + lp -= 0.19175375 * (1 if inp.multivitamin else 0) + lp += 0.11020556 * (1 if inp.diabetes else 0) + + lp += 0.073141733 * total_meat + lp -= 0.0043503766 * _cubic_spline_term(total_meat, 0.59081962) + lp += 0.0065250851 * _cubic_spline_term(total_meat, 2.0822052) + lp -= 0.0021747085 * _cubic_spline_term(total_meat, 5.0656345) + + lp -= 0.090669913 * activity + lp += 0.0093816671 * _cubic_spline_term(activity, 0.10714286) + lp -= 0.011850527 * _cubic_spline_term(activity, 0.82142857) + lp += 0.0024688598 * _cubic_spline_term(activity, 3.5357143) + + return lp + + # ----- Extraction helpers ------------------------------------------ + + def _get_years_education(self, user: UserInput) -> float | None: + """Get the years of education. + + Args: + user: The user profile with demographics. + + Returns: + float | None: Years of formal education if derivable. + """ + level = user.demographics.education_level + if level is not None: + return _EDUCATION_LEVEL_TO_YEARS.get(level) + return None + + def _get_pack_years(self, user: UserInput) -> float | None: + """Get the pack years of smoking. + + Args: + user: The user profile with lifestyle. + + Returns: + float | None: Pack-years value if available. + """ + pack_years = user.lifestyle.smoking.pack_years + if pack_years is not None: + return float(pack_years) + return None + + def _get_alcohol_drinks_per_day(self, user: UserInput) -> float | None: + """Get the alcohol drinks per day. + + Args: + user: The user profile with lifestyle. + + Returns: + float | None: Drinks/day estimate if available. + """ + if user.lifestyle.alcohol_consumption is not None: + return _ALCOHOL_DRINKS_PER_DAY_FALLBACK.get( + user.lifestyle.alcohol_consumption.value + ) + return None + + def _get_body_metrics( + self, user: UserInput + ) -> tuple[float | None, float | None, float | None]: + """Get the body metrics. + + Args: + user: The user profile with demographics. + + Returns: + tuple[float|None,float|None,float|None]: (height_in, weight_lb, bmi). + """ + # Convert from cm to inches and kg to pounds + height_in = None + weight_lb = None + + if user.demographics.anthropometrics.height_cm is not None: + height_in = user.demographics.anthropometrics.height_cm / 2.54 + if user.demographics.anthropometrics.weight_kg is not None: + weight_lb = user.demographics.anthropometrics.weight_kg / 0.45359237 + + if height_in is None or weight_lb is None: + return height_in, weight_lb, None + + try: + bmi = calculate_bmi( + height_in, weight_lb, height_unit="in", weight_unit="lb" + ) + except ValueError: + bmi = None + return height_in, weight_lb, bmi + + def _has_family_crc(self, user: UserInput) -> bool: + """Check if the user has a family history of colorectal cancer. + + Args: + user: The user profile with family history. + + Returns: + bool: True if a first-degree relative has CRC. + """ + if not user.family_history: + return False + + first_degree_relations = { + FamilyRelation.MOTHER, + FamilyRelation.FATHER, + FamilyRelation.SISTER, + FamilyRelation.BROTHER, + FamilyRelation.DAUGHTER, + FamilyRelation.SON, + } + + return any( + member.cancer_type == CancerType.COLORECTAL + and member.relation in first_degree_relations + and member.degree == RelationshipDegree.FIRST + for member in user.family_history + ) + + def _get_diabetes_status(self, user: UserInput) -> bool | None: + """Get the diabetes status. + + Args: + user: The user profile. + + Returns: + bool | None: True/False if determinable, otherwise None. + """ + return ( + ChronicCondition.DIABETES + in user.personal_medical_history.chronic_conditions + ) + + def _get_aspirin_status(self, user: UserInput) -> str | None: + """Get the aspirin use status. + + Args: + user: The user profile. + + Returns: + str | None: Aspirin use status string or None. + """ + if user.personal_medical_history.aspirin_use is None: + return None + + if user.personal_medical_history.aspirin_use == AspirinUse.CURRENT: + return "yes" + elif user.personal_medical_history.aspirin_use == AspirinUse.FORMER: + return "yes_not_currently" + else: + return "no" + + def _get_nsaid_status(self, user: UserInput) -> str | None: + """Get the NSAID use status. + + Args: + user: The user profile. + + Returns: + str | None: NSAID use status string or None. + """ + if user.personal_medical_history.nsaid_use is None: + return None + + if user.personal_medical_history.nsaid_use == NSAIDUse.CURRENT: + return "yes_currently" + elif user.personal_medical_history.nsaid_use == NSAIDUse.FORMER: + return "yes_not_currently" + else: + return "no" + + def _get_estrogen_status(self, user: UserInput) -> str | None: + """Get the estrogen status. + + Args: + user: The user profile with female-specific info. + + Returns: + str | None: One of {"yes_currently","yes_previous","no"} or None. + """ + if ( + user.female_specific is None + or user.female_specific.hormone_use.estrogen_use is None + ): + return None + + if user.female_specific.hormone_use.estrogen_use == HormoneUse.CURRENT: + return "yes_currently" + elif user.female_specific.hormone_use.estrogen_use == HormoneUse.FORMER: + return "yes_previous" + else: + return "no" diff --git a/src/sentinel/risk_models/data/extended_pbcg_coefficients.json b/src/sentinel/risk_models/data/extended_pbcg_coefficients.json new file mode 100644 index 0000000000000000000000000000000000000000..738e9cfe4daf1d0d7ae28c30dbce153b4332bc66 --- /dev/null +++ b/src/sentinel/risk_models/data/extended_pbcg_coefficients.json @@ -0,0 +1,15362 @@ +{ + "ari_use|dre|famhist_1|famhist_2|famhist_bca|hispanic|priorbiopsy|priorpsa|prosvol|race": [ + -5.82232349197518, + 0.0529313031876652, + 0.540530440277563, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "ari_use|dre|famhist_1|famhist_2|famhist_bca|hispanic|priorbiopsy|priorpsa|prosvol": [ + -5.89358850977359, + 0.0531668016126053, + 0.533143318232447, + 0.2191398075804, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "ari_use|dre|famhist_1|famhist_2|famhist_bca|hispanic|priorpsa|prosvol|race": [ + -6.02076296050646, + 0.0559987535690726, + 0.650207234712462, + null, + -1.38367979072344, + null, + null, + null, + null, + null, + null, + null, + null + ], + "ari_use|dre|famhist_1|famhist_2|famhist_bca|hispanic|priorpsa|prosvol": [ + -6.05188336294039, + 0.0557466965901767, + 0.645632021257339, + 0.1520144674304, + -1.37569758022099, + null, + null, + null, + null, + null, + null, + null, + null + ], + "ari_use|famhist_1|famhist_2|famhist_bca|hispanic|priorbiopsy|priorpsa|prosvol|race": [ + -5.50096981958215, + 0.0428676044146293, + 0.568110252499123, + null, + null, + 0.785630063808854, + null, + null, + null, + null, + null, + null, + null + ], + "ari_use|famhist_1|famhist_2|famhist_bca|hispanic|priorbiopsy|priorpsa|prosvol": [ + -5.60553978833639, + 0.0438623978182559, + 0.55475717470985, + 0.224410417106896, + null, + 0.776351804374853, + null, + null, + null, + null, + null, + null, + null + ], + "ari_use|famhist_1|famhist_2|famhist_bca|hispanic|priorpsa|prosvol|race": [ + -5.74814161768069, + 0.0469600800008406, + 0.673689676085604, + null, + -1.33951818389296, + 0.77768867379346, + null, + null, + null, + null, + null, + null, + null + ], + "ari_use|famhist_1|famhist_2|famhist_bca|hispanic|priorpsa|prosvol": [ + -5.82127268298294, + 0.0474783661393108, + 0.663277421614395, + 0.192619590386845, + -1.34382698991738, + 0.781952059577285, + null, + null, + null, + null, + null, + null, + null + ], + "ari_use|dre|famhist_2|famhist_bca|hispanic|priorbiopsy|priorpsa|prosvol|race": [ + -6.1309669222744, + 0.0554780357184018, + 0.563439687153964, + null, + null, + null, + 0.539460018347765, + null, + null, + null, + null, + null, + null + ], + "ari_use|dre|famhist_2|famhist_bca|hispanic|priorbiopsy|priorpsa|prosvol": [ + -6.24625919780681, + 0.0561700507030637, + 0.553239152316748, + 0.263748166019409, + null, + null, + 0.562835092534328, + null, + null, + null, + null, + null, + null + ], + "ari_use|dre|famhist_2|famhist_bca|hispanic|priorpsa|prosvol|race": [ + -6.32924656018098, + 0.0585099800463537, + 0.666862849492916, + null, + -1.33661335097444, + null, + 0.535187255711676, + null, + null, + null, + null, + null, + null + ], + "ari_use|dre|famhist_2|famhist_bca|hispanic|priorpsa|prosvol": [ + -6.41050255855574, + 0.0587441645070146, + 0.661747521741714, + 0.194371359052507, + -1.3277480796916, + null, + 0.56394524683232, + null, + null, + null, + null, + null, + null + ], + "ari_use|famhist_2|famhist_bca|hispanic|priorbiopsy|priorpsa|prosvol|race": [ + -5.83465302834788, + 0.0448336128181739, + 0.599950815647993, + null, + null, + 0.933235266294218, + 0.647331406844075, + null, + null, + null, + null, + null, + null + ], + "ari_use|famhist_2|famhist_bca|hispanic|priorbiopsy|priorpsa|prosvol": [ + -5.97777644193972, + 0.0460298259515477, + 0.584823364768368, + 0.264817147662638, + null, + 0.952801269667973, + 0.682467379373524, + null, + null, + null, + null, + null, + null + ], + "ari_use|famhist_2|famhist_bca|hispanic|priorpsa|prosvol|race": [ + -6.07272981592742, + 0.0488710733245805, + 0.695770126365896, + null, + -1.26142641465984, + 0.911297289516134, + 0.626012396213663, + null, + null, + null, + null, + null, + null + ], + "ari_use|famhist_2|famhist_bca|hispanic|priorpsa|prosvol": [ + -6.19449237284356, + 0.0496872411976217, + 0.68612515720331, + 0.231746766018854, + -1.26810248373137, + 0.943561735938141, + 0.664550677528451, + null, + null, + null, + null, + null, + null + ], + "ari_use|dre|famhist_1|famhist_bca|hispanic|priorbiopsy|priorpsa|prosvol|race": [ + -4.43237774036979, + 0.0316330705877458, + 0.5697909590195, + null, + null, + null, + null, + 0.289246579266354, + null, + null, + null, + null, + null + ], + "ari_use|dre|famhist_1|famhist_bca|hispanic|priorbiopsy|priorpsa|prosvol": [ + -4.38250207088695, + 0.0309655114347957, + 0.574363079261491, + -0.04470651108647, + null, + null, + null, + 0.286905298140124, + null, + null, + null, + null, + null + ], + "ari_use|dre|famhist_1|famhist_bca|hispanic|priorpsa|prosvol|race": [ + -4.99849404288203, + 0.0388674605001365, + 0.69497364709692, + null, + -1.28947425028662, + null, + null, + 0.358311159700037, + null, + null, + null, + null, + null + ], + "ari_use|dre|famhist_1|famhist_bca|hispanic|priorpsa|prosvol": [ + -4.89310064683453, + 0.037487226559558, + 0.702973031596202, + -0.0868960223170479, + -1.29280725104922, + null, + null, + 0.353364036959746, + null, + null, + null, + null, + null + ], + "ari_use|famhist_1|famhist_bca|hispanic|priorbiopsy|priorpsa|prosvol|race": [ + -4.46953306545529, + 0.0266278960453267, + 0.598923277962993, + null, + null, + 0.785560600102201, + null, + 0.220200317781406, + null, + null, + null, + null, + null + ], + "ari_use|famhist_1|famhist_bca|hispanic|priorbiopsy|priorpsa|prosvol": [ + -4.53113233423379, + 0.0273645387612481, + 0.597444618091885, + 0.040884432167293, + null, + 0.787872474272505, + null, + 0.223239785625797, + null, + null, + null, + null, + null + ], + "ari_use|famhist_1|famhist_bca|hispanic|priorpsa|prosvol|race": [ + -5.14994088696419, + 0.0348458569025624, + 0.74966614614713, + null, + -1.33498849540919, + 0.858148894502211, + null, + 0.281988130207411, + null, + null, + null, + null, + null + ], + "ari_use|famhist_1|famhist_bca|hispanic|priorpsa|prosvol": [ + -5.1800006889129, + 0.0351857433464208, + 0.749744956661525, + 0.0190291709658975, + -1.33336614861238, + 0.857050059546989, + null, + 0.283582891180993, + null, + null, + null, + null, + null + ], + "ari_use|dre|famhist_bca|hispanic|priorbiopsy|priorpsa|prosvol|race": [ + -4.55671275317698, + 0.0315471168343488, + 0.588269295813704, + null, + null, + null, + 0.46590412286881, + 0.207799492744021, + null, + null, + null, + null, + null + ], + "ari_use|dre|famhist_bca|hispanic|priorbiopsy|priorpsa|prosvol": [ + -4.51749262611827, + 0.0310165824345372, + 0.592247555234098, + -0.0366870387785381, + null, + null, + 0.465260082154402, + 0.206181642346977, + null, + null, + null, + null, + null + ], + "ari_use|dre|famhist_bca|hispanic|priorpsa|prosvol|race": [ + -5.11636852017061, + 0.038719328895076, + 0.712928803098957, + null, + -1.28615729571248, + null, + 0.456490938075442, + 0.275833286558552, + null, + null, + null, + null, + null + ], + "ari_use|dre|famhist_bca|hispanic|priorpsa|prosvol": [ + -5.01929524219062, + 0.037447953093311, + 0.720441716094332, + -0.0805662377645318, + -1.28944061775182, + null, + 0.455130498318898, + 0.271670442881779, + null, + null, + null, + null, + null + ], + "ari_use|famhist_bca|hispanic|priorbiopsy|priorpsa|prosvol|race": [ + -4.61659666178697, + 0.0264674558741772, + 0.621832067727512, + null, + null, + 0.7957867489728, + 0.529987120209841, + 0.124753581446629, + null, + null, + null, + null, + null + ], + "ari_use|famhist_bca|hispanic|priorbiopsy|priorpsa|prosvol": [ + -4.69055980290802, + 0.0273492596909534, + 0.619770520393729, + 0.0497825326507257, + null, + 0.799463374406669, + 0.531402481951444, + 0.128031045904233, + null, + null, + null, + null, + null + ], + "ari_use|famhist_bca|hispanic|priorpsa|prosvol|race": [ + -5.29017558187418, + 0.034668674033505, + 0.771520864854173, + null, + -1.32814356938917, + 0.865673414304361, + 0.513437997498266, + 0.18590604380073, + null, + null, + null, + null, + null + ], + "ari_use|famhist_bca|hispanic|priorpsa|prosvol": [ + -5.3287182307569, + 0.0351108871499312, + 0.771168516671024, + 0.0249803873367567, + -1.32628332441937, + 0.865394664423884, + 0.514177445699881, + 0.187798365163129, + null, + null, + null, + null, + null + ], + "ari_use|dre|famhist_1|famhist_2|hispanic|priorbiopsy|priorpsa|prosvol|race": [ + -4.34607886944821, + 0.0303445172213322, + 0.5696089272535, + null, + null, + null, + null, + null, + 0.272921990871964, + null, + null, + null, + null + ], + "ari_use|dre|famhist_1|famhist_2|hispanic|priorbiopsy|priorpsa|prosvol": [ + -4.30319135065095, + 0.0297691957422381, + 0.573841447693537, + -0.0396825794586161, + null, + null, + null, + null, + 0.267554376025367, + null, + null, + null, + null + ], + "ari_use|dre|famhist_1|famhist_2|hispanic|priorpsa|prosvol|race": [ + -4.89960337046448, + 0.0374114372129741, + 0.695357178116868, + null, + -1.29355125904099, + null, + null, + null, + 0.309731051643015, + null, + null, + null, + null + ], + "ari_use|dre|famhist_1|famhist_2|hispanic|priorpsa|prosvol": [ + -4.80165365486752, + 0.036134058452377, + 0.703024630766777, + -0.0820560373724349, + -1.29662095550421, + null, + null, + null, + 0.298464246391331, + null, + null, + null, + null + ], + "ari_use|famhist_1|famhist_2|hispanic|priorbiopsy|priorpsa|prosvol|race": [ + -4.40637848309325, + 0.0256576030218573, + 0.599823459523082, + null, + null, + 0.779673959490649, + null, + null, + 0.20973393103553, + null, + null, + null, + null + ], + "ari_use|famhist_1|famhist_2|hispanic|priorbiopsy|priorpsa|prosvol": [ + -4.46920728375767, + 0.0264058672752603, + 0.59822713894224, + 0.0424472265935572, + null, + 0.782109474618686, + null, + null, + 0.214547401464454, + null, + null, + null, + null + ], + "ari_use|famhist_1|famhist_2|hispanic|priorpsa|prosvol|race": [ + -5.07923127194334, + 0.033847876829439, + 0.750866060594371, + null, + -1.33773734741679, + 0.851824394801662, + null, + null, + 0.193894898140204, + null, + null, + null, + null + ], + "ari_use|famhist_1|famhist_2|hispanic|priorpsa|prosvol": [ + -5.10779867186143, + 0.0341677367312257, + 0.751002158911884, + 0.0182468060382115, + -1.33610153994496, + 0.85053596838919, + null, + null, + 0.196221287148422, + null, + null, + null, + null + ], + "ari_use|dre|famhist_2|hispanic|priorbiopsy|priorpsa|prosvol|race": [ + -4.49363347931103, + 0.0304891848601647, + 0.589368140821811, + null, + null, + null, + 0.470254412111655, + null, + 0.238953203646651, + null, + null, + null, + null + ], + "ari_use|dre|famhist_2|hispanic|priorbiopsy|priorpsa|prosvol": [ + -4.46132566267936, + 0.0300485959447746, + 0.593006000573556, + -0.0316576876629413, + null, + null, + 0.46979257731617, + null, + 0.234897287742025, + null, + null, + null, + null + ], + "ari_use|dre|famhist_2|hispanic|priorpsa|prosvol|race": [ + -5.04241815684005, + 0.037504302383404, + 0.71491286506911, + null, + -1.29145407003532, + null, + 0.463939023749906, + null, + 0.271596014854261, + null, + null, + null, + null + ], + "ari_use|dre|famhist_2|hispanic|priorpsa|prosvol": [ + -4.95263221023216, + 0.0363327557038781, + 0.722096331754917, + -0.0758012437292601, + -1.29448849574994, + null, + 0.462774670599372, + null, + 0.261481066063991, + null, + null, + null, + null + ], + "ari_use|famhist_2|hispanic|priorbiopsy|priorpsa|prosvol|race": [ + -4.57982257267127, + 0.0258003907808521, + 0.623679303883325, + null, + null, + 0.78911602106028, + 0.528331735525269, + null, + 0.169619998909627, + null, + null, + null, + null + ], + "ari_use|famhist_2|hispanic|priorbiopsy|priorpsa|prosvol": [ + -4.65510605080518, + 0.0266940434210609, + 0.621497311641391, + 0.0514255733149445, + null, + 0.792899762722512, + 0.529818741717823, + null, + 0.175166566048751, + null, + null, + null, + null + ], + "ari_use|famhist_2|hispanic|priorpsa|prosvol|race": [ + -5.24868718039874, + 0.0339799377355402, + 0.774405387839978, + null, + -1.3330021094949, + 0.859075815638572, + 0.516114206816824, + null, + 0.149696772887248, + null, + null, + null, + null + ], + "ari_use|famhist_2|hispanic|priorpsa|prosvol": [ + -5.28584464045724, + 0.0344030945303891, + 0.774097035335958, + 0.0243150422137911, + -1.33110811541652, + 0.858594435225607, + 0.516860711864917, + null, + 0.152674847111191, + null, + null, + null, + null + ], + "ari_use|dre|famhist_1|hispanic|priorbiopsy|priorpsa|prosvol|race": [ + -4.41842720691935, + 0.030914391402613, + 0.575141157892813, + null, + null, + null, + null, + 0.281939253958951, + 0.258913305035637, + null, + null, + null, + null + ], + "ari_use|dre|famhist_1|hispanic|priorbiopsy|priorpsa|prosvol": [ + -4.38202344664806, + 0.0304226561113062, + 0.578974966626408, + -0.0345051116856151, + null, + null, + null, + 0.280430303222237, + 0.254328822885875, + null, + null, + null, + null + ], + "ari_use|dre|famhist_1|hispanic|priorpsa|prosvol|race": [ + -4.99529456479064, + 0.0382059638505618, + 0.702894938661113, + null, + -1.30243427878145, + null, + null, + 0.351157605473676, + 0.293148751693854, + null, + null, + null, + null + ], + "ari_use|dre|famhist_1|hispanic|priorpsa|prosvol": [ + -4.9045482842451, + 0.0370247053876221, + 0.7099917730528, + -0.0758560867024198, + -1.30511382814728, + null, + null, + 0.34732861005369, + 0.282925486325844, + null, + null, + null, + null + ], + "ari_use|famhist_1|hispanic|priorbiopsy|priorpsa|prosvol|race": [ + -4.4571064542355, + 0.0260236164495978, + 0.603851925632626, + null, + null, + 0.778961855326881, + null, + 0.217023529610565, + 0.199074124601223, + null, + null, + null, + null + ], + "ari_use|famhist_1|hispanic|priorbiopsy|priorpsa|prosvol": [ + -4.52762445322122, + 0.0268590856890874, + 0.601951239508569, + 0.0479059234104856, + null, + 0.782259615593812, + null, + 0.220291209512948, + 0.204345323988057, + null, + null, + null, + null + ], + "ari_use|famhist_1|hispanic|priorpsa|prosvol|race": [ + -5.15016157499745, + 0.0343684711979543, + 0.757155945208677, + null, + -1.34512541837109, + 0.852565886152393, + null, + 0.281248677761694, + 0.180835833711121, + null, + null, + null, + null + ], + "ari_use|famhist_1|hispanic|priorpsa|prosvol": [ + -5.18826554498565, + 0.0347999464536303, + 0.756843355003957, + 0.0253039522843694, + -1.343360605197, + 0.852332688016865, + null, + 0.283126775653943, + 0.183868094104091, + null, + null, + null, + null + ], + "ari_use|dre|hispanic|priorbiopsy|priorpsa|prosvol|race": [ + -4.53991220587128, + 0.0308760627917898, + 0.592773981216921, + null, + null, + null, + 0.455739252141201, + 0.203976183528474, + 0.22974764621229, + null, + null, + null, + null + ], + "ari_use|dre|hispanic|priorbiopsy|priorpsa|prosvol": [ + -4.51234536752497, + 0.0304957160221106, + 0.59613301555503, + -0.0279065725649362, + null, + null, + 0.455454471149982, + 0.202984160395704, + 0.226225660425776, + null, + null, + null, + null + ], + "ari_use|dre|hispanic|priorpsa|prosvol|race": [ + -5.10916849449178, + 0.0381017803092469, + 0.719853833962923, + null, + -1.29832063910203, + null, + 0.443720592272353, + 0.272858771248678, + 0.26032941323411, + null, + null, + null, + null + ], + "ari_use|dre|hispanic|priorpsa|prosvol": [ + -5.02492211515171, + 0.0370033396386458, + 0.726596334767225, + -0.0709842260154867, + -1.30100843580585, + null, + 0.442901880228253, + 0.269569148437791, + 0.25099089919055, + null, + null, + null, + null + ], + "ari_use|hispanic|priorbiopsy|priorpsa|prosvol|race": [ + -4.60128736558869, + 0.0259290960014238, + 0.625613457801324, + null, + null, + 0.789731584242936, + 0.520684250650044, + 0.125416530964212, + 0.163553122894672, + null, + null, + null, + null + ], + "ari_use|hispanic|priorbiopsy|priorpsa|prosvol": [ + -4.68211218941527, + 0.0268848147942083, + 0.623231683538054, + 0.0552615882128551, + null, + 0.794130282878323, + 0.522022774798499, + 0.128784193907636, + 0.16935253162156, + null, + null, + null, + null + ], + "ari_use|hispanic|priorpsa|prosvol|race": [ + -5.28679270564555, + 0.0342504045211424, + 0.777912861173062, + null, + -1.33818867227767, + 0.860583705674433, + 0.502881744533562, + 0.189435226954565, + 0.141503260119666, + null, + null, + null, + null + ], + "ari_use|hispanic|priorpsa|prosvol": [ + -5.33136116202704, + 0.0347597588554285, + 0.777269182577638, + 0.0297347343213044, + -1.3361951412957, + 0.860923630949567, + 0.503578273768959, + 0.191488348954155, + 0.144984888743494, + null, + null, + null, + null + ], + "ari_use|dre|famhist_1|famhist_2|famhist_bca|hispanic|priorbiopsy|priorpsa|race": [ + -1.22510461638434, + 0.0699340147858794, + 0.749631318423906, + null, + null, + null, + null, + null, + null, + -1.15964795660616, + null, + null, + null + ], + "ari_use|dre|famhist_1|famhist_2|famhist_bca|hispanic|priorbiopsy|priorpsa": [ + -1.17699384351398, + 0.0695711272097222, + 0.743419740012579, + 0.0560771302381862, + null, + null, + null, + null, + null, + -1.16922677886793, + null, + null, + null + ], + "ari_use|dre|famhist_1|famhist_2|famhist_bca|hispanic|priorpsa|race": [ + -1.6970470214901, + 0.0715397876301848, + 0.822905622149146, + null, + -1.21498185970075, + null, + null, + null, + null, + -1.08136437825814, + null, + null, + null + ], + "ari_use|dre|famhist_1|famhist_2|famhist_bca|hispanic|priorpsa": [ + -1.61443673514047, + 0.070807358463795, + 0.816583298373762, + 0.00672126843997224, + -1.20441267830904, + null, + null, + null, + null, + -1.0905910325111, + null, + null, + null + ], + "ari_use|famhist_1|famhist_2|famhist_bca|hispanic|priorbiopsy|priorpsa|race": [ + -1.16878150185411, + 0.0633334806838128, + 0.783089973005982, + null, + null, + 0.781894885031995, + null, + null, + null, + -1.16033646978199, + null, + null, + null + ], + "ari_use|famhist_1|famhist_2|famhist_bca|hispanic|priorbiopsy|priorpsa": [ + -1.19649193260985, + 0.0644239690037266, + 0.767948858289922, + 0.090358776155721, + null, + 0.765547872406194, + null, + null, + null, + -1.16937832542128, + null, + null, + null + ], + "ari_use|famhist_1|famhist_2|famhist_bca|hispanic|priorpsa|race": [ + -1.66544912356624, + 0.0656227509671702, + 0.854694296508853, + null, + -1.18101662986064, + 0.766983685177397, + null, + null, + null, + -1.08207520036383, + null, + null, + null + ], + "ari_use|famhist_1|famhist_2|famhist_bca|hispanic|priorpsa": [ + -1.67968411382607, + 0.0663774445999849, + 0.839443562421633, + 0.0691447812769023, + -1.17940372261484, + 0.762197320187789, + null, + null, + null, + -1.08850839817107, + null, + null, + null + ], + "ari_use|dre|famhist_2|famhist_bca|hispanic|priorbiopsy|priorpsa|race": [ + -1.67091903996522, + 0.072096101685364, + 0.764958042960309, + null, + null, + null, + 0.678794445391902, + null, + null, + -1.13023660456871, + null, + null, + null + ], + "ari_use|dre|famhist_2|famhist_bca|hispanic|priorbiopsy|priorpsa": [ + -1.67426968619708, + 0.0722460186557438, + 0.75736867876333, + 0.0985036618614674, + null, + null, + 0.729224253163177, + null, + null, + -1.14024761189051, + null, + null, + null + ], + "ari_use|dre|famhist_2|famhist_bca|hispanic|priorpsa|race": [ + -2.11254829035013, + 0.073279164246374, + 0.836674102866434, + null, + -1.17123446914772, + null, + 0.65415540374271, + null, + null, + -1.05416278171711, + null, + null, + null + ], + "ari_use|dre|famhist_2|famhist_bca|hispanic|priorpsa": [ + -2.09219007206061, + 0.0730232161582484, + 0.830783794908801, + 0.0487275039096112, + -1.1565463290609, + null, + 0.706555976348415, + null, + null, + -1.06201269902909, + null, + null, + null + ], + "ari_use|famhist_2|famhist_bca|hispanic|priorbiopsy|priorpsa|race": [ + -1.64372690186493, + 0.0654456840367853, + 0.803141631544499, + null, + null, + 0.922028565491582, + 0.711678021441691, + null, + null, + -1.13248611845771, + null, + null, + null + ], + "ari_use|famhist_2|famhist_bca|hispanic|priorbiopsy|priorpsa": [ + -1.73651712666001, + 0.0671240358088106, + 0.786902878041162, + 0.140475724153, + null, + 0.933196015753743, + 0.770904087549263, + null, + null, + -1.14223596282222, + null, + null, + null + ], + "ari_use|famhist_2|famhist_bca|hispanic|priorpsa|race": [ + -2.0990180347671, + 0.0673565286918093, + 0.872419766867027, + null, + -1.11551236540579, + 0.896540712000414, + 0.680824933831979, + null, + null, + -1.05897000182826, + null, + null, + null + ], + "ari_use|famhist_2|famhist_bca|hispanic|priorpsa": [ + -2.19219925956506, + 0.0686711523054841, + 0.858457429366225, + 0.117716153829408, + -1.11246321639742, + 0.919709946733183, + 0.740575760880857, + null, + null, + -1.06393888772988, + null, + null, + null + ], + "ari_use|dre|famhist_1|famhist_bca|hispanic|priorbiopsy|priorpsa|race": [ + -0.297647757040194, + 0.057571099088345, + 0.728307542447467, + null, + null, + null, + null, + 0.427388838091982, + null, + -1.17026421709864, + null, + null, + null + ], + "ari_use|dre|famhist_1|famhist_bca|hispanic|priorbiopsy|priorpsa": [ + 0.0079196557513443, + 0.0546881100272465, + 0.745675593366526, + -0.208063133695583, + null, + null, + null, + 0.416573723034545, + null, + -1.18487335866842, + null, + null, + null + ], + "ari_use|dre|famhist_1|famhist_bca|hispanic|priorpsa|race": [ + -1.02452161970909, + 0.0604482949114195, + 0.806934603045818, + null, + -0.966889420416015, + null, + null, + 0.468257724618501, + null, + -1.07520531951629, + null, + null, + null + ], + "ari_use|dre|famhist_1|famhist_bca|hispanic|priorpsa": [ + -0.703993086541927, + 0.057359991241631, + 0.82530019682284, + -0.218239103648289, + -0.969851953767281, + null, + null, + 0.455985042533543, + null, + -1.08969721103937, + null, + null, + null + ], + "ari_use|famhist_1|famhist_bca|hispanic|priorbiopsy|priorpsa|race": [ + -0.325516063802895, + 0.0559288573005049, + 0.792602896871079, + null, + null, + 0.881418039416215, + null, + 0.378351525006511, + null, + -1.2357468166961, + null, + null, + null + ], + "ari_use|famhist_1|famhist_bca|hispanic|priorbiopsy|priorpsa": [ + -0.150727694372571, + 0.0543241047756338, + 0.802158992830406, + -0.12474852929466, + null, + 0.857235691015899, + null, + 0.372227620249322, + null, + -1.24267110990466, + null, + null, + null + ], + "ari_use|famhist_1|famhist_bca|hispanic|priorpsa|race": [ + -1.17965100193826, + 0.0595335267284848, + 0.887988347907865, + null, + -1.0060473648869, + 0.918944974852354, + null, + 0.410764966172191, + null, + -1.12987218109408, + null, + null, + null + ], + "ari_use|famhist_1|famhist_bca|hispanic|priorpsa": [ + -1.01157513787766, + 0.0579505564697766, + 0.896957813177429, + -0.118180927253533, + -1.00385394840319, + 0.896332667533347, + null, + 0.40435875086821, + null, + -1.13619905227929, + null, + null, + null + ], + "ari_use|dre|famhist_bca|hispanic|priorbiopsy|priorpsa|race": [ + -0.421778730836019, + 0.057740660398077, + 0.749754468729511, + null, + null, + null, + 0.518357975337953, + 0.342371418753884, + null, + -1.17702292485146, + null, + null, + null + ], + "ari_use|dre|famhist_bca|hispanic|priorbiopsy|priorpsa": [ + -0.134848664069886, + 0.055024380277367, + 0.765724353564955, + -0.195155926970182, + null, + null, + 0.511688358731648, + 0.333045341806975, + null, + -1.19025696167445, + null, + null, + null + ], + "ari_use|dre|famhist_bca|hispanic|priorpsa|race": [ + -1.14189969339495, + 0.0605216373126058, + 0.827761217238504, + null, + -0.959384464651693, + null, + 0.506744707481501, + 0.38264544796683, + null, + -1.08153936132604, + null, + null, + null + ], + "ari_use|dre|famhist_bca|hispanic|priorpsa": [ + -0.836930870057202, + 0.0575684548216632, + 0.845053068151991, + -0.207714988898962, + -0.963075385766262, + null, + 0.500889655408214, + 0.371655388162225, + null, + -1.09483596028592, + null, + null, + null + ], + "ari_use|famhist_bca|hispanic|priorbiopsy|priorpsa|race": [ + -0.476272192851764, + 0.0560831431414924, + 0.817080641939529, + null, + null, + 0.889608802122464, + 0.549636236206412, + 0.284648209638515, + null, + -1.24058565155853, + null, + null, + null + ], + "ari_use|famhist_bca|hispanic|priorbiopsy|priorpsa": [ + -0.319447358639009, + 0.0546406317555625, + 0.825601216214989, + -0.112754632140606, + null, + 0.86724512200272, + 0.546654494439494, + 0.279794891573076, + null, + -1.24656716430152, + null, + null, + null + ], + "ari_use|famhist_bca|hispanic|priorpsa|race": [ + -1.32029685969548, + 0.0595696097842811, + 0.91145044233696, + null, + -0.996563950654746, + 0.9256285892955, + 0.5353089271264, + 0.316964917414785, + null, + -1.1342910633303, + null, + null, + null + ], + "ari_use|famhist_bca|hispanic|priorpsa": [ + -1.16604095159323, + 0.0581111042574278, + 0.91970385965917, + -0.109337305679169, + -0.994977022374019, + 0.904307813067959, + 0.533175132700916, + 0.311430960794448, + null, + -1.13988845689012, + null, + null, + null + ], + "ari_use|dre|famhist_1|famhist_2|hispanic|priorbiopsy|priorpsa|race": [ + -0.182206492612468, + 0.0558222248037762, + 0.730044561938147, + null, + null, + null, + null, + null, + 0.408375147560657, + -1.1710401342689, + null, + null, + null + ], + "ari_use|dre|famhist_1|famhist_2|hispanic|priorbiopsy|priorpsa": [ + 0.108680044784313, + 0.053101009533417, + 0.746709096901233, + -0.200829899237671, + null, + null, + null, + null, + 0.381224989740496, + -1.18485161006306, + null, + null, + null + ], + "ari_use|dre|famhist_1|famhist_2|hispanic|priorpsa|race": [ + -0.903351417223213, + 0.0586140268935274, + 0.808809792148696, + null, + -0.971199754326307, + null, + null, + null, + 0.421169298376086, + -1.0755899688078, + null, + null, + null + ], + "ari_use|dre|famhist_1|famhist_2|hispanic|priorpsa": [ + -0.597843252508981, + 0.0556998239318548, + 0.826459702662485, + -0.211205787663946, + -0.974031041432348, + null, + null, + null, + 0.39142116127564, + -1.08933068007604, + null, + null, + null + ], + "ari_use|famhist_1|famhist_2|hispanic|priorbiopsy|priorpsa|race": [ + -0.239940981296267, + 0.0545366775617585, + 0.794122669629911, + null, + null, + 0.870120099554168, + null, + null, + 0.351543658653237, + -1.23458693823991, + null, + null, + null + ], + "ari_use|famhist_1|famhist_2|hispanic|priorbiopsy|priorpsa": [ + -0.0697492334585717, + 0.0529823436389403, + 0.803589186239342, + -0.12281970125553, + null, + 0.846672387061761, + null, + null, + 0.338885559427511, + -1.24132236770159, + null, + null, + null + ], + "ari_use|famhist_1|famhist_2|hispanic|priorpsa|race": [ + -1.09427364241174, + 0.0581671081963606, + 0.889159579774396, + null, + -1.00694219781206, + 0.907668898823542, + null, + null, + 0.324067236502755, + -1.12792717923629, + null, + null, + null + ], + "ari_use|famhist_1|famhist_2|hispanic|priorpsa": [ + -0.928524190708807, + 0.0566142096064538, + 0.898188811452743, + -0.117972094533641, + -1.00504722811042, + 0.885475250498524, + null, + null, + 0.311449560467681, + -1.13415630094958, + null, + null, + null + ], + "ari_use|dre|famhist_2|hispanic|priorbiopsy|priorpsa|race": [ + -0.33119119949141, + 0.0562084199245115, + 0.752383936576518, + null, + null, + null, + 0.520981741617381, + null, + 0.362200258022269, + -1.17691606006312, + null, + null, + null + ], + "ari_use|dre|famhist_2|hispanic|priorbiopsy|priorpsa": [ + -0.0575304149888606, + 0.0536410036559671, + 0.767770376300647, + -0.188763276646584, + null, + null, + 0.515162135800831, + null, + 0.337428476267689, + -1.18950240559729, + null, + null, + null + ], + "ari_use|dre|famhist_2|hispanic|priorpsa|race": [ + -1.04738809411895, + 0.058904426116471, + 0.830810261654834, + null, + -0.964742182494047, + null, + 0.511536040014082, + null, + 0.373577652878258, + -1.08086499111027, + null, + null, + null + ], + "ari_use|dre|famhist_2|hispanic|priorpsa": [ + -0.755915042955494, + 0.0561116668996116, + 0.847521895662812, + -0.201596687923998, + -0.968343813028354, + null, + 0.506549565723608, + null, + 0.345706309890603, + -1.09357368216498, + null, + null, + null + ], + "ari_use|famhist_2|hispanic|priorbiopsy|priorpsa|race": [ + -0.416758781506748, + 0.0549569139728159, + 0.819272455889073, + null, + null, + 0.87765915935376, + 0.547833911287065, + null, + 0.302193585092698, + -1.23869543043348, + null, + null, + null + ], + "ari_use|famhist_2|hispanic|priorbiopsy|priorpsa": [ + -0.263430708074271, + 0.0535549522505456, + 0.82775951592643, + -0.111452789629045, + null, + 0.855859979302946, + 0.545152666240048, + null, + 0.291411436374365, + -1.24456383203906, + null, + null, + null + ], + "ari_use|famhist_2|hispanic|priorpsa|race": [ + -1.26385188484769, + 0.0584694944788185, + 0.913825281156062, + null, + -0.999380912151487, + 0.913986467228782, + 0.536895027849061, + null, + 0.273470628165285, + -1.13140273517011, + null, + null, + null + ], + "ari_use|famhist_2|hispanic|priorpsa": [ + -1.1107954298011, + 0.0570312588905589, + 0.922202716352995, + -0.109765232579914, + -0.998088548179303, + 0.892942845336351, + 0.534992251260866, + null, + 0.262230982138199, + -1.1369876993825, + null, + null, + null + ], + "ari_use|dre|famhist_1|hispanic|priorbiopsy|priorpsa|race": [ + -0.25442217174585, + 0.0568150205920457, + 0.738627163660788, + null, + null, + null, + null, + 0.419002454097206, + 0.392546709820372, + -1.17955152856063, + null, + null, + null + ], + "ari_use|dre|famhist_1|hispanic|priorbiopsy|priorpsa": [ + 0.0254178842959912, + 0.0541937216531468, + 0.754460416469429, + -0.193612010163978, + null, + null, + null, + 0.4098209601474, + 0.36659666642225, + -1.19256069435549, + null, + null, + null + ], + "ari_use|dre|famhist_1|hispanic|priorpsa|race": [ + -0.99333269265318, + 0.0597417848133706, + 0.819225535454492, + null, + -0.980598958718999, + null, + null, + 0.461916500262376, + 0.405781267071994, + -1.08357658951261, + null, + null, + null + ], + "ari_use|dre|famhist_1|hispanic|priorpsa": [ + -0.699457712855148, + 0.0569333179340663, + 0.835873799190526, + -0.20305882016484, + -0.983013682554317, + null, + null, + 0.451287978596148, + 0.377320876540486, + -1.09646927245888, + null, + null, + null + ], + "ari_use|famhist_1|hispanic|priorbiopsy|priorpsa|race": [ + -0.28768331354001, + 0.0553489075321082, + 0.803016176676331, + null, + null, + 0.873134957824728, + null, + 0.374625700756127, + 0.338766876243182, + -1.24546953084229, + null, + null, + null + ], + "ari_use|famhist_1|hispanic|priorbiopsy|priorpsa": [ + -0.131544246859707, + 0.0539194743583976, + 0.811607502204209, + -0.11374245386799, + null, + 0.850987043516423, + null, + 0.369358620444824, + 0.32714113542512, + -1.25137506406216, + null, + null, + null + ], + "ari_use|famhist_1|hispanic|priorpsa|race": [ + -1.15491450906055, + 0.0590603944691103, + 0.899834004939709, + null, + -1.01493941979181, + 0.911562665455991, + null, + 0.410424469990826, + 0.312246439408243, + -1.138582055467, + null, + null, + null + ], + "ari_use|famhist_1|hispanic|priorpsa": [ + -1.00476386834492, + 0.0576480943932732, + 0.907873874838033, + -0.107626954264416, + -1.01291149091726, + 0.890871512972454, + null, + 0.404813094189754, + 0.300773573906812, + -1.14393792123566, + null, + null, + null + ], + "ari_use|dre|hispanic|priorbiopsy|priorpsa|race": [ + -0.378092333620143, + 0.0569982105218055, + 0.758564411149851, + null, + null, + null, + 0.500995392908034, + 0.338233584184946, + 0.350397519831747, + -1.18467775156375, + null, + null, + null + ], + "ari_use|dre|hispanic|priorbiopsy|priorpsa": [ + -0.113659658490093, + 0.0545120412612749, + 0.773267300145911, + -0.182725460041508, + null, + null, + 0.495697443876646, + 0.330200171704497, + 0.326589982611411, + -1.19658629963121, + null, + null, + null + ], + "ari_use|dre|hispanic|priorpsa|race": [ + -1.10959751273342, + 0.059833372421082, + 0.838447934198666, + null, + -0.972555965679622, + null, + 0.48787596070273, + 0.380833318756005, + 0.362277165202798, + -1.08836370466388, + null, + null, + null + ], + "ari_use|dre|hispanic|priorpsa": [ + -0.827947138319267, + 0.057126987190256, + 0.854300595423066, + -0.194625470394207, + -0.97570344358094, + null, + 0.483478923424478, + 0.371142793757949, + 0.335486233252969, + -1.10033431215585, + null, + null, + null + ], + "ari_use|hispanic|priorbiopsy|priorpsa|race": [ + -0.437349058020948, + 0.0555242659993412, + 0.825650751926355, + null, + null, + 0.881710508939612, + 0.532370247768984, + 0.285465009843194, + 0.292857265117411, + -1.24863523556161, + null, + null, + null + ], + "ari_use|hispanic|priorbiopsy|priorpsa": [ + -0.295826003499393, + 0.0542260459748947, + 0.83342796026646, + -0.103868141250494, + null, + 0.861020796386841, + 0.530155982742315, + 0.281195391341307, + 0.282873238186208, + -1.25381984955269, + null, + null, + null + ], + "ari_use|hispanic|priorpsa|race": [ + -1.29455808908708, + 0.0591226127391328, + 0.921557010098205, + null, + -1.00584681030475, + 0.918674269883804, + 0.517912438669728, + 0.321229217975472, + 0.264972499943099, + -1.14140363284643, + null, + null, + null + ], + "ari_use|hispanic|priorpsa": [ + -1.15485838213187, + 0.057803435982988, + 0.929090096226237, + -0.10089736983888, + -1.00435664336454, + 0.898928693225549, + 0.516498173861756, + 0.316248666453771, + 0.254674212411772, + -1.14624167640236, + null, + null, + null + ], + "dre|famhist_1|famhist_2|famhist_bca|hispanic|priorbiopsy|priorpsa|prosvol|race": [ + -5.75008480465665, + 0.0562483292017744, + 0.513465039454638, + null, + null, + null, + null, + null, + null, + null, + -0.307302727332211, + null, + null + ], + "dre|famhist_1|famhist_2|famhist_bca|hispanic|priorbiopsy|priorpsa|prosvol": [ + -5.71751051933187, + 0.0554652942904581, + 0.506110886306431, + 0.033722048527481, + null, + null, + null, + null, + null, + null, + -0.32430010931299, + null, + null + ], + "dre|famhist_1|famhist_2|famhist_bca|hispanic|priorpsa|prosvol|race": [ + -6.04365589806855, + 0.0601008624944963, + 0.639886105092102, + null, + -1.48915587140911, + null, + null, + null, + null, + null, + -0.0882350026228923, + null, + null + ], + "dre|famhist_1|famhist_2|famhist_bca|hispanic|priorpsa|prosvol": [ + -6.00653058322477, + 0.0591469088909409, + 0.642574224359452, + 0.0290218236304415, + -1.50704884356686, + null, + null, + null, + null, + null, + -0.109647562272774, + null, + null + ], + "famhist_1|famhist_2|famhist_bca|hispanic|priorbiopsy|priorpsa|prosvol|race": [ + -5.15093413565161, + 0.0435722613487516, + 0.543832819492314, + null, + null, + 0.531891169415405, + null, + null, + null, + null, + -0.366695285247369, + null, + null + ], + "famhist_1|famhist_2|famhist_bca|hispanic|priorbiopsy|priorpsa|prosvol": [ + -5.0809546460597, + 0.0429638641240812, + 0.528771533883274, + -0.0539965971884438, + null, + 0.485000971235155, + null, + null, + null, + null, + -0.382352795163267, + null, + null + ], + "famhist_1|famhist_2|famhist_bca|hispanic|priorpsa|prosvol|race": [ + -5.50472211904331, + 0.048287720191528, + 0.663901199403364, + null, + -1.45149542710733, + 0.560588647487645, + null, + null, + null, + null, + -0.146021670474026, + null, + null + ], + "famhist_1|famhist_2|famhist_bca|hispanic|priorpsa|prosvol": [ + -5.41818514973126, + 0.0471971631732948, + 0.660167309249094, + -0.0458455864894994, + -1.48623788210432, + 0.535756839955375, + null, + null, + null, + null, + -0.17059840977813, + null, + null + ], + "dre|famhist_2|famhist_bca|hispanic|priorbiopsy|priorpsa|prosvol|race": [ + -6.17838475165552, + 0.0620443145612664, + 0.520396006184341, + null, + null, + null, + 0.398792708236333, + null, + null, + null, + -0.241314440546806, + null, + null + ], + "dre|famhist_2|famhist_bca|hispanic|priorbiopsy|priorpsa|prosvol": [ + -6.17310884841091, + 0.0613628370476009, + 0.513186811907641, + 0.175469182345275, + null, + null, + 0.425239225057235, + null, + null, + null, + -0.244180422971757, + null, + null + ], + "dre|famhist_2|famhist_bca|hispanic|priorpsa|prosvol|race": [ + -6.52349049251495, + 0.0662343975451322, + 0.649019563501731, + null, + -1.45180660555544, + null, + 0.427602622893632, + null, + null, + null, + -0.0137819024224249, + null, + null + ], + "dre|famhist_2|famhist_bca|hispanic|priorpsa|prosvol": [ + -6.54400972540675, + 0.0657480934435009, + 0.655073636448155, + 0.135655086861937, + -1.47861888065897, + null, + 0.462445664116462, + null, + null, + null, + -0.0190694864110173, + null, + null + ], + "famhist_2|famhist_bca|hispanic|priorbiopsy|priorpsa|prosvol|race": [ + -5.58078450156624, + 0.0480048489644166, + 0.565003867326077, + null, + null, + 0.763359512243466, + 0.500623617084234, + null, + null, + null, + -0.390428600089574, + null, + null + ], + "famhist_2|famhist_bca|hispanic|priorbiopsy|priorpsa|prosvol": [ + -5.49776464755902, + 0.0465383550390156, + 0.553227592536586, + 0.0695252164474688, + null, + 0.765825612535181, + 0.548521955442134, + null, + null, + null, + -0.413200958299119, + null, + null + ], + "famhist_2|famhist_bca|hispanic|priorpsa|prosvol|race": [ + -5.9663022372058, + 0.0530263499306191, + 0.67867338626733, + null, + -1.35630997638532, + 0.75970685115613, + 0.519800906147743, + null, + null, + null, + -0.149272224159991, + null, + null + ], + "famhist_2|famhist_bca|hispanic|priorpsa|prosvol": [ + -5.90459168068053, + 0.0515682757671806, + 0.681602234917207, + 0.037454086484743, + -1.40963316952693, + 0.786811791750025, + 0.573412647565906, + null, + null, + null, + -0.173504919611347, + null, + null + ], + "dre|famhist_1|famhist_bca|hispanic|priorbiopsy|priorpsa|prosvol|race": [ + -4.82622340697375, + 0.0456775783501897, + 0.435593750106204, + null, + null, + null, + null, + 0.217762779879228, + null, + null, + -0.279757323475974, + null, + null + ], + "dre|famhist_1|famhist_bca|hispanic|priorbiopsy|priorpsa|prosvol": [ + -4.79716350033608, + 0.0454475341157456, + 0.440694170290634, + -0.229918500156764, + null, + null, + null, + 0.224492963777645, + null, + null, + -0.272051337877313, + null, + null + ], + "dre|famhist_1|famhist_bca|hispanic|priorpsa|prosvol|race": [ + -5.35867077435214, + 0.0513747303122012, + 0.626490516653962, + null, + -1.57574081226889, + null, + null, + 0.257373710974354, + null, + null, + -0.0212469114908822, + null, + null + ], + "dre|famhist_1|famhist_bca|hispanic|priorpsa|prosvol": [ + -5.30901752053824, + 0.0509459739817115, + 0.632573931272317, + -0.293471533799624, + -1.57807805583014, + null, + null, + 0.266703542613528, + null, + null, + -0.0225301726255558, + null, + null + ], + "famhist_1|famhist_bca|hispanic|priorbiopsy|priorpsa|prosvol|race": [ + -5.0232099346681, + 0.0446956306525219, + 0.44351197574018, + null, + null, + 0.447291516020102, + null, + 0.25628892376629, + null, + null, + -0.335057501743367, + null, + null + ], + "famhist_1|famhist_bca|hispanic|priorbiopsy|priorpsa|prosvol": [ + -5.01138661552889, + 0.0447566287244426, + 0.447264603681557, + -0.166075312749523, + null, + 0.433819397751559, + null, + 0.26247806956051, + null, + null, + -0.326219747162957, + null, + null + ], + "famhist_1|famhist_bca|hispanic|priorpsa|prosvol|race": [ + -5.49865807167767, + 0.0485994580392445, + 0.633917011258339, + null, + -1.55471017851841, + 0.572947112553046, + null, + 0.292791671531651, + null, + null, + -0.062671001000795, + null, + null + ], + "famhist_1|famhist_bca|hispanic|priorpsa|prosvol": [ + -5.46101026947653, + 0.048385529744097, + 0.639009643522226, + -0.243796414280998, + -1.55705012678846, + 0.560832022873122, + null, + 0.301275428745953, + null, + null, + -0.0615695012111768, + null, + null + ], + "dre|famhist_bca|hispanic|priorbiopsy|priorpsa|prosvol|race": [ + -5.04525881999896, + 0.0460390818912409, + 0.463163172259446, + null, + null, + null, + 0.649708855844582, + 0.124706472303927, + null, + null, + -0.308479203590236, + null, + null + ], + "dre|famhist_bca|hispanic|priorbiopsy|priorpsa|prosvol": [ + -5.01287498566055, + 0.0457458781483951, + 0.468790738131098, + -0.240953294308949, + null, + null, + 0.654846876071038, + 0.131695089520956, + null, + null, + -0.302010379278179, + null, + null + ], + "dre|famhist_bca|hispanic|priorpsa|prosvol|race": [ + -5.57187452254529, + 0.0517526019082764, + 0.652542435092265, + null, + -1.56536098276492, + null, + 0.619335405663479, + 0.164368701854624, + null, + null, + -0.0482033575221882, + null, + null + ], + "dre|famhist_bca|hispanic|priorpsa|prosvol": [ + -5.51755483237502, + 0.0512258614734936, + 0.659579645750334, + -0.308384826187558, + -1.56916266122181, + null, + 0.627118594568171, + 0.173812247373734, + null, + null, + -0.0502342715120865, + null, + null + ], + "famhist_bca|hispanic|priorbiopsy|priorpsa|prosvol|race": [ + -5.27206351488502, + 0.0452446934740097, + 0.473323116452942, + null, + null, + 0.459206634345563, + 0.684838658847938, + 0.16549328763823, + null, + null, + -0.363673523752426, + null, + null + ], + "famhist_bca|hispanic|priorbiopsy|priorpsa|prosvol": [ + -5.25820336039964, + 0.0452685933136788, + 0.477538884817499, + -0.176000891924511, + null, + 0.445250836959811, + 0.688901398427597, + 0.172156231105712, + null, + null, + -0.356128040356128, + null, + null + ], + "famhist_bca|hispanic|priorpsa|prosvol|race": [ + -5.74556283524999, + 0.0492020198514407, + 0.662628276508078, + null, + -1.54921635123093, + 0.582193135448945, + 0.669576494618435, + 0.199441815851432, + null, + null, + -0.0882658305284941, + null, + null + ], + "famhist_bca|hispanic|priorpsa|prosvol": [ + -5.70315922896406, + 0.0488991118943669, + 0.668604948388078, + -0.259105284781551, + -1.55327674008795, + 0.56973437811235, + 0.676242874729668, + 0.208263531130107, + null, + null, + -0.0879159100951274, + null, + null + ], + "dre|famhist_1|famhist_2|hispanic|priorbiopsy|priorpsa|prosvol|race": [ + -4.71635503183099, + 0.0440711505355865, + 0.43855858977498, + null, + null, + null, + null, + null, + 0.10900493023505, + null, + -0.300014499682848, + null, + null + ], + "dre|famhist_1|famhist_2|hispanic|priorbiopsy|priorpsa|prosvol": [ + -4.68274174361114, + 0.0437817564128048, + 0.443578859535674, + -0.231638148627584, + null, + null, + null, + null, + 0.115510784199668, + null, + -0.292616989326127, + null, + null + ], + "dre|famhist_1|famhist_2|hispanic|priorpsa|prosvol|race": [ + -5.24203229020838, + 0.0495844184106425, + 0.634713296468629, + null, + -1.59226731240003, + null, + null, + null, + 0.105179087842366, + null, + -0.0447862498791675, + null, + null + ], + "dre|famhist_1|famhist_2|hispanic|priorpsa|prosvol": [ + -5.18630067682086, + 0.049080432030291, + 0.640669904539717, + -0.294746326233408, + -1.59433099549821, + null, + null, + null, + 0.11202488794406, + null, + -0.0466630342769896, + null, + null + ], + "famhist_1|famhist_2|hispanic|priorbiopsy|priorpsa|prosvol|race": [ + -4.90713564581721, + 0.042918467051246, + 0.447347513912564, + null, + null, + 0.448791784866504, + null, + null, + 0.167628416272514, + null, + -0.357567572370114, + null, + null + ], + "famhist_1|famhist_2|hispanic|priorbiopsy|priorpsa|prosvol": [ + -4.88995369720542, + 0.0429039003787779, + 0.451184696497927, + -0.170007982529076, + null, + 0.435901417105812, + null, + null, + 0.172307536373423, + null, + -0.348977303375164, + null, + null + ], + "famhist_1|famhist_2|hispanic|priorpsa|prosvol|race": [ + -5.37523137236675, + 0.0467012942291979, + 0.642333954545779, + null, + -1.56879014619976, + 0.572158285177852, + null, + null, + 0.14648009579187, + null, + -0.0881686522132749, + null, + null + ], + "famhist_1|famhist_2|hispanic|priorpsa|prosvol": [ + -5.33098898429558, + 0.0463969530110577, + 0.647483608566913, + -0.247613678960871, + -1.57102926185504, + 0.560837284436947, + null, + null, + 0.152391749960336, + null, + -0.0875224665233293, + null, + null + ], + "dre|famhist_2|hispanic|priorbiopsy|priorpsa|prosvol|race": [ + -4.98297553536813, + 0.0449985349625417, + 0.466749659455225, + null, + null, + null, + 0.648432927291113, + null, + 0.0877271136430602, + null, + -0.326385698811547, + null, + null + ], + "dre|famhist_2|hispanic|priorbiopsy|priorpsa|prosvol": [ + -4.94518055646662, + 0.0446325780701772, + 0.472358762256862, + -0.245858623653938, + null, + null, + 0.654783005971797, + null, + 0.0949864917140145, + null, + -0.320326329743209, + null, + null + ], + "dre|famhist_2|hispanic|priorpsa|prosvol|race": [ + -5.50609018579571, + 0.050573112050001, + 0.661765788792516, + null, + -1.58283882802701, + null, + 0.61819961076801, + null, + 0.078819630205796, + null, + -0.0695166543196277, + null, + null + ], + "dre|famhist_2|hispanic|priorpsa|prosvol": [ + -5.4443168549286, + 0.0499489117221774, + 0.668797440158016, + -0.313429990666413, + -1.58664256388739, + null, + 0.627658942269949, + null, + 0.0855857819517868, + null, + -0.0722667226448752, + null, + null + ], + "famhist_2|hispanic|priorbiopsy|priorpsa|prosvol|race": [ + -5.2012067967141, + 0.044085536014814, + 0.476665259603517, + null, + null, + 0.457851477698129, + 0.682593338245614, + null, + 0.136001732937597, + null, + -0.383577905196699, + null, + null + ], + "famhist_2|hispanic|priorbiopsy|priorpsa|prosvol": [ + -5.18085516473077, + 0.0440166762183608, + 0.48107046674865, + -0.183816109585298, + null, + 0.444443579691436, + 0.687834691372232, + null, + 0.141585880155992, + null, + -0.376369050704573, + null, + null + ], + "famhist_2|hispanic|priorpsa|prosvol|race": [ + -5.67105653110785, + 0.0479673522681119, + 0.671071697543318, + null, + -1.56429526066431, + 0.578557267589998, + 0.666772028641286, + null, + 0.107845432169725, + null, + -0.111635169024266, + null, + null + ], + "famhist_2|hispanic|priorpsa|prosvol": [ + -5.62041528699918, + 0.0475467121649171, + 0.677282227713694, + -0.267524698393685, + -1.56861359726704, + 0.566965643619424, + 0.67514492692073, + null, + 0.114070274374879, + null, + -0.111822999376011, + null, + null + ], + "dre|famhist_1|hispanic|priorbiopsy|priorpsa|prosvol|race": [ + -4.8323992855862, + 0.0452065524465761, + 0.443798015348154, + null, + null, + null, + null, + 0.227069044071169, + 0.109070302468132, + null, + -0.296675575495936, + null, + null + ], + "dre|famhist_1|hispanic|priorbiopsy|priorpsa|prosvol": [ + -4.80543358496337, + 0.0449934955139429, + 0.44895612759954, + -0.230517752202838, + null, + null, + null, + 0.234033330683163, + 0.115890479881099, + null, + -0.289059102122816, + null, + null + ], + "dre|famhist_1|hispanic|priorpsa|prosvol|race": [ + -5.39066167591145, + 0.0510994321248615, + 0.64133257348464, + null, + -1.59793692643065, + null, + null, + 0.26819718298168, + 0.105159926187912, + null, + -0.038744690128198, + null, + null + ], + "dre|famhist_1|hispanic|priorpsa|prosvol": [ + -5.34314192056593, + 0.0506886541597033, + 0.647501254053508, + -0.294817502545448, + -1.60034393895676, + null, + null, + 0.277760990909235, + 0.112261242810635, + null, + -0.0402291809589912, + null, + null + ], + "famhist_1|hispanic|priorbiopsy|priorpsa|prosvol|race": [ + -5.04562460493624, + 0.0442958850157043, + 0.452688025140916, + null, + null, + 0.449628524813683, + null, + 0.26745614279475, + 0.167777043136658, + null, + -0.355194543487677, + null, + null + ], + "famhist_1|hispanic|priorbiopsy|priorpsa|prosvol": [ + -5.03527969423202, + 0.0443666962818789, + 0.456578138228232, + -0.168241572926788, + null, + 0.436268902227022, + null, + 0.273895479488408, + 0.172735044042202, + null, + -0.34645183574288, + null, + null + ], + "famhist_1|hispanic|priorpsa|prosvol|race": [ + -5.54425133525649, + 0.0483856138226188, + 0.649665136219073, + null, + -1.57677624980977, + 0.576313057026549, + null, + 0.306020157442729, + 0.146707398814406, + null, + -0.0830514048539723, + null, + null + ], + "famhist_1|hispanic|priorpsa|prosvol": [ + -5.50843414313462, + 0.0481838820758654, + 0.654929446597667, + -0.246427875807515, + -1.57918379746519, + 0.564391080737743, + null, + 0.314787237049235, + 0.152913494323542, + null, + -0.0820842536572095, + null, + null + ], + "dre|hispanic|priorbiopsy|priorpsa|prosvol|race": [ + -5.04128403054266, + 0.0455625413971094, + 0.469403895320683, + null, + null, + null, + 0.640933057654065, + 0.136287720338883, + 0.0870129583958614, + null, + -0.32399550689861, + null, + null + ], + "dre|hispanic|priorbiopsy|priorpsa|prosvol": [ + -5.01118892793675, + 0.0452892628099635, + 0.475051310915698, + -0.241292175672118, + null, + null, + 0.646047553855776, + 0.143488927568076, + 0.0944601675250666, + null, + -0.317591182800432, + null, + null + ], + "dre|hispanic|priorpsa|prosvol|race": [ + -5.59168715099848, + 0.0514507641228808, + 0.665591573503305, + null, + -1.5868743698199, + null, + 0.607511428853816, + 0.17809825597405, + 0.0781292676063939, + null, + -0.0652828266451379, + null, + null + ], + "dre|hispanic|priorpsa|prosvol": [ + -5.53948002780689, + 0.050943478283935, + 0.672654871002664, + -0.309218848673974, + -1.59071213438869, + null, + 0.615208896218576, + 0.187768467209457, + 0.0850878709846472, + null, + -0.0675191366649927, + null, + null + ], + "hispanic|priorbiopsy|priorpsa|prosvol|race": [ + -5.28093977999532, + 0.0448542586287956, + 0.479804049197672, + null, + null, + 0.460510755155678, + 0.673393265652911, + 0.179461909716774, + 0.135308666128271, + null, + -0.381855445377289, + null, + null + ], + "hispanic|priorbiopsy|priorpsa|prosvol": [ + -5.26874487639678, + 0.044889252383726, + 0.484141988756855, + -0.178020722341407, + null, + 0.446723959154768, + 0.677478572289162, + 0.1863500158741, + 0.141056631095187, + null, + -0.374376561260693, + null, + null + ], + "hispanic|priorpsa|prosvol|race": [ + -5.77462025126346, + 0.0489717127380643, + 0.675795609568105, + null, + -1.57052372344164, + 0.583937182959596, + 0.65528377702912, + 0.216188812202953, + 0.107144768614505, + null, + -0.108185107978506, + null, + null + ], + "hispanic|priorpsa|prosvol": [ + -5.73405103459869, + 0.0486814094494299, + 0.681890206588029, + -0.261233961736246, + -1.57461073648165, + 0.571712977048013, + 0.661885658816802, + 0.225283482664415, + 0.113596301721287, + null, + -0.107974324574331, + null, + null + ], + "dre|famhist_1|famhist_2|famhist_bca|hispanic|priorbiopsy|priorpsa|race": [ + -0.248957799781813, + 0.0712233921954444, + 0.753658723582425, + null, + null, + null, + null, + null, + null, + -1.29528553517291, + -0.280448698723948, + null, + null + ], + "dre|famhist_1|famhist_2|famhist_bca|hispanic|priorbiopsy|priorpsa": [ + 0.2141516374629, + 0.06941755951616, + 0.754808352525162, + -0.118635621745677, + null, + null, + null, + null, + null, + -1.35790350446374, + -0.339869090458952, + null, + null + ], + "dre|famhist_1|famhist_2|famhist_bca|hispanic|priorpsa|race": [ + -0.909101307494049, + 0.0738322495978113, + 0.824926111709595, + null, + -1.22861925994164, + null, + null, + null, + null, + -1.19833744778169, + -0.107435213085878, + null, + null + ], + "dre|famhist_1|famhist_2|famhist_bca|hispanic|priorpsa": [ + -0.500166639940089, + 0.0722235537781943, + 0.82589085717251, + -0.113362071385763, + -1.22410513103898, + null, + null, + null, + null, + -1.25137471963078, + -0.182360446557811, + null, + null + ], + "famhist_1|famhist_2|famhist_bca|hispanic|priorbiopsy|priorpsa|race": [ + 0.016586029469363, + 0.0621450494008043, + 0.781736458599355, + null, + null, + 0.539573486717927, + null, + null, + null, + -1.28951392000492, + -0.275598097348466, + null, + null + ], + "famhist_1|famhist_2|famhist_bca|hispanic|priorbiopsy|priorpsa": [ + 0.491352517754988, + 0.0614399914898794, + 0.771606043767406, + -0.223999062429252, + null, + 0.496364454648367, + null, + null, + null, + -1.35732419238902, + -0.336433675283392, + null, + null + ], + "famhist_1|famhist_2|famhist_bca|hispanic|priorpsa|race": [ + -0.666312342552917, + 0.0648954244943249, + 0.848955102293702, + null, + -1.19562323089337, + 0.562451867195023, + null, + null, + null, + -1.18959065251731, + -0.107323746884822, + null, + null + ], + "famhist_1|famhist_2|famhist_bca|hispanic|priorpsa": [ + -0.251391165287329, + 0.064151863856716, + 0.839594452969423, + -0.200836804167907, + -1.20372049357761, + 0.539588449871348, + null, + null, + null, + -1.2455924068796, + -0.186650703651993, + null, + null + ], + "dre|famhist_2|famhist_bca|hispanic|priorbiopsy|priorpsa|race": [ + -0.757930866381192, + 0.0780170699948384, + 0.759277641796201, + null, + null, + null, + 0.462977714221735, + null, + null, + -1.28985483061752, + -0.237129557969796, + null, + null + ], + "dre|famhist_2|famhist_bca|hispanic|priorbiopsy|priorpsa": [ + -0.264769688585027, + 0.0765259736289114, + 0.762900993169671, + 0.0583315078465349, + null, + null, + 0.518652198820682, + null, + null, + -1.36719188049738, + -0.295287090036481, + null, + null + ], + "dre|famhist_2|famhist_bca|hispanic|priorpsa|race": [ + -1.43291730304284, + 0.0801412904807946, + 0.840247548035838, + null, + -1.18738637039701, + null, + 0.47736006499267, + null, + null, + -1.19306258372092, + -0.0530005317228033, + null, + null + ], + "dre|famhist_2|famhist_bca|hispanic|priorpsa": [ + -1.03707814035939, + 0.0789598749478004, + 0.845700849591552, + 0.0320260946000451, + -1.18300171003801, + null, + 0.536865411886643, + null, + null, + -1.25511809627445, + -0.124632906033761, + null, + null + ], + "famhist_2|famhist_bca|hispanic|priorbiopsy|priorpsa|race": [ + -0.50182367175865, + 0.0681780077287944, + 0.799832881656336, + null, + null, + 0.767491639160305, + 0.49515905370582, + null, + null, + -1.28878454043404, + -0.310078676877069, + null, + null + ], + "famhist_2|famhist_bca|hispanic|priorbiopsy|priorpsa": [ + 0.0384088288709795, + 0.0672872287092578, + 0.794936488685614, + -0.0568552369505526, + null, + 0.782412591126197, + 0.564137053202616, + null, + null, + -1.3762046105601, + -0.388293566504659, + null, + null + ], + "famhist_2|famhist_bca|hispanic|priorpsa|race": [ + -1.1544873068737, + 0.0705387748358553, + 0.871661871883092, + null, + -1.09941127542177, + 0.762100639585785, + 0.505426222838305, + null, + null, + -1.19647126202098, + -0.122947670287577, + null, + null + ], + "famhist_2|famhist_bca|hispanic|priorpsa": [ + -0.72226629066348, + 0.0697724735306004, + 0.870412083835772, + -0.0653135142485244, + -1.11430026882844, + 0.800530120977269, + 0.576601418959218, + null, + null, + -1.26634275381492, + -0.214541346369205, + null, + null + ], + "dre|famhist_1|famhist_bca|hispanic|priorbiopsy|priorpsa|race": [ + 1.1359430191102, + 0.0689199569921416, + 0.677781003649423, + null, + null, + null, + null, + 0.216510375412254, + null, + -1.4852729519781, + -0.111374531011489, + null, + null + ], + "dre|famhist_1|famhist_bca|hispanic|priorbiopsy|priorpsa": [ + 1.28607194846889, + 0.0681721346455749, + 0.685774665878511, + -0.406044699833448, + null, + null, + null, + 0.223266636946752, + null, + -1.49861361976665, + -0.103743440800875, + null, + null + ], + "dre|famhist_1|famhist_bca|hispanic|priorpsa|race": [ + 0.133199671567708, + 0.0706251893453331, + 0.792703494759468, + null, + -1.17292042875303, + null, + null, + 0.252892564643657, + null, + -1.33146904127991, + 0.0500085045272119, + null, + null + ], + "dre|famhist_1|famhist_bca|hispanic|priorpsa": [ + 0.290070957903417, + 0.0697531425529376, + 0.800480118975116, + -0.419570077104355, + -1.17279235627729, + null, + null, + 0.260585360251374, + null, + -1.34397847007659, + 0.0502452572180229, + null, + null + ], + "famhist_1|famhist_bca|hispanic|priorbiopsy|priorpsa|race": [ + 0.920716274488847, + 0.0674186916622887, + 0.699251948896467, + null, + null, + 0.603027092650784, + null, + 0.282866163495852, + null, + -1.49831770768775, + -0.166455769050804, + null, + null + ], + "famhist_1|famhist_bca|hispanic|priorbiopsy|priorpsa": [ + 1.09865522519812, + 0.0668344677862683, + 0.708057425178143, + -0.406436329876865, + null, + 0.586626522759505, + null, + 0.290787406583875, + null, + -1.51755979287369, + -0.15490874377655, + null, + null + ], + "famhist_1|famhist_bca|hispanic|priorpsa|race": [ + -0.0422226589733008, + 0.0681430244014976, + 0.81093231375978, + null, + -1.13395085610429, + 0.673854640371363, + null, + 0.310953640442962, + null, + -1.3445998876225, + -0.000582461986530435, + null, + null + ], + "famhist_1|famhist_bca|hispanic|priorpsa": [ + 0.144329339508741, + 0.0674351512234474, + 0.819408469088775, + -0.421066291807789, + -1.13369078181775, + 0.658901714953156, + null, + 0.319770470237041, + null, + -1.36345322850652, + 0.00389617038444336, + null, + null + ], + "dre|famhist_bca|hispanic|priorbiopsy|priorpsa|race": [ + 0.879853830396698, + 0.069375312315558, + 0.706559749915699, + null, + null, + null, + 0.645795079994653, + 0.131589519844932, + null, + -1.48051378049601, + -0.14017574656806, + null, + null + ], + "dre|famhist_bca|hispanic|priorbiopsy|priorpsa": [ + 1.0343081103005, + 0.0684925730468475, + 0.713908665000383, + -0.400919100865733, + null, + null, + 0.64660378293312, + 0.138945466168688, + null, + -1.49278964168201, + -0.134826880058296, + null, + null + ], + "dre|famhist_bca|hispanic|priorpsa|race": [ + -0.124504010537163, + 0.0710443988117119, + 0.821625366121056, + null, + -1.1598363874108, + null, + 0.624659766915414, + 0.167293664416992, + null, + -1.3255904799827, + 0.0225245899134138, + null, + null + ], + "dre|famhist_bca|hispanic|priorpsa": [ + 0.0408575160199021, + 0.0700041839119728, + 0.82947320476911, + -0.421680651666887, + -1.16160652197294, + null, + 0.628879287203239, + 0.174895944968615, + null, + -1.33763308044534, + 0.021126270906799, + null, + null + ], + "famhist_bca|hispanic|priorbiopsy|priorpsa|race": [ + 0.641783746109139, + 0.0680889722119265, + 0.731599493276717, + null, + null, + 0.612811799976097, + 0.680482706583784, + 0.200726693694598, + null, + -1.49546696826716, + -0.195043015065113, + null, + null + ], + "famhist_bca|hispanic|priorbiopsy|priorpsa": [ + 0.823324383960497, + 0.0673751575412436, + 0.739624872616369, + -0.399437657300493, + null, + 0.595794304396951, + 0.680533770168355, + 0.209353730147743, + null, + -1.51347934634877, + -0.185958799237064, + null, + null + ], + "famhist_bca|hispanic|priorpsa|race": [ + -0.325017691123364, + 0.0687546232979501, + 0.844161434335178, + null, + -1.12869674579975, + 0.682383629645187, + 0.673137049293525, + 0.226476059447046, + null, + -1.34043402887662, + -0.0265218056076424, + null, + null + ], + "famhist_bca|hispanic|priorpsa": [ + -0.129788793306639, + 0.0678766188171585, + 0.852378798855082, + -0.42107299652452, + -1.13035422024905, + 0.666768380879092, + 0.675990576949325, + 0.235516530507834, + null, + -1.35862593078232, + -0.0235962529899503, + null, + null + ], + "dre|famhist_1|famhist_2|hispanic|priorbiopsy|priorpsa|race": [ + 1.18386255709381, + 0.0676393605587038, + 0.68482967005371, + null, + null, + null, + null, + null, + 0.0712150180760869, + -1.47951232255278, + -0.136035565172192, + null, + null + ], + "dre|famhist_1|famhist_2|hispanic|priorbiopsy|priorpsa": [ + 1.35362175493613, + 0.0667893805011202, + 0.693446582022707, + -0.421617677647889, + null, + null, + null, + null, + 0.0804708974637896, + -1.49513466010817, + -0.128692774232337, + null, + null + ], + "dre|famhist_1|famhist_2|hispanic|priorpsa|race": [ + 0.185593984449894, + 0.0691205708261166, + 0.804352287646282, + null, + -1.19501368058861, + null, + null, + null, + 0.0840492125091041, + -1.32479037787853, + 0.024387683985829, + null, + null + ], + "dre|famhist_1|famhist_2|hispanic|priorpsa": [ + 0.36343356749235, + 0.0681389738460659, + 0.81266950498088, + -0.434981946807962, + -1.19491883079908, + null, + null, + null, + 0.0911661144639955, + -1.33960888014889, + 0.0241826022745104, + null, + null + ], + "famhist_1|famhist_2|hispanic|priorbiopsy|priorpsa|race": [ + 0.967141928000987, + 0.0658815348931798, + 0.706323170633543, + null, + null, + 0.598099245562363, + null, + null, + 0.166683885435743, + -1.48945766534078, + -0.195459824727147, + null, + null + ], + "famhist_1|famhist_2|hispanic|priorbiopsy|priorpsa": [ + 1.1683571917024, + 0.0651733032708466, + 0.716146953684225, + -0.425080696960852, + null, + 0.582686211600225, + null, + null, + 0.17378102210416, + -1.5115117289913, + -0.183587880145864, + null, + null + ], + "famhist_1|famhist_2|hispanic|priorpsa|race": [ + 0.00859004660580046, + 0.0664185353861825, + 0.822140310935296, + null, + -1.15272668993017, + 0.668388308641359, + null, + null, + 0.158643806517399, + -1.33463483856701, + -0.030452369403318, + null, + null + ], + "famhist_1|famhist_2|hispanic|priorpsa": [ + 0.218488701535474, + 0.0655925467684065, + 0.831409651417756, + -0.439174505617196, + -1.15235207802828, + 0.654244928940057, + null, + null, + 0.164159597921488, + -1.35619598145134, + -0.02580282335861, + null, + null + ], + "dre|famhist_2|hispanic|priorbiopsy|priorpsa|race": [ + 0.881401353172229, + 0.0685744405431919, + 0.713078639261917, + null, + null, + null, + 0.630988650782389, + null, + 0.0445678427809933, + -1.47240978890188, + -0.163647363791107, + null, + null + ], + "dre|famhist_2|hispanic|priorbiopsy|priorpsa": [ + 1.05506895044217, + 0.0675809546293439, + 0.721088442162593, + -0.420398666354914, + null, + null, + 0.633278346138187, + null, + 0.0551325488387969, + -1.48681445883525, + -0.158708512398236, + null, + null + ], + "dre|famhist_2|hispanic|priorpsa|race": [ + -0.121942346840257, + 0.0700586530391404, + 0.833081622764077, + null, + -1.1833517205226, + null, + 0.611109613923716, + null, + 0.0546551600539492, + -1.31651475648392, + -0.00176401098951855, + null, + null + ], + "dre|famhist_2|hispanic|priorpsa": [ + 0.0639066999504393, + 0.0688980195120218, + 0.84163145587943, + -0.441379339751624, + -1.18538945093943, + null, + 0.617279536396363, + null, + 0.0625250738256869, + -1.33073591952351, + -0.00375098366723597, + null, + null + ], + "famhist_2|hispanic|priorbiopsy|priorpsa|race": [ + 0.64800174548236, + 0.0670192611796832, + 0.736822203776761, + null, + null, + 0.603984234795625, + 0.662981987558546, + null, + 0.13073746842473, + -1.48389628118272, + -0.22292666936728, + null, + null + ], + "famhist_2|hispanic|priorbiopsy|priorpsa": [ + 0.852741059829493, + 0.0661751996329896, + 0.745942365684109, + -0.422710265710023, + null, + 0.587747695135888, + 0.664650501905276, + null, + 0.139660326776755, + -1.50465738085701, + -0.213628977361371, + null, + null + ], + "famhist_2|hispanic|priorpsa|race": [ + -0.318323135047778, + 0.0675360229984182, + 0.853861345649262, + null, + -1.14875412433425, + 0.673256467068699, + 0.656736641494535, + null, + 0.118355795839993, + -1.32765503053471, + -0.0550439335408939, + null, + null + ], + "famhist_2|hispanic|priorpsa": [ + -0.0996613416869625, + 0.0665269611481884, + 0.863074275183574, + -0.444216080565887, + -1.15054622962296, + 0.658294420259221, + 0.66158781815209, + null, + 0.125101231105537, + -1.34850051233162, + -0.052132267037498, + null, + null + ], + "dre|famhist_1|hispanic|priorbiopsy|priorpsa|race": [ + 1.14113805921014, + 0.0686262560831525, + 0.691461364639985, + null, + null, + null, + null, + 0.227346528153646, + 0.069941589356084, + -1.49122367208356, + -0.134908705090682, + null, + null + ], + "dre|famhist_1|hispanic|priorbiopsy|priorpsa": [ + 1.28903904283888, + 0.0678931147021265, + 0.699518696601972, + -0.405914487849961, + null, + null, + null, + 0.234216673623273, + 0.0792153973400157, + -1.50458509157201, + -0.127228644658132, + null, + null + ], + "dre|famhist_1|hispanic|priorpsa|race": [ + 0.110617043313094, + 0.070472973090989, + 0.812148276888803, + null, + -1.20080306983436, + null, + null, + 0.265574108264563, + 0.0837443560137544, + -1.33622005544092, + 0.0274132613312266, + null, + null + ], + "dre|famhist_1|hispanic|priorpsa": [ + 0.265808785300699, + 0.0696116143233642, + 0.819968402829147, + -0.420050557321155, + -1.20082869935851, + null, + null, + 0.273453333855714, + 0.0910068769285396, + -1.34872279000424, + 0.027674760160111, + null, + null + ], + "famhist_1|hispanic|priorbiopsy|priorpsa|race": [ + 0.90528069415587, + 0.067230264780098, + 0.715601982530496, + null, + null, + 0.607698348158015, + null, + 0.29714904755575, + 0.166929743504199, + -1.50590596261379, + -0.194186335514065, + null, + null + ], + "famhist_1|hispanic|priorbiopsy|priorpsa": [ + 1.08161332319443, + 0.066660332029544, + 0.724579940608334, + -0.407256536665858, + null, + 0.591470573334898, + null, + 0.305032148738534, + 0.173769429095171, + -1.52526796893653, + -0.182191192231663, + null, + null + ], + "famhist_1|hispanic|priorpsa|race": [ + -0.0825005082607792, + 0.0680766064578927, + 0.832894009850493, + null, + -1.16113261581788, + 0.679419706945785, + null, + 0.327634947181188, + 0.160519645678488, + -1.35078191433482, + -0.0266973930561778, + null, + null + ], + "famhist_1|hispanic|priorpsa": [ + 0.102455800264811, + 0.0673829672815349, + 0.841438869465271, + -0.421809931167651, + -1.16078367153674, + 0.664469746535309, + null, + 0.336500574318248, + 0.165840840927091, + -1.3696510307838, + -0.0218434415423822, + null, + null + ], + "dre|hispanic|priorbiopsy|priorpsa|race": [ + 0.894895836399579, + 0.0690526084169665, + 0.718313163249658, + null, + null, + null, + 0.633914909616235, + 0.144884715762084, + 0.0411325734787038, + -1.48584088117038, + -0.163300039089312, + null, + null + ], + "dre|hispanic|priorbiopsy|priorpsa": [ + 1.04681950884808, + 0.0681861302805394, + 0.725681516796932, + -0.400799709154184, + null, + null, + 0.634557967537651, + 0.15238120909441, + 0.0515934146430758, + -1.49810504383299, + -0.158014595424805, + null, + null + ], + "dre|hispanic|priorpsa|race": [ + -0.135495338861786, + 0.0708611985116973, + 0.839190060798713, + null, + -1.18703220157326, + null, + 0.610724930537695, + 0.182705177597229, + 0.0521333002566433, + -1.32991418870004, + -0.000222531552118684, + null, + null + ], + "dre|hispanic|priorpsa": [ + 0.0280316319237623, + 0.0698322870625597, + 0.847048974350952, + -0.422296105919889, + -1.18895302498215, + null, + 0.614880127692227, + 0.190537656520978, + 0.0600744847584931, + -1.34193072465031, + -0.00169853586988048, + null, + null + ], + "hispanic|priorbiopsy|priorpsa|race": [ + 0.639729951834181, + 0.0678648268847686, + 0.744957244306552, + null, + null, + 0.615699785977301, + 0.663991920698375, + 0.217830562916121, + 0.12850891455161, + -1.50189273651119, + -0.222669203727912, + null, + null + ], + "hispanic|priorbiopsy|priorpsa": [ + 0.819693194074183, + 0.0671662915400128, + 0.753156951589414, + -0.400968026466397, + null, + 0.598970520566259, + 0.664084074400893, + 0.226459351351926, + 0.137076186320361, + -1.52008638002372, + -0.213285835674628, + null, + null + ], + "hispanic|priorpsa|race": [ + -0.349844107026057, + 0.0686490819870476, + 0.863097246934161, + null, + -1.15518654939396, + 0.686067834593177, + 0.655213941443391, + 0.246302213132286, + 0.117513254286819, + -1.34563386578097, + -0.0532481073153119, + null, + null + ], + "hispanic|priorpsa": [ + -0.155990093852587, + 0.0677842519969421, + 0.87138668796361, + -0.422569314661981, + -1.15675570106065, + 0.670576306005039, + 0.658148300931847, + 0.255472676996857, + 0.124037468491998, + -1.36390835842689, + -0.05010285151121, + null, + null + ], + "ari_use|dre|famhist_1|famhist_2|famhist_bca|priorbiopsy|priorpsa|prosvol|race": [ + -5.25370880351358, + 0.0473458317356627, + 0.523052423097652, + null, + null, + null, + null, + null, + null, + null, + null, + -0.105245106067569, + null + ], + "ari_use|dre|famhist_1|famhist_2|famhist_bca|priorbiopsy|priorpsa|prosvol": [ + -5.14041176181382, + 0.045311655287253, + 0.525347363613192, + -0.0404094232563133, + null, + null, + null, + null, + null, + null, + null, + -0.0892190404234612, + null + ], + "ari_use|dre|famhist_1|famhist_2|famhist_bca|priorpsa|prosvol|race": [ + -5.62741890571533, + 0.0519813993741946, + 0.645265929506864, + null, + -1.37251290379951, + null, + null, + null, + null, + null, + null, + -0.0121908625737228, + null + ], + "ari_use|dre|famhist_1|famhist_2|famhist_bca|priorpsa|prosvol": [ + -5.48225412322442, + 0.0495043243571226, + 0.653044119339641, + -0.082670335805729, + -1.36954631417797, + null, + null, + null, + null, + null, + null, + -0.00496269060603742, + null + ], + "ari_use|famhist_1|famhist_2|famhist_bca|priorbiopsy|priorpsa|prosvol|race": [ + -5.07246395473077, + 0.0401395560052238, + 0.551403639989895, + null, + null, + 0.747107396083474, + null, + null, + null, + null, + null, + -0.348717945152067, + null + ], + "ari_use|famhist_1|famhist_2|famhist_bca|priorbiopsy|priorpsa|prosvol": [ + -4.96766103588527, + 0.0387603104700136, + 0.549204364106157, + -0.0589021282322699, + null, + 0.720262218559556, + null, + null, + null, + null, + null, + -0.345066387253942, + null + ], + "ari_use|famhist_1|famhist_2|famhist_bca|priorpsa|prosvol|race": [ + -5.49371207947746, + 0.0455635861584082, + 0.677150537906435, + null, + -1.35496499853366, + 0.745453734141366, + null, + null, + null, + null, + null, + -0.246136175979738, + null + ], + "ari_use|famhist_1|famhist_2|famhist_bca|priorpsa|prosvol": [ + -5.37571488505988, + 0.0438868309181334, + 0.679601656190245, + -0.0668456799514074, + -1.35739968125477, + 0.727344191848175, + null, + null, + null, + null, + null, + -0.246832958316575, + null + ], + "ari_use|dre|famhist_2|famhist_bca|priorbiopsy|priorpsa|prosvol|race": [ + -5.44161687345928, + 0.0478948340111342, + 0.555281711821505, + null, + null, + null, + 0.402744343405844, + null, + null, + null, + null, + 0.0480510114610398, + null + ], + "ari_use|dre|famhist_2|famhist_bca|priorbiopsy|priorpsa|prosvol": [ + -5.29382990796433, + 0.045109456549417, + 0.557696263292885, + -0.0119128847985868, + null, + null, + 0.401243427297402, + null, + null, + null, + null, + 0.0828307228661637, + null + ], + "ari_use|dre|famhist_2|famhist_bca|priorpsa|prosvol|race": [ + -5.88692972294905, + 0.0533406274444021, + 0.673077205357764, + null, + -1.30077309595971, + null, + 0.441287745540814, + null, + null, + null, + null, + 0.142451653104034, + null + ], + "ari_use|dre|famhist_2|famhist_bca|priorpsa|prosvol": [ + -5.73396939409788, + 0.0504298139090152, + 0.68209904662881, + -0.0437329944242169, + -1.30024316267259, + null, + 0.439667895798881, + null, + null, + null, + null, + 0.171089290322676, + null + ], + "ari_use|famhist_2|famhist_bca|priorbiopsy|priorpsa|prosvol|race": [ + -5.27520305744069, + 0.039206062332898, + 0.600189915922537, + null, + null, + 0.921243504570375, + 0.47204292517535, + null, + null, + null, + null, + -0.193132473612262, + null + ], + "ari_use|famhist_2|famhist_bca|priorbiopsy|priorpsa|prosvol": [ + -5.13355834804407, + 0.0369228464389229, + 0.597901876677366, + -0.0206638154365977, + null, + 0.911806691792016, + 0.480302548322529, + null, + null, + null, + null, + -0.177740456971285, + null + ], + "ari_use|famhist_2|famhist_bca|priorpsa|prosvol|race": [ + -5.76518432254967, + 0.0455336967693765, + 0.719226722540227, + null, + -1.26711150508487, + 0.913236208127401, + 0.497405293034975, + null, + null, + null, + null, + -0.104610612842701, + null + ], + "ari_use|famhist_2|famhist_bca|priorpsa|prosvol": [ + -5.63900305033197, + 0.0432874175085526, + 0.723062537956541, + -0.0198948855088449, + -1.27803429656526, + 0.915353595315984, + 0.504534464724404, + null, + null, + null, + null, + -0.0901230264919994, + null + ], + "ari_use|dre|famhist_1|famhist_bca|priorbiopsy|priorpsa|prosvol|race": [ + -4.38007767098293, + 0.0298643475378971, + 0.577546025422991, + null, + null, + null, + null, + 0.260175697509707, + null, + null, + null, + 0.163004965399364, + null + ], + "ari_use|dre|famhist_1|famhist_bca|priorbiopsy|priorpsa|prosvol": [ + -4.38988924292244, + 0.0298879681300911, + 0.578431437800037, + 0.012120575662903, + null, + null, + null, + 0.258789078624437, + null, + null, + null, + 0.169913562789549, + null + ], + "ari_use|dre|famhist_1|famhist_bca|priorpsa|prosvol|race": [ + -4.90104647448061, + 0.0353944203173036, + 0.715720663387619, + null, + -1.32943183555418, + null, + null, + 0.306454742393684, + null, + null, + null, + 0.311635235867363, + null + ], + "ari_use|dre|famhist_1|famhist_bca|priorpsa|prosvol": [ + -4.9126328588085, + 0.0353996234095968, + 0.716930818534382, + 0.0155324937181096, + -1.32993968518301, + null, + null, + 0.304960527512889, + null, + null, + null, + 0.321233176101628, + null + ], + "ari_use|famhist_1|famhist_bca|priorbiopsy|priorpsa|prosvol|race": [ + -4.54534672451674, + 0.0286199510625522, + 0.588659138929209, + null, + null, + 0.816423188801001, + null, + 0.242682083958513, + null, + null, + null, + -0.133676773826366, + null + ], + "ari_use|famhist_1|famhist_bca|priorbiopsy|priorpsa|prosvol": [ + -4.5422752377834, + 0.0285398598113593, + 0.589870595637138, + -7.70199656710578e-06, + null, + 0.814514814187565, + null, + 0.241407670985731, + null, + null, + null, + -0.131392049224863, + null + ], + "ari_use|famhist_1|famhist_bca|priorpsa|prosvol|race": [ + -5.17095693813481, + 0.0354976911900884, + 0.744011150213929, + null, + -1.32835322404994, + 0.858199941482398, + null, + 0.285591576339754, + null, + null, + null, + -0.0255507805380925, + null + ], + "ari_use|famhist_1|famhist_bca|priorpsa|prosvol": [ + -5.17892977433641, + 0.0354859214109186, + 0.74518181012962, + 0.0119946804717575, + -1.32875484318001, + 0.856780626479067, + null, + 0.284148537148715, + null, + null, + null, + -0.0173233152405777, + null + ], + "ari_use|dre|famhist_bca|priorbiopsy|priorpsa|prosvol|race": [ + -4.49968690861921, + 0.0297656059411091, + 0.59560961651947, + null, + null, + null, + 0.453703824821977, + 0.181837774657426, + null, + null, + null, + 0.160771501149573, + null + ], + "ari_use|dre|famhist_bca|priorbiopsy|priorpsa|prosvol": [ + -4.51815892111787, + 0.0298760738516328, + 0.59608655114517, + 0.0200472040821542, + null, + null, + 0.453252896409333, + 0.180524250154736, + null, + null, + null, + 0.170820722482995, + null + ], + "ari_use|dre|famhist_bca|priorpsa|prosvol|race": [ + -5.01324713436856, + 0.0352135113490135, + 0.733319055002703, + null, + -1.32669402347638, + null, + 0.444206369865167, + 0.227192123615171, + null, + null, + null, + 0.310168686799049, + null + ], + "ari_use|dre|famhist_bca|priorpsa|prosvol": [ + -5.03157321482907, + 0.0352876958303668, + 0.734197067349048, + 0.0216580027698655, + -1.32719830370149, + null, + 0.44380052075515, + 0.225812136940419, + null, + null, + null, + 0.322145676422736, + null + ], + "ari_use|famhist_bca|priorbiopsy|priorpsa|prosvol|race": [ + -4.68654039486107, + 0.0284129879253184, + 0.611303013520245, + null, + null, + 0.8259418707024, + 0.517161830563226, + 0.148485504791226, + null, + null, + null, + -0.131613808378896, + null + ], + "ari_use|famhist_bca|priorbiopsy|priorpsa|prosvol": [ + -4.69532749191846, + 0.0284524995486239, + 0.61194702244498, + 0.0106395720610908, + null, + 0.824638546521673, + 0.516512419516742, + 0.147363223526398, + null, + null, + null, + -0.125402569654112, + null + ], + "ari_use|famhist_bca|priorpsa|prosvol|race": [ + -5.30480806521308, + 0.0352454221442314, + 0.765925225687522, + null, + -1.32223763043664, + 0.864746872642695, + 0.501775668152242, + 0.190997342686386, + null, + null, + null, + -0.0217327326421058, + null + ], + "ari_use|famhist_bca|priorpsa|prosvol": [ + -5.32167143444208, + 0.0353247178640551, + 0.766667762461299, + 0.0198615097187497, + -1.32271013138812, + 0.863778862741091, + 0.501397564873681, + 0.1897146762734, + null, + null, + null, + -0.0106697802322442, + null + ], + "ari_use|dre|famhist_1|famhist_2|priorbiopsy|priorpsa|prosvol|race": [ + -4.30046812818607, + 0.0286415528090629, + 0.577926824348809, + null, + null, + null, + null, + null, + 0.238910175478933, + null, + null, + 0.166622660339305, + null + ], + "ari_use|dre|famhist_1|famhist_2|priorbiopsy|priorpsa|prosvol": [ + -4.3178408059262, + 0.0287383300602542, + 0.578572840996937, + 0.0185076706685154, + null, + null, + null, + null, + 0.238628209738297, + null, + null, + 0.176053087294825, + null + ], + "ari_use|dre|famhist_1|famhist_2|priorpsa|prosvol|race": [ + -4.81492431441077, + 0.0340696925474525, + 0.717205578899268, + null, + -1.33527565107307, + null, + null, + null, + 0.246083597834014, + null, + null, + 0.318838626574845, + null + ], + "ari_use|dre|famhist_1|famhist_2|priorpsa|prosvol": [ + -4.83439468707919, + 0.0341509699315583, + 0.718185497984883, + 0.0221315737850082, + -1.33586195887209, + null, + null, + null, + 0.245946333236132, + null, + null, + 0.331039106503729, + null + ], + "ari_use|famhist_1|famhist_2|priorbiopsy|priorpsa|prosvol|race": [ + -4.47171382436507, + 0.0274634743448892, + 0.589720485782333, + null, + null, + 0.80723788053509, + null, + null, + 0.226589933314532, + null, + null, + -0.123288120377539, + null + ], + "ari_use|famhist_1|famhist_2|priorbiopsy|priorpsa|prosvol": [ + -4.4750801354135, + 0.0274461520686128, + 0.590725949315505, + 0.00536834000813308, + null, + 0.805606895642348, + null, + null, + 0.225719942696146, + null, + null, + -0.118987309716831, + null + ], + "ari_use|famhist_1|famhist_2|priorpsa|prosvol|race": [ + -5.09339290010724, + 0.0343128766923011, + 0.74620755110403, + null, + -1.33258479796313, + 0.847391943694044, + null, + null, + 0.194787932090372, + null, + null, + -0.00906143983580939, + null + ], + "ari_use|famhist_1|famhist_2|priorpsa|prosvol": [ + -5.1070754316442, + 0.03435567204133, + 0.747243782236606, + 0.0166243382308624, + -1.33310950918854, + 0.846196157808589, + null, + null, + 0.194420421976966, + null, + null, + 0.000893713690361924, + null + ], + "ari_use|dre|famhist_2|priorbiopsy|priorpsa|prosvol|race": [ + -4.4435056374832, + 0.0288031482568172, + 0.597025586399976, + null, + null, + null, + 0.456963809307307, + null, + 0.207568789129273, + null, + null, + 0.161460716864706, + null + ], + "ari_use|dre|famhist_2|priorbiopsy|priorpsa|prosvol": [ + -4.46832627891968, + 0.0289748514789381, + 0.597307763167783, + 0.0253553335195522, + null, + null, + 0.456492810969688, + null, + 0.207708340033633, + null, + null, + 0.173563010508379, + null + ], + "ari_use|dre|famhist_2|priorpsa|prosvol|race": [ + -4.95187814818304, + 0.0341543112295585, + 0.736134575201235, + null, + -1.33349978910765, + null, + 0.449764193116465, + null, + 0.210581298430241, + null, + null, + 0.314876778408552, + null + ], + "ari_use|dre|famhist_2|priorpsa|prosvol": [ + -4.97694584606654, + 0.0342935637127504, + 0.736814175542917, + 0.0272805841058544, + -1.33406336613632, + null, + 0.449313056765316, + null, + 0.210824255627208, + null, + null, + 0.329031977581592, + null + ], + "ari_use|famhist_2|priorbiopsy|priorpsa|prosvol|race": [ + -4.64104630284164, + 0.0276030914109354, + 0.613117486106763, + null, + null, + 0.81729671912578, + 0.516917178107054, + null, + 0.187298897164088, + null, + null, + -0.125605433192625, + null + ], + "ari_use|famhist_2|priorbiopsy|priorpsa|prosvol": [ + -4.65466661771831, + 0.0276894945575215, + 0.613613949479438, + 0.0146127547241548, + null, + 0.816190484133118, + 0.516254891540586, + null, + 0.187007265004292, + null, + null, + -0.11793839015087, + null + ], + "ari_use|famhist_2|priorpsa|prosvol|race": [ + -5.25743128981287, + 0.034404699813219, + 0.769557118986793, + null, + -1.32820090014882, + 0.854714173456202, + 0.504799155057073, + null, + 0.151548481362113, + null, + null, + -0.00910006093933466, + null + ], + "ari_use|famhist_2|priorpsa|prosvol": [ + -5.27859015576572, + 0.0345250245797127, + 0.77020144117455, + 0.0233057179956675, + -1.32876070922682, + 0.853898455853778, + 0.504335490257315, + null, + 0.151611347149762, + null, + null, + 0.00322112479143608, + null + ], + "ari_use|dre|famhist_1|priorbiopsy|priorpsa|prosvol|race": [ + -4.3726769130425, + 0.0293599759116165, + 0.582004538280795, + null, + null, + null, + null, + 0.256630393224215, + 0.229505113491776, + null, + null, + 0.150785724304937, + null + ], + "ari_use|dre|famhist_1|priorbiopsy|priorpsa|prosvol": [ + -4.38928250998892, + 0.0294498600116291, + 0.582622056168734, + 0.0182079758007323, + null, + null, + null, + 0.255224111061049, + 0.229287046851363, + null, + null, + 0.16015010130499, + null + ], + "ari_use|dre|famhist_1|priorpsa|prosvol|race": [ + -4.90371968387422, + 0.0349630343001285, + 0.722491910404588, + null, + -1.34061019534621, + null, + null, + 0.303999194473865, + 0.235568183926194, + null, + null, + 0.300927118619493, + null + ], + "ari_use|dre|famhist_1|priorpsa|prosvol": [ + -4.92285351597074, + 0.0350415508743103, + 0.723421268444508, + 0.0222542102815169, + -1.34116151709268, + null, + null, + 0.302510498625179, + 0.235525610256171, + null, + null, + 0.313224131184625, + null + ], + "ari_use|famhist_1|priorbiopsy|priorpsa|prosvol|race": [ + -4.53490721385446, + 0.0280615511905721, + 0.593244906363521, + null, + null, + 0.811214664551369, + null, + 0.239698908840276, + 0.217148030660462, + null, + null, + -0.140275904716596, + null + ], + "ari_use|famhist_1|priorbiopsy|priorpsa|prosvol": [ + -4.53882213763315, + 0.0280505550786226, + 0.594174335245133, + 0.0061368858951517, + null, + 0.809640218950614, + null, + 0.238449367283056, + 0.216391247272305, + null, + null, + -0.135652733379248, + null + ], + "ari_use|famhist_1|priorpsa|prosvol|race": [ + -5.17235805620747, + 0.0350500989948253, + 0.751316707177156, + null, + -1.33812397666168, + 0.853342423341039, + null, + 0.285242776300499, + 0.184384247980424, + null, + null, + -0.0287203495692504, + null + ], + "ari_use|famhist_1|priorpsa|prosvol": [ + -5.18726559283361, + 0.0351059644382403, + 0.752237541122742, + 0.0180529389694161, + -1.33862757153615, + 0.852238923268507, + null, + 0.283838847961116, + 0.184185296509989, + null, + null, + -0.0182066094243297, + null + ], + "ari_use|dre|priorbiopsy|priorpsa|prosvol|race": [ + -4.48889194574854, + 0.0292832992882774, + 0.599372747704643, + null, + null, + null, + 0.444375478152507, + 0.181184366606234, + 0.201609743699229, + null, + null, + 0.150448045493241, + null + ], + "ari_use|dre|priorbiopsy|priorpsa|prosvol": [ + -4.51323011328692, + 0.0294500483266573, + 0.599629616417242, + 0.0252889062809138, + null, + null, + 0.444031328728411, + 0.179827901827238, + 0.201809177264592, + null, + null, + 0.162585174064441, + null + ], + "ari_use|dre|priorpsa|prosvol|race": [ + -5.01175258654332, + 0.0348006141122657, + 0.73933009086078, + null, + -1.33743645422345, + null, + 0.433257623186266, + 0.228095059360423, + 0.203816685073443, + null, + null, + 0.301594806675744, + null + ], + "ari_use|dre|priorpsa|prosvol": [ + -5.03672342174643, + 0.0349385793521052, + 0.739968616165253, + 0.0275666136529586, + -1.33797396423658, + null, + 0.432951169037771, + 0.226695460038222, + 0.204140678823104, + null, + null, + 0.31591913063246, + null + ], + "ari_use|priorbiopsy|priorpsa|prosvol|race": [ + -4.67239685985278, + 0.0279026337876801, + 0.614798247209557, + null, + null, + 0.820804439300588, + 0.50724933343532, + 0.149097706690874, + 0.181702007376883, + null, + null, + -0.136538534264545, + null + ], + "ari_use|priorbiopsy|priorpsa|prosvol": [ + -4.68682106138923, + 0.0279967272436564, + 0.615227537728447, + 0.0155894817231554, + null, + 0.819764956112594, + 0.506725284454096, + 0.14795665181077, + 0.181511989196868, + null, + null, + -0.128463934917771, + null + ], + "ari_use|priorpsa|prosvol|race": [ + -5.30185879843136, + 0.0348362376101316, + 0.772247555599484, + null, + -1.33209984346715, + 0.859854352467446, + 0.491034136647564, + 0.19460463714649, + 0.145168453184368, + null, + null, + -0.0229368284517464, + null + ], + "ari_use|priorpsa|prosvol": [ + -5.32443866032105, + 0.0349703524021883, + 0.772794076115422, + 0.0248549626977364, + -1.33264792554781, + 0.85913729456859, + 0.490743407978202, + 0.193324489564062, + 0.145371916281534, + null, + null, + -0.0100018171134774, + null + ], + "ari_use|dre|famhist_1|famhist_2|famhist_bca|priorbiopsy|priorpsa|race": [ + -0.550165097083812, + 0.0658839631906309, + 0.743332192737508, + null, + null, + null, + null, + null, + null, + -1.21371404349558, + null, + 0.0496230413705503, + null + ], + "ari_use|dre|famhist_1|famhist_2|famhist_bca|priorbiopsy|priorpsa": [ + -0.257591756930784, + 0.0629227177746709, + 0.747751960748423, + -0.22271631267506, + null, + null, + null, + null, + null, + -1.22776453139457, + null, + 0.0420513972962163, + null + ], + "ari_use|dre|famhist_1|famhist_2|famhist_bca|priorpsa|race": [ + -1.19658719769554, + 0.0682650188724543, + 0.816840588574488, + null, + -1.13916088407502, + null, + null, + null, + null, + -1.12098188338707, + null, + 0.11465939403769, + null + ], + "ari_use|dre|famhist_1|famhist_2|famhist_bca|priorpsa": [ + -0.909144817981527, + 0.0652284378925202, + 0.821940667355665, + -0.229840404250278, + -1.12519353096396, + null, + null, + null, + null, + -1.13264458293209, + null, + 0.104053790301107, + null + ], + "ari_use|famhist_1|famhist_2|famhist_bca|priorbiopsy|priorpsa|race": [ + -0.570791244014944, + 0.062267162788978, + 0.77804654600468, + null, + null, + 0.729629465674828, + null, + null, + null, + -1.22003541126874, + null, + -0.206858902081889, + null + ], + "ari_use|famhist_1|famhist_2|famhist_bca|priorbiopsy|priorpsa": [ + -0.274739456002913, + 0.0602209264768744, + 0.778414764647318, + -0.255500988604946, + null, + 0.674621031683409, + null, + null, + null, + -1.23773092504972, + null, + -0.215618002582921, + null + ], + "ari_use|famhist_1|famhist_2|famhist_bca|priorpsa|race": [ + -1.25070409237173, + 0.0653144201276003, + 0.855728501434595, + null, + -1.13483772802622, + 0.72860441916427, + null, + null, + null, + -1.12798996009096, + null, + -0.136856586861572, + null + ], + "ari_use|famhist_1|famhist_2|famhist_bca|priorpsa": [ + -0.987742144348191, + 0.0633217252377603, + 0.855949816498905, + -0.239204698702425, + -1.1208511664706, + 0.682755198568354, + null, + null, + null, + -1.14089528480843, + null, + -0.147913649851432, + null + ], + "ari_use|dre|famhist_2|famhist_bca|priorbiopsy|priorpsa|race": [ + -0.911832921068729, + 0.0657159160333504, + 0.766930703511257, + null, + null, + null, + 0.490843829329367, + null, + null, + -1.17174048635831, + null, + 0.246878749214576, + null + ], + "ari_use|dre|famhist_2|famhist_bca|priorbiopsy|priorpsa": [ + -0.558502108966267, + 0.0615878698669016, + 0.771710387723273, + -0.18372813919704, + null, + null, + 0.497525111153142, + null, + null, + -1.18605620564969, + null, + 0.252518741667526, + null + ], + "ari_use|dre|famhist_2|famhist_bca|priorpsa|race": [ + -1.58610361651288, + 0.0681536430846544, + 0.842622663567736, + null, + -1.06368427281766, + null, + 0.509383513545016, + null, + null, + -1.08047235379491, + null, + 0.308104758431039, + null + ], + "ari_use|dre|famhist_2|famhist_bca|priorpsa": [ + -1.27259849435592, + 0.0642084934353913, + 0.847788848829228, + -0.178495013928394, + -1.04491486899125, + null, + 0.515760461228225, + null, + null, + -1.09007884180408, + null, + 0.314938901414409, + null + ], + "ari_use|famhist_2|famhist_bca|priorbiopsy|priorpsa|race": [ + -0.951977412654319, + 0.0617797951874474, + 0.816758554968416, + null, + null, + 0.913035387035661, + 0.521745881448122, + null, + null, + -1.18934038892149, + null, + -0.0136478880955734, + null + ], + "ari_use|famhist_2|famhist_bca|priorbiopsy|priorpsa": [ + -0.59630499569067, + 0.0585750655871868, + 0.816784157656238, + -0.209359663427241, + null, + 0.871746104604235, + 0.535454962216736, + null, + null, + -1.20745809899834, + null, + -0.0178550727421101, + null + ], + "ari_use|famhist_2|famhist_bca|priorpsa|race": [ + -1.65387556249348, + 0.0649980924297456, + 0.896475069010178, + null, + -1.04563979741503, + 0.902661763308695, + 0.536472213970781, + null, + null, + -1.10094030817794, + null, + 0.0456979769232812, + null + ], + "ari_use|famhist_2|famhist_bca|priorpsa": [ + -1.37030501913039, + 0.0621056250941304, + 0.896569509500811, + -0.182409164730954, + -1.03166993289297, + 0.873605120832681, + 0.549396214338577, + null, + null, + -1.11141640908519, + null, + 0.0434889271450656, + null + ], + "ari_use|dre|famhist_1|famhist_bca|priorbiopsy|priorpsa|race": [ + 0.101521792573159, + 0.0525200337211696, + 0.766559862996642, + null, + null, + null, + null, + 0.342259953976537, + null, + -1.22754213981277, + null, + 0.506070097170275, + null + ], + "ari_use|dre|famhist_1|famhist_bca|priorbiopsy|priorpsa": [ + 0.163498188965457, + 0.0519089194465667, + 0.769717192998721, + -0.0535527506043043, + null, + null, + null, + 0.341920581098177, + null, + -1.22792941050839, + null, + 0.48531381645229, + null + ], + "ari_use|dre|famhist_1|famhist_bca|priorpsa|race": [ + -0.618194224975325, + 0.0547286685578465, + 0.853770259099277, + null, + -1.01660701229829, + null, + null, + 0.373443521177707, + null, + -1.13167104444651, + null, + 0.578750726037648, + null + ], + "ari_use|dre|famhist_1|famhist_bca|priorpsa": [ + -0.567787578227782, + 0.0541790735676226, + 0.856659445482953, + -0.0422377037847379, + -1.01646909076201, + null, + null, + 0.372634088291697, + null, + -1.13167614650581, + null, + 0.563529964527428, + null + ], + "ari_use|famhist_1|famhist_bca|priorbiopsy|priorpsa|race": [ + -0.166681344859937, + 0.0542740155353747, + 0.802225283372036, + null, + null, + 0.812875862675856, + null, + 0.344615773266905, + null, + -1.25670162988743, + null, + 0.212050215372045, + null + ], + "ari_use|famhist_1|famhist_bca|priorbiopsy|priorpsa": [ + -0.0804494336220785, + 0.0534656011333128, + 0.806373307663171, + -0.0721654746850134, + null, + 0.807486230459241, + null, + 0.343898991278778, + null, + -1.25771926577818, + null, + 0.186284607019249, + null + ], + "ari_use|famhist_1|famhist_bca|priorpsa|race": [ + -0.992144225620887, + 0.0574210649193975, + 0.900690945478714, + null, + -1.01422707317763, + 0.841399750829633, + null, + 0.36973309786967, + null, + -1.15349193194477, + null, + 0.255431713605356, + null + ], + "ari_use|famhist_1|famhist_bca|priorpsa": [ + -0.928959832009011, + 0.0567750927690838, + 0.903907410759798, + -0.0512577626772323, + -1.01313356830149, + 0.837058255189007, + null, + 0.368607928126934, + null, + -1.15387630698935, + null, + 0.238287026821219, + null + ], + "ari_use|dre|famhist_bca|priorbiopsy|priorpsa|race": [ + -0.0166080864767465, + 0.0526089791724463, + 0.787642775784747, + null, + null, + null, + 0.502833160794509, + 0.261595227148303, + null, + -1.23365112200748, + null, + 0.503459135736101, + null + ], + "ari_use|dre|famhist_bca|priorbiopsy|priorpsa": [ + 0.0297796582756629, + 0.0521281265826143, + 0.790015937934118, + -0.0401582800417255, + null, + null, + 0.500791286347096, + 0.26135463200839, + null, + -1.2335594041284, + null, + 0.488084056275092, + null + ], + "ari_use|dre|famhist_bca|priorpsa|race": [ + -0.728882644614675, + 0.0546968139420277, + 0.874641649047839, + null, + -1.01046971037739, + null, + 0.492789585410099, + 0.292035511945682, + null, + -1.13747041592882, + null, + 0.577439062295396, + null + ], + "ari_use|dre|famhist_bca|priorpsa": [ + -0.691549366894578, + 0.0542577598602634, + 0.876911782783123, + -0.0313149032606074, + -1.01061391656746, + null, + 0.49125066127038, + 0.291347962124082, + null, + -1.13707381494101, + null, + 0.56655950051999, + null + ], + "ari_use|famhist_bca|priorbiopsy|priorpsa|race": [ + -0.307217149901283, + 0.0543154965418132, + 0.826877869198633, + null, + null, + 0.820496804192025, + 0.538289021101739, + 0.253195355192641, + null, + -1.26194902834012, + null, + 0.215696796679774, + null + ], + "ari_use|famhist_bca|priorbiopsy|priorpsa": [ + -0.237995693732704, + 0.0536525559164385, + 0.830121649030243, + -0.0577648686434426, + null, + 0.815959377598353, + 0.5356822969218, + 0.252837784196354, + null, + -1.26245357226949, + null, + 0.195216588880145, + null + ], + "ari_use|famhist_bca|priorpsa|race": [ + -1.12244338588537, + 0.0573322290439031, + 0.924592067747972, + null, + -1.00596035794688, + 0.847132501104798, + 0.525383364084907, + 0.277878761012303, + null, + -1.15837634709574, + null, + 0.260121184404694, + null + ], + "ari_use|famhist_bca|priorpsa": [ + -1.07302555763621, + 0.0568057169059674, + 0.927151108116407, + -0.0400692108318319, + -1.00535751101963, + 0.84350230808615, + 0.523684619973624, + 0.277021860351329, + null, + -1.15835564938147, + null, + 0.247031060485534, + null + ], + "ari_use|dre|famhist_1|famhist_2|priorbiopsy|priorpsa|race": [ + 0.198911712192833, + 0.0510264041645975, + 0.769347766715389, + null, + null, + null, + null, + null, + 0.310341345573681, + -1.22888310171175, + null, + 0.512441694266108, + null + ], + "ari_use|dre|famhist_1|famhist_2|priorbiopsy|priorpsa": [ + 0.250634802966847, + 0.0505002222564101, + 0.772091795448409, + -0.0451588932701067, + null, + null, + null, + null, + 0.30670396799179, + -1.22896842514629, + null, + 0.495309255824229, + null + ], + "ari_use|dre|famhist_1|famhist_2|priorpsa|race": [ + -0.518095909914364, + 0.0531771419793731, + 0.857051140133842, + null, + -1.02313430280587, + null, + null, + null, + 0.307893530146464, + -1.13256058935014, + null, + 0.587816066201484, + null + ], + "ari_use|dre|famhist_1|famhist_2|priorpsa": [ + -0.478503460970227, + 0.0527167463449127, + 0.859549405482882, + -0.0336845994662818, + -1.02315016287394, + null, + null, + null, + 0.304651342429643, + -1.13224007111491, + null, + 0.576227387799922, + null + ], + "ari_use|famhist_1|famhist_2|priorbiopsy|priorpsa|race": [ + -0.076599745855278, + 0.0528385963193443, + 0.805291028564043, + null, + null, + 0.798139078618901, + null, + null, + 0.32101099991585, + -1.25736266965816, + null, + 0.227365319515184, + null + ], + "ari_use|famhist_1|famhist_2|priorbiopsy|priorpsa": [ + 0.000824586596764378, + 0.0521063013067217, + 0.809074265508279, + -0.0652046826176942, + null, + 0.793235817864116, + null, + null, + 0.316367403273066, + -1.25812642506987, + null, + 0.204391767700556, + null + ], + "ari_use|famhist_1|famhist_2|priorpsa|race": [ + -0.902798662686697, + 0.0560012442018118, + 0.903978367202208, + null, + -1.01756564376298, + 0.825945414208123, + null, + null, + 0.286343580202284, + -1.15353515383213, + null, + 0.27512543377845, + null + ], + "ari_use|famhist_1|famhist_2|priorpsa": [ + -0.847837650597516, + 0.0554249886670401, + 0.906914099960119, + -0.0450445562859997, + -1.01674125158695, + 0.822012690475102, + null, + null, + 0.282679181503857, + -1.15366892633142, + null, + 0.260449895731623, + null + ], + "ari_use|dre|famhist_2|priorbiopsy|priorpsa|race": [ + 0.0551303340295733, + 0.0513656778442293, + 0.791091207827018, + null, + null, + null, + 0.502817446200675, + null, + 0.267880405186073, + -1.23403695891465, + null, + 0.507573396213274, + null + ], + "ari_use|dre|famhist_2|priorbiopsy|priorpsa": [ + 0.0933056021965127, + 0.0509531522871815, + 0.793155145730676, + -0.0334830942267089, + null, + null, + 0.501135101581281, + null, + 0.265150175116334, + -1.23372863239176, + null, + 0.495058232376409, + null + ], + "ari_use|dre|famhist_2|priorpsa|race": [ + -0.656075026816674, + 0.0533980546681243, + 0.878875978724122, + null, + -1.01806919680699, + null, + 0.494887903917794, + null, + 0.263837221982794, + -1.13730881480274, + null, + 0.584527453185058, + null + ], + "ari_use|dre|famhist_2|priorpsa": [ + -0.627478983132103, + 0.0530313659698575, + 0.880851033595544, + -0.0244323759243767, + -1.01832072258228, + null, + 0.493629738967482, + null, + 0.261344991117891, + -1.1366739124947, + null, + 0.5765530205919, + null + ], + "ari_use|famhist_2|priorbiopsy|priorpsa|race": [ + -0.245264522954488, + 0.0531809508919681, + 0.830342447788273, + null, + null, + 0.806004357649641, + 0.534940421131723, + null, + 0.273042140109972, + -1.26157225706862, + null, + 0.227383748138611, + null + ], + "ari_use|famhist_2|priorbiopsy|priorpsa": [ + -0.182448752993338, + 0.0525735479445981, + 0.833339897810674, + -0.0527509990848678, + null, + 0.801835337797204, + 0.532722889820129, + null, + 0.269514112133844, + -1.26191073718781, + null, + 0.208928992851474, + null + ], + "ari_use|famhist_2|priorpsa|race": [ + -1.06364686191263, + 0.0562133446990271, + 0.928789355686914, + null, + -1.0111314927429, + 0.832088527825877, + 0.525154238672536, + null, + 0.236767577354489, + -1.15724313526576, + null, + 0.276455984243408, + null + ], + "ari_use|famhist_2|priorpsa": [ + -1.02008252997367, + 0.0557365975887363, + 0.931169405512062, + -0.0356845454154256, + -1.01071486096739, + 0.828758951527165, + 0.523700353692169, + null, + 0.23392035292551, + -1.15706043309969, + null, + 0.265119801918824, + null + ], + "ari_use|dre|famhist_1|priorbiopsy|priorpsa|race": [ + 0.125028058761392, + 0.0520471238904432, + 0.774925540559043, + null, + null, + null, + null, + 0.340341149122803, + 0.302072532054557, + -1.23383344250576, + null, + 0.491874660145369, + null + ], + "ari_use|dre|famhist_1|priorbiopsy|priorpsa": [ + 0.176609986251451, + 0.0515247017500596, + 0.777622971003244, + -0.0451507268670802, + null, + null, + null, + 0.339952023909255, + 0.29840853412502, + -1.23391320761446, + null, + 0.474729611539287, + null + ], + "ari_use|dre|famhist_1|priorpsa|race": [ + -0.605277157668122, + 0.0543123068718004, + 0.863861037853341, + null, + -1.02887427703169, + null, + null, + 0.373303440809116, + 0.300137874429262, + -1.13721129590592, + null, + 0.565706670358727, + null + ], + "ari_use|dre|famhist_1|priorpsa": [ + -0.566057010879363, + 0.0538576441931671, + 0.866279464411185, + -0.0332655876269403, + -1.02884177325025, + null, + null, + 0.372467020714996, + 0.296910266614944, + -1.13688720292334, + null, + 0.554289279995658, + null + ], + "ari_use|famhist_1|priorbiopsy|priorpsa|race": [ + -0.138001364202442, + 0.0537947465205553, + 0.812034513770924, + null, + null, + 0.807622405295045, + null, + 0.343401698838423, + 0.312698894821923, + -1.26509371709775, + null, + 0.203773359743635, + null + ], + "ari_use|famhist_1|priorbiopsy|priorpsa": [ + -0.063524864101628, + 0.0530889869201814, + 0.81563222306174, + -0.0630210528446264, + null, + 0.802817182681512, + null, + 0.342673771350376, + 0.308118718095869, + -1.2657461901757, + null, + 0.181599639198057, + null + ], + "ari_use|famhist_1|priorpsa|race": [ + -0.975128041424567, + 0.0570347893555636, + 0.912003643376154, + null, + -1.02332079823447, + 0.836527890656819, + null, + 0.371507204319869, + 0.279307624354742, + -1.16099777905223, + null, + 0.249530553453575, + null + ], + "ari_use|famhist_1|priorpsa": [ + -0.923655934412673, + 0.0564902794152595, + 0.914716878578405, + -0.0421979414166013, + -1.02249066291123, + 0.832763846884735, + null, + 0.370399061622655, + 0.275778475279365, + -1.16102765117864, + null, + 0.235900651516205, + null + ], + "ari_use|dre|priorbiopsy|priorpsa|race": [ + 0.00909157194651594, + 0.0521328474488042, + 0.794872578177456, + null, + null, + null, + 0.488733636251077, + 0.262826232860973, + 0.26184277096263, + -1.23889198584328, + null, + 0.492021252036093, + null + ], + "ari_use|dre|priorbiopsy|priorpsa": [ + 0.0465341438353951, + 0.0517279226857273, + 0.796879924530735, + -0.0329463720129461, + null, + null, + 0.48704308507553, + 0.262515084828636, + 0.259135321184871, + -1.2385614903236, + null, + 0.479707716037827, + null + ], + "ari_use|dre|priorpsa|race": [ + -0.71338639407876, + 0.0542809593971783, + 0.883557370175915, + null, + -1.02259722238626, + null, + 0.477993168624362, + 0.295155898515818, + 0.258276245955922, + -1.14203414575351, + null, + 0.567354748781509, + null + ], + "ari_use|dre|priorpsa": [ + -0.685796173310245, + 0.0539241186292677, + 0.885446047624619, + -0.023509831704443, + -1.02281048600475, + null, + 0.476774673979967, + 0.294426794860431, + 0.255848340111532, + -1.14137467546509, + null, + 0.5597492347121, + null + ], + "ari_use|priorbiopsy|priorpsa|race": [ + -0.275970723265731, + 0.0538440712318361, + 0.835028097309352, + null, + null, + 0.814946755931841, + 0.521853024100634, + 0.25597052209367, + 0.267043055590123, + -1.26897657054592, + null, + 0.209609681162201, + null + ], + "ari_use|priorbiopsy|priorpsa": [ + -0.216377470257239, + 0.0532648995091594, + 0.837852299925954, + -0.0503080046371215, + null, + 0.810901796974862, + 0.519682948999082, + 0.255550452986761, + 0.263610664367915, + -1.26921042782058, + null, + 0.192036642218412, + null + ], + "ari_use|priorpsa|race": [ + -1.10302754374172, + 0.0569581401588953, + 0.934370385103856, + null, + -1.01561956055479, + 0.841983728228218, + 0.509233554553251, + 0.283615642536071, + 0.231869813037964, + -1.16459243937819, + null, + 0.256635217532418, + null + ], + "ari_use|priorpsa": [ + -1.06326237414259, + 0.0565148411004284, + 0.936539423060332, + -0.032599341790884, + -1.01520007903483, + 0.838835522550612, + 0.507882103443439, + 0.282740972475196, + 0.229187765063666, + -1.16430302431773, + null, + 0.246425976938569, + null + ], + "dre|famhist_1|famhist_2|famhist_bca|priorbiopsy|priorpsa|prosvol|race": [ + -5.56050986803548, + 0.0563908726887283, + 0.47927592242372, + null, + null, + null, + null, + null, + null, + null, + -0.297478741255332, + -0.24952176581444, + null + ], + "dre|famhist_1|famhist_2|famhist_bca|priorbiopsy|priorpsa|prosvol": [ + -5.44912715105243, + 0.0545222659512973, + 0.474293179742133, + -0.143688750052214, + null, + null, + null, + null, + null, + null, + -0.278032239790049, + -0.215405828033969, + null + ], + "dre|famhist_1|famhist_2|famhist_bca|priorpsa|prosvol|race": [ + -5.89920390194998, + 0.0605414132911172, + 0.603735826449833, + null, + -1.44897839480442, + null, + null, + null, + null, + null, + -0.105459159147829, + -0.171477729737706, + null + ], + "dre|famhist_1|famhist_2|famhist_bca|priorpsa|prosvol": [ + -5.77839694382896, + 0.0584633285617121, + 0.606925816102445, + -0.144244500563247, + -1.4545216184539, + null, + null, + null, + null, + null, + -0.0798637889720287, + -0.14981198097384, + null + ], + "famhist_1|famhist_2|famhist_bca|priorbiopsy|priorpsa|prosvol|race": [ + -5.18652075970731, + 0.0465314231923313, + 0.511864882561289, + null, + null, + 0.604210700493386, + null, + null, + null, + null, + -0.281780618926455, + -0.431290790961044, + null + ], + "famhist_1|famhist_2|famhist_bca|priorbiopsy|priorpsa|prosvol": [ + -5.06641843848589, + 0.0452897034662958, + 0.500095280716269, + -0.146138072314461, + null, + 0.556608613077234, + null, + null, + null, + null, + -0.254747792235367, + -0.407778694784086, + null + ], + "famhist_1|famhist_2|famhist_bca|priorpsa|prosvol|race": [ + -5.54354750981909, + 0.0507680622416112, + 0.634293174184995, + null, + -1.42894462506282, + 0.609911136901869, + null, + null, + null, + null, + -0.0894853628267865, + -0.318789639365882, + null + ], + "famhist_1|famhist_2|famhist_bca|priorpsa|prosvol": [ + -5.40989516939081, + 0.0491716222751212, + 0.630092912177115, + -0.137424327532837, + -1.44694663898252, + 0.577295144527979, + null, + null, + null, + null, + -0.0566596693841731, + -0.30723635721428, + null + ], + "dre|famhist_2|famhist_bca|priorbiopsy|priorpsa|prosvol|race": [ + -5.97797413438717, + 0.0608428878687684, + 0.494753252121201, + null, + null, + null, + 0.453940163307669, + null, + null, + null, + -0.253632754461003, + -0.0904155543550075, + null + ], + "dre|famhist_2|famhist_bca|priorbiopsy|priorpsa|prosvol": [ + -5.85049015310257, + 0.0584879497483359, + 0.487974180036984, + -0.0386297429700573, + null, + null, + 0.463836632564646, + null, + null, + null, + -0.219034711127467, + -0.0485711015025296, + null + ], + "dre|famhist_2|famhist_bca|priorpsa|prosvol|race": [ + -6.41435059197083, + 0.0661136201284913, + 0.620614505184486, + null, + -1.39929954046244, + null, + 0.50709173905692, + null, + null, + null, + -0.0530240848558456, + -0.023944693343962, + null + ], + "dre|famhist_2|famhist_bca|priorpsa|prosvol": [ + -6.30899996625497, + 0.0639397141398757, + 0.625755239492448, + -0.0762084009650622, + -1.41205916240682, + null, + 0.517312901784583, + null, + null, + null, + -0.0117270469285613, + 0.0067509035048401, + null + ], + "famhist_2|famhist_bca|priorbiopsy|priorpsa|prosvol|race": [ + -5.58369312290138, + 0.0486765647298208, + 0.546239065077818, + null, + null, + 0.830353973910755, + 0.519972166282687, + null, + null, + null, + -0.299749294968313, + -0.282028030193741, + null + ], + "famhist_2|famhist_bca|priorbiopsy|priorpsa|prosvol": [ + -5.42327833817549, + 0.0464581409292328, + 0.532329201077687, + -0.0499235183483632, + null, + 0.814020922815641, + 0.54101167410726, + null, + null, + null, + -0.265104582809857, + -0.262835571823813, + null + ], + "famhist_2|famhist_bca|priorpsa|prosvol|race": [ + -6.01164553560733, + 0.05398641594236, + 0.661192280460308, + null, + -1.34288890186615, + 0.81597691945085, + 0.56580158884294, + null, + null, + null, + -0.0829391290053617, + -0.193153881126397, + null + ], + "famhist_2|famhist_bca|priorpsa|prosvol": [ + -5.8629580482757, + 0.051745473936254, + 0.658238820061474, + -0.0872392069415356, + -1.37659619944694, + 0.817248674104594, + 0.584960708111521, + null, + null, + null, + -0.0377817662917409, + -0.18465058828586, + null + ], + "dre|famhist_1|famhist_bca|priorbiopsy|priorpsa|prosvol|race": [ + -4.86740106719009, + 0.0474635110829617, + 0.42954975798035, + null, + null, + null, + null, + 0.228276504253379, + null, + null, + -0.278933447063443, + -0.0958028383767183, + null + ], + "dre|famhist_1|famhist_bca|priorbiopsy|priorpsa|prosvol": [ + -4.79201438605804, + 0.047182055218656, + 0.432557293730769, + -0.261570704049875, + null, + null, + null, + 0.2328279658313, + null, + null, + -0.274149782374242, + -0.137785552928383, + null + ], + "dre|famhist_1|famhist_bca|priorpsa|prosvol|race": [ + -5.40817113327975, + 0.0518192223242737, + 0.627001984475878, + null, + -1.57280517453516, + null, + null, + 0.264357376248586, + null, + null, + -0.0173599688931977, + 0.0168619435500838, + null + ], + "dre|famhist_1|famhist_bca|priorpsa|prosvol": [ + -5.31987078874954, + 0.0514571736451785, + 0.630055995722658, + -0.297429180151207, + -1.57343089209313, + null, + null, + 0.270435041823949, + null, + null, + -0.0219293385922852, + -0.0278863633203717, + null + ], + "famhist_1|famhist_bca|priorbiopsy|priorpsa|prosvol|race": [ + -5.04285268662669, + 0.0480448291063625, + 0.428256693437244, + null, + null, + 0.499636266992731, + null, + 0.274220005132924, + null, + null, + -0.344997343059531, + -0.28064408811667, + null + ], + "famhist_1|famhist_bca|priorbiopsy|priorpsa|prosvol": [ + -4.96657000791762, + 0.0476714482188364, + 0.431934444393, + -0.242890277152749, + null, + 0.499028413009412, + null, + 0.27882394376357, + null, + null, + -0.339956572963891, + -0.320596393062592, + null + ], + "famhist_1|famhist_bca|priorpsa|prosvol|race": [ + -5.51736319620014, + 0.0509520802632828, + 0.620740400734753, + null, + -1.53536878688388, + 0.60143381147505, + null, + 0.304501557053704, + null, + null, + -0.0700227766876191, + -0.182814464016947, + null + ], + "famhist_1|famhist_bca|priorpsa|prosvol": [ + -5.42460080498351, + 0.0504764206463757, + 0.624982129203912, + -0.296995500820249, + -1.53877454782368, + 0.604736438438873, + null, + 0.311086805676242, + null, + null, + -0.0737041655661839, + -0.229128245317805, + null + ], + "dre|famhist_bca|priorbiopsy|priorpsa|prosvol|race": [ + -5.08546062427744, + 0.0476173343986933, + 0.457935898175083, + null, + null, + null, + 0.641261285691687, + 0.135326303522717, + null, + null, + -0.306875418787988, + -0.0779978793600604, + null + ], + "dre|famhist_bca|priorbiopsy|priorpsa|prosvol": [ + -5.00501880095147, + 0.0472734517104884, + 0.461172998030918, + -0.2684257898082, + null, + null, + 0.642112277646203, + 0.140312108416018, + null, + null, + -0.303164781009697, + -0.120735690384134, + null + ], + "dre|famhist_bca|priorpsa|prosvol|race": [ + -5.62055311606668, + 0.0519243083387646, + 0.65443000033151, + null, + -1.56465609154851, + null, + 0.615500390433045, + 0.171328934653346, + null, + null, + -0.04368054585265, + 0.0383964771916429, + null + ], + "dre|famhist_bca|priorpsa|prosvol": [ + -5.52637389701135, + 0.051474063819211, + 0.658118499391969, + -0.307479013834345, + -1.56643940161536, + null, + 0.618373918373351, + 0.177816210108875, + null, + null, + -0.0486827530199341, + -0.00717475033341439, + null + ], + "famhist_bca|priorbiopsy|priorpsa|prosvol|race": [ + -5.29386962412247, + 0.0484455897367321, + 0.458832198498965, + null, + null, + 0.505785253954964, + 0.668195985497421, + 0.183500112156296, + null, + null, + -0.372160985831482, + -0.256910288605857, + null + ], + "famhist_bca|priorbiopsy|priorpsa|prosvol": [ + -5.21285764838046, + 0.0480115688959937, + 0.462705174426331, + -0.247215450605104, + null, + 0.505138592324421, + 0.668220407967954, + 0.188764334441589, + null, + null, + -0.368452217737416, + -0.297372733283027, + null + ], + "famhist_bca|priorpsa|prosvol|race": [ + -5.76638800971334, + 0.0513097330148102, + 0.651085080700217, + null, + -1.53143254885659, + 0.604197376520164, + 0.65660746331202, + 0.21150807405044, + null, + null, + -0.0935580831102946, + -0.15299869265118, + null + ], + "famhist_bca|priorpsa|prosvol": [ + -5.6670681121758, + 0.0507342624177261, + 0.655909374235349, + -0.304985584110012, + -1.53627006585871, + 0.607671434249349, + 0.658597565613587, + 0.218725898259658, + null, + null, + -0.0976651955340424, + -0.199987913581157, + null + ], + "dre|famhist_1|famhist_2|priorbiopsy|priorpsa|prosvol|race": [ + -4.75358133280608, + 0.0455178726787637, + 0.433656708471035, + null, + null, + null, + null, + null, + 0.107867545357818, + null, + -0.298438626504465, + -0.0719012699308071, + null + ], + "dre|famhist_1|famhist_2|priorbiopsy|priorpsa|prosvol": [ + -4.67550356500317, + 0.0452003593980087, + 0.436444047882879, + -0.256980344243243, + null, + null, + null, + null, + 0.108044563451346, + null, + -0.293782989721871, + -0.113352441281328, + null + ], + "dre|famhist_1|famhist_2|priorpsa|prosvol|race": [ + -5.29350664323208, + 0.049690809107212, + 0.637183473807372, + null, + -1.59148852650931, + null, + null, + null, + 0.11467833606323, + null, + -0.0401530151504191, + 0.044258296723185, + null + ], + "dre|famhist_1|famhist_2|priorpsa|prosvol": [ + -5.20209502099931, + 0.049288819897679, + 0.640011595027247, + -0.29184874438707, + -1.59183896960463, + null, + null, + null, + 0.114545469623813, + null, + -0.0450015455535715, + 3.06105544457027e-05, + null + ], + "famhist_1|famhist_2|priorbiopsy|priorpsa|prosvol|race": [ + -4.91617393160179, + 0.0458804615619244, + 0.432655165376215, + null, + null, + 0.493027415215635, + null, + null, + 0.151560141991309, + null, + -0.364995262028454, + -0.249113575731401, + null + ], + "famhist_1|famhist_2|priorbiopsy|priorpsa|prosvol": [ + -4.83671979263559, + 0.0454619991531535, + 0.436158381883576, + -0.238805642297081, + null, + 0.493036278203665, + null, + null, + 0.151226434243814, + null, + -0.360032193777291, + -0.288765897589177, + null + ], + "famhist_1|famhist_2|priorpsa|prosvol|race": [ + -5.38987928267416, + 0.0486639429636726, + 0.630822688282276, + null, + -1.55169746295925, + 0.592518178807171, + null, + null, + 0.139287640542067, + null, + -0.0932935869601247, + -0.147829349988943, + null + ], + "famhist_1|famhist_2|priorpsa|prosvol": [ + -5.29355107422944, + 0.0481409013400088, + 0.634869964212077, + -0.292210647242457, + -1.55483931462588, + 0.596413910119431, + null, + null, + 0.139230812336058, + null, + -0.0971973399078354, + -0.193822604839155, + null + ], + "dre|famhist_2|priorbiopsy|priorpsa|prosvol|race": [ + -5.01942510768442, + 0.0462606664751739, + 0.462596836355564, + null, + null, + null, + 0.64155326625899, + null, + 0.0879467929672245, + null, + -0.324227184136764, + -0.0564233896908144, + null + ], + "dre|famhist_2|priorbiopsy|priorpsa|prosvol": [ + -4.9349890646406, + 0.0458727117861183, + 0.465622172176788, + -0.267845015833193, + null, + null, + 0.643555536325234, + null, + 0.0886208363967754, + null, + -0.320708465615853, + -0.0993207614222326, + null + ], + "dre|famhist_2|priorpsa|prosvol|race": [ + -5.55698164599774, + 0.0504108711652011, + 0.665609698248728, + null, + -1.58443846797846, + null, + 0.61589009691104, + null, + 0.090184257806466, + null, + -0.0644687286153153, + 0.0649573612418484, + null + ], + "dre|famhist_2|priorpsa|prosvol": [ + -5.45793257351968, + 0.0499045209871951, + 0.669128181185583, + -0.306077393282643, + -1.58610543751642, + null, + 0.620152168319337, + null, + 0.0897163281919488, + null, + -0.0698887149601846, + 0.0192821842976902, + null + ], + "famhist_2|priorbiopsy|priorpsa|prosvol|race": [ + -5.21315210965846, + 0.0469225230816028, + 0.462693162991891, + null, + null, + 0.497208427145392, + 0.668826075540376, + null, + 0.121670745510029, + null, + -0.389734400052632, + -0.228272357316116, + null + ], + "famhist_2|priorbiopsy|priorpsa|prosvol": [ + -5.1273314629526, + 0.0464300671171693, + 0.466446507223004, + -0.248068713250862, + null, + 0.497349838648236, + 0.670159589723262, + null, + 0.122124366772343, + null, + -0.386174717182197, + -0.269276667565621, + null + ], + "famhist_2|priorpsa|prosvol|race": [ + -5.68848999303529, + 0.0496910416293004, + 0.661217423879223, + null, + -1.54873747766849, + 0.592973212380771, + 0.65640255806276, + null, + 0.102988794013453, + null, + -0.114956278127052, + -0.119337993230909, + null + ], + "famhist_2|priorpsa|prosvol": [ + -5.5835050446461, + 0.0490451904629988, + 0.665959597638043, + -0.305358910071277, + -1.55355226204221, + 0.597316756412981, + 0.660041761068174, + null, + 0.103010604246961, + null, + -0.119400830103604, + -0.166782809525922, + null + ], + "dre|famhist_1|priorbiopsy|priorpsa|prosvol|race": [ + -4.87508757458235, + 0.0467872485132993, + 0.438762311932819, + null, + null, + null, + null, + 0.236950830044364, + 0.10741716811664, + null, + -0.295094647859582, + -0.077918293856221, + null + ], + "dre|famhist_1|priorbiopsy|priorpsa|prosvol": [ + -4.80161748457637, + 0.0465229363784997, + 0.441657955498462, + -0.257298490525808, + null, + null, + null, + 0.241498209300901, + 0.107856703713642, + null, + -0.29023736089955, + -0.119231327905141, + null + ], + "dre|famhist_1|priorpsa|prosvol|race": [ + -5.44628847030184, + 0.0513136063641329, + 0.643619199168452, + null, + -1.59693453951986, + null, + null, + 0.274947599474569, + 0.114369045171933, + null, + -0.0340438288552817, + 0.039330659717925, + null + ], + "dre|famhist_1|priorpsa|prosvol": [ + -5.3600348114748, + 0.0509682763898981, + 0.646597602304895, + -0.293078826500999, + -1.59752623296828, + null, + null, + 0.281019087839076, + 0.114437608972244, + null, + -0.0386541738055405, + -0.00461905029859968, + null + ], + "famhist_1|priorbiopsy|priorpsa|prosvol|race": [ + -5.06337135439657, + 0.04744938369234, + 0.437834323914255, + null, + null, + 0.496090668386579, + null, + 0.284208166515564, + 0.150710811895281, + null, + -0.362935570508679, + -0.258687448194945, + null + ], + "famhist_1|priorbiopsy|priorpsa|prosvol": [ + -4.98914051234685, + 0.0470963737257194, + 0.441393439159084, + -0.238779026633359, + null, + 0.495468438734276, + null, + 0.288831342351282, + 0.15066532909741, + null, + -0.357771620624767, + -0.297954751651275, + null + ], + "famhist_1|priorpsa|prosvol|race": [ + -5.56500860856107, + 0.0505157056698949, + 0.637781017656576, + null, + -1.55899695264179, + 0.598561970004553, + null, + 0.316958153146718, + 0.138832322141084, + null, + -0.0885686770060006, + -0.157041547699071, + null + ], + "famhist_1|priorpsa|prosvol": [ + -5.47469032180407, + 0.0500611899396728, + 0.641946200033357, + -0.292573778593339, + -1.56231102851078, + 0.601772616061989, + null, + 0.323579748519033, + 0.139046891816997, + null, + -0.0921920210021323, + -0.202411322656088, + null + ], + "dre|priorbiopsy|priorpsa|prosvol|race": [ + -5.08284673920903, + 0.0469515324318782, + 0.465097838026195, + null, + null, + null, + 0.633000801693762, + 0.146335643627049, + 0.0868088771980267, + null, + -0.321753697719413, + -0.0618999242276507, + null + ], + "dre|priorbiopsy|priorpsa|prosvol": [ + -5.00442164399242, + 0.046627772629213, + 0.46818422774612, + -0.264410273882415, + null, + null, + 0.633983225308823, + 0.151294391529557, + 0.08771448581525, + null, + -0.317924171272015, + -0.104036297866228, + null + ], + "dre|priorpsa|prosvol|race": [ + -5.64638691169343, + 0.0514040269697707, + 0.669160296476905, + null, + -1.58812429022955, + null, + 0.604126300991538, + 0.184803832548803, + 0.089130138942739, + null, + -0.0601161756424701, + 0.0594837336076853, + null + ], + "dre|priorpsa|prosvol": [ + -5.55422316569142, + 0.0509725612739353, + 0.672727946221615, + -0.303070670015023, + -1.58983920327317, + null, + 0.607045558739075, + 0.191262946973977, + 0.0888902522253343, + null, + -0.0651619744048703, + 0.0146582801082916, + null + ], + "priorbiopsy|priorpsa|prosvol|race": [ + -5.3004623902197, + 0.0478713940668131, + 0.465607494924641, + null, + null, + 0.502122050366461, + 0.658241876155077, + 0.196347648680047, + 0.120171004023907, + null, + -0.388332031864576, + -0.237708584737331, + null + ], + "priorbiopsy|priorpsa|prosvol": [ + -5.22160119010446, + 0.0474598715049267, + 0.469339513741971, + -0.243747306621089, + null, + 0.501545359251763, + 0.6584998217635, + 0.201613486314443, + 0.120904775040487, + null, + -0.384460713967112, + -0.277618081152363, + null + ], + "priorpsa|prosvol|race": [ + -5.7973026144638, + 0.0508643874955958, + 0.665483207404637, + null, + -1.55430212839522, + 0.600473962460105, + 0.64358734988803, + 0.227450814590365, + 0.101673166029863, + null, + -0.111877196268541, + -0.129398986380903, + null + ], + "priorpsa|prosvol": [ + -5.70038536404911, + 0.0503115186007481, + 0.670183526970269, + -0.300808029641343, + -1.55900211615356, + 0.603934223757887, + 0.645709126509144, + 0.234699439516356, + 0.102021377016339, + null, + -0.11595495402024, + -0.175562232212102, + null + ], + "dre|famhist_1|famhist_2|famhist_bca|priorbiopsy|priorpsa|race": [ + -0.165930012144199, + 0.0727559852847884, + 0.73145909882454, + null, + null, + null, + null, + null, + null, + -1.30865460004064, + -0.190278049973619, + -0.225002167287687, + null + ], + "dre|famhist_1|famhist_2|famhist_bca|priorbiopsy|priorpsa": [ + 0.0971121009334002, + 0.0704499666929508, + 0.720345416688084, + -0.32605468488165, + null, + null, + null, + null, + null, + -1.3240327007767, + -0.21032533378861, + -0.187081060922353, + null + ], + "dre|famhist_1|famhist_2|famhist_bca|priorpsa|race": [ + -0.861024603099889, + 0.0751450473412642, + 0.797509793073004, + null, + -1.21163353050842, + null, + null, + null, + null, + -1.20485604961274, + -0.0438173510942182, + -0.154103233930323, + null + ], + "dre|famhist_1|famhist_2|famhist_bca|priorpsa": [ + -0.64786338600059, + 0.0730735952397038, + 0.786346814538032, + -0.30518693715819, + -1.19623427759709, + null, + null, + null, + null, + -1.21286722192483, + -0.0638269888498608, + -0.123362828498295, + null + ], + "famhist_1|famhist_2|famhist_bca|priorbiopsy|priorpsa|race": [ + -0.0136489654138535, + 0.0653624696376269, + 0.757649917603963, + null, + null, + 0.611024068794723, + null, + null, + null, + -1.29659720341681, + -0.161942613393107, + -0.410480656147898, + null + ], + "famhist_1|famhist_2|famhist_bca|priorbiopsy|priorpsa": [ + 0.286701535846198, + 0.0641779759413431, + 0.740955994568552, + -0.383430626747013, + null, + 0.551369773210576, + null, + null, + null, + -1.32174856413358, + -0.183344376559349, + -0.379212362397695, + null + ], + "famhist_1|famhist_2|famhist_bca|priorpsa|race": [ + -0.706775504390182, + 0.0676440174504874, + 0.821981108116794, + null, + -1.18677656926745, + 0.616208507712061, + null, + null, + null, + -1.19256892449234, + -0.0220345117765698, + -0.316688749077807, + null + ], + "famhist_1|famhist_2|famhist_bca|priorpsa": [ + -0.469166089192867, + 0.0666167398601066, + 0.804923577318778, + -0.353040562077625, + -1.17678205067736, + 0.569755242328386, + null, + null, + null, + -1.20801915341226, + -0.0420611686546654, + -0.294289784420295, + null + ], + "dre|famhist_2|famhist_bca|priorbiopsy|priorpsa|race": [ + -0.675414969124569, + 0.0774785232165478, + 0.756680493358449, + null, + null, + null, + 0.518567492601435, + null, + null, + -1.3011527872119, + -0.152760349288898, + -0.0205550434920239, + null + ], + "dre|famhist_2|famhist_bca|priorbiopsy|priorpsa": [ + -0.37675478131416, + 0.0742719339215553, + 0.742415590125262, + -0.184979779270676, + null, + null, + 0.552579061641885, + null, + null, + -1.31666450077112, + -0.163583943397913, + 0.0280534898520775, + null + ], + "dre|famhist_2|famhist_bca|priorpsa|race": [ + -1.43140354473513, + 0.0797772285220634, + 0.833128276980361, + null, + -1.16604337801546, + null, + 0.553581186648486, + null, + null, + -1.19283338552485, + 0.00453995042537905, + 0.0325480114966916, + null + ], + "dre|famhist_2|famhist_bca|priorpsa": [ + -1.22481391871825, + 0.07700381299358, + 0.821304110732176, + -0.193433351818752, + -1.15172680886468, + null, + 0.585086082726359, + null, + null, + -1.19612547000948, + -0.0067748048902823, + 0.0742116104643483, + null + ], + "famhist_2|famhist_bca|priorbiopsy|priorpsa|race": [ + -0.539891703905858, + 0.069251712705029, + 0.799202470917111, + null, + null, + 0.829719200428403, + 0.555588906568106, + null, + null, + -1.29719070789163, + -0.186299743601392, + -0.21893242386424, + null + ], + "famhist_2|famhist_bca|priorbiopsy|priorpsa": [ + -0.186247552727628, + 0.0670876724460636, + 0.778181874762152, + -0.25948739445032, + null, + 0.796679115397723, + 0.596082687225803, + null, + null, + -1.32465332301261, + -0.205436020274962, + -0.190746858719584, + null + ], + "famhist_2|famhist_bca|priorpsa|race": [ + -1.2405927247496, + 0.0715949579684397, + 0.868565899146958, + null, + -1.10365748166119, + 0.816090080420929, + 0.590197742925793, + null, + null, + -1.19797939725959, + -0.0234177920879782, + -0.147811112747013, + null + ], + "famhist_2|famhist_bca|priorpsa": [ + -0.985530610406807, + 0.0697135110699062, + 0.84996890180076, + -0.260520092388714, + -1.10140223716395, + 0.800122536065365, + 0.626545874257567, + null, + null, + -1.21128599395352, + -0.0393193191072077, + -0.128553570377789, + null + ], + "dre|famhist_1|famhist_bca|priorbiopsy|priorpsa|race": [ + 1.12372607258455, + 0.0675163503016667, + 0.694308050207433, + null, + null, + null, + null, + 0.216538314118516, + null, + -1.50317202844953, + -0.0988111075800523, + 0.214753398993028, + null + ], + "dre|famhist_1|famhist_bca|priorbiopsy|priorpsa": [ + 1.27764694226423, + 0.0670543434276176, + 0.697479736587288, + -0.362557201144033, + null, + null, + null, + 0.221302866120792, + null, + -1.51166014915252, + -0.0958096898278706, + 0.160133725622081, + null + ], + "dre|famhist_1|famhist_bca|priorpsa|race": [ + 0.122520439494982, + 0.0687686053807294, + 0.811845059364444, + null, + -1.17975496895824, + null, + null, + 0.252831981986883, + null, + -1.35026933098965, + 0.0617370428908674, + 0.25511599302002, + null + ], + "dre|famhist_1|famhist_bca|priorpsa": [ + 0.279829641169528, + 0.0682196675340813, + 0.815094534946733, + -0.367551011136486, + -1.17937017502299, + null, + null, + 0.258273908606636, + null, + -1.35852872824214, + 0.058627815606267, + 0.203086371858637, + null + ], + "famhist_1|famhist_bca|priorbiopsy|priorpsa|race": [ + 0.860662588714782, + 0.0681569934090617, + 0.698096361830275, + null, + null, + 0.587740474044685, + null, + 0.290834063467367, + null, + -1.4969492777746, + -0.160783341020463, + 0.0102703695183179, + null + ], + "famhist_1|famhist_bca|priorbiopsy|priorpsa": [ + 1.07054045482868, + 0.0675608571426144, + 0.704096306528488, + -0.415226629424852, + null, + 0.591950833827682, + null, + 0.296636931463771, + null, + -1.5136631455116, + -0.1551032309016, + -0.0514687372479865, + null + ], + "famhist_1|famhist_bca|priorpsa|race": [ + -0.085940324660909, + 0.0685280237968676, + 0.811297750600894, + null, + -1.12919468310333, + 0.651016020914778, + null, + 0.317143093943852, + null, + -1.34652283908346, + 0.00593089954270583, + 0.0449513449906158, + null + ], + "famhist_1|famhist_bca|priorpsa": [ + 0.124608931407879, + 0.0678632046252942, + 0.817216341285584, + -0.421062800039455, + -1.12921470394777, + 0.655787128065481, + null, + 0.32382343022912, + null, + -1.36266636296569, + 0.00537206244328556, + -0.01419420403474, + null + ], + "dre|famhist_bca|priorbiopsy|priorpsa|race": [ + 0.877752500773342, + 0.067735159499097, + 0.723575855158502, + null, + null, + null, + 0.643085394375394, + 0.132031843439216, + null, + -1.49938124139078, + -0.128080739414354, + 0.228964322208636, + null + ], + "dre|famhist_bca|priorbiopsy|priorpsa": [ + 1.03608896335306, + 0.0671404856735898, + 0.726086128695319, + -0.354267285443277, + null, + null, + 0.639884539797189, + 0.137231794484351, + null, + -1.50689354882623, + -0.12687989252182, + 0.175698502305758, + null + ], + "dre|famhist_bca|priorpsa|race": [ + -0.124909131746351, + 0.0689057099691315, + 0.841755225624241, + null, + -1.16847457438546, + null, + 0.62499483744661, + 0.167024060916743, + null, + -1.34543973364568, + 0.0333996966724214, + 0.271889058156259, + null + ], + "dre|famhist_bca|priorpsa": [ + 0.0403893167328712, + 0.0682080279115116, + 0.84486936642785, + -0.366508922704818, + -1.1698154967976, + null, + 0.624704215340298, + 0.172445683964715, + null, + -1.35320877419626, + 0.029118881942889, + 0.22032001911106, + null + ], + "famhist_bca|priorbiopsy|priorpsa|race": [ + 0.590584722100837, + 0.068608232254304, + 0.731408889078376, + null, + null, + 0.593197192699255, + 0.673778043822296, + 0.20843791879037, + null, + -1.4955635403546, + -0.188179729116716, + 0.0316879332693026, + null + ], + "famhist_bca|priorbiopsy|priorpsa": [ + 0.80550066147181, + 0.0678535183533378, + 0.736535900928122, + -0.403529923940866, + null, + 0.596742694097502, + 0.668973188654378, + 0.21492222409187, + null, + -1.51096562042463, + -0.184741280392319, + -0.0285549960836672, + null + ], + "famhist_bca|priorpsa|race": [ + -0.359951324948918, + 0.0688785496863475, + 0.845899868555372, + null, + -1.12569658798014, + 0.654910071598653, + 0.668883159457748, + 0.232123055363615, + null, + -1.34392659187032, + -0.0189947444582498, + 0.0696942235658529, + null + ], + "famhist_bca|priorpsa": [ + -0.140199184444592, + 0.06803468236562, + 0.851371426950058, + -0.415998889878206, + -1.12764051463596, + 0.659235489698459, + 0.666807931262034, + 0.239163383304049, + null, + -1.35927780653451, + -0.0208702171018548, + 0.011119051788235, + null + ], + "dre|famhist_1|famhist_2|priorbiopsy|priorpsa|race": [ + 1.1762082257458, + 0.0659115207995824, + 0.704995868691141, + null, + null, + null, + null, + null, + 0.101688543846903, + -1.5015121378027, + -0.12283362393802, + 0.250691054727493, + null + ], + "dre|famhist_1|famhist_2|priorbiopsy|priorpsa": [ + 1.34323758113318, + 0.0654057220242955, + 0.708304083892701, + -0.369561257405564, + null, + null, + null, + null, + 0.101866258008312, + -1.51137716727167, + -0.120062921200053, + 0.194398684107394, + null + ], + "dre|famhist_1|famhist_2|priorpsa|race": [ + 0.177719416857982, + 0.0669130470025224, + 0.827406162849404, + null, + -1.20411932800579, + null, + null, + null, + 0.11794944107713, + -1.34755797146447, + 0.0368681579334315, + 0.294161676952712, + null + ], + "dre|famhist_1|famhist_2|priorpsa": [ + 0.348718988472135, + 0.0663215584380211, + 0.830702237530172, + -0.373732717937292, + -1.20363350251476, + null, + null, + null, + 0.116710120117244, + -1.35724063632726, + 0.0334635191400961, + 0.240458234625309, + null + ], + "famhist_1|famhist_2|priorbiopsy|priorpsa|race": [ + 0.921326490187974, + 0.066193649244294, + 0.708545275377484, + null, + null, + 0.572105406184028, + null, + null, + 0.17703796367881, + -1.49257708285814, + -0.186778241291796, + 0.0592119884872795, + null + ], + "famhist_1|famhist_2|priorbiopsy|priorpsa": [ + 1.14607460374308, + 0.0655442561261347, + 0.714907438945551, + -0.422857417636299, + null, + 0.577488858363313, + null, + null, + 0.176087365789342, + -1.51095828875229, + -0.180925880558694, + -0.00465203266041546, + null + ], + "famhist_1|famhist_2|priorpsa|race": [ + -0.0225230976288465, + 0.0663615169123808, + 0.826177444696811, + null, + -1.14994357401767, + 0.634832373387714, + null, + null, + 0.172039051529295, + -1.34102553119335, + -0.0211575350887972, + 0.0962055800474354, + null + ], + "famhist_1|famhist_2|priorpsa": [ + 0.202819181575293, + 0.0656518766971159, + 0.832284790497886, + -0.428033501032651, + -1.14985074390485, + 0.640543450535441, + null, + null, + 0.169853720610881, + -1.35880333117169, + -0.0216176859846252, + 0.035095147906278, + null + ], + "dre|famhist_2|priorbiopsy|priorpsa|race": [ + 0.88333790316406, + 0.0666265020908286, + 0.733563887742268, + null, + null, + null, + 0.628243775262898, + null, + 0.0770602908735386, + -1.49511967636487, + -0.151335013425393, + 0.262384330869395, + null + ], + "dre|famhist_2|priorbiopsy|priorpsa": [ + 1.05515995554614, + 0.0659826207345416, + 0.736256694218362, + -0.366101373206023, + null, + null, + 0.626492983498395, + null, + 0.0781506783824869, + -1.50396386094711, + -0.150432968268462, + 0.207082697427189, + null + ], + "dre|famhist_2|priorpsa|race": [ + -0.120954624361121, + 0.0675832108227573, + 0.857042670439549, + null, + -1.1945498582758, + null, + 0.611851902808094, + null, + 0.0912720631335088, + -1.34007230296298, + 0.00964804765730901, + 0.309202068635174, + null + ], + "dre|famhist_2|priorpsa": [ + 0.0585388638749359, + 0.0668341407780983, + 0.860353538878286, + -0.377797446348486, + -1.19592590323014, + null, + 0.613331110100244, + null, + 0.0903304709958963, + -1.3492462317361, + 0.00489680978989855, + 0.255608752483266, + null + ], + "famhist_2|priorbiopsy|priorpsa|race": [ + 0.610240894556677, + 0.0671312421100924, + 0.739933102135915, + null, + null, + 0.574599400152925, + 0.6577514853367, + null, + 0.142960359903352, + -1.48833306699305, + -0.213535034753619, + 0.0774418288168552, + null + ], + "famhist_2|priorbiopsy|priorpsa": [ + 0.840225886563433, + 0.0663217717705562, + 0.745497654990927, + -0.416490400056019, + null, + 0.579227553917204, + 0.65464222032038, + null, + 0.143672345976635, + -1.5054109868607, + -0.209953013768913, + 0.014841484251998, + null + ], + "famhist_2|priorpsa|race": [ + -0.341870088358434, + 0.0672342907997495, + 0.859271838759838, + null, + -1.14786708972015, + 0.635783403330249, + 0.65426334366552, + null, + 0.13408364558431, + -1.33549559809445, + -0.0450951030540704, + 0.118483793498879, + null + ], + "famhist_2|priorpsa": [ + -0.106819930187794, + 0.066336851473342, + 0.86512236304005, + -0.42858797559795, + -1.14983829591453, + 0.640995493695524, + 0.654126704170772, + null, + 0.132897172716854, + -1.35250587827561, + -0.0470526397815098, + 0.0577225130858457, + null + ], + "dre|famhist_1|priorbiopsy|priorpsa|race": [ + 1.12815866292515, + 0.067013531235606, + 0.710577794006931, + null, + null, + null, + null, + 0.227106731317184, + 0.0997505386920303, + -1.51166549479466, + -0.121744416812614, + 0.239410565267698, + null + ], + "dre|famhist_1|priorbiopsy|priorpsa": [ + 1.27894907911866, + 0.0665670485638618, + 0.713633557311636, + -0.356504270269961, + null, + null, + null, + 0.231776645978844, + 0.100080134044042, + -1.51997130805154, + -0.118666133275046, + 0.185769255562199, + null + ], + "dre|famhist_1|priorpsa|race": [ + 0.0969292342829718, + 0.0683761363259926, + 0.834209349732333, + null, + -1.20974400407412, + null, + null, + 0.265521645725108, + 0.117039623363462, + -1.35739526572389, + 0.0401119342261768, + 0.283218622072217, + null + ], + "dre|famhist_1|priorpsa": [ + 0.251466476512894, + 0.0678428453906773, + 0.837327942761672, + -0.361603091030357, + -1.20939799390845, + null, + null, + 0.27093526352577, + 0.116054755800278, + -1.36550114391116, + 0.0370480812903969, + 0.232191747205689, + null + ], + "famhist_1|priorbiopsy|priorpsa|race": [ + 0.849996949860482, + 0.0677365884350138, + 0.716719704602841, + null, + null, + 0.585306944849478, + null, + 0.30423992879435, + 0.176018865921785, + -1.50737807422664, + -0.186394822958357, + 0.0420505258133868, + null + ], + "famhist_1|priorbiopsy|priorpsa": [ + 1.05547392776639, + 0.0671642323072172, + 0.722552743095445, + -0.408290430457039, + null, + 0.58923791344041, + null, + 0.309786742558825, + 0.174945962906132, + -1.52372801885395, + -0.180287840987496, + -0.018545093380464, + null + ], + "famhist_1|priorpsa|race": [ + -0.122354993290337, + 0.0682068651126385, + 0.835770696070832, + null, + -1.15774428229088, + 0.649292177684077, + null, + 0.333087742859037, + 0.172702558503544, + -1.35554332142629, + -0.0180718476181205, + 0.0791440779538779, + null + ], + "famhist_1|priorpsa": [ + 0.083829773406156, + 0.0675663468371714, + 0.841476128882436, + -0.413903729357852, + -1.15772798896451, + 0.653594296893493, + null, + 0.33959880022862, + 0.170445639675497, + -1.37129817860601, + -0.0182897446500187, + 0.0213410870399837, + null + ], + "dre|priorbiopsy|priorpsa|race": [ + 0.891872603717751, + 0.0672130060486466, + 0.737822807145542, + null, + null, + null, + 0.6305852804094, + 0.145109126783525, + 0.072828524345708, + -1.50710016772024, + -0.151039550036288, + 0.251950659493608, + null + ], + "dre|priorbiopsy|priorpsa": [ + 1.0468755783952, + 0.0666377814611863, + 0.740199802052943, + -0.348747442659819, + null, + null, + 0.627543639854253, + 0.150235770119404, + 0.0740494031939498, + -1.51444502277935, + -0.149770180793534, + 0.19959009128497, + null + ], + "dre|priorpsa|race": [ + -0.139285783979436, + 0.0684905844969796, + 0.862133047130202, + null, + -1.19786789145488, + null, + 0.610671104824923, + 0.182455502424422, + 0.0879004748376245, + -1.35199036187649, + 0.0112925059231469, + 0.298634533365482, + null + ], + "dre|priorpsa": [ + 0.0231483524867164, + 0.067811960067926, + 0.86510396164104, + -0.36112814823274, + -1.19920398446723, + null, + 0.610556492040816, + 0.18787495910869, + 0.0872321696750762, + -1.35962137117247, + 0.00701412049399917, + 0.247959022649707, + null + ], + "priorbiopsy|priorpsa|race": [ + 0.593045607746394, + 0.0681639968546635, + 0.746952744596887, + null, + null, + 0.589749759485667, + 0.657545960902857, + 0.224766286154652, + 0.139433702354703, + -1.50470379866924, + -0.214047118880049, + 0.0609724938084566, + null + ], + "priorbiopsy|priorpsa": [ + 0.803482989249021, + 0.0674389337518164, + 0.751918147028531, + -0.397940458224457, + null, + 0.593181026858948, + 0.653171323460348, + 0.231049288842524, + 0.140047102455069, + -1.51983016047583, + -0.210234518750532, + 0.00169498223509593, + null + ], + "priorpsa|race": [ + -0.381227651662557, + 0.0685276099249364, + 0.867284652246589, + null, + -1.15360730568864, + 0.651968612711219, + 0.651403933569126, + 0.25122597402863, + 0.131912967907407, + -1.35185268454303, + -0.043914411199687, + 0.101691461431155, + null + ], + "priorpsa": [ + -0.165738976498888, + 0.0677123545287475, + 0.872555116879179, + -0.410161633107585, + -1.15546409905488, + 0.655998445008607, + 0.649727432098411, + 0.258181544779391, + 0.130738258205504, + -1.36690633822195, + -0.0455569517669714, + 0.044241481971404, + null + ], + "ari_use|dre|famhist_1|famhist_2|famhist_bca|hispanic|priorbiopsy|prosvol|race": [ + -5.8743251132487, + 0.0546837318344316, + 0.537504485770582, + null, + null, + null, + null, + null, + null, + null, + null, + null, + 0.128588746375097 + ], + "ari_use|dre|famhist_1|famhist_2|famhist_bca|hispanic|priorbiopsy|prosvol": [ + -5.90825362412021, + 0.0535344606130374, + 0.532217414129048, + 0.0586893778622046, + null, + null, + null, + null, + null, + null, + null, + null, + 0.218036503261804 + ], + "ari_use|dre|famhist_1|famhist_2|famhist_bca|hispanic|prosvol|race": [ + -6.42841712176692, + 0.0593550854117875, + 0.670853085754064, + null, + -1.46258030947587, + null, + null, + null, + null, + null, + null, + null, + 0.343768359560066 + ], + "ari_use|dre|famhist_1|famhist_2|famhist_bca|hispanic|prosvol": [ + -6.44744024728219, + 0.0581121013618874, + 0.674397742209243, + 0.0234523943369053, + -1.48069124515216, + null, + null, + null, + null, + null, + null, + null, + 0.416667442601322 + ], + "ari_use|famhist_1|famhist_2|famhist_bca|hispanic|priorbiopsy|prosvol|race": [ + -5.03439441835852, + 0.0415527746045532, + 0.572478238773887, + null, + null, + 0.581278399964913, + null, + null, + null, + null, + null, + null, + -0.138978324682011 + ], + "ari_use|famhist_1|famhist_2|famhist_bca|hispanic|priorbiopsy|prosvol": [ + -4.95677334395533, + 0.041168666456487, + 0.561374077055178, + 0.0286302613287318, + null, + 0.532262850947182, + null, + null, + null, + null, + null, + null, + -0.176886194532138 + ], + "ari_use|famhist_1|famhist_2|famhist_bca|hispanic|prosvol|race": [ + -5.67603356819016, + 0.04695725122915, + 0.697228264279695, + null, + -1.40554709971715, + 0.616495338110006, + null, + null, + null, + null, + null, + null, + 0.11438330485459 + ], + "ari_use|famhist_1|famhist_2|famhist_bca|hispanic|prosvol": [ + -5.59241418365419, + 0.0462417376549008, + 0.695832724619934, + 0.00611546151287874, + -1.43739144756223, + 0.588544403347158, + null, + null, + null, + null, + null, + null, + 0.0761210604040863 + ], + "ari_use|dre|famhist_2|famhist_bca|hispanic|priorbiopsy|prosvol|race": [ + -6.30945131434248, + 0.0596607495884053, + 0.555394398988769, + null, + null, + null, + 0.381465095176087, + null, + null, + null, + null, + null, + 0.165036373776611 + ], + "ari_use|dre|famhist_2|famhist_bca|hispanic|priorbiopsy|prosvol": [ + -6.37441519086462, + 0.058831379911905, + 0.550207567965196, + 0.125822857672617, + null, + null, + 0.399715499265078, + null, + null, + null, + null, + null, + 0.249808090247696 + ], + "ari_use|dre|famhist_2|famhist_bca|hispanic|prosvol|race": [ + -6.88243241965705, + 0.0646176304823458, + 0.687817882702782, + null, + -1.41320035002887, + null, + 0.403819305763118, + null, + null, + null, + null, + null, + 0.360006813722863 + ], + "ari_use|dre|famhist_2|famhist_bca|hispanic|prosvol": [ + -6.95559969690179, + 0.0639802290960022, + 0.693626136683461, + 0.0771505497923858, + -1.43011705822432, + null, + 0.431507971444734, + null, + null, + null, + null, + null, + 0.426345999185405 + ], + "ari_use|famhist_2|famhist_bca|hispanic|priorbiopsy|prosvol|race": [ + -5.47932057567232, + 0.0448636561824283, + 0.606433578321868, + null, + null, + 0.791033837005446, + 0.48356704890506, + null, + null, + null, + null, + null, + -0.0823728139526149 + ], + "ari_use|famhist_2|famhist_bca|hispanic|priorbiopsy|prosvol": [ + -5.39218398575378, + 0.0440392449428035, + 0.599538096297985, + 0.085667611062901, + null, + 0.790861288016151, + 0.52136632504233, + null, + null, + null, + null, + null, + -0.144526247322944 + ], + "ari_use|famhist_2|famhist_bca|hispanic|prosvol|race": [ + -6.10429172507658, + 0.0505521985498749, + 0.721654941250178, + null, + -1.30371855391193, + 0.800853369801531, + 0.490567576242794, + null, + null, + null, + null, + null, + 0.134552219488342 + ], + "ari_use|famhist_2|famhist_bca|hispanic|prosvol": [ + -6.04423488045113, + 0.0497992042100021, + 0.726405308994919, + 0.051957907289448, + -1.33976585388572, + 0.822209595986718, + 0.536349592764467, + null, + null, + null, + null, + null, + 0.0745692598031587 + ], + "ari_use|dre|famhist_1|famhist_bca|hispanic|priorbiopsy|prosvol|race": [ + -4.93453645422782, + 0.0415774806390004, + 0.571324828147737, + null, + null, + null, + null, + 0.270414294734651, + null, + null, + null, + null, + -0.0756545072781306 + ], + "ari_use|dre|famhist_1|famhist_bca|hispanic|priorbiopsy|prosvol": [ + -4.96525875476075, + 0.041422965747369, + 0.57448268789125, + -0.0302797691370273, + null, + null, + null, + 0.27043300378121, + null, + null, + null, + null, + -0.0333523294024869 + ], + "ari_use|dre|famhist_1|famhist_bca|hispanic|prosvol|race": [ + -5.91713192057995, + 0.0509597135065125, + 0.747779259519327, + null, + -1.424318733579, + null, + null, + 0.312603924421617, + null, + null, + null, + null, + 0.143578244232362 + ], + "ari_use|dre|famhist_1|famhist_bca|hispanic|prosvol": [ + -5.89450126614414, + 0.049926556847985, + 0.755077472665945, + -0.100623511092116, + -1.42764471575744, + null, + null, + 0.310530146718702, + null, + null, + null, + null, + 0.201063292401852 + ], + "ari_use|famhist_1|famhist_bca|hispanic|priorbiopsy|prosvol|race": [ + -4.79508516415597, + 0.0361677055973785, + 0.600857072700774, + null, + null, + 0.587218816197642, + null, + 0.266072889045555, + null, + null, + null, + null, + -0.204038177118702 + ], + "ari_use|famhist_1|famhist_bca|hispanic|priorbiopsy|prosvol": [ + -4.92045438058139, + 0.0375558185222252, + 0.595604634846077, + 0.104050007000973, + null, + 0.59534686398878, + null, + 0.27184983169952, + null, + null, + null, + null, + -0.191260454350991 + ], + "ari_use|famhist_1|famhist_bca|hispanic|prosvol|race": [ + -5.75915085339748, + 0.044587900854689, + 0.779831842777879, + null, + -1.42879138925494, + 0.702225453728145, + null, + 0.312465589079572, + null, + null, + null, + null, + 0.00872317078654321 + ], + "ari_use|famhist_1|famhist_bca|hispanic|prosvol": [ + -5.83769899237304, + 0.0452923913165911, + 0.777398418255392, + 0.042321810716608, + -1.42361737050599, + 0.701573022162857, + null, + 0.315813398114073, + null, + null, + null, + null, + 0.0336311253511174 + ], + "ari_use|dre|famhist_bca|hispanic|priorbiopsy|prosvol|race": [ + -5.03145929862505, + 0.040664352568955, + 0.598268141594368, + null, + null, + null, + 0.575760478630939, + 0.163334499902051, + null, + null, + null, + null, + -0.0953573769329886 + ], + "ari_use|dre|famhist_bca|hispanic|priorbiopsy|prosvol": [ + -5.07272142176647, + 0.0406586832641254, + 0.600775527281823, + -0.0180897093118244, + null, + null, + 0.576132268188109, + 0.163897059313326, + null, + null, + null, + null, + -0.0543830185830066 + ], + "ari_use|dre|famhist_bca|hispanic|prosvol|race": [ + -5.99520626874941, + 0.0499592675936976, + 0.771918601765921, + null, + -1.41157214247687, + null, + 0.54243140253422, + 0.205901609118152, + null, + null, + null, + null, + 0.121702441487894 + ], + "ari_use|dre|famhist_bca|hispanic|prosvol": [ + -5.98100195675561, + 0.0490422587043509, + 0.778779410920251, + -0.0912725586912079, + -1.41489776387405, + null, + 0.541675881356022, + 0.204547535788041, + null, + null, + null, + null, + 0.178337539978058 + ], + "ari_use|famhist_bca|hispanic|priorbiopsy|prosvol|race": [ + -4.89274688354847, + 0.0351281025331394, + 0.629422245514815, + null, + null, + 0.592609373016665, + 0.602297291454288, + 0.155422987924138, + null, + null, + null, + null, + -0.224702961512946 + ], + "ari_use|famhist_bca|hispanic|priorbiopsy|prosvol": [ + -5.02977905207195, + 0.0366665913818097, + 0.62349308240183, + 0.116920036112441, + null, + 0.602487569329012, + 0.605923636567498, + 0.160868735273837, + null, + null, + null, + null, + -0.213904718206711 + ], + "ari_use|famhist_bca|hispanic|prosvol|race": [ + -5.83913128493283, + 0.0435614597760241, + 0.804327412522021, + null, + -1.41457924486332, + 0.703527876402048, + 0.569284925545301, + 0.202271088439878, + null, + null, + null, + null, + -0.0147580780629099 + ], + "ari_use|famhist_bca|hispanic|prosvol": [ + -5.92714879949165, + 0.0443865905915499, + 0.801341371894703, + 0.0521562145647133, + -1.40875356553888, + 0.704054497488794, + 0.57154755197205, + 0.205615616019126, + null, + null, + null, + null, + 0.00899756298532937 + ], + "ari_use|dre|famhist_1|famhist_2|hispanic|priorbiopsy|prosvol|race": [ + -4.84004967516113, + 0.0401599228448663, + 0.574261896187565, + null, + null, + null, + null, + null, + 0.146742383515896, + null, + null, + null, + -0.0759185316103017 + ], + "ari_use|dre|famhist_1|famhist_2|hispanic|priorbiopsy|prosvol": [ + -4.86917364078548, + 0.0399829414342877, + 0.577524173853256, + -0.032082940088359, + null, + null, + null, + null, + 0.145638379946722, + null, + null, + null, + -0.0333321003433083 + ], + "ari_use|dre|famhist_1|famhist_2|hispanic|prosvol|race": [ + -5.82542058454035, + 0.0494980125812681, + 0.754463633139192, + null, + -1.43610934502254, + null, + null, + null, + 0.152283799465804, + null, + null, + null, + 0.14456003643133 + ], + "ari_use|dre|famhist_1|famhist_2|hispanic|prosvol": [ + -5.80102914362293, + 0.0484421003247556, + 0.761909230922618, + -0.102766865441335, + -1.43943964422955, + null, + null, + null, + 0.147604425539239, + null, + null, + null, + 0.202448848085716 + ], + "ari_use|famhist_1|famhist_2|hispanic|priorbiopsy|prosvol|race": [ + -4.70879368360938, + 0.0348687804499956, + 0.604411173395364, + null, + null, + 0.584495813609407, + null, + null, + 0.167137609583196, + null, + null, + null, + -0.206998440985554 + ], + "ari_use|famhist_1|famhist_2|hispanic|priorbiopsy|prosvol": [ + -4.83018498650561, + 0.0362043876208222, + 0.599312603552774, + 0.100052920447998, + null, + 0.591924023491847, + null, + null, + 0.171297168881859, + null, + null, + null, + -0.193508609168664 + ], + "ari_use|famhist_1|famhist_2|hispanic|prosvol|race": [ + -5.67457996403933, + 0.0433027234371422, + 0.78619101112858, + null, + -1.43807023154417, + 0.698189050479212, + null, + null, + 0.152108083085607, + null, + null, + null, + 0.00773420745469781 + ], + "ari_use|famhist_1|famhist_2|hispanic|prosvol": [ + -5.74820742342126, + 0.0439377417671175, + 0.784037783367145, + 0.0369783292313304, + -1.43308561301794, + 0.696813505059438, + null, + null, + 0.154595460315609, + null, + null, + null, + 0.0335982581446556 + ], + "ari_use|dre|famhist_2|hispanic|priorbiopsy|prosvol|race": [ + -4.97196290544948, + 0.0396384985003279, + 0.602367450166769, + null, + null, + null, + 0.580500910195644, + null, + 0.107799545528648, + null, + null, + null, + -0.097280076156594 + ], + "ari_use|dre|famhist_2|hispanic|priorbiopsy|prosvol": [ + -5.01195943370828, + 0.0396143212586038, + 0.604954520438968, + -0.0194750737522835, + null, + null, + 0.58092597140181, + null, + 0.107328223110716, + null, + null, + null, + -0.0560404256074592 + ], + "ari_use|dre|famhist_2|hispanic|prosvol|race": [ + -5.94135846686586, + 0.0489044687183592, + 0.780328202872487, + null, + -1.4248829312843, + null, + 0.549750930840732, + null, + 0.109408741489471, + null, + null, + null, + 0.121607989021034 + ], + "ari_use|dre|famhist_2|hispanic|prosvol": [ + -5.92547858021883, + 0.0479631518235939, + 0.787341816373252, + -0.0934516736313907, + -1.42831468652615, + null, + 0.549142047629153, + null, + 0.105338185697503, + null, + null, + null, + 0.178794895562915 + ], + "ari_use|famhist_2|hispanic|priorbiopsy|prosvol|race": [ + -4.84320468623765, + 0.0342891505743516, + 0.633481560889898, + null, + null, + 0.587940512861502, + 0.603599135394607, + null, + 0.119313713058247, + null, + null, + null, + -0.228217291050824 + ], + "ari_use|famhist_2|hispanic|priorbiopsy|prosvol": [ + -4.97644451864101, + 0.0357770187934885, + 0.627716706430335, + 0.113046521679394, + null, + 0.597084849059771, + 0.607234247337504, + null, + 0.12317384710483, + null, + null, + null, + -0.216708848162872 + ], + "ari_use|famhist_2|hispanic|prosvol|race": [ + -5.79455935553669, + 0.0427333916100279, + 0.812159759404211, + null, + -1.42617830712735, + 0.698183585942984, + 0.574131964234599, + null, + 0.100210183754016, + null, + null, + null, + -0.0156128651938828 + ], + "ari_use|famhist_2|hispanic|prosvol": [ + -5.87763314082629, + 0.0434870219135779, + 0.809479664817419, + 0.0465560118562115, + -1.42056127995192, + 0.697894594842454, + 0.576359076337286, + null, + 0.10292289772482, + null, + null, + null, + 0.00923722126991522 + ], + "ari_use|dre|famhist_1|hispanic|priorbiopsy|prosvol|race": [ + -4.92709567083716, + 0.0409571690634319, + 0.578915620341553, + null, + null, + null, + null, + 0.272725760158624, + 0.139396396289148, + null, + null, + null, + -0.0808331572846893 + ], + "ari_use|dre|famhist_1|hispanic|priorbiopsy|prosvol": [ + -4.96132941086201, + 0.0408567661707932, + 0.581828598415107, + -0.026091717802112, + null, + null, + null, + 0.272941146199311, + 0.138564999979493, + null, + null, + null, + -0.03910868235896 + ], + "ari_use|dre|famhist_1|hispanic|prosvol|race": [ + -5.93510740405988, + 0.0505227808798221, + 0.760617643511725, + null, + -1.44242949242243, + null, + null, + 0.316450263021233, + 0.144742098210417, + null, + null, + null, + 0.140413945206056 + ], + "ari_use|dre|famhist_1|hispanic|prosvol": [ + -5.91618497636083, + 0.049551378815585, + 0.767599054291692, + -0.0964919907594837, + -1.44550139971297, + null, + null, + 0.314665621798105, + 0.140473433959789, + null, + null, + null, + 0.197543385313684 + ], + "ari_use|famhist_1|hispanic|priorbiopsy|prosvol|race": [ + -4.79169111708919, + 0.035618225976336, + 0.608626422507244, + null, + null, + 0.583354342417816, + null, + 0.268757876593791, + 0.159456661591191, + null, + null, + null, + -0.211024859533604 + ], + "ari_use|famhist_1|hispanic|priorbiopsy|prosvol": [ + -4.91977489890097, + 0.0370394519084898, + 0.603175356036363, + 0.107849800206762, + null, + 0.591930878930388, + null, + 0.274590395414277, + 0.163753359648143, + null, + null, + null, + -0.199034650848172 + ], + "ari_use|famhist_1|hispanic|prosvol|race": [ + -5.78023470466992, + 0.0442235570902641, + 0.792790600771565, + null, + -1.44652738043425, + 0.69974277702524, + null, + 0.317866832782986, + 0.144343423207601, + null, + null, + null, + 0.00439014177804528 + ], + "ari_use|famhist_1|hispanic|prosvol": [ + -5.86155224600241, + 0.0449627992941908, + 0.790174112946865, + 0.0457451207850449, + -1.44122253970355, + 0.699474479053231, + null, + 0.321287646745772, + 0.147149793588008, + null, + null, + null, + 0.0286862738443989 + ], + "ari_use|dre|hispanic|priorbiopsy|prosvol|race": [ + -5.01855117740343, + 0.0400911440317867, + 0.604389131834911, + null, + null, + null, + 0.565571966161502, + 0.169203578581154, + 0.103919268668887, + null, + null, + null, + -0.099351476761849 + ], + "ari_use|dre|hispanic|priorbiopsy|prosvol": [ + -5.06232728682422, + 0.0401238267301455, + 0.606726900199754, + -0.0150883747012949, + null, + null, + 0.566037034821529, + 0.169873844574342, + 0.103608865860981, + null, + null, + null, + -0.0588294683789289 + ], + "ari_use|dre|hispanic|prosvol|race": [ + -6.00698909660894, + 0.0495649825714423, + 0.783314370418501, + null, + -1.42928772270907, + null, + 0.529502430622035, + 0.213938568081164, + 0.105635966453325, + null, + null, + null, + 0.119935726475065 + ], + "ari_use|dre|hispanic|prosvol": [ + -5.99537351197025, + 0.0486910673322034, + 0.789963062133411, + -0.0884811335900404, + -1.43243679372086, + null, + 0.52901289288328, + 0.212742754649398, + 0.101869377081125, + null, + null, + null, + 0.176367336858883 + ], + "ari_use|hispanic|priorbiopsy|prosvol|race": [ + -4.88317795741781, + 0.0346420498860114, + 0.63528949910113, + null, + null, + 0.588810218218193, + 0.590553979595816, + 0.162364368163218, + 0.115075439369627, + null, + null, + null, + -0.229818857543849 + ], + "ari_use|hispanic|priorbiopsy|prosvol": [ + -5.02164610159928, + 0.0361977752186856, + 0.629237057268756, + 0.119219309561906, + null, + 0.59892826219054, + 0.593973230474844, + 0.167824216155587, + 0.118970215538558, + null, + null, + null, + -0.219600693955802 + ], + "ari_use|hispanic|prosvol|race": [ + -5.85345361642034, + 0.0432516324481202, + 0.815529427663458, + null, + -1.43227391889629, + 0.700994966832489, + 0.555428118831042, + 0.212389361138227, + 0.0960373475297101, + null, + null, + null, + -0.016876084103441 + ], + "ari_use|hispanic|prosvol": [ + -5.94292640147295, + 0.0440938752338486, + 0.812449132100475, + 0.0539928403460314, + -1.42636342273931, + 0.701696543026827, + 0.557554770377663, + 0.215763311062931, + 0.0989999442237085, + null, + null, + null, + 0.0064859170373709 + ], + "ari_use|dre|famhist_1|famhist_2|famhist_bca|hispanic|priorbiopsy|race": [ + -0.589498309620244, + 0.0717358892406106, + 0.77488697751551, + null, + null, + null, + null, + null, + null, + -1.26899152174323, + null, + null, + 0.0559722184607469 + ], + "ari_use|dre|famhist_1|famhist_2|famhist_bca|hispanic|priorbiopsy": [ + -0.140285179139559, + 0.0700013908759813, + 0.780149618874316, + -0.15378564659256, + null, + null, + null, + null, + null, + -1.32735745948861, + null, + null, + 0.0392244751306427 + ], + "ari_use|dre|famhist_1|famhist_2|famhist_bca|hispanic|race": [ + -1.47303347511395, + 0.0747596054206672, + 0.855195397733276, + null, + -1.19721651837281, + null, + null, + null, + null, + -1.16828126991347, + null, + null, + 0.226845404300127 + ], + "ari_use|dre|famhist_1|famhist_2|famhist_bca|hispanic": [ + -1.06728853326349, + 0.0731249442402859, + 0.859826134615345, + -0.164179732704144, + -1.18594691777485, + null, + null, + null, + null, + -1.21715971288902, + null, + null, + 0.200919747803429 + ], + "ari_use|famhist_1|famhist_2|famhist_bca|hispanic|priorbiopsy|race": [ + -0.188613437661031, + 0.0622792524865182, + 0.804529739998439, + null, + null, + 0.583774977353393, + null, + null, + null, + -1.26358847425544, + null, + null, + -0.0795251793233923 + ], + "ari_use|famhist_1|famhist_2|famhist_bca|hispanic|priorbiopsy": [ + 0.287781256431353, + 0.0623536895612823, + 0.798143245269256, + -0.165436063296424, + null, + 0.527346922801719, + null, + null, + null, + -1.32223156380981, + null, + null, + -0.19095851224343 + ], + "ari_use|famhist_1|famhist_2|famhist_bca|hispanic|race": [ + -1.0996902380779, + 0.0653507455678114, + 0.880425926874375, + null, + -1.15232353554947, + 0.607791420632519, + null, + null, + null, + -1.16050184074286, + null, + null, + 0.105280235134688 + ], + "ari_use|famhist_1|famhist_2|famhist_bca|hispanic": [ + -0.678686022252962, + 0.0653032462095797, + 0.874284841589029, + -0.164030162660427, + -1.15031436145532, + 0.569132921977782, + null, + null, + null, + -1.20788663479653, + null, + null, + -0.00703520820250432 + ], + "ari_use|dre|famhist_2|famhist_bca|hispanic|priorbiopsy|race": [ + -1.15143910588845, + 0.0772569797027502, + 0.788831669979876, + null, + null, + null, + 0.48409560981035, + null, + null, + -1.2523377626331, + null, + null, + 0.0979656590523028 + ], + "ari_use|dre|famhist_2|famhist_bca|hispanic|priorbiopsy": [ + -0.713462951365742, + 0.0761717790579239, + 0.79784707144146, + -0.0879702373566334, + null, + null, + 0.527746010891244, + null, + null, + -1.32074252029008, + null, + null, + 0.079258524814507 + ], + "ari_use|dre|famhist_2|famhist_bca|hispanic|race": [ + -2.00967142516245, + 0.0798103145311923, + 0.873420945408866, + null, + -1.13893446896805, + null, + 0.489559222613913, + null, + null, + -1.15267699084443, + null, + null, + 0.246995207670459 + ], + "ari_use|dre|famhist_2|famhist_bca|hispanic": [ + -1.63820865037539, + 0.0789403848768903, + 0.882972083800265, + -0.106889739390695, + -1.11718838242236, + null, + 0.538218305479016, + null, + null, + -1.20902631626594, + null, + null, + 0.215004340706572 + ], + "ari_use|famhist_2|famhist_bca|hispanic|priorbiopsy|race": [ + -0.756307081633721, + 0.0667772172581493, + 0.828415574295435, + null, + null, + 0.776166873527389, + 0.515313236542657, + null, + null, + -1.24939629619669, + null, + null, + -0.0225868940406061 + ], + "ari_use|famhist_2|famhist_bca|hispanic|priorbiopsy": [ + -0.266915601535977, + 0.0672014362224215, + 0.82841332665801, + -0.0943140024778003, + null, + 0.772431096657123, + 0.572777585484005, + null, + null, + -1.32079184795522, + null, + null, + -0.152925505668523 + ], + "ari_use|famhist_2|famhist_bca|hispanic|race": [ + -1.59769024293145, + 0.069495488537557, + 0.903971167894241, + null, + -1.04551753192538, + 0.77917985024659, + 0.515240467858051, + null, + null, + -1.15361740948071, + null, + null, + 0.130846412467067 + ], + "ari_use|famhist_2|famhist_bca|hispanic": [ + -1.19133097814372, + 0.0699579363483876, + 0.90568117830662, + -0.100770458995335, + -1.03483971350523, + 0.793695441861143, + 0.576978684965282, + null, + null, + -1.21148665839643, + null, + null, + -0.000968107272134316 + ], + "ari_use|dre|famhist_1|famhist_bca|hispanic|priorbiopsy|race": [ + -0.136039425159744, + 0.0719122669160994, + 0.78070749464336, + null, + null, + null, + null, + 0.36024524145932, + null, + -1.34554598728176, + null, + null, + -0.100854413103443 + ], + "ari_use|dre|famhist_1|famhist_bca|hispanic|priorbiopsy": [ + 0.00376860981679496, + 0.0698308811889943, + 0.795445447976739, + -0.197439825743893, + null, + null, + null, + 0.353477741722301, + null, + -1.35652197662919, + null, + null, + -0.0236753723085173 + ], + "ari_use|dre|famhist_1|famhist_bca|hispanic|race": [ + -1.28558411613427, + 0.0754502250131679, + 0.88492188150219, + null, + -1.01760669403236, + null, + null, + 0.389184650737964, + null, + -1.21395806490938, + null, + null, + 0.0485932562605492 + ], + "ari_use|dre|famhist_1|famhist_bca|hispanic": [ + -1.12003645888177, + 0.0730061191324206, + 0.901929973623887, + -0.225084951822507, + -1.02421974846648, + null, + null, + 0.381399939484457, + null, + -1.22576628531556, + null, + null, + 0.131949822595138 + ], + "ari_use|famhist_1|famhist_bca|hispanic|priorbiopsy|race": [ + 0.0102842029985304, + 0.0666204723400423, + 0.819664469057129, + null, + null, + 0.702571503566521, + null, + 0.374461318687927, + null, + -1.36367940456585, + null, + null, + -0.229853883992973 + ], + "ari_use|famhist_1|famhist_bca|hispanic|priorbiopsy": [ + 0.0177387808193411, + 0.0661731652088137, + 0.824282901608698, + -0.0645130916457283, + null, + 0.687293866594805, + null, + 0.37322933979985, + null, + -1.36589764808368, + null, + null, + -0.181112220253721 + ], + "ari_use|famhist_1|famhist_bca|hispanic|race": [ + -1.13139101690617, + 0.0694404920558589, + 0.92386878068633, + null, + -1.0036582956182, + 0.765322507995381, + null, + 0.399343103604634, + null, + -1.22898544736311, + null, + null, + -0.0798150086443155 + ], + "ari_use|famhist_1|famhist_bca|hispanic": [ + -1.10222275666244, + 0.0687765980784401, + 0.929615009205696, + -0.0837805863888828, + -1.00382323178566, + 0.747783340634747, + null, + 0.397449816298183, + null, + -1.23268141152756, + null, + null, + -0.0268757619109744 + ], + "ari_use|dre|famhist_bca|hispanic|priorbiopsy|race": [ + -0.220668037694302, + 0.0710501729825849, + 0.809960425527527, + null, + null, + null, + 0.599245879541938, + 0.249985978770777, + null, + -1.34962329717132, + null, + null, + -0.126624664416448 + ], + "ari_use|dre|famhist_bca|hispanic|priorbiopsy": [ + -0.101517755123704, + 0.0691588705714348, + 0.822975201376747, + -0.177515942326149, + null, + null, + 0.592876745814424, + 0.245140933501976, + null, + -1.35884099059923, + null, + null, + -0.0509845542953551 + ], + "ari_use|dre|famhist_bca|hispanic|race": [ + -1.35411847832652, + 0.074526493072401, + 0.911668078283181, + null, + -0.999082645635894, + null, + 0.571006057716034, + 0.279237850750638, + null, + -1.21820260981964, + null, + null, + 0.0205523359897842 + ], + "ari_use|dre|famhist_bca|hispanic": [ + -1.20544637454997, + 0.0722249722553296, + 0.927450845130134, + -0.209232225912563, + -1.00656430902653, + null, + 0.564781969481482, + 0.273146382043978, + null, + -1.22854516472471, + null, + null, + 0.103247868761519 + ], + "ari_use|famhist_bca|hispanic|priorbiopsy|race": [ + -0.0754322692257945, + 0.0657415254271144, + 0.850008829833925, + null, + null, + 0.704573917589814, + 0.618082050681199, + 0.264210372350482, + null, + -1.36858187994632, + null, + null, + -0.254108296112474 + ], + "ari_use|famhist_bca|hispanic|priorbiopsy": [ + -0.0902224356957475, + 0.0654985039637191, + 0.853197657446926, + -0.044297243299452, + null, + 0.691934315660975, + 0.617014292289369, + 0.264018383986258, + null, + -1.36933156630485, + null, + null, + -0.207968334839812 + ], + "ari_use|famhist_bca|hispanic|race": [ + -1.19746955550927, + 0.0685129295936887, + 0.951471496967603, + null, + -0.986492557694448, + 0.765318940914132, + 0.593639836261109, + 0.289079204537209, + null, + -1.23465865484261, + null, + null, + -0.106791832535045 + ], + "ari_use|famhist_bca|hispanic": [ + -1.18663393922176, + 0.0680110098367819, + 0.956103213771431, + -0.0675341547248076, + -0.986764311028279, + 0.749807222657342, + 0.592245361813912, + 0.288057949490396, + null, + -1.23715167845123, + null, + null, + -0.0554243891947773 + ], + "ari_use|dre|famhist_1|famhist_2|hispanic|priorbiopsy|race": [ + -0.0493832443037956, + 0.0702721117365936, + 0.785894690358758, + null, + null, + null, + null, + null, + 0.196031610057824, + -1.34159824161047, + null, + null, + -0.105357309390806 + ], + "ari_use|dre|famhist_1|famhist_2|hispanic|priorbiopsy": [ + 0.0965209212873283, + 0.0681476805128218, + 0.801202088005874, + -0.202710396885535, + null, + null, + null, + null, + 0.187141698197478, + -1.35327375384648, + null, + null, + -0.0270337791206971 + ], + "ari_use|dre|famhist_1|famhist_2|hispanic|race": [ + -1.20743822474259, + 0.0737613763429706, + 0.892662534520576, + null, + -1.03146029793035, + null, + null, + null, + 0.193497712631821, + -1.20837504317924, + null, + null, + 0.0457564112124754 + ], + "ari_use|dre|famhist_1|famhist_2|hispanic": [ + -1.03527982112614, + 0.0712779929789703, + 0.910240334867684, + -0.230665721634367, + -1.03814712284994, + null, + null, + null, + 0.181753282910338, + -1.22095457124412, + null, + null, + 0.130303106307855 + ], + "ari_use|famhist_1|famhist_2|hispanic|priorbiopsy|race": [ + 0.0874228460976755, + 0.0650814598845918, + 0.825201954169369, + null, + null, + 0.694984076612866, + null, + null, + 0.238826011758092, + -1.35881116116999, + null, + null, + -0.237452444906516 + ], + "ari_use|famhist_1|famhist_2|hispanic|priorbiopsy": [ + 0.103118409952055, + 0.0645535988837876, + 0.830433909731878, + -0.0721545241240719, + null, + 0.678799765437553, + null, + null, + 0.235804182541293, + -1.36162921309283, + null, + null, + -0.187025844042277 + ], + "ari_use|famhist_1|famhist_2|hispanic|race": [ + -1.06359938817904, + 0.0679066769586914, + 0.931149760792223, + null, + -1.01393446502443, + 0.757569580826805, + null, + null, + 0.217677019241402, + -1.22244472248903, + null, + null, + -0.0848953104423404 + ], + "ari_use|famhist_1|famhist_2|hispanic": [ + -1.02540859745741, + 0.0671556486178329, + 0.937566957959596, + -0.0922349083941112, + -1.01432661785191, + 0.739032559504359, + null, + null, + 0.213537522967782, + -1.22678097507304, + null, + null, + -0.0301261301824064 + ], + "ari_use|dre|famhist_2|hispanic|priorbiopsy|race": [ + -0.173887147098815, + 0.069822545570153, + 0.816008788111681, + null, + null, + null, + 0.600173265634577, + null, + 0.146158400719703, + -1.34468763139838, + null, + null, + -0.131469650803273 + ], + "ari_use|dre|famhist_2|hispanic|priorbiopsy": [ + -0.0484365160856482, + 0.0678832685439332, + 0.829574885711636, + -0.183178757074142, + null, + null, + 0.593853387907692, + null, + 0.138813320600189, + -1.35459444005393, + null, + null, + -0.0545355217052089 + ], + "ari_use|dre|famhist_2|hispanic|race": [ + -1.31884827858454, + 0.0732703913530819, + 0.920794975449994, + null, + -1.01496754877844, + null, + 0.574949997208919, + null, + 0.143036228756655, + -1.21167395206746, + null, + null, + 0.0178605886784956 + ], + "ari_use|dre|famhist_2|hispanic": [ + -1.1632299130493, + 0.0709204701521079, + 0.937202434649701, + -0.215527758077639, + -1.02271113588412, + null, + 0.568978731081808, + null, + 0.132454505547361, + -1.22280123237439, + null, + null, + 0.10204824454134 + ], + "ari_use|famhist_2|hispanic|priorbiopsy|race": [ + -0.0385485820015316, + 0.0646446908908373, + 0.85544455467788, + null, + null, + 0.694488859915247, + 0.614356115471881, + null, + 0.180852096210468, + -1.36227533047517, + null, + null, + -0.260537541390079 + ], + "ari_use|famhist_2|hispanic|priorbiopsy": [ + -0.0442096765313101, + 0.0643092075865514, + 0.85928169873918, + -0.0529089910615043, + null, + 0.680764933453433, + 0.613163166054546, + null, + 0.178985492546434, + -1.3636580653027, + null, + null, + -0.212393918356176 + ], + "ari_use|famhist_2|hispanic|race": [ + -1.17368243893732, + 0.0674307666455511, + 0.959387017543036, + null, + -0.999547307890339, + 0.755506945625686, + 0.593573565756081, + null, + 0.159022812746368, + -1.22662623711733, + null, + null, + -0.11012189628563 + ], + "ari_use|famhist_2|hispanic": [ + -1.15282181272443, + 0.0668270704201786, + 0.964772407869679, + -0.0771833330320651, + -1.00016969483253, + 0.738814142113733, + 0.592144556286031, + null, + 0.155867729643313, + -1.22981270414383, + null, + null, + -0.0565036243689492 + ], + "ari_use|dre|famhist_1|hispanic|priorbiopsy|race": [ + -0.121171516278702, + 0.071464806754145, + 0.793820587497601, + null, + null, + null, + null, + 0.364994486632807, + 0.19038317928566, + -1.35257219611913, + null, + null, + -0.107868851749396 + ], + "ari_use|dre|famhist_1|hispanic|priorbiopsy": [ + 0.0120263252676687, + 0.0694612026612265, + 0.808117729375976, + -0.192379872577079, + null, + null, + null, + 0.358480792349294, + 0.181914475153512, + -1.36311913503784, + null, + null, + -0.030840221394277 + ], + "ari_use|dre|famhist_1|hispanic|race": [ + -1.29745913883272, + 0.0751368919741957, + 0.902108440440433, + null, + -1.03840756601527, + null, + null, + 0.396154748817547, + 0.189428809274352, + -1.2194019212071, + null, + null, + 0.0440412215860162 + ], + "ari_use|dre|famhist_1|hispanic": [ + -1.13829918592448, + 0.0727758342035053, + 0.918557129739535, + -0.220031061985852, + -1.04473895602208, + null, + null, + 0.38854634927225, + 0.178043491922906, + -1.23081164319391, + null, + null, + 0.12743051674613 + ], + "ari_use|famhist_1|hispanic|priorbiopsy|race": [ + 0.0258697817140853, + 0.0662764041070667, + 0.834446757226182, + null, + null, + 0.699726608996479, + null, + 0.380609597058274, + 0.233545364741264, + -1.37302579332997, + null, + null, + -0.240285881964915 + ], + "ari_use|famhist_1|hispanic|priorbiopsy": [ + 0.0268956137956185, + 0.0658910251243824, + 0.83866050583028, + -0.0589840637328619, + null, + 0.685217161740991, + null, + 0.379562466856094, + 0.231052754309438, + -1.37479748791271, + null, + null, + -0.192290385702295 + ], + "ari_use|famhist_1|hispanic|race": [ + -1.14158035667862, + 0.0692212693987019, + 0.942240314726623, + null, + -1.0223050899205, + 0.763331385915862, + null, + 0.408543287484337, + 0.214609900484435, + -1.23647462638388, + null, + null, + -0.0869390119422864 + ], + "ari_use|famhist_1|hispanic": [ + -1.11875423441978, + 0.0686206593436407, + 0.947561016240197, + -0.0784518028518165, + -1.02235679154516, + 0.746528160225768, + null, + 0.406779766901924, + 0.211019856360261, + -1.23974005879604, + null, + null, + -0.0345926974371145 + ], + "ari_use|dre|hispanic|priorbiopsy|race": [ + -0.202451309012082, + 0.0706210635126467, + 0.821049104967211, + null, + null, + null, + 0.583422218247863, + 0.25878039285719, + 0.142305452796608, + -1.35526568407935, + null, + null, + -0.131894475538475 + ], + "ari_use|dre|hispanic|priorbiopsy": [ + -0.087849663735946, + 0.0687861958321565, + 0.833785119926116, + -0.174214662492959, + null, + null, + 0.577569020790759, + 0.254021332093681, + 0.135276136947746, + -1.36423554217145, + null, + null, + -0.0562771654668443 + ], + "ari_use|dre|hispanic|race": [ + -1.36215232076357, + 0.0742400371050547, + 0.926843008683823, + null, + -1.01991702458572, + null, + 0.553617234457502, + 0.290437082392954, + 0.140772926445465, + -1.22244454029993, + null, + null, + 0.0179533420008539 + ], + "ari_use|dre|hispanic": [ + -1.21765418011294, + 0.0719966730860499, + 0.942278077377549, + -0.206144566969416, + -1.02720379036468, + null, + 0.548104662988153, + 0.284343805360588, + 0.130519408732148, + -1.23257108585413, + null, + null, + 0.100836135592794 + ], + "ari_use|hispanic|priorbiopsy|race": [ + -0.0567916020243032, + 0.0654166800981853, + 0.862012104242387, + null, + null, + 0.701376381129029, + 0.598458947201494, + 0.275204278908666, + 0.177199222519528, + -1.37591680211476, + null, + null, + -0.262132555200502 + ], + "ari_use|hispanic|priorbiopsy": [ + -0.0754582040459074, + 0.0652116031925308, + 0.864978136040013, + -0.0410367343196272, + null, + 0.689193006764536, + 0.59763628325688, + 0.275066666505819, + 0.175753128605695, + -1.37642131742383, + null, + null, + -0.216439068823495 + ], + "ari_use|hispanic|race": [ + -1.20462609269237, + 0.0683182793767989, + 0.967237321209893, + null, + -1.00582108100142, + 0.763122760322144, + 0.573485774059273, + 0.303077672354324, + 0.157456049375064, + -1.24029919231113, + null, + null, + -0.111247882240969 + ], + "ari_use|hispanic": [ + -1.19745161708667, + 0.067853388659799, + 0.97164298491333, + -0.0645968681043389, + -1.00601772832544, + 0.74802675974987, + 0.572406014486372, + 0.30206129816542, + 0.154798070057157, + -1.24256316799389, + null, + null, + -0.0601549420166437 + ], + "dre|famhist_1|famhist_2|famhist_bca|hispanic|priorbiopsy|prosvol|race": [ + -5.91224152656699, + 0.0573857178250387, + 0.503956500072055, + null, + null, + null, + null, + null, + null, + null, + -0.31713363896292, + null, + 0.12155779349892 + ], + "dre|famhist_1|famhist_2|famhist_bca|hispanic|priorbiopsy|prosvol": [ + -5.93107387336293, + 0.05622058139803, + 0.49577862182749, + -0.0213645361639918, + null, + null, + null, + null, + null, + null, + -0.340179938621421, + null, + 0.211991215198353 + ], + "dre|famhist_1|famhist_2|famhist_bca|hispanic|prosvol|race": [ + -6.3712603496501, + 0.0606198126869612, + 0.637133008938853, + null, + -1.50377613154773, + null, + null, + null, + null, + null, + -0.101643457000612, + null, + 0.331067365831121 + ], + "dre|famhist_1|famhist_2|famhist_bca|hispanic|prosvol": [ + -6.3685502863871, + 0.0592977772929973, + 0.63848772975679, + -0.0334393986821227, + -1.52241081765576, + null, + null, + null, + null, + null, + -0.12946938391666, + null, + 0.402025903722403 + ], + "famhist_1|famhist_2|famhist_bca|hispanic|priorbiopsy|prosvol|race": [ + -5.00258715773575, + 0.0444492979427236, + 0.532988609103468, + null, + null, + 0.549410459870289, + null, + null, + null, + null, + -0.37712476774536, + null, + -0.19807397829702 + ], + "famhist_1|famhist_2|famhist_bca|hispanic|priorbiopsy|prosvol": [ + -4.86043055242569, + 0.0438513224957155, + 0.518483516878484, + -0.111927135276931, + null, + 0.502030428504403, + null, + null, + null, + null, + -0.400519085472444, + null, + -0.265329204265264 + ], + "famhist_1|famhist_2|famhist_bca|hispanic|prosvol|race": [ + -5.55088346862669, + 0.048413395855021, + 0.656962394295814, + null, + -1.44851423262101, + 0.577828127487096, + null, + null, + null, + null, + -0.162249588120269, + null, + 0.054555365046924 + ], + "famhist_1|famhist_2|famhist_bca|hispanic|prosvol": [ + -5.39629017729673, + 0.0473879178009965, + 0.653045956745283, + -0.110581198922684, + -1.48259349094109, + 0.549141136286675, + null, + null, + null, + null, + -0.194247010642256, + null, + -0.0116103225729585 + ], + "dre|famhist_2|famhist_bca|hispanic|priorbiopsy|prosvol|race": [ + -6.39113888540949, + 0.0632975307837587, + 0.513153970922374, + null, + null, + null, + 0.383210158058344, + null, + null, + null, + -0.238734915829433, + null, + 0.166642605227424 + ], + "dre|famhist_2|famhist_bca|hispanic|priorbiopsy|prosvol": [ + -6.43563855570419, + 0.0621950525437299, + 0.505651579742319, + 0.116692421397569, + null, + null, + 0.407684211502432, + null, + null, + null, + -0.243106757502421, + null, + 0.258139875440089 + ], + "dre|famhist_2|famhist_bca|hispanic|prosvol|race": [ + -6.8802797667761, + 0.0669275029784614, + 0.646057401704264, + null, + -1.45592035969505, + null, + 0.409948434296996, + null, + null, + null, + -0.0143668270531546, + null, + 0.356099006693026 + ], + "dre|famhist_2|famhist_bca|hispanic|prosvol": [ + -6.93380444021338, + 0.0660611967723868, + 0.65085575574462, + 0.0690255739639566, + -1.48258523565893, + null, + 0.443931473225402, + null, + null, + null, + -0.0208847161792816, + null, + 0.426879998743606 + ], + "famhist_2|famhist_bca|hispanic|priorbiopsy|prosvol|race": [ + -5.51271717523031, + 0.0489421403755114, + 0.556989034542106, + null, + null, + 0.790035938986138, + 0.494850005486646, + null, + null, + null, + -0.38580021638035, + null, + -0.127489928229059 + ], + "famhist_2|famhist_bca|hispanic|priorbiopsy|prosvol": [ + -5.34529582633427, + 0.0475695347499573, + 0.547260985464704, + 0.00546325576689928, + null, + 0.793506397646241, + 0.545756178357034, + null, + null, + null, + -0.411908033671878, + null, + -0.219583070811389 + ], + "famhist_2|famhist_bca|hispanic|prosvol|race": [ + -6.04945669504922, + 0.0532798277953911, + 0.670856702035643, + null, + -1.33547211831082, + 0.787000378342542, + 0.508336640044051, + null, + null, + null, + -0.150759126469025, + null, + 0.0836705797224148 + ], + "famhist_2|famhist_bca|hispanic|prosvol": [ + -5.91311322934102, + 0.0520042638029393, + 0.674494592565857, + -0.0337784996349589, + -1.38622297279191, + 0.810434112303759, + 0.566897189039409, + null, + null, + null, + -0.176819311382261, + null, + -0.00264778235417023 + ], + "dre|famhist_1|famhist_bca|hispanic|priorbiopsy|prosvol|race": [ + -4.63511797334658, + 0.0467412978851622, + 0.431741320318091, + null, + null, + null, + null, + 0.224850338911085, + null, + null, + -0.278883225102689, + null, + -0.266358941840433 + ], + "dre|famhist_1|famhist_bca|hispanic|priorbiopsy|prosvol": [ + -4.65831361324005, + 0.0462225625484774, + 0.437682368021117, + -0.220177924731399, + null, + null, + null, + 0.229258230445417, + null, + null, + -0.271459061057254, + null, + -0.194216969670644 + ], + "dre|famhist_1|famhist_bca|hispanic|prosvol|race": [ + -5.32961312756935, + 0.0515384098230929, + 0.625633876004171, + null, + -1.57415596404245, + null, + null, + 0.258357679917965, + null, + null, + -0.02142163870026, + null, + -0.0402767457128314 + ], + "dre|famhist_1|famhist_bca|hispanic|prosvol": [ + -5.33971511336927, + 0.0507719121175749, + 0.633535610858876, + -0.29581480002346, + -1.57981207278301, + null, + null, + 0.26577459628128, + null, + null, + -0.0223745578573713, + null, + 0.0427658788254224 + ], + "famhist_1|famhist_bca|hispanic|priorbiopsy|prosvol|race": [ + -4.67316845335419, + 0.0463606669375094, + 0.438536065252926, + null, + null, + 0.470124870231449, + null, + 0.268703140638402, + null, + null, + -0.336632316905016, + null, + -0.485839417779106 + ], + "famhist_1|famhist_bca|hispanic|priorbiopsy|prosvol": [ + -4.70936694198881, + 0.0461890639577417, + 0.442432902875749, + -0.141984152374984, + null, + 0.455067465785959, + null, + 0.27220944683506, + null, + null, + -0.327882360871939, + null, + -0.421070844263773 + ], + "famhist_1|famhist_bca|hispanic|prosvol|race": [ + -5.29125403120645, + 0.0496288294497154, + 0.629008437451497, + null, + -1.54513262027065, + 0.586764132034665, + null, + 0.299809809931441, + null, + null, + -0.0661106933578977, + null, + -0.287990073808462 + ], + "famhist_1|famhist_bca|hispanic|prosvol": [ + -5.30970870152637, + 0.0491421544805584, + 0.63510926565096, + -0.230770833072945, + -1.54974085724869, + 0.571699851699928, + null, + 0.305807118301378, + null, + null, + -0.0640517472975228, + null, + -0.211677763345138 + ], + "dre|famhist_bca|hispanic|priorbiopsy|prosvol|race": [ + -4.8363610068825, + 0.0472253343572105, + 0.458723288798726, + null, + null, + null, + 0.653002879153563, + 0.132160608105985, + null, + null, + -0.307438995484728, + null, + -0.292952042879339 + ], + "dre|famhist_bca|hispanic|priorbiopsy|prosvol": [ + -4.85766657558053, + 0.0466359132073721, + 0.465224773873654, + -0.230457589855196, + null, + null, + 0.65700957428574, + 0.136863587262325, + null, + null, + -0.301250494540747, + null, + -0.218709729448411 + ], + "dre|famhist_bca|hispanic|prosvol|race": [ + -5.52313024393624, + 0.0520308219648122, + 0.651065427138219, + null, + -1.5626657763306, + null, + 0.620214248753411, + 0.165940739927605, + null, + null, + -0.0485398584167625, + null, + -0.0679105569919605 + ], + "dre|famhist_bca|hispanic|prosvol": [ + -5.5301024738302, + 0.0511530663607501, + 0.659987269850219, + -0.309317366325361, + -1.56988631124711, + null, + 0.626922985675997, + 0.173445106151802, + null, + null, + -0.0501599185983718, + null, + 0.0176017743835738 + ], + "famhist_bca|hispanic|priorbiopsy|prosvol|race": [ + -4.89766583097148, + 0.0470449423200438, + 0.467609647411584, + null, + null, + 0.483366391714361, + 0.692276481557513, + 0.178228353228128, + null, + null, + -0.365511677720089, + null, + -0.521314215747045 + ], + "famhist_bca|hispanic|priorbiopsy|prosvol": [ + -4.93481194529276, + 0.0468333362448433, + 0.471925438393725, + -0.150365709915197, + null, + 0.467714936435152, + 0.694475078263379, + 0.182198811628758, + null, + null, + -0.35790951228691, + null, + -0.452858617511402 + ], + "famhist_bca|hispanic|prosvol|race": [ + -5.51128513076199, + 0.0503624370420219, + 0.657042040536245, + null, + -1.53851783874431, + 0.597893458177243, + 0.67506111012858, + 0.207008627731878, + null, + null, + -0.0923471950968855, + null, + -0.326110286745082 + ], + "famhist_bca|hispanic|prosvol": [ + -5.52885294684511, + 0.049778859129513, + 0.663992236101642, + -0.244069744104644, + -1.54482778085516, + 0.582269466423263, + 0.679704301023208, + 0.213306034648635, + null, + null, + -0.0908973073905235, + null, + -0.244802596319014 + ], + "dre|famhist_1|famhist_2|hispanic|priorbiopsy|prosvol|race": [ + -4.52721735453582, + 0.0450619703992949, + 0.434745640262492, + null, + null, + null, + null, + null, + 0.113177274248062, + null, + -0.299229734979227, + null, + -0.258910417849145 + ], + "dre|famhist_1|famhist_2|hispanic|priorbiopsy|prosvol": [ + -4.54720023001701, + 0.044497460767332, + 0.440647932237434, + -0.22226951454067, + null, + null, + null, + null, + 0.118162755956349, + null, + -0.292077964121727, + null, + -0.186481857641904 + ], + "dre|famhist_1|famhist_2|hispanic|prosvol|race": [ + -5.21968698749597, + 0.0497037740924264, + 0.634056423764065, + null, + -1.59106212827265, + null, + null, + null, + 0.105741360725579, + null, + -0.0449188269996716, + null, + -0.0304935325743645 + ], + "dre|famhist_1|famhist_2|hispanic|prosvol": [ + -5.22480202637888, + 0.0488717650144377, + 0.641870630989859, + -0.297653607722131, + -1.59648345628781, + null, + null, + null, + 0.111140600801743, + null, + -0.046478275853229, + null, + 0.0529344091242115 + ], + "famhist_1|famhist_2|hispanic|priorbiopsy|prosvol|race": [ + -4.55498610856966, + 0.044486485551952, + 0.442465994978051, + null, + null, + 0.471911519598128, + null, + null, + 0.179248057128663, + null, + -0.359237869729543, + null, + -0.481577651068803 + ], + "famhist_1|famhist_2|hispanic|priorbiopsy|prosvol": [ + -4.58740224274865, + 0.0442578552622563, + 0.446459028774488, + -0.14630978177402, + null, + 0.457252183439706, + null, + null, + 0.18154518302097, + null, + -0.350682669773072, + null, + -0.416381752017257 + ], + "famhist_1|famhist_2|hispanic|prosvol|race": [ + -5.17066538779159, + 0.0476630017047817, + 0.637531182415817, + null, + -1.55933187768386, + 0.58584259445795, + null, + null, + 0.153951440431504, + null, + -0.0914897064109514, + null, + -0.280509455330811 + ], + "famhist_1|famhist_2|hispanic|prosvol": [ + -5.18392170609997, + 0.0471009084210673, + 0.643722522813756, + -0.235112047523876, + -1.56391508019343, + 0.571424228621408, + null, + null, + 0.157454040725819, + null, + -0.0898636167042422, + null, + -0.203727558525225 + ], + "dre|famhist_2|hispanic|priorbiopsy|prosvol|race": [ + -4.77242271802817, + 0.0461292961843787, + 0.462269758045708, + null, + null, + null, + 0.652322733113182, + null, + 0.0926888363627567, + null, + -0.325506626184269, + null, + -0.290516320782189 + ], + "dre|famhist_2|hispanic|priorbiopsy|prosvol": [ + -4.78993604415602, + 0.0454789621246311, + 0.468795407845188, + -0.235484420825942, + null, + null, + 0.657317543967595, + null, + 0.0982866116042705, + null, + -0.319669023569324, + null, + -0.215534073267134 + ], + "dre|famhist_2|hispanic|prosvol|race": [ + -5.46074062057511, + 0.0508191871030526, + 0.66038875169028, + null, + -1.58034507986603, + null, + 0.61912213326173, + null, + 0.0799965108862559, + null, + -0.0698413420815173, + null, + -0.0622629353430922 + ], + "dre|famhist_2|hispanic|prosvol": [ + -5.46183660536928, + 0.0498514953901284, + 0.669367562750879, + -0.314723622690641, + -1.58764950930582, + null, + 0.627353240179327, + null, + 0.0851617509902802, + null, + -0.0721619848525935, + null, + 0.0242735851291422 + ], + "famhist_2|hispanic|priorbiopsy|prosvol|race": [ + -4.82252028122985, + 0.0458036095870982, + 0.470964595196775, + null, + null, + 0.482443236305205, + 0.690790896187779, + null, + 0.148793194713747, + null, + -0.385627463675612, + null, + -0.520557758118 + ], + "famhist_2|hispanic|priorbiopsy|prosvol": [ + -4.85517978638544, + 0.0455160150338335, + 0.475486928531685, + -0.1584079243231, + null, + 0.467131397738908, + 0.693904453779861, + null, + 0.151810907256493, + null, + -0.378290846508787, + null, + -0.45104081800196 + ], + "famhist_2|hispanic|prosvol|race": [ + -5.4376121864596, + 0.0490673174742991, + 0.665480346462172, + null, + -1.55353024476495, + 0.594207955452976, + 0.672525203617434, + null, + 0.116292587967238, + null, + -0.115701840060579, + null, + -0.321167523796056 + ], + "famhist_2|hispanic|prosvol": [ + -5.44897481834109, + 0.0483787536804325, + 0.672723405016678, + -0.252890183244632, + -1.56021092382385, + 0.579276173897104, + 0.678675987005311, + null, + 0.11997714814766, + null, + -0.114730563383074, + null, + -0.238564529079181 + ], + "dre|famhist_1|hispanic|priorbiopsy|prosvol|race": [ + -4.63869445638111, + 0.0462900802199853, + 0.439951193531556, + null, + null, + null, + null, + 0.234357683847495, + 0.113574039149191, + null, + -0.295783982215529, + null, + -0.271140434184149 + ], + "dre|famhist_1|hispanic|priorbiopsy|prosvol": [ + -4.66368778707207, + 0.0457872553044247, + 0.445925333516736, + -0.220606618327851, + null, + null, + null, + 0.238966142476576, + 0.118815408603599, + null, + -0.288446979146902, + null, + -0.198970629568856 + ], + "dre|famhist_1|hispanic|prosvol|race": [ + -5.36024428789646, + 0.0512723133005147, + 0.64044104890611, + null, + -1.59626698816314, + null, + null, + 0.269242303280218, + 0.105963434945518, + null, + -0.0389118045591244, + null, + -0.0423983765136562 + ], + "dre|famhist_1|hispanic|prosvol": [ + -5.37231625725514, + 0.0505219776045004, + 0.648409641033811, + -0.297044143837309, + -1.60200209733163, + null, + null, + 0.276865631405899, + 0.111557965351611, + null, + -0.0400987258706878, + null, + 0.0408473165153708 + ], + "famhist_1|hispanic|priorbiopsy|prosvol|race": [ + -4.69023020510733, + 0.0459984134240176, + 0.447853415736388, + null, + null, + 0.473288649895979, + null, + 0.280428905503886, + 0.180082310829145, + null, + -0.356875260055518, + null, + -0.496703317896849 + ], + "famhist_1|hispanic|priorbiopsy|prosvol": [ + -4.72753361781102, + 0.0458337327050126, + 0.451834871953974, + -0.143814283753943, + null, + 0.458282028658759, + null, + 0.28410571674711, + 0.182570991637033, + null, + -0.348217637124927, + null, + -0.431636603634276 + ], + "famhist_1|hispanic|prosvol|race": [ + -5.33319318053065, + 0.0494470377543455, + 0.64475784830094, + null, + -1.56690229849502, + 0.590689236307368, + null, + 0.313375868381164, + 0.154766813542752, + null, + -0.0864736540451175, + null, + -0.295626703104527 + ], + "famhist_1|hispanic|prosvol": [ + -5.35315883457866, + 0.0489694586366664, + 0.650990947872116, + -0.233076891178586, + -1.5715947228992, + 0.575756779736961, + null, + 0.319584728106999, + 0.158488428737235, + null, + -0.0845661160707073, + null, + -0.21897599870907 + ], + "dre|hispanic|priorbiopsy|prosvol|race": [ + -4.83076704385488, + 0.0467658625768503, + 0.464965068675168, + null, + null, + null, + 0.644170327528357, + 0.143921219465748, + 0.09218703193245, + null, + -0.322951098007874, + null, + -0.296573284680963 + ], + "dre|hispanic|priorbiopsy|prosvol": [ + -4.85393300427225, + 0.046195334332877, + 0.471469408326966, + -0.230693814658747, + null, + null, + 0.648185744987928, + 0.148806138142003, + 0.0979419449581117, + null, + -0.316820527185761, + null, + -0.222450450173458 + ], + "dre|hispanic|prosvol|race": [ + -5.54278243733983, + 0.0517323553859074, + 0.664107573713038, + null, + -1.58414461382789, + null, + 0.608363138572847, + 0.179701999106907, + 0.0794510602001406, + null, + -0.0656002992221772, + null, + -0.0684835131847901 + ], + "dre|hispanic|prosvol": [ + -5.55159390615978, + 0.0508726803128042, + 0.673048501866033, + -0.310119404862402, + -1.59141709841566, + null, + 0.615026894668696, + 0.187408503359072, + 0.0847834097131471, + null, + -0.0674534941416303, + null, + 0.0170717988899651 + ], + "hispanic|priorbiopsy|prosvol|race": [ + -4.90316402076805, + 0.0466885300284391, + 0.474193133963193, + null, + null, + 0.485429384313989, + 0.68058505256952, + 0.192713401776485, + 0.148561710845278, + null, + -0.383807051194724, + null, + -0.529788648172304 + ], + "hispanic|priorbiopsy|prosvol": [ + -4.94143363196121, + 0.0464849119731358, + 0.478593545721156, + -0.152220825692874, + null, + 0.469875818193026, + 0.682863113185997, + 0.196836463185665, + 0.151692775048569, + null, + -0.376265442127932, + null, + -0.461206538203182 + ], + "hispanic|prosvol|race": [ + -5.53893609521021, + 0.0501548355905864, + 0.670194166320639, + null, + -1.55955875298173, + 0.600055727357244, + 0.660501706275918, + 0.224057488066622, + 0.115990001170449, + null, + -0.11224142779797, + null, + -0.330640154645499 + ], + "hispanic|prosvol": [ + -5.5578890686874, + 0.0495811114654338, + 0.677245984428337, + -0.246060458407936, + -1.56592585077585, + 0.584605599209501, + 0.665165858420773, + 0.230557418144755, + 0.119868104734741, + null, + -0.110937159317644, + null, + -0.249199054854656 + ], + "dre|famhist_1|famhist_2|famhist_bca|hispanic|priorbiopsy|race": [ + -0.389413633184921, + 0.0727551510844796, + 0.745842287381919, + null, + null, + null, + null, + null, + null, + -1.29276760774328, + -0.287423958732361, + null, + 0.0526171553805509 + ], + "dre|famhist_1|famhist_2|famhist_bca|hispanic|priorbiopsy": [ + 0.18684865218377, + 0.0707038029600129, + 0.748317979391404, + -0.209174389141772, + null, + null, + null, + null, + null, + -1.36429590366076, + -0.350024531973577, + null, + 0.00805012738989533 + ], + "dre|famhist_1|famhist_2|famhist_bca|hispanic|race": [ + -1.18550122024298, + 0.0749654476168154, + 0.825459892395367, + null, + -1.24961633782293, + null, + null, + null, + null, + -1.19760251367549, + -0.118346598400276, + null, + 0.219562522704938 + ], + "dre|famhist_1|famhist_2|famhist_bca|hispanic": [ + -0.664883424378005, + 0.073141885198643, + 0.826594835605756, + -0.205926153421032, + -1.24159341115253, + null, + null, + null, + null, + -1.25780441216297, + -0.197252764830539, + null, + 0.16487037954208 + ], + "famhist_1|famhist_2|famhist_bca|hispanic|priorbiopsy|race": [ + 0.0556798471785331, + 0.0632970324610496, + 0.773335265242697, + null, + null, + 0.548289298548375, + null, + null, + null, + -1.28699738178879, + -0.279617798507432, + null, + -0.119727786413642 + ], + "famhist_1|famhist_2|famhist_bca|hispanic|priorbiopsy": [ + 0.729011722519032, + 0.062857206629347, + 0.765489248130129, + -0.325832888523005, + null, + 0.508240174005008, + null, + null, + null, + -1.36582987821234, + -0.346796761521955, + null, + -0.27936295682504 + ], + "famhist_1|famhist_2|famhist_bca|hispanic|race": [ + -0.767401084053987, + 0.0655615406897619, + 0.848017717043897, + null, + -1.20710407806229, + 0.570090114973152, + null, + null, + null, + -1.18938214184669, + -0.117368666943865, + null, + 0.0625667532727922 + ], + "famhist_1|famhist_2|famhist_bca|hispanic": [ + -0.161054747551278, + 0.0650891707842713, + 0.839397716015259, + -0.304314810463039, + -1.20744892467569, + 0.547003330757084, + null, + null, + null, + -1.25485145501433, + -0.203816550763735, + null, + -0.0969045484361596 + ], + "dre|famhist_2|famhist_bca|hispanic|priorbiopsy|race": [ + -0.974096972131507, + 0.0794193487790713, + 0.750193613657762, + null, + null, + null, + 0.46737234859496, + null, + null, + -1.28146298718575, + -0.225611131389616, + null, + 0.113345812065095 + ], + "dre|famhist_2|famhist_bca|hispanic|priorbiopsy": [ + -0.371253865604953, + 0.0776285038243473, + 0.75563275540343, + -0.0422190279019204, + null, + null, + 0.525377677606436, + null, + null, + -1.36753055818268, + -0.277852876127111, + null, + 0.0728821928473763 + ], + "dre|famhist_2|famhist_bca|hispanic|race": [ + -1.7536333929898, + 0.0811434405500717, + 0.835318321730825, + null, + -1.18799206810556, + null, + 0.477517871982975, + null, + null, + -1.1864764313885, + -0.0474893295972763, + null, + 0.256432463572711 + ], + "dre|famhist_2|famhist_bca|hispanic": [ + -1.24446448783989, + 0.079724260763576, + 0.84091344654105, + -0.0691325134543316, + -1.17830406109747, + null, + 0.540747315729437, + null, + null, + -1.25615066705084, + -0.114124311639561, + null, + 0.202158693114698 + ], + "famhist_2|famhist_bca|hispanic|priorbiopsy|race": [ + -0.563302211206844, + 0.0691030686065725, + 0.789877062879471, + null, + null, + 0.773691453197043, + 0.506835089235773, + null, + null, + -1.27792988616745, + -0.292324512479351, + null, + -0.0414670485748415 + ], + "famhist_2|famhist_bca|hispanic|priorbiopsy": [ + 0.181232961963371, + 0.0685314954344495, + 0.78805672976254, + -0.171686707408806, + null, + 0.788046898291597, + 0.583484662280753, + null, + null, + -1.37549116860799, + -0.366134421775783, + null, + -0.220554785879517 + ], + "famhist_2|famhist_bca|hispanic|race": [ + -1.31080459343084, + 0.0709942482031608, + 0.863922529488589, + null, + -1.083577060388, + 0.768913219919577, + 0.511548703480369, + null, + null, + -1.1884233708503, + -0.113900787804516, + null, + 0.104442045788342 + ], + "famhist_2|famhist_bca|hispanic": [ + -0.678461886305672, + 0.0705922388984073, + 0.863546407462934, + -0.179156720960764, + -1.08601442113905, + 0.802362822684355, + 0.592515904291978, + null, + null, + -1.26803906066896, + -0.202212708022776, + null, + -0.0734235084937101 + ], + "dre|famhist_1|famhist_bca|hispanic|priorbiopsy|race": [ + 1.33197595715058, + 0.069839639496495, + 0.673521688374888, + null, + null, + null, + null, + 0.22249889314751, + null, + -1.48506763683055, + -0.108016836092887, + null, + -0.261806094664495 + ], + "dre|famhist_1|famhist_bca|hispanic|priorbiopsy": [ + 1.40752813011983, + 0.068760178057391, + 0.682821996141959, + -0.397155813747792, + null, + null, + null, + 0.226757500071934, + null, + -1.49805580237398, + -0.101758732467643, + null, + -0.166095097208535 + ], + "dre|famhist_1|famhist_bca|hispanic|race": [ + 0.226265008148758, + 0.0710444638342454, + 0.790184925637274, + null, + -1.16839764369584, + null, + null, + 0.255222309575603, + null, + -1.33224286233224, + 0.050584853209442, + null, + -0.117695541444108 + ], + "dre|famhist_1|famhist_bca|hispanic": [ + 0.304979891869882, + 0.0698227145142467, + 0.800038522101952, + -0.418493025558036, + -1.17204328883423, + null, + null, + 0.260925044032954, + null, + -1.34406106771148, + 0.0503364404047635, + null, + -0.0192840682854187 + ], + "famhist_1|famhist_bca|hispanic|priorbiopsy|race": [ + 1.29319875390845, + 0.0689797392617979, + 0.692600358077596, + null, + null, + 0.625666525241736, + null, + 0.294512087585709, + null, + -1.49845621249683, + -0.163037543031115, + null, + -0.497357496927766 + ], + "famhist_1|famhist_bca|hispanic|priorbiopsy": [ + 1.38911516184243, + 0.0680985012542147, + 0.702075640057331, + -0.382393907427067, + null, + 0.606223680773388, + null, + 0.299290251675974, + null, + -1.5164014311519, + -0.152913722429641, + null, + -0.399998493515137 + ], + "famhist_1|famhist_bca|hispanic|race": [ + 0.245297541397577, + 0.0693067308334853, + 0.804160312297765, + null, + -1.12137658855728, + 0.690867280473184, + null, + 0.318717559568396, + null, + -1.34680134924167, + -0.00120890338326135, + null, + -0.366918773077606 + ], + "famhist_1|famhist_bca|hispanic": [ + 0.347436830866539, + 0.0682956312243327, + 0.814073661482841, + -0.404315192275454, + -1.12449179608789, + 0.672235278346676, + null, + 0.324782132136504, + null, + -1.36427438064536, + 0.00315408357155333, + null, + -0.267309495768311 + ], + "dre|famhist_bca|hispanic|priorbiopsy|race": [ + 1.10424413145709, + 0.0704237244545873, + 0.701888859443204, + null, + null, + null, + 0.650519225815763, + 0.138177972208512, + null, + -1.48074641167117, + -0.136640853189738, + null, + -0.298279580847205 + ], + "dre|famhist_bca|hispanic|priorbiopsy": [ + 1.18139160193338, + 0.0692044882747131, + 0.710461487186378, + -0.390060018965054, + null, + null, + 0.649508721362949, + 0.143028053638388, + null, + -1.49242354443309, + -0.132575028007195, + null, + -0.200358954141484 + ], + "dre|famhist_bca|hispanic|race": [ + -0.0025435808117672, + 0.0715930494172442, + 0.818431737088584, + null, + -1.15385578295771, + null, + 0.627227425037654, + 0.17017697838355, + null, + -1.32684137708336, + 0.023109045822681, + null, + -0.153681366716447 + ], + "dre|famhist_bca|hispanic": [ + 0.0812055501726828, + 0.0701928553062032, + 0.828308349071551, + -0.418738884482304, + -1.15954951930616, + null, + 0.629648152768549, + 0.175771886109337, + null, + -1.33793353566452, + 0.0213286130216568, + null, + -0.0520356647581654 + ], + "famhist_bca|hispanic|priorbiopsy|race": [ + 1.05028693103552, + 0.0697606252791753, + 0.724689340577161, + null, + null, + 0.637192849663529, + 0.690187033167415, + 0.213432569855367, + null, + -1.49633752926776, + -0.1919997983511, + null, + -0.541156206194465 + ], + "famhist_bca|hispanic|priorbiopsy": [ + 1.14544293656728, + 0.0687493137414886, + 0.73329479819773, + -0.37214854990972, + null, + 0.61721701729661, + 0.687628187617222, + 0.218794028111, + null, + -1.51281079284277, + -0.184139630151678, + null, + -0.44021405601666 + ], + "famhist_bca|hispanic|race": [ + -0.000494201255832822, + 0.0700418874088387, + 0.83691016351805, + null, + -1.11475065800341, + 0.701411626699116, + 0.681062867002218, + 0.235108069937392, + null, + -1.34342987151079, + -0.0276109922024713, + null, + -0.411793119547414 + ], + "famhist_bca|hispanic": [ + 0.104971811537928, + 0.068854941654932, + 0.846479465324534, + -0.401231670255202, + -1.11981621815772, + 0.68209518720105, + 0.681157227812374, + 0.241282562733043, + null, + -1.35997027891464, + -0.0246379966842156, + null, + -0.307245247489525 + ], + "dre|famhist_1|famhist_2|hispanic|priorbiopsy|race": [ + 1.38088978319371, + 0.0685056986862535, + 0.680608020938219, + null, + null, + null, + null, + null, + 0.0756154604741101, + -1.47942205975059, + -0.132673632192979, + null, + -0.258394526414448 + ], + "dre|famhist_1|famhist_2|hispanic|priorbiopsy": [ + 1.47278606606167, + 0.0673336695154702, + 0.690585143772286, + -0.413006682686692, + null, + null, + null, + null, + 0.0828939901359816, + -1.49463860407834, + -0.126732414457606, + null, + -0.160357513875186 + ], + "dre|famhist_1|famhist_2|hispanic|race": [ + 0.275360589372586, + 0.0695032540718078, + 0.801962529159604, + null, + -1.1907248447753, + null, + null, + null, + 0.0859165155621501, + -1.32558504598083, + 0.0249749602763761, + null, + -0.111784185234502 + ], + "dre|famhist_1|famhist_2|hispanic": [ + 0.372032271948236, + 0.0681772580722034, + 0.812418432268021, + -0.43436609008631, + -1.19449293980156, + null, + null, + null, + 0.0913316891229854, + -1.33965978705157, + 0.0242393753084725, + null, + -0.0109801620203144 + ], + "famhist_1|famhist_2|hispanic|priorbiopsy|race": [ + 1.34419961075837, + 0.067362977000127, + 0.699850572512392, + null, + null, + 0.62110705408103, + null, + null, + 0.179795027748008, + -1.48980406045684, + -0.191933854926526, + null, + -0.496420512729972 + ], + "famhist_1|famhist_2|hispanic|priorbiopsy": [ + 1.45927288594175, + 0.0663726777903705, + 0.710335800649807, + -0.401326199863441, + null, + 0.60234925529207, + null, + null, + 0.183623203339704, + -1.51044565240166, + -0.181491587998239, + null, + -0.396177245148411 + ], + "famhist_1|famhist_2|hispanic|race": [ + 0.295823563518666, + 0.0675199397777408, + 0.815546645366482, + null, + -1.14014834987544, + 0.685385001518182, + null, + null, + 0.167958186579949, + -1.33696603221501, + -0.0309526442438688, + null, + -0.362257816968408 + ], + "famhist_1|famhist_2|hispanic": [ + 0.417485605975846, + 0.0664008962933133, + 0.826284003304399, + -0.422887791320602, + -1.14329599390204, + 0.667301067626587, + null, + null, + 0.17048746842851, + -1.35704786584606, + -0.0264213917221798, + null, + -0.259712378035064 + ], + "dre|famhist_2|hispanic|priorbiopsy|race": [ + 1.10817233490159, + 0.0695726307950985, + 0.708366727145567, + null, + null, + null, + 0.63609443994347, + null, + 0.0498379556744268, + -1.47264781747983, + -0.160143050303976, + null, + -0.297101481506846 + ], + "dre|famhist_2|hispanic|priorbiopsy": [ + 1.2013034435466, + 0.068251363274181, + 0.717666272161901, + -0.409738583893587, + null, + null, + 0.636358113674003, + null, + 0.0582281975739372, + -1.48643492735129, + -0.156490450920397, + null, + -0.196620421019082 + ], + "dre|famhist_2|hispanic|race": [ + -0.00191353292941781, + 0.0705719277980615, + 0.829952930040674, + null, + -1.1775220622785, + null, + 0.613769784301621, + null, + 0.0572638057795577, + -1.31774574398739, + -0.00116467224147938, + null, + -0.149430433437283 + ], + "dre|famhist_2|hispanic": [ + 0.0992076475064051, + 0.069056013931353, + 0.840615513990836, + -0.438827528624316, + -1.18360223647601, + null, + 0.617983188295309, + null, + 0.0632400425205037, + -1.33099489779511, + -0.00356227354090964, + null, + -0.0450642976156508 + ], + "famhist_2|hispanic|priorbiopsy|race": [ + 1.06048702823419, + 0.0686040276418307, + 0.729970196045831, + null, + null, + 0.628624281611457, + 0.672943194906929, + null, + 0.145109201236934, + -1.4847495627607, + -0.219862288900768, + null, + -0.540112599144385 + ], + "famhist_2|hispanic|priorbiopsy": [ + 1.17463824218699, + 0.0674763039626881, + 0.739689784663385, + -0.395857065384342, + null, + 0.609121020201326, + 0.671773376767151, + null, + 0.15053086168908, + -1.50390964905298, + -0.211769926756593, + null, + -0.435787729759869 + ], + "famhist_2|hispanic|race": [ + 0.00523493822589846, + 0.0687523866850557, + 0.846682130426331, + null, + -1.13471407947932, + 0.692153131077787, + 0.664675442119212, + null, + 0.128883947974602, + -1.33060470157651, + -0.0560354403569437, + null, + -0.406560771184181 + ], + "famhist_2|hispanic": [ + 0.130052626345918, + 0.0674439057849017, + 0.857314136833169, + -0.424991388253953, + -1.14010112334398, + 0.673218651851046, + 0.666587978994707, + null, + 0.13243854449392, + -1.34974860748156, + -0.0530583648759045, + null, + -0.298486655891578 + ], + "dre|famhist_1|hispanic|priorbiopsy|race": [ + 1.33844411655784, + 0.0695560701563447, + 0.687214665148923, + null, + null, + null, + null, + 0.233489377728084, + 0.0746713380579963, + -1.49101286180796, + -0.131504159956024, + null, + -0.264609249228597 + ], + "dre|famhist_1|hispanic|priorbiopsy": [ + 1.41207989081784, + 0.0684910224474379, + 0.696553163489721, + -0.396905003064562, + null, + null, + null, + 0.237821410392884, + 0.0819053279047532, + -1.50401614630715, + -0.12519710076265, + null, + -0.168894172753204 + ], + "dre|famhist_1|hispanic|race": [ + 0.203493005871951, + 0.0708934433449649, + 0.809653296468569, + null, + -1.19628295036331, + null, + null, + 0.267951239027388, + 0.0858217974574695, + -1.33699424768991, + 0.0280112610749095, + null, + -0.117937630942432 + ], + "dre|famhist_1|hispanic": [ + 0.280639197576364, + 0.0696811186294412, + 0.819532007442941, + -0.418975573849667, + -1.20008138888056, + null, + null, + 0.273798605777839, + 0.091314069843349, + -1.3488051269537, + 0.0277696409002872, + null, + -0.0192550476921869 + ], + "famhist_1|hispanic|priorbiopsy|race": [ + 1.28410807928667, + 0.0688300324763278, + 0.709133870807152, + null, + null, + 0.631308132988731, + null, + 0.309516408393033, + 0.180901450292157, + -1.5062396250392, + -0.190650236583903, + null, + -0.508769865341335 + ], + "famhist_1|hispanic|priorbiopsy": [ + 1.37857014242005, + 0.0679617726313517, + 0.718692720266472, + -0.382674684439476, + null, + 0.611951163256074, + null, + 0.314137014850839, + 0.184409084077285, + -1.52422646984403, + -0.180106392148411, + null, + -0.411210911910201 + ], + "famhist_1|hispanic|race": [ + 0.209168223017672, + 0.0692678438876776, + 0.826212707489245, + null, + -1.14820763193887, + 0.697073188448455, + null, + 0.335921258143097, + 0.170582844512583, + -1.35315265601792, + -0.027278413995981, + null, + -0.37442082151843 + ], + "famhist_1|hispanic": [ + 0.309763094435545, + 0.0682689248243321, + 0.836124510426131, + -0.404637495244679, + -1.15125871881873, + 0.678361196631093, + null, + 0.341900082698008, + 0.172840917197238, + -1.37058078636421, + -0.0225613505101764, + null, + -0.274426603190519 + ], + "dre|hispanic|priorbiopsy|race": [ + 1.11920750444548, + 0.0701044472380399, + 0.713655134404563, + null, + null, + null, + 0.638518231722079, + 0.151578037610794, + 0.0466035166881842, + -1.48604575686731, + -0.159755723538948, + null, + -0.299378818557095 + ], + "dre|hispanic|priorbiopsy": [ + 1.19433873571182, + 0.0689019138406515, + 0.722235599765878, + -0.389918963696667, + null, + null, + 0.637405496125032, + 0.156537606559396, + 0.0548673657778396, + -1.49772094693947, + -0.155737992179284, + null, + -0.201651170197537 + ], + "dre|hispanic|race": [ + -0.0152701164815708, + 0.0714041494852267, + 0.836043602630379, + null, + -1.18112118810245, + null, + 0.613190987034107, + 0.185607306140622, + 0.0548852257805756, + -1.33113600761777, + 0.000387942243179944, + null, + -0.152091074461951 + ], + "dre|hispanic": [ + 0.0669347325059891, + 0.0700147548093756, + 0.84592698481872, + -0.419453307689334, + -1.18696178355956, + null, + 0.615602327417521, + 0.191397899993919, + 0.0609026141296383, + -1.34221704896058, + -0.00148966825757583, + null, + -0.0503484003492816 + ], + "hispanic|priorbiopsy|race": [ + 1.0522190114304, + 0.0695640167064045, + 0.738205824894213, + null, + null, + 0.640927810751625, + 0.67332893459771, + 0.231158236423131, + 0.143521065770667, + -1.50292149120235, + -0.219564884135821, + null, + -0.549427619321109 + ], + "hispanic|priorbiopsy": [ + 1.14623764435061, + 0.0685672332666152, + 0.746922598452593, + -0.373393540808527, + null, + 0.621144616383244, + 0.670924492929663, + 0.23641838364346, + 0.148567427957941, + -1.51952569487891, + -0.211404490870915, + null, + -0.448503977795757 + ], + "hispanic|race": [ + -0.0238434424275586, + 0.0699526597445259, + 0.855930562628749, + null, + -1.14094961584352, + 0.705587834338423, + 0.662789899647911, + 0.255396966162868, + 0.128636940504676, + -1.34874929881804, + -0.0542692555796477, + null, + -0.415931444979124 + ], + "hispanic": [ + 0.0805601560431196, + 0.0687774911469699, + 0.865529807924991, + -0.402549402938141, + -1.14595620706848, + 0.686309693273933, + 0.663077760608663, + 0.261575892282884, + 0.131938135119706, + -1.36533809980021, + -0.0510870450197037, + null, + -0.311215821809876 + ], + "ari_use|dre|famhist_1|famhist_2|famhist_bca|priorbiopsy|prosvol|race": [ + -5.41917308385565, + 0.0542200203632215, + 0.515053980771765, + null, + null, + null, + null, + null, + null, + null, + null, + -0.188101619089836, + -0.198846158116136 + ], + "ari_use|dre|famhist_1|famhist_2|famhist_bca|priorbiopsy|prosvol": [ + -5.24899233938155, + 0.051919244008401, + 0.512873357232034, + -0.0333873436141384, + null, + null, + null, + null, + null, + null, + null, + -0.166608053125772, + -0.231270959408735 + ], + "ari_use|dre|famhist_1|famhist_2|famhist_bca|prosvol|race": [ + -6.07317305484509, + 0.0594783195659781, + 0.650168140790622, + null, + -1.42250924378728, + null, + null, + null, + null, + null, + null, + -0.113226001685573, + 0.0454677851579491 + ], + "ari_use|dre|famhist_1|famhist_2|famhist_bca|prosvol": [ + -5.89442833341053, + 0.0569822402734681, + 0.655204356992585, + -0.0615953817822861, + -1.42366309629541, + null, + null, + null, + null, + null, + null, + -0.0994495244717985, + 0.0114697033890704 + ], + "ari_use|famhist_1|famhist_2|famhist_bca|priorbiopsy|prosvol|race": [ + -5.024999766293, + 0.0438786341472418, + 0.553150266947012, + null, + null, + 0.652643277604701, + null, + null, + null, + null, + null, + -0.387715197244276, + -0.212211974618438 + ], + "ari_use|famhist_1|famhist_2|famhist_bca|priorbiopsy|prosvol": [ + -4.87989961382616, + 0.0427479205734054, + 0.545593794560463, + -0.0193952778889614, + null, + 0.61031219952533, + null, + null, + null, + null, + null, + -0.369299037224277, + -0.263239097851275 + ], + "ari_use|famhist_1|famhist_2|famhist_bca|prosvol|race": [ + -5.68551299422413, + 0.0491916050205593, + 0.684100354515328, + null, + -1.39609800459287, + 0.66700205315936, + null, + null, + null, + null, + null, + -0.282153742490875, + 0.0258532002466187 + ], + "ari_use|famhist_1|famhist_2|famhist_bca|prosvol": [ + -5.53071894351442, + 0.047761604684345, + 0.68300551661047, + -0.0344799968838368, + -1.40548177008788, + 0.637084079474315, + null, + null, + null, + null, + null, + -0.271253755311507, + -0.0254954673941254 + ], + "ari_use|dre|famhist_2|famhist_bca|priorbiopsy|prosvol|race": [ + -5.81345778961416, + 0.0576215449945514, + 0.544513601923873, + null, + null, + null, + 0.426927795757904, + null, + null, + null, + null, + -0.034913438410624, + -0.182336341431728 + ], + "ari_use|dre|famhist_2|famhist_bca|priorbiopsy|prosvol": [ + -5.63677163243932, + 0.0549778891226998, + 0.543103188902941, + 0.0392284305947841, + null, + null, + 0.437476642843408, + null, + null, + null, + null, + 0.0033211732690857, + -0.223281386680111 + ], + "ari_use|dre|famhist_2|famhist_bca|prosvol|race": [ + -6.52309723838578, + 0.0637720853789548, + 0.677829751736028, + null, + -1.35499975445024, + null, + 0.468830680466537, + null, + null, + null, + null, + 0.03980907891157, + 0.0414266028002323 + ], + "ari_use|dre|famhist_2|famhist_bca|prosvol": [ + -6.36001696206555, + 0.0612303725174536, + 0.686000988543406, + -0.00214844582935391, + -1.35916904267745, + null, + 0.479496514133718, + null, + null, + null, + null, + 0.0696080819810335, + -0.00225695705395376 + ], + "ari_use|famhist_2|famhist_bca|priorbiopsy|prosvol|race": [ + -5.40262918077558, + 0.045110031148052, + 0.600513074053526, + null, + null, + 0.842114966550643, + 0.485810674264434, + null, + null, + null, + null, + -0.238922867857942, + -0.188331534412878 + ], + "ari_use|famhist_2|famhist_bca|priorbiopsy|prosvol": [ + -5.24812236792015, + 0.0434564948257366, + 0.594232308086834, + 0.0609787413562974, + null, + 0.823977807292459, + 0.507621799868752, + null, + null, + null, + null, + -0.211522171658343, + -0.251525890166921 + ], + "ari_use|famhist_2|famhist_bca|prosvol|race": [ + -6.08748309054311, + 0.0513192828940236, + 0.720726724965306, + null, + -1.29439083147917, + 0.841230328179814, + 0.514411185466798, + null, + null, + null, + null, + -0.147964044087627, + 0.023722405581167 + ], + "ari_use|famhist_2|famhist_bca|prosvol": [ + -5.9449375533167, + 0.0496297330808261, + 0.722787879558774, + 0.0307850329370771, + -1.31112601986109, + 0.836250501837892, + 0.534854315496646, + null, + null, + null, + null, + -0.128764759509611, + -0.036155827356273 + ], + "ari_use|dre|famhist_1|famhist_bca|priorbiopsy|prosvol|race": [ + -4.97729804959979, + 0.0414639708611744, + 0.573589588416183, + null, + null, + null, + null, + 0.268894988514727, + null, + null, + null, + 0.037943925102107, + -0.0503099230512309 + ], + "ari_use|dre|famhist_1|famhist_bca|priorbiopsy|prosvol": [ + -4.97247578947734, + 0.0414059825982549, + 0.574607582555202, + -0.016293404111724, + null, + null, + null, + 0.268174011341283, + null, + null, + null, + 0.0301426174408259, + -0.0443092214315352 + ], + "ari_use|dre|famhist_1|famhist_bca|prosvol|race": [ + -5.90732195925432, + 0.0490873221422473, + 0.762609520363501, + null, + -1.44296829586644, + null, + null, + 0.299613543703302, + null, + null, + null, + 0.167830221647367, + 0.140477782846741 + ], + "ari_use|dre|famhist_1|famhist_bca|prosvol": [ + -5.88761240390381, + 0.0488541811763967, + 0.763539575848383, + -0.0338707464632595, + -1.44072804483184, + null, + null, + 0.299038157432497, + null, + null, + null, + 0.154451718051142, + 0.15045171491496 + ], + "ari_use|famhist_1|famhist_bca|priorbiopsy|prosvol|race": [ + -4.93287352640154, + 0.0387113362217145, + 0.587360910012923, + null, + null, + 0.629634851960025, + null, + 0.28830399101659, + null, + null, + null, + -0.184298939928575, + -0.131096697788055 + ], + "ari_use|famhist_1|famhist_bca|priorbiopsy|prosvol": [ + -4.95867723468505, + 0.0390312208557455, + 0.586514078592132, + 0.037313356418748, + null, + 0.629855214625766, + null, + 0.287848623750855, + null, + null, + null, + -0.172484327538982, + -0.14031788716169 + ], + "ari_use|famhist_1|famhist_bca|prosvol|race": [ + -5.84006723668093, + 0.0457863387317509, + 0.772403841478339, + null, + -1.41712369597592, + 0.709955580502422, + null, + 0.321881762687091, + null, + null, + null, + -0.06887618241822, + 0.0559152575963376 + ], + "ari_use|famhist_1|famhist_bca|prosvol": [ + -5.85163781950083, + 0.0459390050592064, + 0.771655421305942, + 0.0186941242281458, + -1.41438365500306, + 0.70982627895828, + null, + 0.321591546669331, + null, + null, + null, + -0.0631317225923257, + 0.0509565752937708 + ], + "ari_use|dre|famhist_bca|priorbiopsy|prosvol|race": [ + -5.06926005828587, + 0.0404376608580287, + 0.600825946789409, + null, + null, + null, + 0.569140520318974, + 0.162645579292827, + null, + null, + null, + 0.0442218931001842, + -0.0699555587696883 + ], + "ari_use|dre|famhist_bca|priorbiopsy|prosvol": [ + -5.07359965220836, + 0.0404928397255934, + 0.601246319848159, + 0.0008890392866993, + null, + null, + 0.568240319492362, + 0.162157756352626, + null, + null, + null, + 0.0427518096089284, + -0.0687781625078064 + ], + "ari_use|dre|famhist_bca|prosvol|race": [ + -5.98107372071679, + 0.0479438561882445, + 0.787452690336035, + null, + -1.43241940498731, + null, + 0.539126704857462, + 0.193528570142155, + null, + null, + null, + 0.175935989512376, + 0.118955409350405 + ], + "ari_use|dre|famhist_bca|prosvol": [ + -5.96967485639153, + 0.047814329949974, + 0.787943005062087, + -0.0195575672267236, + -1.43022948972633, + null, + 0.538108499764339, + 0.193229359947707, + null, + null, + null, + 0.167815943666321, + 0.12489477972217 + ], + "ari_use|famhist_bca|priorbiopsy|prosvol|race": [ + -5.022486746865, + 0.0375312405265548, + 0.616273143229687, + null, + null, + 0.631204402699141, + 0.590022775840507, + 0.176987279629975, + null, + null, + null, + -0.170895660276951, + -0.153432070894607 + ], + "ari_use|famhist_bca|priorbiopsy|prosvol": [ + -5.05991869881574, + 0.0379919176987123, + 0.614676707952023, + 0.0578005553904064, + null, + 0.632338635154205, + 0.591205971660883, + 0.176458837972426, + null, + null, + null, + -0.151849488381419, + -0.168736214632147 + ], + "ari_use|famhist_bca|prosvol|race": [ + -5.91340189863467, + 0.0445877106863444, + 0.798000979241094, + null, + -1.40492604668237, + 0.70749336878372, + 0.561338345433744, + 0.211152923742737, + null, + null, + null, + -0.0532890267585701, + 0.0308520143525885 + ], + "ari_use|famhist_bca|prosvol": [ + -5.93561583759919, + 0.0448715943778913, + 0.796620815074027, + 0.0364166396770606, + -1.40198105434778, + 0.708072208445126, + 0.562142551491336, + 0.210903184615755, + null, + null, + null, + -0.0413638366112062, + 0.020665129735557 + ], + "ari_use|dre|famhist_1|famhist_2|priorbiopsy|prosvol|race": [ + -4.87572801947819, + 0.0398199564940339, + 0.577869839772337, + null, + null, + null, + null, + null, + 0.147138183388634, + null, + null, + 0.0541393741400052, + -0.0545050849064663 + ], + "ari_use|dre|famhist_1|famhist_2|priorbiopsy|prosvol": [ + -4.87452250708541, + 0.0398070234169921, + 0.578699050147366, + -0.0100789108576395, + null, + null, + null, + null, + 0.145769277542955, + null, + null, + 0.0485856858598952, + -0.050235575769638 + ], + "ari_use|dre|famhist_1|famhist_2|prosvol|race": [ + -5.81361341182547, + 0.0474201511297274, + 0.771392093461516, + null, + -1.4575443453672, + null, + null, + null, + 0.150110110218479, + null, + null, + 0.187001473658129, + 0.137656275755325 + ], + "ari_use|dre|famhist_1|famhist_2|prosvol": [ + -5.7980988394197, + 0.0472408174894954, + 0.772099725971153, + -0.0266856986011483, + -1.4551994365311, + null, + null, + null, + 0.148397075514232, + null, + null, + 0.176215505625442, + 0.145579202580102 + ], + "ari_use|famhist_1|famhist_2|priorbiopsy|prosvol|race": [ + -4.82965314878789, + 0.0371131249943807, + 0.591819667778471, + null, + null, + 0.620271052806618, + null, + null, + 0.16937759786212, + null, + null, + -0.161734753134299, + -0.139417963956908 + ], + "ari_use|famhist_1|famhist_2|priorbiopsy|prosvol": [ + -4.85897058927921, + 0.0374741657115849, + 0.590815816473713, + 0.0424622349917295, + null, + 0.62062018843547, + null, + null, + 0.170069940053178, + null, + null, + -0.148115096462385, + -0.1500546148114 + ], + "ari_use|famhist_1|famhist_2|prosvol|race": [ + -5.74314537449411, + 0.0442002924575338, + 0.780708524568168, + null, + -1.42904697725528, + 0.698564739512987, + null, + null, + 0.154019305415867, + null, + null, + -0.0422493423732875, + 0.0491684884574937 + ], + "ari_use|famhist_1|famhist_2|prosvol": [ + -5.75823876343197, + 0.0443952205274187, + 0.779775669004367, + 0.0241420441222502, + -1.4262559724002, + 0.698599845010722, + null, + null, + 0.154647978491936, + null, + null, + -0.03460102878948, + 0.0426681768188635 + ], + "ari_use|dre|famhist_2|priorbiopsy|prosvol|race": [ + -5.00443584287437, + 0.0392374495446913, + 0.605987557796178, + null, + null, + null, + 0.57386009737665, + null, + 0.108799457902506, + null, + null, + 0.0565127436300639, + -0.0747892638176619 + ], + "ari_use|dre|famhist_2|priorbiopsy|prosvol": [ + -5.01162120758539, + 0.0393268701654253, + 0.606273204948657, + 0.00558863090938144, + null, + null, + 0.573068407318688, + null, + 0.108105311434202, + null, + null, + 0.0567388041524115, + -0.0749026218322123 + ], + "ari_use|dre|famhist_2|prosvol|race": [ + -5.92640612453795, + 0.046727913815082, + 0.797662510364373, + null, + -1.44832052425509, + null, + 0.545490310552509, + null, + 0.107913624236598, + null, + null, + 0.191955419658382, + 0.115757843094475 + ], + "ari_use|dre|famhist_2|prosvol": [ + -5.91829173419804, + 0.0466393884203705, + 0.797978706806395, + -0.0140301127948869, + -1.44605485263145, + null, + 0.544669105596172, + null, + 0.106870250329681, + null, + null, + 0.185825663687489, + 0.120150212307369 + ], + "ari_use|famhist_2|priorbiopsy|prosvol|race": [ + -4.95866888608257, + 0.0364363947020154, + 0.621052682765532, + null, + null, + 0.621471288981284, + 0.593874158911628, + null, + 0.121399271031058, + null, + null, + -0.153334681685507, + -0.160906214987023 + ], + "ari_use|famhist_2|priorbiopsy|prosvol": [ + -4.99852855554321, + 0.0369250227878978, + 0.619382105395291, + 0.0609443144833735, + null, + 0.622616556456712, + 0.594908576713974, + null, + 0.122566363141831, + null, + null, + -0.13316542382228, + -0.177113461476411 + ], + "ari_use|famhist_2|prosvol|race": [ + -5.85837601227936, + 0.0434946803747179, + 0.807561531351179, + null, + -1.41882771915536, + 0.695982171262629, + 0.567617615965212, + null, + 0.102326688857553, + null, + null, + -0.0307924955224797, + 0.0253115264080329 + ], + "ari_use|famhist_2|prosvol": [ + -5.8829461796115, + 0.0438066809289193, + 0.806065224594893, + 0.0397922701572649, + -1.4158424664922, + 0.696623775325234, + 0.568346580340698, + null, + 0.103584803976962, + null, + null, + -0.0176856472791629, + 0.0141342430342916 + ], + "ari_use|dre|famhist_1|priorbiopsy|prosvol|race": [ + -4.96799747818951, + 0.0407720264176825, + 0.581676159973055, + null, + null, + null, + null, + 0.270787979354621, + 0.140130948710725, + null, + null, + 0.0431105289550896, + -0.056591828779416 + ], + "ari_use|dre|famhist_1|priorbiopsy|prosvol": [ + -4.96738337856174, + 0.0407663262673535, + 0.582443107531057, + -0.00860308665273582, + null, + null, + null, + 0.270114954959503, + 0.138838267629445, + null, + null, + 0.0381337579441692, + -0.0527099268567653 + ], + "ari_use|dre|famhist_1|prosvol|race": [ + -5.92375399542082, + 0.0485574545384812, + 0.776359710268289, + null, + -1.46232411789365, + null, + null, + 0.30310349075513, + 0.143384957409543, + null, + null, + 0.175728789067479, + 0.135992980580523 + ], + "ari_use|dre|famhist_1|prosvol": [ + -5.90922035598768, + 0.0483900823593159, + 0.776994562834876, + -0.0250125383457642, + -1.46000120768315, + null, + null, + 0.302596469908664, + 0.141758319200373, + null, + null, + 0.165588679379784, + 0.143514450330104 + ], + "ari_use|famhist_1|priorbiopsy|prosvol|race": [ + -4.92534594507719, + 0.0380758840672621, + 0.595368574827107, + null, + null, + 0.62332775640749, + null, + 0.289911028188192, + 0.161007929912062, + null, + null, + -0.176645510907641, + -0.139775691050146 + ], + "ari_use|famhist_1|priorbiopsy|prosvol": [ + -4.9555750652314, + 0.0384475083419841, + 0.594266462839886, + 0.045212024714596, + null, + 0.623849925777317, + null, + 0.289508428435681, + 0.161849915433463, + null, + null, + -0.162034726992753, + -0.151297472938722 + ], + "ari_use|famhist_1|prosvol|race": [ + -5.85763482650903, + 0.0453129281299698, + 0.786181470727236, + null, + -1.43590583381394, + 0.704431930073171, + null, + 0.326121624816692, + 0.146217638600067, + null, + null, + -0.0583882440862257, + 0.0495451872998945 + ], + "ari_use|famhist_1|prosvol": [ + -5.87428930934742, + 0.0455268293607015, + 0.785149311412584, + 0.0273285676173256, + -1.43311563482786, + 0.704605779678315, + null, + 0.32588769243651, + 0.147006602530666, + null, + null, + -0.0496067313605097, + 0.0420554595309713 + ], + "ari_use|dre|priorbiopsy|prosvol|race": [ + -5.05451735992643, + 0.0397928175202178, + 0.607458064647395, + null, + null, + null, + 0.558931929790923, + 0.168105089711408, + 0.105099092250028, + null, + null, + 0.0494164942110061, + -0.0751283458178077 + ], + "ari_use|dre|priorbiopsy|prosvol": [ + -5.0623436041063, + 0.0398895650011522, + 0.607689689976667, + 0.00718299513372286, + null, + null, + 0.55825055760605, + 0.167611350818707, + 0.104470090693869, + null, + null, + 0.0502477102843988, + -0.0756932519208197 + ], + "ari_use|dre|prosvol|race": [ + -5.99132037513598, + 0.0474547827633036, + 0.79980406738069, + null, + -1.45146732023544, + null, + 0.526441250335231, + 0.201119140154538, + 0.104607534545524, + null, + null, + 0.184075150664399, + 0.115699268616548 + ], + "ari_use|dre|prosvol": [ + -5.98428676109771, + 0.0473793339350266, + 0.800053969858027, + -0.0121539486469836, + -1.44919105422868, + null, + 0.525684812650727, + 0.200823802562769, + 0.103654234645708, + null, + null, + 0.178639076804345, + 0.119583747705962 + ], + "ari_use|priorbiopsy|prosvol|race": [ + -5.00896919466946, + 0.0369627082027559, + 0.622348954926851, + null, + null, + 0.62518441712185, + 0.578418282817876, + 0.182930414932493, + 0.116682505125225, + null, + null, + -0.163680501992069, + -0.160140013903073 + ], + "ari_use|priorbiopsy|prosvol": [ + -5.04973729275177, + 0.0374606088562617, + 0.620588814808158, + 0.063763925880632, + null, + 0.626532367518045, + 0.579704508893966, + 0.18241026941686, + 0.11795167129655, + null, + null, + -0.142509397373603, + -0.177239739824571 + ], + "ari_use|prosvol|race": [ + -5.92422289074801, + 0.0441677034595143, + 0.810037652953155, + null, + -1.42370571804959, + 0.701982316022999, + 0.547637400103534, + 0.220161180159604, + 0.098226359659567, + null, + null, + -0.0428669467525603, + 0.0266655062983172 + ], + "ari_use|prosvol": [ + -5.95044661518012, + 0.0444986769502065, + 0.808447501486541, + 0.0431760787871057, + -1.42069966267423, + 0.702794535730272, + 0.548588863232094, + 0.219922759468912, + 0.0996168979993301, + null, + null, + -0.0285734528145185, + 0.014438365450814 + ], + "ari_use|dre|famhist_1|famhist_2|famhist_bca|priorbiopsy|race": [ + -0.375928487855398, + 0.0736601411087144, + 0.767861816444695, + null, + null, + null, + null, + null, + null, + -1.27970038591396, + null, + -0.132233999329451, + -0.226799842125858 + ], + "ari_use|dre|famhist_1|famhist_2|famhist_bca|priorbiopsy": [ + -0.0146827641187578, + 0.0707986272205479, + 0.763852625566405, + -0.209859176789253, + null, + null, + null, + null, + null, + -1.296823858702, + null, + -0.121565077702024, + -0.282550186870482 + ], + "ari_use|dre|famhist_1|famhist_2|famhist_bca|race": [ + -1.29716387537755, + 0.0765185758169807, + 0.845250948797114, + null, + -1.16625935233618, + null, + null, + null, + null, + -1.1734779297445, + null, + -0.0676120396432453, + -0.0536679216450671 + ], + "ari_use|dre|famhist_1|famhist_2|famhist_bca": [ + -0.973351494217716, + 0.0738205777046726, + 0.841905739675837, + -0.206163688723077, + -1.14899246789422, + null, + null, + null, + null, + -1.18505612940791, + null, + -0.0595278790086373, + -0.1138632748762 + ], + "ari_use|famhist_1|famhist_2|famhist_bca|priorbiopsy|race": [ + -0.157669561982167, + 0.0658891172934527, + 0.793417685228823, + null, + null, + 0.661346754384641, + null, + null, + null, + -1.27067307821996, + null, + -0.337278178790184, + -0.258810211466423 + ], + "ari_use|famhist_1|famhist_2|famhist_bca|priorbiopsy": [ + 0.167473760045117, + 0.0644478440865094, + 0.783853239247671, + -0.218506977434326, + null, + 0.600610221766307, + null, + null, + null, + -1.29031331461648, + null, + -0.327637725132286, + -0.309936362256868 + ], + "ari_use|famhist_1|famhist_2|famhist_bca|race": [ + -1.0775408844706, + 0.0685622768420617, + 0.869025976625811, + null, + -1.13894692545072, + 0.670120681556623, + null, + null, + null, + -1.16386414537874, + null, + -0.251973499507304, + -0.0822701445749957 + ], + "ari_use|famhist_1|famhist_2|famhist_bca": [ + -0.801490303454155, + 0.0672454647851767, + 0.86002586233605, + -0.205787974649603, + -1.12673112860328, + 0.620348304492742, + null, + null, + null, + -1.17632468779675, + null, + -0.246365958830387, + -0.137149689976462 + ], + "ari_use|dre|famhist_2|famhist_bca|priorbiopsy|race": [ + -0.925745466044203, + 0.0770745330491943, + 0.803646817762671, + null, + null, + null, + 0.506855409006616, + null, + null, + -1.25964709705848, + null, + 0.0691989061557101, + -0.193027765795227 + ], + "ari_use|dre|famhist_2|famhist_bca|priorbiopsy": [ + -0.556725701982563, + 0.0736415193427872, + 0.800500833344511, + -0.116464152151128, + null, + null, + 0.533911140591355, + null, + null, + -1.27662829164201, + null, + 0.0987572836069564, + -0.255889151532138 + ], + "ari_use|dre|famhist_2|famhist_bca|race": [ + -1.85058836546529, + 0.0798383453027798, + 0.885392061696698, + null, + -1.09436660151755, + null, + 0.528126813163638, + null, + null, + -1.15348861187393, + null, + 0.124413796483165, + -0.0390678249921373 + ], + "ari_use|dre|famhist_2|famhist_bca": [ + -1.53957261897547, + 0.0767063928178946, + 0.884401684150337, + -0.123926235874334, + -1.07565016051793, + null, + 0.55336030575664, + null, + null, + -1.16281366572091, + null, + 0.150224593951467, + -0.108068282185235 + ], + "ari_use|famhist_2|famhist_bca|priorbiopsy|race": [ + -0.70441728042324, + 0.0683590073228076, + 0.840090899824011, + null, + null, + 0.830063354686135, + 0.533866644165681, + null, + null, + -1.25578519496152, + null, + -0.140816278600342, + -0.224331958983201 + ], + "ari_use|famhist_2|famhist_bca|priorbiopsy": [ + -0.380664131251631, + 0.066474291897569, + 0.830507139017143, + -0.114597862836792, + null, + 0.787200673359436, + 0.567591293446608, + null, + null, + -1.27491155911752, + null, + -0.118764832216824, + -0.288747046534373 + ], + "ari_use|famhist_2|famhist_bca|race": [ + -1.58176486993282, + 0.071079036177561, + 0.91514003169784, + null, + -1.0331196402388, + 0.825765661814764, + 0.552040477805379, + null, + null, + -1.15633266446238, + null, + -0.0721643270596867, + -0.0711691787383412 + ], + "ari_use|famhist_2|famhist_bca": [ + -1.32602127275618, + 0.0694094922040462, + 0.907773271951683, + -0.113539760842002, + -1.02370685177321, + 0.795585535985181, + 0.583018247443789, + null, + null, + -1.16649833168583, + null, + -0.0552008128061115, + -0.13761331044166 + ], + "ari_use|dre|famhist_1|famhist_bca|priorbiopsy|race": [ + 0.0700478142977798, + 0.068647920480559, + 0.812029588966255, + null, + null, + null, + null, + 0.328699950298451, + null, + -1.38058136397307, + null, + 0.316939570487788, + -0.137915911284845 + ], + "ari_use|dre|famhist_1|famhist_bca|priorbiopsy": [ + 0.116531272349667, + 0.0681339040509537, + 0.814748349885166, + -0.0755444570232864, + null, + null, + null, + 0.327952607703744, + null, + -1.38148816766525, + null, + 0.288443672263334, + -0.115827579561072 + ], + "ari_use|dre|famhist_1|famhist_bca|race": [ + -1.05819873186128, + 0.0714852330506671, + 0.923594917108326, + null, + -1.04556640099766, + null, + null, + 0.353192452419009, + null, + -1.24926600735751, + null, + 0.375918210479246, + -0.00367796942725294 + ], + "ari_use|dre|famhist_1|famhist_bca": [ + -1.00303525901685, + 0.0709061203546323, + 0.926263636416102, + -0.0800102713164267, + -1.04425311198652, + null, + null, + 0.352481116152899, + null, + -1.25088372646336, + null, + 0.346503822349263, + 0.0192121497337152 + ], + "ari_use|famhist_1|famhist_bca|priorbiopsy|race": [ + 0.00829440127694368, + 0.0664877083722147, + 0.824929125911018, + null, + null, + 0.664986823056492, + null, + 0.368078019552307, + null, + -1.37150616649149, + null, + 0.0831099596307761, + -0.213301395938253 + ], + "ari_use|famhist_1|famhist_bca|priorbiopsy": [ + 0.0308610079678464, + 0.0662225718356548, + 0.8265974495116, + -0.0399390003060163, + null, + 0.662512496811685, + null, + 0.367377969943637, + null, + -1.37179888509993, + null, + 0.0683528653069554, + -0.200306887058757 + ], + "ari_use|famhist_1|famhist_bca|race": [ + -1.09371762234869, + 0.068772817344605, + 0.933432848390039, + null, + -1.00763865776547, + 0.714309213067225, + null, + 0.387418768832346, + null, + -1.24085311120153, + null, + 0.135530269863638, + -0.0751557148176842 + ], + "ari_use|famhist_1|famhist_bca": [ + -1.06673798753615, + 0.0684907043797074, + 0.934825497672534, + -0.0393630075712209, + -1.00597374918158, + 0.712044902346982, + null, + 0.38683733526586, + null, + -1.24170601574993, + null, + 0.121420919733888, + -0.062906612675432 + ], + "ari_use|dre|famhist_bca|priorbiopsy|race": [ + -0.00877370661144141, + 0.0676233537806869, + 0.841676915133733, + null, + null, + null, + 0.593132742835193, + 0.221103251386053, + null, + -1.38493148725854, + null, + 0.322439296264183, + -0.160149643976613 + ], + "ari_use|dre|famhist_bca|priorbiopsy": [ + 0.0208708037551687, + 0.0672766062924456, + 0.843341427162498, + -0.050058810771217, + null, + null, + 0.59030571948347, + 0.220776489722031, + null, + -1.38506752175958, + null, + 0.303224677699758, + -0.145151411026568 + ], + "ari_use|dre|famhist_bca|race": [ + -1.12112765498036, + 0.0703578017164368, + 0.951597495169571, + null, + -1.03001334319055, + null, + 0.568473145169294, + 0.245453006249443, + null, + -1.25388099646739, + null, + 0.383586589845404, + -0.0282679502227717 + ], + "ari_use|dre|famhist_bca": [ + -1.08059485520475, + 0.0699241873836238, + 0.953415714619349, + -0.0586509545531154, + -1.02900219128692, + null, + 0.56582307776086, + 0.245100238111922, + null, + -1.25481588500832, + null, + 0.361937659868419, + -0.0113170405620663 + ], + "ari_use|famhist_bca|priorbiopsy|race": [ + -0.0666608252246485, + 0.0654264636389407, + 0.856236949122843, + null, + null, + 0.664074052931883, + 0.611450729230806, + 0.2580933113744, + null, + -1.37737433484215, + null, + 0.0965731483221815, + -0.237369006658231 + ], + "ari_use|famhist_bca|priorbiopsy": [ + -0.0640667414628026, + 0.0653568388622627, + 0.856683971659386, + -0.0114834782840014, + null, + 0.66281113823271, + 0.610252048312924, + 0.257735858748307, + null, + -1.37670549737769, + null, + 0.0916473261463553, + -0.232934821989478 + ], + "ari_use|famhist_bca|race": [ + -1.14978758154216, + 0.0676367873311609, + 0.962654328533565, + null, + -0.993000322471357, + 0.711033317359783, + 0.590241347865838, + 0.277084982428334, + null, + -1.24750610975469, + null, + 0.150942969945602, + -0.102099229102382 + ], + "ari_use|famhist_bca": [ + -1.14062786414446, + 0.0675298571196337, + 0.963039423318382, + -0.0148020713811878, + -0.991525077894835, + 0.709849105266512, + 0.589220089461765, + 0.276801493416333, + null, + -1.24751526045641, + null, + 0.145254391915176, + -0.0972024239332511 + ], + "ari_use|dre|famhist_1|famhist_2|priorbiopsy|race": [ + 0.168949999730051, + 0.0668389874178805, + 0.8206745226053, + null, + null, + null, + null, + null, + 0.191557029366061, + -1.3803899528344, + null, + 0.340548388183215, + -0.14667202751877 + ], + "ari_use|dre|famhist_1|famhist_2|priorbiopsy": [ + 0.211696563438096, + 0.0663698247880663, + 0.823176119574839, + -0.0697373459077077, + null, + null, + null, + null, + 0.188088040228409, + -1.38116728269289, + null, + 0.314083742813367, + -0.126251054891615 + ], + "ari_use|dre|famhist_1|famhist_2|race": [ + -0.969825619891414, + 0.0696336977898538, + 0.935126914293673, + null, + -1.06222314922742, + null, + null, + null, + 0.186559103894533, + -1.2473358120042, + null, + 0.401431904241948, + -0.0109513088315886 + ], + "ari_use|dre|famhist_1|famhist_2": [ + -0.918764276917689, + 0.0691064771461382, + 0.937540179026201, + -0.0736430094510004, + -1.06081326248617, + null, + null, + null, + 0.182797241080497, + -1.24882893963792, + null, + 0.374220088266695, + 0.0100825421549646 + ], + "ari_use|famhist_1|famhist_2|priorbiopsy|race": [ + 0.108963030550415, + 0.0646718771361276, + 0.833641416432939, + null, + null, + 0.64828454783345, + null, + null, + 0.238543415547804, + -1.37027620387529, + null, + 0.115735993084165, + -0.227202233473334 + ], + "ari_use|famhist_1|famhist_2|priorbiopsy": [ + 0.127671152025219, + 0.0644466595047524, + 0.835087782270752, + -0.0345827958420916, + null, + 0.646071938852914, + null, + null, + 0.236366776469125, + -1.37037641950525, + null, + 0.102761825790663, + -0.215814599148351 + ], + "ari_use|famhist_1|famhist_2|race": [ + -1.00441456572645, + 0.0669645496670207, + 0.944414620991282, + null, + -1.0209064287153, + 0.697188217115868, + null, + null, + 0.216718216021072, + -1.23795508240029, + null, + 0.170506622018572, + -0.0868870568122018 + ], + "ari_use|famhist_1|famhist_2": [ + -0.981320781439545, + 0.0667237476328843, + 0.945586547864472, + -0.0340044658699086, + -1.0192442964439, + 0.695186458438539, + null, + null, + 0.214750591613028, + -1.23862585319388, + null, + 0.158178793607758, + -0.0762620219126737 + ], + "ari_use|dre|famhist_2|priorbiopsy|race": [ + 0.0473871858058623, + 0.0662816951625083, + 0.850641018294673, + null, + null, + null, + 0.591129021193478, + null, + 0.143540936910721, + -1.38327766069786, + null, + 0.341864858164776, + -0.168457382878519 + ], + "ari_use|dre|famhist_2|priorbiopsy": [ + 0.074959790530128, + 0.0659608337298368, + 0.852193697748699, + -0.0468671709114564, + null, + null, + 0.588649943170968, + null, + 0.141188197239429, + -1.3833661623337, + null, + 0.323766561822813, + -0.15435112966401 + ], + "ari_use|dre|famhist_2|race": [ + -1.07842096917487, + 0.0689911768164265, + 0.964016597003926, + null, + -1.04871497798903, + null, + 0.569284317716408, + null, + 0.137740595523238, + -1.2505675584922, + null, + 0.405388378522488, + -0.0346685773957113 + ], + "ari_use|dre|famhist_2": [ + -1.04014953596847, + 0.0685877318570898, + 0.965705402146785, + -0.0550723743857077, + -1.04764249986247, + null, + 0.567048817175726, + null, + 0.134921205374968, + -1.25146701453171, + null, + 0.384984469866822, + -0.0187350910847431 + ], + "ari_use|famhist_2|priorbiopsy|race": [ + -0.00992513758084617, + 0.0640971751333852, + 0.864489095051562, + null, + null, + 0.646345559374602, + 0.60740685926731, + null, + 0.181305088761215, + -1.37426700647929, + null, + 0.124019926754032, + -0.248999618729176 + ], + "ari_use|famhist_2|priorbiopsy": [ + -0.00892179421488617, + 0.0640443296579445, + 0.864858399719201, + -0.00943989940156527, + null, + 0.645189784577772, + 0.606365238222732, + null, + 0.180381087937964, + -1.37353343818739, + null, + 0.119769440909279, + -0.245172642489288 + ], + "ari_use|famhist_2|race": [ + -1.10785751891578, + 0.0663235390353776, + 0.973916672295195, + null, + -1.00889553105277, + 0.693119390256173, + 0.589523971537314, + null, + 0.158433786953962, + -1.24272393907311, + null, + 0.181159736876671, + -0.111065281959312 + ], + "ari_use|famhist_2": [ + -1.10016402973753, + 0.0662325510558224, + 0.97422973549074, + -0.0128484600979642, + -1.00741865423394, + 0.692045486841715, + 0.588663507282108, + null, + 0.157588231721957, + -1.24267913357955, + null, + 0.176117738092849, + -0.10676101959616 + ], + "ari_use|dre|famhist_1|priorbiopsy|race": [ + 0.0907050455658476, + 0.0681145358219141, + 0.826501391207271, + null, + null, + null, + null, + 0.333322980334657, + 0.187242745816463, + -1.38884789783307, + null, + 0.3256255087231, + -0.145998626632754 + ], + "ari_use|dre|famhist_1|priorbiopsy": [ + 0.129859278364702, + 0.0676765760835817, + 0.828791225149811, + -0.0653718461724311, + null, + null, + null, + 0.332573853445361, + 0.183867100892915, + -1.38942734498191, + null, + 0.300814406046455, + -0.126631964774953 + ], + "ari_use|dre|famhist_1|race": [ + -1.0640545763431, + 0.0710735714731339, + 0.942247698237405, + null, + -1.06758096136895, + null, + null, + 0.359937935651833, + 0.183715408217139, + -1.25586419312241, + null, + 0.386133653244556, + -0.00966598299302537 + ], + "ari_use|dre|famhist_1": [ + -1.01669826693892, + 0.0705781947507232, + 0.944447180364256, + -0.0692514704332879, + -1.06619422387257, + null, + null, + 0.359212565306509, + 0.180032297566112, + -1.25715815492985, + null, + 0.360614706613957, + 0.0103459245256834 + ], + "ari_use|famhist_1|priorbiopsy|race": [ + 0.0319294500339431, + 0.066036902433002, + 0.840926907339365, + null, + null, + 0.658885967290744, + null, + 0.373197432700925, + 0.233758359688717, + -1.38207289605611, + null, + 0.0948615213887796, + -0.225637088289634 + ], + "ari_use|famhist_1|priorbiopsy": [ + 0.0460891421163667, + 0.0658538152180253, + 0.842084032776745, + -0.0284574491109806, + null, + 0.656892356253405, + null, + 0.372533800955394, + 0.231806089872658, + -1.38192703221138, + null, + 0.0840733724471339, + -0.216029197282397 + ], + "ari_use|famhist_1|race": [ + -1.09576852812278, + 0.0684361105812286, + 0.953215911492044, + null, + -1.02738738545822, + 0.708866563978614, + null, + 0.395475649426746, + 0.214241929087174, + -1.24955583183129, + null, + 0.14871070144234, + -0.0844294605688867 + ], + "ari_use|famhist_1": [ + -1.07752575883334, + 0.0682407173277132, + 0.95410430409296, + -0.0276398949545375, + -1.02575671023904, + 0.70711318101915, + null, + 0.394927498451685, + 0.21251143604327, + -1.24997915095192, + null, + 0.138638742664658, + -0.0756425274908673 + ], + "ari_use|dre|priorbiopsy|race": [ + 0.0156195059719923, + 0.0671088243108895, + 0.854213704982394, + null, + null, + null, + 0.57750679291644, + 0.229590302460817, + 0.140364073635292, + -1.39193550450123, + null, + 0.331408599771071, + -0.166821214196867 + ], + "ari_use|dre|priorbiopsy": [ + 0.0392177593721786, + 0.0668249026734864, + 0.855563893770038, + -0.0417497591109064, + null, + null, + 0.575157395111784, + 0.229226867188454, + 0.138176325720835, + -1.39184497392885, + null, + 0.315176182011959, + -0.154069045805083 + ], + "ari_use|dre|race": [ + -1.12276845220262, + 0.0699714764988557, + 0.968351755213247, + null, + -1.05220283890648, + null, + 0.55174185093647, + 0.256153211451114, + 0.135949560543952, + -1.2593920005521, + null, + 0.394301530037166, + -0.0327167657573517 + ], + "ari_use|dre": [ + -1.08867465541051, + 0.0696069143711582, + 0.969818222450376, + -0.0498554006555381, + -1.05109002503899, + null, + 0.549580569292409, + 0.255761062767869, + 0.133319181254016, + -1.26009710082433, + null, + 0.375800973069844, + -0.0181399497339944 + ], + "ari_use|priorbiopsy|race": [ + -0.0399723892890631, + 0.0649982323912302, + 0.869500345242646, + null, + null, + 0.657652863601787, + 0.591931087792431, + 0.268089849803683, + 0.17794833173939, + -1.38597861857432, + null, + 0.10808773004312, + -0.247381623633347 + ], + "ari_use|priorbiopsy": [ + -0.0438275731335433, + 0.0649918997355538, + 0.869605652840827, + -0.00269601171386794, + null, + 0.656774410909565, + 0.591212402758501, + 0.267732060531573, + 0.177285679124692, + -1.38502589919591, + null, + 0.106186124746805, + -0.245611309490817 + ], + "ari_use|race": [ + -1.14866184741982, + 0.0673261530906579, + 0.979891555008183, + null, + -1.01349973176079, + 0.705306035003915, + 0.570476883533704, + 0.289878994101521, + 0.157164438045131, + -1.25441837798851, + null, + 0.164171305012905, + -0.108877219734622 + ], + "ari_use": [ + -1.1462226163412, + 0.067286246152162, + 0.979930178821537, + -0.00578588129002465, + -1.01200005801, + 0.704532641749727, + 0.569907456559981, + 0.289604334053758, + 0.156620642801256, + -1.25414236633282, + null, + 0.161561424113003, + -0.106719466214667 + ], + "dre|famhist_1|famhist_2|famhist_bca|priorbiopsy|prosvol|race": [ + -5.39262073735834, + 0.0571962218555936, + 0.469853901675122, + null, + null, + null, + null, + null, + null, + null, + -0.303303980479113, + -0.228767475474406, + -0.225220950045259 + ], + "dre|famhist_1|famhist_2|famhist_bca|priorbiopsy|prosvol": [ + -5.17135583131351, + 0.0546426642775916, + 0.462805359198914, + -0.134962948293732, + null, + null, + null, + null, + null, + null, + -0.288657317624263, + -0.201057419082299, + -0.277723238031857 + ], + "dre|famhist_1|famhist_2|famhist_bca|prosvol|race": [ + -5.94529862848346, + 0.0610036822526484, + 0.600016151268495, + null, + -1.45651035589704, + null, + null, + null, + null, + null, + -0.111630826753175, + -0.164575497588244, + 0.0223055690706541 + ], + "dre|famhist_1|famhist_2|famhist_bca|prosvol": [ + -5.71139681501931, + 0.0582148317727259, + 0.600934128533629, + -0.14476940021241, + -1.46273007962055, + null, + null, + null, + null, + null, + -0.0906175951273328, + -0.148027437777614, + -0.0333306135762612 + ], + "famhist_1|famhist_2|famhist_bca|priorbiopsy|prosvol|race": [ + -4.95079499445049, + 0.0467440364399589, + 0.504488758061632, + null, + null, + 0.615346456271023, + null, + null, + null, + null, + -0.291896324339263, + -0.414551147254371, + -0.261963078438813 + ], + "famhist_1|famhist_2|famhist_bca|priorbiopsy|prosvol": [ + -4.7373506277714, + 0.0452846040440364, + 0.491377063009972, + -0.137148421534124, + null, + 0.566127779605083, + null, + null, + null, + null, + -0.269989213564291, + -0.393116007463534, + -0.335070000437642 + ], + "famhist_1|famhist_2|famhist_bca|prosvol|race": [ + -5.50796761827836, + 0.0505441376096363, + 0.631376072154155, + null, + -1.4394207911047, + 0.622163890398418, + null, + null, + null, + null, + -0.0994796003789832, + -0.314881241106836, + -0.0202206541132662 + ], + "famhist_1|famhist_2|famhist_bca|prosvol": [ + -5.27945289603511, + 0.048696436065013, + 0.625860563356289, + -0.137505662256849, + -1.45760284735173, + 0.587407621613614, + null, + null, + null, + null, + -0.0713010578356056, + -0.305475102919686, + -0.0931358664025942 + ], + "dre|famhist_2|famhist_bca|priorbiopsy|prosvol|race": [ + -5.84130223271165, + 0.0615536406349453, + 0.491168469662092, + null, + null, + null, + 0.453891799373711, + null, + null, + null, + -0.246371229971783, + -0.0716664926720156, + -0.204993468651934 + ], + "dre|famhist_2|famhist_bca|priorbiopsy|prosvol": [ + -5.59604262842384, + 0.0584849795834097, + 0.482812638544351, + -0.0310066529196432, + null, + null, + 0.470174268372865, + null, + null, + null, + -0.216603277771334, + -0.0368476927867896, + -0.265834742576752 + ], + "dre|famhist_2|famhist_bca|prosvol|race": [ + -6.46417928576789, + 0.0664894400386195, + 0.619248130043261, + null, + -1.39401314544092, + null, + 0.504170829907838, + null, + null, + null, + -0.0493530868166634, + -0.0133483914202559, + 0.0196726858799275 + ], + "dre|famhist_2|famhist_bca|prosvol": [ + -6.2369557674755, + 0.0635918259905609, + 0.622010771781144, + -0.0750357340921161, + -1.4071744022739, + null, + 0.520454711325663, + null, + null, + null, + -0.0120119278484347, + 0.0111018309430643, + -0.0453707326466267 + ], + "famhist_2|famhist_bca|priorbiopsy|prosvol|race": [ + -5.3917365676229, + 0.0489074645051627, + 0.545288143880148, + null, + null, + 0.837535612347624, + 0.522147681310003, + null, + null, + null, + -0.293110869160387, + -0.266641092570948, + -0.237867783268484 + ], + "famhist_2|famhist_bca|priorbiopsy|prosvol": [ + -5.13839911838433, + 0.0466203153571881, + 0.531098558460091, + -0.0407604366225648, + null, + 0.818024423561498, + 0.550541552918986, + null, + null, + null, + -0.262817100203271, + -0.249634190194624, + -0.324901988083848 + ], + "famhist_2|famhist_bca|prosvol|race": [ + -5.98146194823308, + 0.0538064006651997, + 0.659936394367678, + null, + -1.33606101293576, + 0.822932748929701, + 0.562757445041086, + null, + null, + null, + -0.0801877436167244, + -0.184766594305213, + -0.0282325253320098 + ], + "famhist_2|famhist_bca|prosvol": [ + -5.7442088939151, + 0.0514988827803776, + 0.656165973999445, + -0.0841772606037209, + -1.36924374966809, + 0.819191640891177, + 0.59008178587813, + null, + null, + null, + -0.0384787003246895, + -0.178565835109456, + -0.110339005553367 + ], + "dre|famhist_1|famhist_bca|priorbiopsy|prosvol|race": [ + -4.72716954493297, + 0.0480060957818402, + 0.427820174132005, + null, + null, + null, + null, + 0.232611730567515, + null, + null, + -0.277743844901523, + -0.0774431883921775, + -0.195353099098864 + ], + "dre|famhist_1|famhist_bca|priorbiopsy|prosvol": [ + -4.69210525245143, + 0.0475975314667658, + 0.431184325363525, + -0.250335057960805, + null, + null, + null, + 0.235742472553131, + null, + null, + -0.273217907900253, + -0.122388657292332, + -0.144127557816499 + ], + "dre|famhist_1|famhist_bca|prosvol|race": [ + -5.40332206125628, + 0.0518383876849288, + 0.626907236009113, + null, + -1.57259807113855, + null, + null, + 0.264502411208746, + null, + null, + -0.0173655915047177, + 0.0175287341618806, + -0.00676204210581387 + ], + "dre|famhist_1|famhist_bca|prosvol": [ + -5.35701051443997, + 0.0512984882616038, + 0.63085154303947, + -0.301871777694077, + -1.5751441486332, + null, + null, + 0.26940074152016, + null, + null, + -0.0219850798485287, + -0.0339073992837705, + 0.0537794669698227 + ], + "famhist_1|famhist_bca|priorbiopsy|prosvol|race": [ + -4.78431398242431, + 0.0489462709851185, + 0.425972389115697, + null, + null, + 0.50998995559496, + null, + 0.281463724432348, + null, + null, + -0.344437070995647, + -0.25042716459292, + -0.35797302890537 + ], + "famhist_1|famhist_bca|priorbiopsy|prosvol": [ + -4.7522146522933, + 0.0485035825810565, + 0.429623808859621, + -0.217161279784569, + null, + 0.507945358756007, + null, + 0.284449821476247, + null, + null, + -0.339367419564079, + -0.290228345513318, + -0.309377311092948 + ], + "famhist_1|famhist_bca|prosvol|race": [ + -5.37960017424588, + 0.0514485948366935, + 0.618529656730644, + null, + -1.53023150007703, + 0.60720734926358, + null, + 0.308320344257563, + null, + null, + -0.0713324264321684, + -0.165915546682248, + -0.191126959233665 + ], + "famhist_1|famhist_bca|prosvol": [ + -5.33567468311778, + 0.0508366605475372, + 0.623324689519034, + -0.285677583189079, + -1.53504006989293, + 0.608488593052501, + null, + 0.313375386045348, + null, + null, + -0.0743792806108895, + -0.215852131627125, + -0.129032022124988 + ], + "dre|famhist_bca|priorbiopsy|prosvol|race": [ + -4.92253050436568, + 0.0482598205744303, + 0.455746857958449, + null, + null, + null, + 0.644289343302279, + 0.140166058223112, + null, + null, + -0.305482187523725, + -0.0568568082200359, + -0.227883743011881 + ], + "dre|famhist_bca|priorbiopsy|prosvol": [ + -4.88277822243497, + 0.0477963883200054, + 0.459342765834438, + -0.255091904188237, + null, + null, + 0.64446296308095, + 0.143747822759521, + null, + null, + -0.302013017085159, + -0.102050799114134, + -0.177351612559801 + ], + "dre|famhist_bca|prosvol|race": [ + -5.59090729708231, + 0.0520424492216905, + 0.653821194181545, + null, + -1.56339332824169, + null, + 0.616132485308135, + 0.172161561228037, + null, + null, + -0.043752755677724, + 0.0424433744128751, + -0.0414460676921529 + ], + "dre|famhist_bca|prosvol": [ + -5.5392018637767, + 0.0514179880641908, + 0.658409643311969, + -0.308970862140422, + -1.56703467958706, + null, + 0.618095321380806, + 0.17748010252934, + null, + null, + -0.0486811234539656, + -0.00924032628094025, + 0.0186683153881693 + ], + "famhist_bca|priorbiopsy|prosvol|race": [ + -5.00480712290173, + 0.0494559063848514, + 0.456042226976623, + null, + null, + 0.517475655000379, + 0.675058326113888, + 0.191212639351916, + null, + null, + -0.371830385492718, + -0.223758809662656, + -0.400761829900343 + ], + "famhist_bca|priorbiopsy|prosvol": [ + -4.96967568496238, + 0.048968442242508, + 0.459835715070866, + -0.218323201092628, + null, + 0.515362651460086, + 0.674140187775848, + 0.194839117828378, + null, + null, + -0.367992667806559, + -0.263406340774064, + -0.352126108986165 + ], + "famhist_bca|prosvol|race": [ + -5.5947124245562, + 0.0519223129413909, + 0.648308176013152, + null, + -1.52526304049956, + 0.61164461572928, + 0.661429897151502, + 0.215950557389276, + null, + null, + -0.0954069394898163, + -0.132367133680501, + -0.238422481048929 + ], + "famhist_bca|prosvol": [ + -5.54651243836802, + 0.0512257340491606, + 0.653606551745334, + -0.289760510299567, + -1.53131308270227, + 0.612938270319452, + 0.662029559151126, + 0.221615119467098, + null, + null, + -0.0987685313262202, + -0.182270950958944, + -0.175544705365115 + ], + "dre|famhist_1|famhist_2|priorbiopsy|prosvol|race": [ + -4.61005388357132, + 0.0460374525716933, + 0.431929052160902, + null, + null, + null, + null, + null, + 0.112693404562665, + null, + -0.297333166083379, + -0.0531725847590118, + -0.197575713851853 + ], + "dre|famhist_1|famhist_2|priorbiopsy|prosvol": [ + -4.57249177219447, + 0.0456054374016172, + 0.435072571488321, + -0.245521323598698, + null, + null, + null, + null, + 0.111644514431627, + null, + -0.292893672234523, + -0.0975012315512669, + -0.147157418563182 + ], + "dre|famhist_1|famhist_2|prosvol|race": [ + -5.28760209721696, + 0.0497128697937644, + 0.637069441105464, + null, + -1.59123980584886, + null, + null, + null, + 0.114892191130971, + null, + -0.0401608524156429, + 0.0450659053087869, + -0.00814937178796414 + ], + "dre|famhist_1|famhist_2|prosvol": [ + -5.23796521819466, + 0.049142092526235, + 0.640769038074515, + -0.296107302111941, + -1.59347346657974, + null, + null, + null, + 0.113164394341449, + null, + -0.0450561281619867, + -0.00577538941627835, + 0.0515554859348756 + ], + "famhist_1|famhist_2|priorbiopsy|prosvol|race": [ + -4.65083776478611, + 0.0467449801226706, + 0.4305323925469, + null, + null, + 0.504019864368366, + null, + null, + 0.163105361268141, + null, + -0.364643933492166, + -0.217978531390083, + -0.364616198468951 + ], + "famhist_1|famhist_2|priorbiopsy|prosvol": [ + -4.61609054160431, + 0.0462745446379246, + 0.433993121152339, + -0.212508215386477, + null, + 0.502485022297633, + null, + null, + 0.161323040489048, + null, + -0.359599638167803, + -0.257309273964356, + -0.316836220695162 + ], + "famhist_1|famhist_2|prosvol|race": [ + -5.24764293692504, + 0.0491506875020397, + 0.628607280696837, + null, + -1.54640388867273, + 0.598668379574371, + null, + null, + 0.145879473218551, + null, + -0.0946530831478245, + -0.130351517835993, + -0.196195184276904 + ], + "famhist_1|famhist_2|prosvol": [ + -5.20034467512254, + 0.0485049707895915, + 0.633182865351493, + -0.280381090203053, + -1.55092669883854, + 0.600463940297722, + null, + null, + 0.143865725660007, + null, + -0.097895226697588, + -0.179822762591844, + -0.134881072909887 + ], + "dre|famhist_2|priorbiopsy|prosvol|race": [ + -4.85019513175159, + 0.0468866017518913, + 0.460373050705124, + null, + null, + null, + 0.645048474913292, + null, + 0.0938721791542195, + null, + -0.322979126981939, + -0.0344390271120201, + -0.234161401729643 + ], + "dre|famhist_2|priorbiopsy|prosvol": [ + -4.80738374023905, + 0.0463903514826794, + 0.463762493581055, + -0.254018002856839, + null, + null, + 0.646213131610241, + null, + 0.0932726395774472, + null, + -0.319646323124661, + -0.0797519488855638, + -0.18354029521133 + ], + "dre|famhist_2|prosvol|race": [ + -5.52329271897148, + 0.050537736656413, + 0.664921856345789, + null, + -1.5830202674447, + null, + 0.616675708360782, + null, + 0.0914280317287088, + null, + -0.0645668758788433, + 0.0695514422266359, + -0.0466404133074727 + ], + "dre|famhist_2|prosvol": [ + -5.46736553694272, + 0.0498650172643767, + 0.669340945628811, + -0.307171006153672, + -1.58653933301306, + null, + 0.619933874218246, + null, + 0.0893423001708336, + null, + -0.0698848327403681, + 0.0177591943010188, + 0.0136331809209255 + ], + "famhist_2|priorbiopsy|prosvol|race": [ + -4.91578427132712, + 0.0478991669469557, + 0.460018595910883, + null, + null, + 0.509536271406504, + 0.676174916383367, + null, + 0.134749060770454, + null, + -0.389706306039573, + -0.193818979823018, + -0.409638484655319 + ], + "famhist_2|priorbiopsy|prosvol": [ + -4.87720349184429, + 0.0473690602630994, + 0.463684623157079, + -0.218466140889154, + null, + 0.508070510909301, + 0.676349773877411, + null, + 0.133677647220387, + null, + -0.385955320099891, + -0.233907141388392, + -0.360750086728859 + ], + "famhist_2|prosvol|race": [ + -5.51091210837745, + 0.0502934183705175, + 0.658377073992798, + null, + -1.54233575173982, + 0.600810330490304, + 0.661506714005668, + null, + 0.11109217679334, + null, + -0.116927006458826, + -0.0978313803498451, + -0.245239549864455 + ], + "famhist_2|prosvol": [ + -5.45818907414107, + 0.0495388085649598, + 0.663590569294257, + -0.289543257750333, + -1.54837052548995, + 0.602870502276861, + 0.663608462866248, + null, + 0.109181075862189, + null, + -0.120571205279443, + -0.148160148814474, + -0.1819976290937 + ], + "dre|famhist_1|priorbiopsy|prosvol|race": [ + -4.7270667707275, + 0.047363513141231, + 0.437039363780311, + null, + null, + null, + null, + 0.241663340323618, + 0.112634167645615, + null, + -0.293883343538791, + -0.0581267368759135, + -0.207778672305123 + ], + "dre|famhist_1|priorbiopsy|prosvol": [ + -4.69314496874254, + 0.0469763176159224, + 0.440250124816083, + -0.245077837966988, + null, + null, + null, + 0.244766507658003, + 0.111814608724666, + null, + -0.289265743533188, + -0.102193015584697, + -0.157675448804988 + ], + "dre|famhist_1|prosvol|race": [ + -5.43312987034138, + 0.0513662973347456, + 0.643369511366229, + null, + -1.59637427357289, + null, + null, + 0.275352928840013, + 0.114867257349487, + null, + -0.0340530051720343, + 0.0411809066075334, + -0.0185189245824881 + ], + "dre|famhist_1|prosvol": [ + -5.38794514379924, + 0.050847494292186, + 0.647178476290959, + -0.296432609640994, + -1.5988100321636, + null, + null, + 0.280216397922438, + 0.113319351587556, + null, + -0.0387113010364323, + -0.00923846561314785, + 0.0408009998818987 + ], + "famhist_1|priorbiopsy|prosvol|race": [ + -4.79447916341386, + 0.0483943161787554, + 0.435757129964692, + null, + null, + 0.507183051055149, + null, + 0.292089512789635, + 0.162941595320617, + null, + -0.362524645190164, + -0.226319014357131, + -0.376267308117786 + ], + "famhist_1|priorbiopsy|prosvol": [ + -4.76364602726552, + 0.0479771474125681, + 0.439233680250924, + -0.211609640774077, + null, + 0.505133473653907, + null, + 0.29504874313268, + 0.161402331413704, + null, + -0.35732673844067, + -0.265154674813943, + -0.328918936081914 + ], + "famhist_1|prosvol|race": [ + -5.41704812787416, + 0.0510578098074467, + 0.635532113394078, + null, + -1.55345158397152, + 0.604970620453431, + null, + 0.321249018687994, + 0.145976087344094, + null, + -0.0899362412166239, + -0.138339749535918, + -0.20779885515438 + ], + "famhist_1|prosvol": [ + -5.37457149991797, + 0.050473106775234, + 0.640172294893195, + -0.279749265488456, + -1.55808780983434, + 0.606150314632161, + null, + 0.326286750038813, + 0.144208655948571, + null, + -0.0929216858891918, + -0.187051667539165, + -0.147123311887472 + ], + "dre|priorbiopsy|prosvol|race": [ + -4.91371378326038, + 0.0476237985738346, + 0.462924522170604, + null, + null, + null, + 0.636023657744508, + 0.151523945551544, + 0.0929589230533805, + null, + -0.32037136093835, + -0.0394490290354375, + -0.238422545324154 + ], + "dre|priorbiopsy|prosvol": [ + -4.87524902098037, + 0.0471839761548071, + 0.466336850113826, + -0.250283457325201, + null, + null, + 0.636364044884946, + 0.15505515513328, + 0.0925807721989708, + null, + -0.316764775210086, + -0.083878840503375, + -0.188882008344028 + ], + "dre|prosvol|race": [ + -5.61014916683072, + 0.0515502481931694, + 0.668428452771751, + null, + -1.58658101546601, + null, + 0.604870137203191, + 0.185854974026079, + 0.0905175546918733, + null, + -0.0601917654069832, + 0.0645462195888664, + -0.0510995521346472 + ], + "dre|prosvol": [ + -5.55962178075384, + 0.0509486785209762, + 0.672848384094889, + -0.303701971987271, + -1.59008975318004, + null, + 0.606933131573125, + 0.191116467138151, + 0.0886696075643191, + null, + -0.0651636983971978, + 0.0137699680207121, + 0.00792721855500444 + ], + "priorbiopsy|prosvol|race": [ + -5.00373307881167, + 0.0489197368049411, + 0.463006720282491, + null, + null, + 0.51444960858361, + 0.664910970385759, + 0.204659628756684, + 0.133679320399171, + null, + -0.388184648812538, + -0.202593705314825, + -0.415730943982871 + ], + "priorbiopsy|prosvol": [ + -4.96997116351028, + 0.0484584249131321, + 0.466620408486829, + -0.21374763588701, + null, + 0.512409473146539, + 0.664271444399209, + 0.208243556009874, + 0.132870261539859, + null, + -0.384180651051512, + -0.241478894612062, + -0.368182152911214 + ], + "prosvol|race": [ + -5.61841361473991, + 0.0515132328817694, + 0.662674519242055, + null, + -1.54780405869117, + 0.608431557061427, + 0.648331002521824, + 0.23232411949175, + 0.11010557481696, + null, + -0.113785532521706, + -0.107226254280669, + -0.251194023205304 + ], + "prosvol": [ + -5.57166326071618, + 0.050844258779371, + 0.66779554706208, + -0.284450077917415, + -1.55365742898857, + 0.609712143628628, + 0.649149640107809, + 0.237962958169839, + 0.108530447182818, + null, + -0.117112242316053, + -0.156107757746617, + -0.189594984224198 + ], + "dre|famhist_1|famhist_2|famhist_bca|priorbiopsy|race": [ + -0.0357815824348256, + 0.0738515398480059, + 0.729607897001065, + null, + null, + null, + null, + null, + null, + -1.30794145662202, + -0.194185230052283, + -0.202527518361754, + -0.232917838710333 + ], + "dre|famhist_1|famhist_2|famhist_bca|priorbiopsy": [ + 0.39696951121048, + 0.0710128286842727, + 0.717603047899961, + -0.316340316249797, + null, + null, + null, + null, + null, + -1.32985177770598, + -0.218331170283106, + -0.167275518901464, + -0.328835089354409 + ], + "dre|famhist_1|famhist_2|famhist_bca|race": [ + -0.878552991926295, + 0.0759963222827719, + 0.802011831558016, + null, + -1.22567281738086, + null, + null, + null, + null, + -1.2051823985878, + -0.0482735533270518, + -0.142639828718368, + -0.0587663993215969 + ], + "dre|famhist_1|famhist_2|famhist_bca": [ + -0.491062116135438, + 0.0733407445383953, + 0.789844876746555, + -0.303435652492296, + -1.20866518445844, + null, + null, + null, + null, + -1.21917956335514, + -0.0717751262678106, + -0.113938540916121, + -0.160125018212284 + ], + "famhist_1|famhist_2|famhist_bca|priorbiopsy|race": [ + 0.227202797300499, + 0.0658975949386329, + 0.7574271295881, + null, + null, + 0.633415203509407, + null, + null, + null, + -1.30133929966483, + -0.169115508732585, + -0.394281989895809, + -0.289943860210596 + ], + "famhist_1|famhist_2|famhist_bca|priorbiopsy": [ + 0.647020298436163, + 0.0645594227787359, + 0.740233664954824, + -0.374173673811273, + null, + 0.574974845893646, + null, + null, + null, + -1.33048665043109, + -0.194680433455425, + -0.36341747902677, + -0.377996226078051 + ], + "famhist_1|famhist_2|famhist_bca|race": [ + -0.615202197137704, + 0.0678637832827473, + 0.827538090711293, + null, + -1.20159730269658, + 0.637964559873406, + null, + null, + null, + -1.19768291054902, + -0.0296477050153789, + -0.310362181546639, + -0.111510406687084 + ], + "famhist_1|famhist_2|famhist_bca": [ + -0.253221067348234, + 0.066632444702076, + 0.810268194584472, + -0.352091107032843, + -1.19090694989002, + 0.592643683515781, + null, + null, + null, + -1.21684117842629, + -0.0532244890762732, + -0.2885903332818, + -0.203765477205181 + ], + "dre|famhist_2|famhist_bca|priorbiopsy|race": [ + -0.606535942735173, + 0.0779177124542997, + 0.755870042391012, + null, + null, + null, + 0.517585141410807, + null, + null, + -1.28947972428571, + -0.144153158549842, + 0.000654023836338211, + -0.191115058269148 + ], + "dre|famhist_2|famhist_bca|priorbiopsy": [ + -0.133120823663385, + 0.0742331093296252, + 0.74154815612847, + -0.174237527971755, + null, + null, + 0.557247843930616, + null, + null, + -1.31313021026208, + -0.158375816850539, + 0.0458389084897833, + -0.290320046984813 + ], + "dre|famhist_2|famhist_bca|race": [ + -1.47094284095281, + 0.0799895132476327, + 0.833343104292208, + null, + -1.1595542182727, + null, + 0.550238745346048, + null, + null, + -1.18406770739707, + 0.00891699622168946, + 0.0474925520114258, + -0.0392566134323769 + ], + "dre|famhist_2|famhist_bca": [ + -1.07855104682172, + 0.0766896155048612, + 0.820789597844831, + -0.187808748530561, + -1.14227827398486, + null, + 0.587460516226905, + null, + null, + -1.19569752067548, + -0.00487897029033454, + 0.0860884403799897, + -0.146190779399013 + ], + "famhist_2|famhist_bca|priorbiopsy|race": [ + -0.371226760362577, + 0.0692604177796876, + 0.798841334495598, + null, + null, + 0.837421015762073, + 0.556782978635, + null, + null, + -1.28862613605846, + -0.177094118703526, + -0.200480200423874, + -0.252434699863244 + ], + "famhist_2|famhist_bca|priorbiopsy": [ + 0.0966726715087983, + 0.0671886337188176, + 0.778418283863412, + -0.245052172164439, + null, + 0.802057036782536, + 0.604155248219027, + null, + null, + -1.32088420828856, + -0.199048805725317, + -0.171592796623004, + -0.351934559698683 + ], + "famhist_2|famhist_bca|race": [ + -1.17188543857382, + 0.0712837718152215, + 0.867731541169576, + null, + -1.09233089154024, + 0.823338085663149, + 0.587637599555251, + null, + null, + -1.19189789641593, + -0.0189344703425261, + -0.134784036562422, + -0.10280276839466 + ], + "famhist_2|famhist_bca": [ + -0.795335114642248, + 0.0694638084643381, + 0.849467663320236, + -0.251486273465623, + -1.08764540870435, + 0.803828141141408, + 0.631530916868044, + null, + null, + -1.21063108854692, + -0.0370681969909193, + -0.115051482401896, + -0.204488862230558 + ], + "dre|famhist_1|famhist_bca|priorbiopsy|race": [ + 1.36967470863071, + 0.0683043700516689, + 0.691429838076722, + null, + null, + null, + null, + 0.222565567593788, + null, + -1.50545273407496, + -0.0938012394650799, + 0.243682291399762, + -0.316246671585709 + ], + "dre|famhist_1|famhist_bca|priorbiopsy": [ + 1.46217350146055, + 0.0677115318102721, + 0.695019418244785, + -0.342962292990545, + null, + null, + null, + 0.225776774913795, + null, + -1.51289548330844, + -0.0918986913207153, + 0.186319727445682, + -0.249884955782361 + ], + "dre|famhist_1|famhist_bca|race": [ + 0.276988952826368, + 0.0692366432466492, + 0.809370487039299, + null, + -1.17400516628472, + null, + null, + 0.255960095772532, + null, + -1.35293884073863, + 0.06336691003805, + 0.274188338776235, + -0.189795197829123 + ], + "dre|famhist_1|famhist_bca": [ + 0.373269395355791, + 0.0685373297655888, + 0.813411645231669, + -0.357703439259879, + -1.17560761177678, + null, + null, + 0.260107188492583, + null, + -1.35998375274846, + 0.0597341258263077, + 0.216824376066516, + -0.120727670660633 + ], + "famhist_1|famhist_bca|priorbiopsy|race": [ + 1.23451952873215, + 0.0692573490023196, + 0.694293606699439, + null, + null, + 0.601159155025763, + null, + 0.299898415036273, + null, + -1.50062401299715, + -0.155165186896093, + 0.0500059794609734, + -0.477855450777626 + ], + "famhist_1|famhist_bca|priorbiopsy": [ + 1.36247205699155, + 0.0685572455256465, + 0.700415141415309, + -0.382164245941934, + null, + 0.602600102410635, + null, + 0.303796864815666, + null, + -1.51530502497341, + -0.150715159922274, + -0.0127850141935821, + -0.398319473999695 + ], + "famhist_1|famhist_bca|race": [ + 0.206741437039233, + 0.0693440647910305, + 0.806845119361882, + null, + -1.11869543393197, + 0.661052577859608, + null, + 0.323207077528422, + null, + -1.35129174886376, + 0.00710354885721054, + 0.0773775430887955, + -0.359017372582891 + ], + "famhist_1|famhist_bca": [ + 0.335940003455981, + 0.0685541032953989, + 0.813414691679062, + -0.39731642561591, + -1.12086365606211, + 0.663119526804192, + null, + 0.32817460349224, + null, + -1.36542730259365, + 0.00626071074255772, + 0.0145667833970749, + -0.276235397727113 + ], + "dre|famhist_bca|priorbiopsy|race": [ + 1.15425913913311, + 0.0686114518854635, + 0.720380575544122, + null, + null, + null, + 0.648302002505094, + 0.138721712585649, + null, + -1.50232696201468, + -0.123003867795698, + 0.26042828233719, + -0.353146939347744 + ], + "dre|famhist_bca|priorbiopsy": [ + 1.25072794312499, + 0.0679039927501741, + 0.723296366100762, + -0.33169821650846, + null, + null, + 0.64438812028587, + 0.142334398630731, + null, + -1.50869663217817, + -0.122702784487507, + 0.205312250933039, + -0.28905924383782 + ], + "dre|famhist_bca|race": [ + 0.0615197109975758, + 0.0694621051129721, + 0.838826523644348, + null, + -1.1615480596655, + null, + 0.628647085802026, + 0.170671043788996, + null, + -1.34890518187264, + 0.0349894878216997, + 0.29419996993663, + -0.227488755394299 + ], + "dre|famhist_bca": [ + 0.164427872530712, + 0.0686284824388146, + 0.84268305945007, + -0.353590692148818, + -1.16478192282229, + null, + 0.627302038614322, + 0.17478755791907, + null, + -1.35533202323158, + 0.0303524786101583, + 0.238075074455285, + -0.159411408298367 + ], + "famhist_bca|priorbiopsy|race": [ + 1.00499757103335, + 0.0697857013655484, + 0.727380412707386, + null, + null, + 0.608140234636337, + 0.683373688516649, + 0.218630877348966, + null, + -1.50014584989832, + -0.182882726985773, + 0.0740009289228635, + -0.524973188936648 + ], + "famhist_bca|priorbiopsy": [ + 1.13551163848477, + 0.0689578343976888, + 0.73259381459443, + -0.365875833012716, + null, + 0.608975929598544, + 0.677426745472134, + 0.223079673889962, + null, + -1.51335782467161, + -0.180398678456339, + 0.0137573997553056, + -0.447059924842033 + ], + "famhist_bca|race": [ + -0.0241995215710863, + 0.069780627157872, + 0.841064039434395, + null, + -1.11405592901978, + 0.666603671540546, + 0.677096992760014, + 0.239055556627664, + null, + -1.34975942734412, + -0.0181978182919691, + 0.105512215180801, + -0.408727273060155 + ], + "famhist_bca": [ + 0.110639800590551, + 0.0688397419838743, + 0.847093303084416, + -0.387570601359655, + -1.11793178059357, + 0.668183011723341, + 0.673374794056909, + 0.244280861085014, + null, + -1.3629026657228, + -0.0201693961539425, + 0.0442192648216174, + -0.326124652993824 + ], + "dre|famhist_1|famhist_2|priorbiopsy|race": [ + 1.43490184775913, + 0.0666807042077096, + 0.702140720288769, + null, + null, + null, + null, + null, + 0.109355733981308, + -1.50413861152671, + -0.117655717560019, + 0.280869827236915, + -0.327654067189301 + ], + "dre|famhist_1|famhist_2|priorbiopsy": [ + 1.53735532272264, + 0.0660537223139822, + 0.705851790832912, + -0.349184197321893, + null, + null, + null, + null, + 0.108069924006467, + -1.51281594159664, + -0.115985713587915, + 0.221820096253919, + -0.259538418335698 + ], + "dre|famhist_1|famhist_2|race": [ + 0.343075136136461, + 0.0673837163340057, + 0.824895208691266, + null, + -1.19817147469723, + null, + null, + null, + 0.122705342613041, + -1.35056140410507, + 0.0386569057878186, + 0.314421832173642, + -0.200642434686003 + ], + "dre|famhist_1|famhist_2": [ + 0.450194447724449, + 0.0666493171897484, + 0.828961312111109, + -0.363114734725778, + -1.19967095103215, + null, + null, + null, + 0.119910971664675, + -1.35889270842084, + 0.0347103489513565, + 0.255339100872677, + -0.129855488881518 + ], + "famhist_1|famhist_2|priorbiopsy|race": [ + 1.31205717382635, + 0.0672660877500048, + 0.705038510327668, + null, + null, + 0.586537083145043, + null, + null, + 0.192911402216127, + -1.49679436286655, + -0.18100181117139, + 0.100677150418415, + -0.49427981009118 + ], + "famhist_1|famhist_2|priorbiopsy": [ + 1.45104943291572, + 0.0665275899864883, + 0.711437213075334, + -0.388597832125449, + null, + 0.588920922678117, + null, + null, + 0.18976827936569, + -1.5129247427719, + -0.176373832280101, + 0.0358800477757828, + -0.412881212306513 + ], + "famhist_1|famhist_2|race": [ + 0.284393173414345, + 0.067171534768663, + 0.821835386645736, + null, + -1.13907673562222, + 0.645584068416423, + null, + null, + 0.183941103156723, + -1.34631610516983, + -0.0198077205808014, + 0.130192302526562, + -0.373437266227468 + ], + "famhist_1|famhist_2": [ + 0.424855068626168, + 0.0663480108256028, + 0.828529235075501, + -0.4031457067841, + -1.14115406966333, + 0.648411571198329, + null, + null, + 0.179478833339497, + -1.36187848899197, + -0.0205643564733295, + 0.0654673291732034, + -0.288765796261242 + ], + "dre|famhist_2|priorbiopsy|race": [ + 1.17335938478881, + 0.067481796043936, + 0.730355103880935, + null, + null, + null, + 0.634003723085722, + null, + 0.0859586723153582, + -1.49832723362408, + -0.146181212793729, + 0.295442487547156, + -0.36607892011989 + ], + "dre|famhist_2|priorbiopsy": [ + 1.27986639327854, + 0.0667334401079502, + 0.733451431028264, + -0.342716696351153, + null, + null, + 0.631367189791316, + null, + 0.0855182486070806, + -1.50590995322787, + -0.146171424794916, + 0.238183120854265, + -0.299615938927361 + ], + "dre|famhist_2|race": [ + 0.0771924520409374, + 0.068139893690775, + 0.8540501668555, + null, + -1.18739789563731, + null, + 0.615894050123811, + null, + 0.0972174375069742, + -1.34383385023676, + 0.0113662401732308, + 0.332966336377597, + -0.239590015862864 + ], + "dre|famhist_2": [ + 0.191019252145883, + 0.0672615027870374, + 0.858097509327471, + -0.364083370995582, + -1.19068422573164, + null, + 0.616167416169485, + null, + 0.0946732713564216, + -1.35154616231851, + 0.0062526282594124, + 0.274667600797252, + -0.169105596053355 + ], + "famhist_2|priorbiopsy|race": [ + 1.0401923288624, + 0.0682679945165686, + 0.736122229757902, + null, + null, + 0.590277125107994, + 0.667587380371001, + null, + 0.160454333954481, + -1.49327914669015, + -0.208211512992902, + 0.121756322723333, + -0.540121197919431 + ], + "famhist_2|priorbiopsy": [ + 1.18174531790661, + 0.06739835886262, + 0.741718078553484, + -0.377903629366609, + null, + 0.591999364468083, + 0.663151937759248, + null, + 0.158893786017264, + -1.50799409759211, + -0.20557532237048, + 0.0590871990715518, + -0.459617889195258 + ], + "famhist_2|race": [ + 0.00667163356633592, + 0.0681173860485052, + 0.854482424159213, + null, + -1.13585563632027, + 0.647946845758004, + 0.662634775564745, + null, + 0.147631972585375, + -1.34169443824515, + -0.0441705878038375, + 0.156030851620853, + -0.421593695978721 + ], + "famhist_2": [ + 0.15290740311915, + 0.0671333635859168, + 0.86085877106293, + -0.399285990985645, + -1.13980813353909, + 0.650268789082041, + 0.660691528677228, + null, + 0.144141157894284, + -1.35632783842248, + -0.0462243844907577, + 0.0924080877629168, + -0.336313754223546 + ], + "dre|famhist_1|priorbiopsy|race": [ + 1.38343181420405, + 0.0678354281541517, + 0.707766539626429, + null, + null, + null, + null, + 0.233628451881651, + 0.10783893217303, + -1.51412018923302, + -0.116533028416285, + 0.270018523394223, + -0.330106962657294 + ], + "dre|famhist_1|priorbiopsy": [ + 1.47361836458649, + 0.0672634324812311, + 0.711187954056705, + -0.335812765906042, + null, + null, + null, + 0.236707233836617, + 0.106693709575507, + -1.5213505696509, + -0.114525311505671, + 0.213873459388054, + -0.265135748281143 + ], + "dre|famhist_1|race": [ + 0.261171443703869, + 0.0688774681568333, + 0.831702135432637, + null, + -1.20373783475062, + null, + null, + 0.269031548796436, + 0.122097407485263, + -1.36029093246555, + 0.0419103890313011, + 0.303915035350532, + -0.203107734374758 + ], + "dre|famhist_1": [ + 0.355560434680631, + 0.0681993442375406, + 0.835539017552812, + -0.350592409477086, + -1.20527622597246, + null, + null, + 0.273096158220155, + 0.119544641046101, + -1.36716208325317, + 0.0383329830692878, + 0.247789111073937, + -0.135408185157015 + ], + "famhist_1|priorbiopsy|race": [ + 1.23940647353561, + 0.0688905904920796, + 0.713257947564461, + null, + null, + 0.599837112144094, + null, + 0.314349501869771, + 0.19277710941645, + -1.51156445376767, + -0.180513255026846, + 0.0844844962753711, + -0.501191354445481 + ], + "famhist_1|priorbiopsy": [ + 1.36357233876387, + 0.0682223925567866, + 0.719106014159824, + -0.373317256643489, + null, + 0.600970518570662, + null, + 0.317903254225721, + 0.189527769685, + -1.5257758677794, + -0.175640387222041, + 0.0232181421850265, + -0.423409069047529 + ], + "famhist_1|race": [ + 0.185113480449986, + 0.0690726917591083, + 0.831441565801965, + null, + -1.14672271468074, + 0.660209524269716, + null, + 0.340012650107206, + 0.185352306143146, + -1.36082646638773, + -0.0167134338538936, + 0.114092365026698, + -0.379914232140991 + ], + "famhist_1": [ + 0.310689448874891, + 0.0683153268301126, + 0.837679298011342, + -0.388270489409364, + -1.14876312427001, + 0.66178831150723, + null, + 0.344694364232205, + 0.180826025332141, + -1.37448291334167, + -0.0172243886646686, + 0.0529650861669291, + -0.298881752410858 + ], + "dre|priorbiopsy|race": [ + 1.17587024386659, + 0.0681155784058406, + 0.734704471455137, + null, + null, + null, + 0.635712684307653, + 0.15222970395697, + 0.0819455488216824, + -1.51020063048986, + -0.145857264228605, + 0.28498172217183, + -0.364709297778395 + ], + "dre|priorbiopsy": [ + 1.26985953094435, + 0.0674325734197446, + 0.737450095311778, + -0.325290373493955, + null, + null, + 0.63202584259984, + 0.155737055690587, + 0.0816564449717356, + -1.51638833151054, + -0.145450035947849, + 0.230947204520867, + -0.301915768273321 + ], + "dre|race": [ + 0.0549589891815493, + 0.0690732271804105, + 0.859198042132319, + null, + -1.19077377924762, + null, + 0.614333385411619, + 0.186447414526004, + 0.0940085642338571, + -1.35564910093581, + 0.0130223898346007, + 0.322426989936764, + -0.238521731005712 + ], + "dre": [ + 0.15585938582852, + 0.0682639887925693, + 0.862853637388075, + -0.347254844785186, + -1.1939036531082, + null, + 0.613225402880149, + 0.190512268100874, + 0.0917598822231976, + -1.36193039771784, + 0.00839927909511791, + 0.267363195611993, + -0.171673611395828 + ], + "priorbiopsy|race": [ + 1.020020985647, + 0.0693826834879715, + 0.743266008699091, + null, + null, + 0.605641378431361, + 0.666792753637148, + 0.235904456827716, + 0.157534597563151, + -1.50977530868355, + -0.208618261338837, + 0.105842005438569, + -0.544330428271066 + ], + "priorbiopsy": [ + 1.14679418077093, + 0.0685915153855825, + 0.748241336103844, + -0.358762407369837, + null, + 0.606344708507378, + 0.661389485032172, + 0.240075271229791, + 0.155926513871594, + -1.52263605994982, + -0.205749469790905, + 0.0468367105495933, + -0.46802275754699 + ], + "race": [ + -0.0339899521576766, + 0.0694674055454372, + 0.862585219219787, + null, + -1.14154613391948, + 0.664371783248823, + 0.659379578821552, + 0.258966635683828, + 0.145995788220358, + -1.35815123931293, + -0.0429554188733805, + 0.139844701796087, + -0.425512091548764 + ], + "": [ + 0.0973696550103029, + 0.0685626071025404, + 0.868327204172581, + -0.380223435347789, + -1.14525401103566, + 0.665655402758357, + 0.656168999001428, + 0.263997135421439, + 0.142564644665647, + -1.370931984702, + -0.0446955684912442, + 0.0799051521528611, + -0.344481486143253 + ] +} diff --git a/src/sentinel/risk_models/extended_pbcg.py b/src/sentinel/risk_models/extended_pbcg.py new file mode 100644 index 0000000000000000000000000000000000000000..743b435afc1e7fdc5b39b02d5ffc7668774fd430 --- /dev/null +++ b/src/sentinel/risk_models/extended_pbcg.py @@ -0,0 +1,416 @@ +"""Extended PBCG model for prostate cancer risk estimation.""" + +import json +from functools import lru_cache +from math import exp, log +from pathlib import Path +from typing import Annotated + +from pydantic import Field + +from sentinel.risk_models.base import RiskModel +from sentinel.user_input import ( + CancerType, + DREResult, + DRETest, + Ethnicity, + PCA3Test, + PercentFreePSATest, + ProstateVolumeTest, + PSATest, + RelationshipDegree, + Sex, + T2ERGTest, + UserInput, +) + +_DATA_PATH = ( + Path(__file__).resolve().parent / "data" / "extended_pbcg_coefficients.json" +) + + +def _log2(value: float) -> float: + """Compute the base-2 logarithm of a value. + + Args: + value: Input value (> 0). + + Returns: + float: log2(value). + """ + return log(value, 2) + + +class ExtendedPBCGRiskModel(RiskModel): + """Extended PBCG risk model.""" + + NAME = "extended_pbcg" + FEATURES = [ + "age", + "psa", + "race", + "priorbiopsy", + "dre", + "famhist_1", + "famhist_2", + "famhist_bca", + "prosvol", + "ari_use", + "hispanic", + "priorpsa", + ] + + def __init__(self) -> None: + super().__init__(self.NAME) + + REQUIRED_INPUTS: dict[str, tuple[type, bool]] = { + "demographics.age_years": (Annotated[int, Field(ge=40, le=90)], True), + "demographics.sex": (Sex, True), + "demographics.ethnicity": (Ethnicity | None, False), + "clinical_tests.psa": (PSATest, True), + "clinical_tests.prostate_volume": (ProstateVolumeTest, False), + "clinical_tests.percent_free_psa": (PercentFreePSATest, False), + "clinical_tests.pca3": (PCA3Test, False), + "clinical_tests.t2erg": (T2ERGTest, False), + "clinical_tests.dre": (DRETest, False), + "personal_medical_history.prior_negative_prostate_biopsy": (bool, False), + "personal_medical_history.use_5ari": (bool, False), + "personal_medical_history.prior_psa_screening": (bool, False), + "family_history": (list, False), # list[FamilyMemberCancer] + } + + def compute_score(self, user: UserInput) -> str: + # Validate inputs + is_valid, errors = self.validate_inputs(user) + if not is_valid: + return f"N/A: Invalid inputs - {'; '.join(errors)}" + + # Sex check + if user.demographics.sex != Sex.MALE: + return "N/A: Extended PBCG is validated for male patients only." + + try: + risk = self.absolute_risk(user) + except ValueError as exc: + return f"N/A: {exc}" + return ( + f"No or Low Grade: {risk['no_or_low']:.1f}%, " + f"High Grade: {risk['high_grade']:.1f}%" + ) + + def absolute_risk(self, user: UserInput) -> dict[str, float]: + """Compute risk buckets for the Extended PBCG model. + + Args: + user: Canonical clinical profile for an individual. + + Returns: + Dictionary containing high_grade and no_or_low percentages. + + Raises: + ValueError: When required inputs are out of range or missing. + """ + age = float(user.demographics.age_years) + if age < 40 or age > 90: + raise ValueError("Age must be between 40 and 90 years.") + + if user.clinical_tests.psa is None: + raise ValueError("PSA is required.") + psa = user.clinical_tests.psa.value_ng_ml + if psa < 2 or psa > 50: + raise ValueError("PSA must be between 2 and 50 ng/mL.") + + prostate_volume = None + if user.clinical_tests.prostate_volume is not None: + prostate_volume = user.clinical_tests.prostate_volume.volume_ml + if prostate_volume < 15 or prostate_volume > 300: + raise ValueError("Prostate volume must be between 15 and 300 cc.") + + percent_free_psa = None + if user.clinical_tests.percent_free_psa is not None: + percent_free_psa = user.clinical_tests.percent_free_psa.value_percent + if not 2 <= percent_free_psa <= 50: + raise ValueError("Percent free PSA must be between 2 and 50.") + + pca3 = None + if user.clinical_tests.pca3 is not None: + pca3 = user.clinical_tests.pca3.score + if not 0.3 <= pca3 <= 332.5: + raise ValueError("PCA3 must be between 0.3 and 332.5.") + + t2erg = None + if user.clinical_tests.t2erg is not None: + t2erg = user.clinical_tests.t2erg.score + if not 0.0 <= t2erg <= 6031.6: + raise ValueError("T2:ERG must be between 0 and 6031.6.") + if t2erg is not None and pca3 is None: + raise ValueError("T2:ERG requires PCA3 to be provided.") + if percent_free_psa is not None and pca3 is not None: + raise ValueError( + "Cannot calculate risk when both percent free PSA and PCA3 are selected." + ) + if percent_free_psa is not None and t2erg is not None: + raise ValueError( + "Cannot calculate risk when percent free PSA and T2:ERG are both selected." + ) + + race_flag = self._normalize_race(user.demographics.ethnicity) + + # Determine missing features directly from UserInput + missing = [] + if user.demographics.age_years is None: + missing.append("age") + if psa is None: + missing.append("psa") + if race_flag is None: + missing.append("race") + if user.personal_medical_history.prior_negative_prostate_biopsy is None: + missing.append("priorbiopsy") + if user.clinical_tests.dre is None: + missing.append("dre") + + # Check family history + first_degree_prostate = any( + member.cancer_type == CancerType.PROSTATE + and member.degree == RelationshipDegree.FIRST + for member in user.family_history + ) + second_degree_prostate = any( + member.cancer_type == CancerType.PROSTATE + and member.degree == RelationshipDegree.SECOND + for member in user.family_history + ) + breast_cancer_family = any( + member.cancer_type == CancerType.BREAST for member in user.family_history + ) + + # For family history, we need to explicitly check if the information is missing + # If family_history is empty, we assume no family history (famhist_1=no, famhist_2=no, famhist_bca=no) + # This means we don't add these to missing - they are explicitly "no" + + if prostate_volume is None or prostate_volume <= 0: + missing.append("prosvol") + if user.personal_medical_history.use_5ari is None: + missing.append("ari_use") + # Hispanic ethnicity is determined by the ethnicity field + # If ethnicity is not HISPANIC, then hispanic is False (not missing) + # Only missing if ethnicity is None + if user.demographics.ethnicity is None: + missing.append("hispanic") + # Prior PSA screening is a separate field from current PSA test + if user.personal_medical_history.prior_psa_screening is None: + missing.append("priorpsa") + + # Get coefficients for this missing pattern + missing_pattern = "|".join(sorted(missing)) + coeffs = self._coefficients().get(missing_pattern) + if coeffs is None: + raise ValueError( + f"Unsupported missing data pattern for Extended PBCG: {missing_pattern}" + ) + + intercept, *weights = coeffs + if intercept is None: + raise ValueError("Missing intercept in Extended PBCG coefficients.") + + linear = intercept + for name, weight in zip(self.FEATURES, weights, strict=False): + # Get feature value directly from UserInput + value = self._get_feature_value_direct( + user, name, psa, prostate_volume, race_flag + ) + if value is None or weight is None: + continue + linear += weight * value + + risk_high = exp(linear) / (1.0 + exp(linear)) * 100.0 + risk_high = min(100.0, max(0.0, round(risk_high))) + return { + "high_grade": risk_high, + "no_or_low": 100.0 - risk_high, + } + + def cancer_type(self) -> str: + """Get the type of cancer the model predicts. + + Returns: + str: Cancer type label. + """ + return "prostate" + + def description(self) -> str: + """Get the description of the Extended PBCG model. + + Returns: + str: Human-readable model description. + """ + return ( + "Extended PBCG estimates the probability of high-grade prostate cancer using " + "the Prostate Biopsy Collaborative Group model with optional biomarkers " + "and family history inputs. This risk calculator is for patients who have " + "been deemed to be suitable candidates for biopsy by their urologist. " + "This means that, for instance, they have been evaluated to see if their " + "PSA level is due to a disease other than cancer, such having an enlarged " + "prostate, a common problem in older men. If you have not been evaluated " + "by your urologist and told that you are a good candidate for biopsy, " + "the risk calculator will likely overestimate your risk of having prostate " + "cancer." + ) + + def interpretation(self) -> str: + """Get the interpretation of the Extended PBCG model. + + Returns: + str: Human-readable interpretation guidance. + """ + return ( + "Outputs two percentages representing the chance a biopsy would show no/low-grade or " + "high-grade prostate cancer." + ) + + def references(self) -> list[str]: + """Get the references for the Extended PBCG model. + + Returns: + list[str]: Reference list. + """ + return [ + "Neumair M, Kattan MW, Freedland SJ, et al. Accommodating heterogeneous missing data patterns for " + "prostate cancer risk prediction. BMC Med Res Methodol. 2022;22:200.", + ] + + def _get_feature_value_direct( + self, + user: UserInput, + feature_name: str, + psa: float, + prostate_volume: float | None, + race_flag: bool | None, + ) -> float | None: + """Get feature value directly from UserInput without intermediate data structures. + + Args: + user: The user profile with clinical observations. + feature_name: Name of the feature to get. + psa: PSA value. + prostate_volume: Prostate volume value. + race_flag: Race flag value. + + Returns: + Feature value or None. + """ + if feature_name == "age": + return ( + float(user.demographics.age_years) + if user.demographics.age_years is not None + else None + ) + elif feature_name == "psa": + return _log2(psa) if psa is not None else None + elif feature_name == "race": + return self._indicator(race_flag) + elif feature_name == "priorbiopsy": + return self._indicator( + user.personal_medical_history.prior_negative_prostate_biopsy + ) + elif feature_name == "dre": + if user.clinical_tests.dre is None: + return None + dre_abnormal = user.clinical_tests.dre.result in { + DREResult.ABNORMAL, + DREResult.SUSPICIOUS, + } + return self._indicator(dre_abnormal) + elif feature_name == "famhist_1": + first_degree_prostate = any( + member.cancer_type == CancerType.PROSTATE + and member.degree == RelationshipDegree.FIRST + for member in user.family_history + ) + return self._indicator(first_degree_prostate) + elif feature_name == "famhist_2": + second_degree_prostate = any( + member.cancer_type == CancerType.PROSTATE + and member.degree == RelationshipDegree.SECOND + for member in user.family_history + ) + return self._indicator(second_degree_prostate) + elif feature_name == "famhist_bca": + breast_cancer_family = any( + member.cancer_type == CancerType.BREAST + for member in user.family_history + ) + return self._indicator(breast_cancer_family) + elif feature_name == "prosvol": + return ( + _log2(prostate_volume) + if prostate_volume and prostate_volume > 0 + else None + ) + elif feature_name == "ari_use": + return self._indicator(user.personal_medical_history.use_5ari) + elif feature_name == "hispanic": + is_hispanic = user.demographics.ethnicity == Ethnicity.HISPANIC + return self._indicator(is_hispanic) + elif feature_name == "priorpsa": + return self._indicator(user.personal_medical_history.prior_psa_screening) + else: + return None + + def _normalize_race(self, ethnicity: Ethnicity | None) -> bool | None: + """Normalize the race of the patient. + + Args: + ethnicity: Ethnicity enum value. + + Returns: + bool | None: True for African ancestry, False for non-African, None unknown. + """ + if ethnicity is None: + return None + if ethnicity == Ethnicity.BLACK: + return True + if ethnicity in { + Ethnicity.WHITE, + Ethnicity.OTHER, + Ethnicity.ASIAN, + Ethnicity.NATIVE_AMERICAN, + Ethnicity.PACIFIC_ISLANDER, + }: + return False + return None + + @staticmethod + @lru_cache(maxsize=1) + def _coefficients() -> dict[str, list[float | None]]: + """Get the coefficients for the Extended PBCG model. + + Returns: + dict[str, list[float | None]]: Coefficient lookup by missing pattern. + + Raises: + FileNotFoundError: When the coefficient file is missing. + """ + if not _DATA_PATH.exists(): + raise FileNotFoundError( + f"Extended PBCG coefficient file missing: {_DATA_PATH}" + ) + with _DATA_PATH.open("r", encoding="utf-8") as handle: + return json.load(handle) + + @staticmethod + def _indicator(flag: bool | None) -> float | None: + """Convert optional boolean flags to numeric indicators. + + Args: + flag: Boolean-like value to convert. + + Returns: + Numeric indicator or None when unspecified. + """ + if flag is None: + return None + return 1.0 if flag else 0.0 + + +__all__ = ["ExtendedPBCGRiskModel"] diff --git a/src/sentinel/risk_models/gail.py b/src/sentinel/risk_models/gail.py new file mode 100644 index 0000000000000000000000000000000000000000..517c18e27e2ca379dd2b3087fd353c92bd93eb42 --- /dev/null +++ b/src/sentinel/risk_models/gail.py @@ -0,0 +1,811 @@ +"""Breast cancer risk estimation using the Gail model. + +This module provides a minimal Python port of the Gail Model, also known as the +Breast Cancer Risk Assessment Tool (BCRAT). The model estimates a woman's risk +of developing invasive breast cancer based on factors such as age, age at +menarche, age at first live birth, number of breast biopsies (with optional +atypical hyperplasia), number of first-degree relatives with breast cancer and +race or ethnicity. The tool is typically applied to women aged 35 to 85 who have +no prior diagnosis of breast cancer, LCIS or DCIS. A five-year score of 1.66 or +higher is commonly considered elevated risk. + +The Gail Model does not account for every risk factor and may overestimate risk +for non‐white populations. Use results in consultation with a healthcare +professional. The original implementation and documentation are available from +the National Cancer Institute at +https://dceg.cancer.gov/tools/risk-assessment/bcra. +""" + +from math import ceil, exp, log +from typing import Annotated + +from pydantic import Field + +from sentinel.risk_models.base import RiskModel +from sentinel.user_input import ( + CancerType, + Ethnicity, + FamilyRelation, + RelationshipDegree, + Sex, + UserInput, +) + + +def _race_code_from_ethnicity(ethnicity: Ethnicity | None) -> int: + """Map ethnicity enum to Gail race code. + + Args: + ethnicity: Ethnicity enum value. + + Returns: + Race code (1=White, 2=Black, 3=Asian, 6=Hispanic). + """ + if not ethnicity: + return 1 + + # Map Ethnicity enum to Gail race codes + if ethnicity == Ethnicity.BLACK: + return 2 + if ethnicity in {Ethnicity.ASIAN, Ethnicity.PACIFIC_ISLANDER}: + return 3 + if ethnicity == Ethnicity.HISPANIC: + return 6 + return 1 # Default to White + + +class GailRiskModel(RiskModel): + """Compute absolute breast cancer risk using the Gail model.""" + + # Model constants + _beta = { + 1: ( + 0.5292641686, + 0.0940103059, + 0.2186262218, + 0.9583027845, + -0.2880424830, + -0.1908113865, + ), + 2: (0.1822121131, 0.2672530336, 0.0, 0.4757242578, -0.1119411682, 0.0), + 3: (0.0970783641, 0.0, 0.2318368334, 0.166685441, 0.0, 0.0), + 4: ( + 0.5292641686, + 0.0940103059, + 0.2186262218, + 0.9583027845, + -0.2880424830, + -0.1908113865, + ), + 5: (0.4798624017, 0.2593922322, 0.4669246218, 0.9076679727, 0.0, 0.0), + 6: ( + 0.55263612260619, + 0.07499257592975, + 0.27638268294593, + 0.79185633720481, + 0.0, + 0.0, + ), + 7: ( + 0.55263612260619, + 0.07499257592975, + 0.27638268294593, + 0.79185633720481, + 0.0, + 0.0, + ), + 8: ( + 0.55263612260619, + 0.07499257592975, + 0.27638268294593, + 0.79185633720481, + 0.0, + 0.0, + ), + 9: ( + 0.55263612260619, + 0.07499257592975, + 0.27638268294593, + 0.79185633720481, + 0.0, + 0.0, + ), + 10: ( + 0.55263612260619, + 0.07499257592975, + 0.27638268294593, + 0.79185633720481, + 0.0, + 0.0, + ), + 11: ( + 0.55263612260619, + 0.07499257592975, + 0.27638268294593, + 0.79185633720481, + 0.0, + 0.0, + ), + } + + _lambda1 = { + 1: [ + 0.00001000, + 0.00007600, + 0.00026600, + 0.00066100, + 0.00126500, + 0.00186600, + 0.00221100, + 0.00272100, + 0.00334800, + 0.00392300, + 0.00417800, + 0.00443900, + 0.00442100, + 0.00410900, + ], + 2: [ + 0.00002696, + 0.00011295, + 0.00031094, + 0.00067639, + 0.00119444, + 0.00187394, + 0.00241504, + 0.00291112, + 0.00310127, + 0.00366560, + 0.00393132, + 0.00408951, + 0.00396793, + 0.00363712, + ], + 3: [ + 0.0000166, + 0.0000741, + 0.0002740, + 0.0006099, + 0.0012225, + 0.0019027, + 0.0023142, + 0.0028357, + 0.0031144, + 0.0030794, + 0.0033344, + 0.0035082, + 0.0025308, + 0.0020414, + ], + 4: [ + 0.00001000, + 0.00007600, + 0.00026600, + 0.00066100, + 0.00126500, + 0.00186600, + 0.00221100, + 0.00272100, + 0.00334800, + 0.00392300, + 0.00417800, + 0.00443900, + 0.00442100, + 0.00410900, + ], + 5: [ + 0.0000102, + 0.0000531, + 0.0001578, + 0.0003602, + 0.0007617, + 0.0011599, + 0.0014111, + 0.0017245, + 0.0020619, + 0.0023603, + 0.0025575, + 0.0028227, + 0.0028295, + 0.0025868, + ], + 6: [ + 0.000004059636, + 0.000045944465, + 0.000188279352, + 0.000492930493, + 0.000913603501, + 0.001471537353, + 0.001421275482, + 0.001970946494, + 0.001674745804, + 0.001821581075, + 0.001834477198, + 0.001919911972, + 0.002233371071, + 0.002247315779, + ], + 7: [ + 0.000000000001, + 0.000099483924, + 0.000287041681, + 0.000545285759, + 0.001152211095, + 0.001859245108, + 0.002606291272, + 0.003221751682, + 0.004006961859, + 0.003521715275, + 0.003593038294, + 0.003589303081, + 0.003538507159, + 0.002051572909, + ], + 8: [ + 0.000007500161, + 0.000081073945, + 0.000227492565, + 0.000549786433, + 0.001129400541, + 0.001813873795, + 0.002223665639, + 0.002680309266, + 0.002891219230, + 0.002534421279, + 0.002457159409, + 0.002286616920, + 0.001814802825, + 0.001750879130, + ], + 9: [ + 0.000045080582, + 0.000098570724, + 0.000339970860, + 0.000852591429, + 0.001668562761, + 0.002552703284, + 0.003321774046, + 0.005373001776, + 0.005237808549, + 0.005581732512, + 0.005677419355, + 0.006513409962, + 0.003889457523, + 0.002949061662, + ], + 10: [ + 0.000000000001, + 0.000071525212, + 0.000288799028, + 0.000602250698, + 0.000755579402, + 0.000766406354, + 0.001893124938, + 0.002365580107, + 0.002843933070, + 0.002920921732, + 0.002330395655, + 0.002036291235, + 0.001482683983, + 0.001012248203, + ], + 11: [ + 0.000012355409, + 0.000059526456, + 0.000184320831, + 0.000454677273, + 0.000791265338, + 0.001048462801, + 0.001372467817, + 0.001495473711, + 0.001646746198, + 0.001478363563, + 0.001216010125, + 0.001067663700, + 0.001376104012, + 0.000661576644, + ], + } + + _lambda2 = { + 1: [ + 0.00049300, + 0.00053100, + 0.00062500, + 0.00082500, + 0.00130700, + 0.00218100, + 0.00365500, + 0.00585200, + 0.00943900, + 0.01502800, + 0.02383900, + 0.03883200, + 0.06682800, + 0.14490800, + ], + 2: [ + 0.00074354, + 0.00101698, + 0.00145937, + 0.00215933, + 0.00315077, + 0.00448779, + 0.00632281, + 0.00963037, + 0.01471818, + 0.02116304, + 0.03266035, + 0.04564087, + 0.06835185, + 0.13271262, + ], + 3: [ + 0.0003561, + 0.0004038, + 0.0005281, + 0.0008875, + 0.0013987, + 0.0020769, + 0.0030912, + 0.0046960, + 0.0076050, + 0.0120555, + 0.0193805, + 0.0288386, + 0.0429634, + 0.0740349, + ], + 4: [ + 0.00049300, + 0.00053100, + 0.00062500, + 0.00082500, + 0.00130700, + 0.00218100, + 0.00365500, + 0.00585200, + 0.00943900, + 0.01502800, + 0.02383900, + 0.03883200, + 0.06682800, + 0.14490800, + ], + 5: [ + 0.0003129, + 0.0002908, + 0.0003515, + 0.0004943, + 0.0007807, + 0.0012840, + 0.0020325, + 0.0034533, + 0.0058674, + 0.0096888, + 0.0154429, + 0.0254675, + 0.0448037, + 0.1125678, + ], + 6: [ + 0.000210649076, + 0.000192644865, + 0.000244435215, + 0.000317895949, + 0.000473261994, + 0.000800271380, + 0.001217480226, + 0.002099836508, + 0.003436889186, + 0.006097405623, + 0.010664526765, + 0.020148678452, + 0.037990796590, + 0.098333900733, + ], + 7: [ + 0.000173593803, + 0.000295805882, + 0.000228322534, + 0.000363242389, + 0.000590633044, + 0.001086079485, + 0.001859999966, + 0.003216600974, + 0.004719402141, + 0.008535331402, + 0.012433511681, + 0.020230197885, + 0.037725498348, + 0.106149118663, + ], + 8: [ + 0.000229120979, + 0.000262988494, + 0.000314844090, + 0.000394471908, + 0.000647622610, + 0.001170202327, + 0.001809380379, + 0.002614170568, + 0.004483330681, + 0.007393665092, + 0.012233059675, + 0.021127058106, + 0.037936954809, + 0.085138518334, + ], + 9: [ + 0.000563507269, + 0.000369640217, + 0.001019912579, + 0.001234013911, + 0.002098344078, + 0.002982934175, + 0.005402445702, + 0.009591474245, + 0.016315472607, + 0.020152229069, + 0.027354838710, + 0.050446998723, + 0.072262026612, + 0.145844504021, + ], + 10: [ + 0.000465500812, + 0.000600466920, + 0.000851057138, + 0.001478265376, + 0.001931486788, + 0.003866623959, + 0.004924932309, + 0.008177071806, + 0.008638202890, + 0.018974658371, + 0.029257567105, + 0.038408980974, + 0.052869579345, + 0.074745721133, + ], + 11: [ + 0.000212632332, + 0.000242170741, + 0.000301552711, + 0.000369053354, + 0.000543002943, + 0.000893862331, + 0.001515172239, + 0.002574669551, + 0.004324370426, + 0.007419621918, + 0.013251765130, + 0.022291427490, + 0.041746550635, + 0.087485802065, + ], + } + + _one_ar = { + 1: (0.5788413, 0.5788413), + 2: (0.72949880, 0.74397137), + 3: (0.749294788397, 0.778215491668), + 4: (0.5788413, 0.5788413), + 5: (0.428864989813, 0.450352338746), + 6: (0.47519806426735, 0.50316401683903), + 7: (0.47519806426735, 0.50316401683903), + 8: (0.47519806426735, 0.50316401683903), + 9: (0.47519806426735, 0.50316401683903), + 10: (0.47519806426735, 0.50316401683903), + 11: (0.47519806426735, 0.50316401683903), + } + + def __init__(self): + super().__init__("gail") + + REQUIRED_INPUTS: dict[str, tuple[type, bool]] = { + "demographics.age_years": (Annotated[int, Field(ge=35, le=85)], True), + "demographics.sex": (Sex, True), + "demographics.ethnicity": (Ethnicity | None, False), + "female_specific.menstrual.age_at_menarche": ( + Annotated[int, Field(ge=7, le=20)], + False, + ), + "female_specific.parity.num_live_births": (Annotated[int, Field(ge=0)], False), + "female_specific.parity.age_at_first_live_birth": ( + Annotated[int, Field(ge=10, le=60)], + False, + ), + "female_specific.breast_health.num_biopsies": ( + Annotated[int, Field(ge=0, le=20)], + False, + ), + "female_specific.breast_health.atypical_hyperplasia": (bool, False), + "family_history": (list, False), # list[FamilyMemberCancer] + } + + def _recode_categories( + self, + *, + age: float, + num_biopsies: int, + hyperplasia: int, + age_menarche: int, + age_first_birth: int, + num_relatives: int, + race: int, + ): + biopsy_category = None + if num_biopsies in (0, 99): + biopsy_category = 0 + elif num_biopsies == 1: + biopsy_category = 1 + elif num_biopsies >= 2: + biopsy_category = 2 + + if age_menarche == 99 or age_menarche > age or age_menarche >= 14: + menarche_category = 0 + elif 12 <= age_menarche <= 13: + menarche_category = 1 + else: + menarche_category = 2 + + first_birth_category = None + if age_first_birth == 99 or age_first_birth < 20: + first_birth_category = 0 + elif 20 <= age_first_birth < 25: + first_birth_category = 1 + elif 25 <= age_first_birth < 30 or age_first_birth == 98: + first_birth_category = 2 + elif age_first_birth >= 30: + first_birth_category = 3 + + relatives_category = None + if num_relatives in (0, 99): + relatives_category = 0 + elif num_relatives == 1: + relatives_category = 1 + elif num_relatives >= 2: + relatives_category = 2 + if race >= 6 and relatives_category == 2: + relatives_category = 1 + + hyperplasia_multiplier = 1.0 + if biopsy_category == 0: + hyperplasia_multiplier = 1.0 + elif biopsy_category and hyperplasia == 0: + hyperplasia_multiplier = 0.93 + elif biopsy_category and hyperplasia == 1: + hyperplasia_multiplier = 1.82 + elif biopsy_category and hyperplasia == 99: + hyperplasia_multiplier = 1.0 + + return ( + biopsy_category, + menarche_category, + first_birth_category, + relatives_category, + hyperplasia_multiplier, + ) + + def _relative_risk( + self, + *, + age: float, + num_biopsies: int, + hyperplasia: int, + age_menarche: int, + age_first_birth: int, + num_relatives: int, + race: int, + ): + ( + biopsy_category, + menarche_category, + first_birth_category, + relatives_category, + hyperplasia_multiplier, + ) = self._recode_categories( + age=age, + num_biopsies=num_biopsies, + hyperplasia=hyperplasia, + age_menarche=age_menarche, + age_first_birth=age_first_birth, + num_relatives=num_relatives, + race=race, + ) + beta = self._beta[race] + linear_predictor_under_50 = ( + biopsy_category * beta[0] + + menarche_category * beta[1] + + first_birth_category * beta[2] + + relatives_category * beta[3] + + first_birth_category * relatives_category * beta[5] + + log(hyperplasia_multiplier) + ) + linear_predictor_over_50 = linear_predictor_under_50 + biopsy_category * beta[4] + return exp(linear_predictor_under_50), exp(linear_predictor_over_50) + + def absolute_risk( + self, + *, + age: float, + projection_age: float, + num_biopsies: int, + hyperplasia: int, + age_menarche: int, + age_first_birth: int, + num_relatives: int, + race: int, + ) -> float: + """Calculate Gail model absolute risk. + + Args: + age: Current age of the patient. + projection_age: Target age for the risk projection. + num_biopsies: Count of prior breast biopsies. + hyperplasia: Indicator for atypical hyperplasia. + age_menarche: Age at menarche category code. + age_first_birth: Age at first live birth category code. + num_relatives: Number of first-degree relatives with breast cancer. + race: Encoded race/ethnicity category. + + Returns: + Risk percentage as a float. + """ + risk_ratio_under_50, risk_ratio_over_50 = self._relative_risk( + age=age, + num_biopsies=num_biopsies, + hyperplasia=hyperplasia, + age_menarche=age_menarche, + age_first_birth=age_first_birth, + num_relatives=num_relatives, + race=race, + ) + cancer_incidence = self._lambda1[race] + competing_hazard = self._lambda2[race] + one_minus_attrib_under, one_minus_attrib_over = self._one_ar[race] + + adjusted_relative_risk = ( + one_minus_attrib_under * risk_ratio_under_50, + one_minus_attrib_over * risk_ratio_over_50, + ) + + risk_sum = 0.0 + cumulative_hazard = 0.0 + + # 5-year band indices covering [age, projection_age) + start_interval = int((age - 20.0) // 5.0) + end_interval = ceil((projection_age - 20.0) / 5.0) + intervals = end_interval - start_interval + + for j in range(intervals): + idx = start_interval + j + if idx >= len(cancer_incidence): + break + + # Years inside this 5-year band: [20+5*idx, 25+5*idx) + band_start_age = 20.0 + 5.0 * idx + band_end_age = band_start_age + 5.0 + start_age = max(age, band_start_age) + stop_age = min(projection_age, band_end_age) + interval_length = max(0.0, stop_age - start_age) + if interval_length <= 0.0: + continue + + # Use <50 RR for bands starting before 50, otherwise ≥50 RR + rr = ( + adjusted_relative_risk[1] + if band_start_age >= 50.0 + else adjusted_relative_risk[0] + ) + + breast_rate = cancer_incidence[idx] + death_rate = competing_hazard[idx] + total_rate = breast_rate * rr + death_rate + + if total_rate > 0.0: + interval_risk = ( + (rr * breast_rate / total_rate) + * exp(-cumulative_hazard) + * (1.0 - exp(-total_rate * interval_length)) + ) + risk_sum += interval_risk + cumulative_hazard += total_rate * interval_length + + return risk_sum * 100.0 + + def compute_score(self, user: UserInput) -> str: + """Compute the Gail risk score for a given user profile. + + Args: + user: The user profile. + + Returns: + str: Risk percentage as a string or an N/A message if inapplicable. + + Raises: + ValueError: If required inputs are missing or invalid. + """ + # Validate inputs first + is_valid, errors = self.validate_inputs(user) + if not is_valid: + raise ValueError(f"Invalid inputs for Gail: {'; '.join(errors)}") + + # Check sex + if user.demographics.sex != Sex.FEMALE: + return "N/A: Gail model is only applicable to female patients." + + # Check female-specific data + if user.female_specific is None: + return "N/A: Missing female-specific information required for Gail." + + # Extract parameters + age_val = user.demographics.age_years + projection_age = min(age_val + 5, 90) + + # Check age range + if age_val < 35 or age_val > 85: + return "N/A: Age is outside the validated 35-85 year range." + + fs = user.female_specific + + # Extract breast health parameters + num_biopsies = fs.breast_health.num_biopsies or 0 + hyperplasia = 1 if (fs.breast_health.atypical_hyperplasia or False) else 0 + + # Extract menstrual/reproductive parameters + age_menarche = fs.menstrual.age_at_menarche or 99 + + # Extract parity parameters + if (fs.parity.num_live_births or 0) > 0: + age_first_birth = fs.parity.age_at_first_live_birth or 98 + else: + age_first_birth = 98 # Nulliparous code + + # Count first-degree relatives with breast cancer + first_degree_relations = { + FamilyRelation.MOTHER, + FamilyRelation.SISTER, + FamilyRelation.DAUGHTER, + } + num_relatives = sum( + 1 + for fh in user.family_history + if fh.cancer_type == CancerType.BREAST + and ( + fh.relation in first_degree_relations + or fh.degree == RelationshipDegree.FIRST + ) + ) + + # Get race code + race = _race_code_from_ethnicity(user.demographics.ethnicity) + + # Calculate risk + try: + risk = self.absolute_risk( + age=age_val, + projection_age=projection_age, + num_biopsies=num_biopsies, + hyperplasia=hyperplasia, + age_menarche=age_menarche, + age_first_birth=age_first_birth, + num_relatives=num_relatives, + race=race, + ) + return f"{risk:.2f}" + except Exception as e: + return f"N/A: Error calculating risk - {e!s}" + + def cancer_type(self) -> str: + return "breast" + + def description(self) -> str: + return "The Gail Model (Breast Cancer Risk Assessment Tool) calculates a woman's chance of developing invasive breast cancer over a given time interval. It uses demographic and reproductive history factors to project risk for women with no prior breast cancer, DCIS or LCIS. Typically applied to ages 35-85." + + def interpretation(self) -> str: + return "A score of 1.66 or higher is generally considered above average. Results should be discussed with a healthcare professional. The model does not include all possible risk factors and may overestimate risk for non-white populations." + + def references(self) -> list[str]: + return ["National Cancer Institute Breast Cancer Risk Assessment Tool"] diff --git a/src/sentinel/risk_models/mrat.py b/src/sentinel/risk_models/mrat.py new file mode 100644 index 0000000000000000000000000000000000000000..fe20c346f0b788f016aafe39861f466e64d0d737 --- /dev/null +++ b/src/sentinel/risk_models/mrat.py @@ -0,0 +1,339 @@ +"""Melanoma Risk Assessment Tool (MRAT) risk model implementation.""" + +from math import exp +from typing import Annotated + +from pydantic import Field + +from sentinel.risk_models.base import RiskModel +from sentinel.user_input import ( + ComplexionLevel, + DermatologicProfile, + FemaleSmallMolesCategory, + FemaleTanResponse, + FrecklingIntensity, + MaleSmallMolesCategory, + Sex, + UserInput, + USGeographicRegion, +) + + +class MRATRiskModel(RiskModel): + """Compute 5-year melanoma risk using the NCI MRAT equations.""" + + name: str = "mrat" + + _SEX = [0.144, 0.106] + _SUNBURN = [1.437, 1] + _FEMALE_COMPLEXION = [1.802, 1, 1] + _MALE_COMPLEXION = [1.767, 1, 1] + _TAN = [1, 1, 1.926, 1.926] + _BIG_MOLES = [1, 2.412] + _FEMALE_SMALL_MOLES = [1, 2.512, 5.154] + _MALE_SMALL_MOLES = [1, 1.935, 4.630] + _FEMALE_FRECKLING = [1, 2.174, 3.856, 3.856] + _MALE_FRECKLING = [1, 1, 1.830, 1.830] + _DAMAGE = [2.803, 1] + + _INCIDENCE = { + "male": [ + (0.0000360, 0.0000412, 0.0000449), + (0.0000630, 0.0000719, 0.0000785), + (0.0000996, 0.0001138, 0.0001242), + (0.0001470, 0.0001679, 0.0001832), + (0.0002057, 0.0002350, 0.0002564), + (0.0002765, 0.0003158, 0.0003446), + (0.0003597, 0.0004110, 0.0004484), + (0.0004559, 0.0005208, 0.0005682), + (0.0005651, 0.0006455, 0.0007043), + (0.0006876, 0.0007855, 0.0008570), + (0.0008235, 0.0009407, 0.0010264), + ], + "female": [ + (0.0000812, 0.0000884, 0.0000935), + (0.0001145, 0.0001247, 0.0001318), + (0.0001491, 0.0001623, 0.0001716), + (0.0001835, 0.0001998, 0.0002112), + (0.0002166, 0.0002358, 0.0002493), + (0.0002475, 0.0002694, 0.0002848), + (0.0002755, 0.0003000, 0.0003171), + (0.0003004, 0.0003270, 0.0003457), + (0.0003217, 0.0003502, 0.0003703), + (0.0003395, 0.0003696, 0.0003908), + (0.0003538, 0.0003851, 0.0004072), + ], + } + + _MORTALITY = { + "male": ( + 0.0012286, + 0.0012725, + 0.0016132, + 0.0020935, + 0.0028355, + 0.0040472, + 0.0060814, + 0.0097982, + 0.0160096, + 0.0249023, + 0.0382402, + ), + "female": ( + 0.0004313, + 0.0004940, + 0.0006613, + 0.0009686, + 0.0014286, + 0.0022175, + 0.0035932, + 0.0058877, + 0.0095541, + 0.0147873, + 0.0231632, + ), + } + + def __init__(self) -> None: + super().__init__("mrat") + + REQUIRED_INPUTS: dict[str, tuple[type, bool]] = { + "demographics.age_years": (Annotated[int, Field(ge=20, le=70)], True), + "demographics.sex": (Sex, True), + "dermatologic": (DermatologicProfile, True), + "dermatologic.region": (USGeographicRegion, True), + "dermatologic.complexion": (ComplexionLevel, True), + "dermatologic.freckling": (FrecklingIntensity, True), + # Sex-specific fields validated in compute_score + } + + def compute_score(self, user: UserInput) -> str: + # Validate inputs first + is_valid, errors = self.validate_inputs(user) + if not is_valid: + raise ValueError(f"Invalid inputs for MRAT: {'; '.join(errors)}") + + try: + risk_percent = self.absolute_risk(user) + except ValueError as error: + return f"N/A: {error}" + return f"{risk_percent:.2f}%" + + def cancer_type(self) -> str: + return "skin" + + def description(self) -> str: + return ( + "The NCI Melanoma Risk Assessment Tool (MRAT) estimates a patient's 5-year probability of melanoma " + "using age, geography, complexion, sun damage, and nevus characteristics." + ) + + def interpretation(self) -> str: + return ( + "Output is the percentage chance of developing melanoma within 5 years. " + "Discuss results with a clinician, as the model is calibrated for non-Hispanic white adults aged 20-70." + ) + + def references(self) -> list[str]: + return [ + "National Cancer Institute Melanoma Risk Assessment Tool (MRAT)", + "Fears TR et al. Identifying individuals at high risk for melanoma: J Am Acad Dermatol. 2006;55:819-826.", + ] + + def absolute_risk(self, user: UserInput) -> float: + """Compute the 5-year melanoma absolute risk percentage. + + Args: + user: Canonical user profile including dermatologic attributes. + + Returns: + Risk percentage between 0 and 100. + + Raises: + ValueError: If required fields are missing or out of model scope. + """ + if user.demographics.sex == Sex.MALE: + gender = "male" + elif user.demographics.sex == Sex.FEMALE: + gender = "female" + else: + raise ValueError("MRAT requires patient sex (male or female).") + + age = user.demographics.age_years + if not 20 <= age <= 70: + raise ValueError("MRAT is validated for ages 20-70") + + if user.dermatologic is None: + raise ValueError("MRAT requires dermatologic profile information") + + derm = user.dermatologic + + # Map USGeographicRegion to integer (0=Northern, 1=Central, 2=Southern) + region_mapping = { + USGeographicRegion.NORTHERN: 0, + USGeographicRegion.CENTRAL: 1, + USGeographicRegion.SOUTHERN: 2, + } + region = region_mapping[derm.region] + + # Convert enums to integers + complexion = self._complexion_to_int(derm.complexion) + freckling = self._freckling_to_int(derm.freckling) + + relative_risk = 1.0 + if gender == "male": + if derm.male_sunburn is None or derm.male_small_moles is None: + raise ValueError("Male profile requires sunburn and small_moles") + + # Boolean to int: True=1 (YES/present), False=0 (NO/absent) + # MRAT arrays use inverted indexing: _SUNBURN[0]=YES, _SUNBURN[1]=NO + sunburn_bool = 1 if derm.male_sunburn else 0 + sunburn = 1 - sunburn_bool # Invert: True→1→0(YES), False→0→1(NO) + + big_moles_bool = 1 if (derm.male_has_two_or_more_big_moles or False) else 0 + big_moles = big_moles_bool # No inversion needed for big_moles array + + solar_damage_bool = 1 if (derm.solar_damage or False) else 0 + solar_damage = 1 - solar_damage_bool # Invert for _DAMAGE array + + small_moles_male = self._male_small_moles_to_int(derm.male_small_moles) + + relative_risk *= self._SUNBURN[sunburn] + relative_risk *= self._MALE_COMPLEXION[complexion] + relative_risk *= self._BIG_MOLES[big_moles] + relative_risk *= self._MALE_SMALL_MOLES[small_moles_male] + relative_risk *= self._MALE_FRECKLING[freckling] + relative_risk *= self._DAMAGE[solar_damage] + else: # female + if derm.female_tan is None or derm.female_small_moles is None: + raise ValueError("Female profile requires tan and small_moles") + + tan = self._female_tan_to_int(derm.female_tan) + small_moles_female = self._female_small_moles_to_int( + derm.female_small_moles + ) + + relative_risk *= self._TAN[tan] + relative_risk *= self._FEMALE_COMPLEXION[complexion] + relative_risk *= self._FEMALE_SMALL_MOLES[small_moles_female] + relative_risk *= self._FEMALE_FRECKLING[freckling] + + gender_index = 0 if gender == "male" else 1 + age_index = int((age - 20) // 5) + age_bin_start = age_index * 5 + 20 + age_bin_end = age_bin_start + 5 + + incidence = self._SEX[gender_index] * self._INCIDENCE[gender][age_index][region] + mortality = self._MORTALITY[gender][age_index] + hazard_sum = incidence * relative_risk + mortality + + risk = ( + incidence + * relative_risk + * (1 - exp((age - age_bin_end) * hazard_sum)) + / hazard_sum + ) + + if age != age_bin_start and age_index + 1 < len(self._INCIDENCE[gender]): + next_incidence = ( + self._SEX[gender_index] * self._INCIDENCE[gender][age_index + 1][region] + ) + next_mortality = self._MORTALITY[gender][age_index + 1] + next_hazard = next_incidence * relative_risk + next_mortality + risk += ( + next_incidence + * relative_risk + * exp((age - age_bin_end) * hazard_sum) + * (1 - exp((age_bin_start - age) * next_hazard)) + / next_hazard + ) + + risk_percent = round(risk * 10000) / 100 + return risk_percent + + @staticmethod + def _complexion_to_int(complexion: ComplexionLevel) -> int: + """Map ComplexionLevel enum to integer. + + Args: + complexion: Skin complexion level. + + Returns: + Integer code (0=LIGHT, 1=MEDIUM, 2=DARK). + """ + mapping = { + ComplexionLevel.LIGHT: 0, + ComplexionLevel.MEDIUM: 1, + ComplexionLevel.DARK: 2, + } + return mapping[complexion] + + @staticmethod + def _freckling_to_int(freckling: FrecklingIntensity) -> int: + """Map FrecklingIntensity enum to integer. + + Args: + freckling: Freckling intensity level. + + Returns: + Integer code (0=ABSENT, 1=MILD, 2=MODERATE, 3=SEVERE). + """ + mapping = { + FrecklingIntensity.ABSENT: 0, + FrecklingIntensity.MILD: 1, + FrecklingIntensity.MODERATE: 2, + FrecklingIntensity.SEVERE: 3, + } + return mapping[freckling] + + @staticmethod + def _male_small_moles_to_int(moles: MaleSmallMolesCategory) -> int: + """Map MaleSmallMolesCategory to integer. + + Args: + moles: Male small moles category. + + Returns: + Integer code (0=<7, 1=7-16, 2=≥17). + """ + mapping = { + MaleSmallMolesCategory.LESS_THAN_SEVEN: 0, + MaleSmallMolesCategory.SEVEN_TO_SIXTEEN: 1, + MaleSmallMolesCategory.SEVENTEEN_OR_MORE: 2, + } + return mapping[moles] + + @staticmethod + def _female_small_moles_to_int(moles: FemaleSmallMolesCategory) -> int: + """Map FemaleSmallMolesCategory to integer. + + Args: + moles: Female small moles category. + + Returns: + Integer code (0=<5, 1=5-11, 2=≥12). + """ + mapping = { + FemaleSmallMolesCategory.LESS_THAN_FIVE: 0, + FemaleSmallMolesCategory.FIVE_TO_ELEVEN: 1, + FemaleSmallMolesCategory.TWELVE_OR_MORE: 2, + } + return mapping[moles] + + @staticmethod + def _female_tan_to_int(tan: FemaleTanResponse) -> int: + """Map FemaleTanResponse to integer. + + Args: + tan: Female tan response category. + + Returns: + Integer code (0=VERY_BROWN, 1=MODERATE, 2=LIGHT, 3=NONE). + """ + mapping = { + FemaleTanResponse.VERY_BROWN: 0, + FemaleTanResponse.MODERATE: 1, + FemaleTanResponse.LIGHT: 2, + FemaleTanResponse.NONE: 3, + } + return mapping[tan] diff --git a/src/sentinel/risk_models/pcpt.py b/src/sentinel/risk_models/pcpt.py new file mode 100644 index 0000000000000000000000000000000000000000..b43ef0d20c96045d65392ba8344e58cc08bcf566 --- /dev/null +++ b/src/sentinel/risk_models/pcpt.py @@ -0,0 +1,633 @@ +"""Prostate cancer risk estimation using the PCPT model. + +This module implements a Python port of the Prostate Cancer Prevention Trial +Risk Calculator (PCPTRC) v2.0. The calculator estimates the probability that a +biopsy would identify no prostate cancer, low-grade cancer (Gleason < 7) or +high-grade cancer (Gleason ≥ 7). The equations are sourced from the public R +implementation distributed with the original Shiny application +(`riskcalc_20180105.r`). + +Currently this port supports the clinical model using PSA, age, race, +family-history, prior negative biopsies, digital rectal exam (DRE) and the +optional percent free PSA, PCA3 and T2:ERG adjustments. Extensions that +incorporate detailed family history or SNP updates are not yet implemented. +""" + +from collections.abc import Iterable +from math import exp, log, pi, sqrt +from typing import Annotated + +from pydantic import Field + +from sentinel.risk_models.base import RiskModel +from sentinel.user_input import ( + CancerType, + DREResult, + DRETest, + Ethnicity, + PCA3Test, + PercentFreePSATest, + PSATest, + Sex, + T2ERGTest, + UserInput, +) + + +def _log2(value: float) -> float: + """Compute the log base 2 of a value. + + Args: + value: Input value (> 0). + + Returns: + float: log2(value). + """ + return log(value, 2) + + +def _normal_pdf(x: float, mean: float, sd: float) -> float: + """Compute the normal probability density function. + + Args: + x: Variable. + mean: Distribution mean. + sd: Standard deviation (> 0). + + Returns: + float: Probability density at x. + + Raises: + ValueError: If sd <= 0. + """ + if sd <= 0: + raise ValueError("Standard deviation must be positive.") + z = (x - mean) / sd + return (1.0 / (sd * sqrt(2.0 * pi))) * exp(-0.5 * z * z) + + +def _map_ethnicity_to_race(ethnicity: Ethnicity | None) -> str: + """Map ethnicity enum to PCPT race string. + + Args: + ethnicity: Ethnicity enum value. + + Returns: + str: PCPT race string. + """ + if ethnicity == Ethnicity.BLACK: + return "african_american" + if ethnicity == Ethnicity.WHITE: + return "caucasian" + if ethnicity == Ethnicity.HISPANIC: + return "hispanic" + return "other" + + +class PCPTRiskModel(RiskModel): + """Compute prostate cancer risk tiers using the PCPT calculator.""" + + _COEFFICIENTS: dict[tuple[bool, bool, bool], dict[str, tuple[float, ...]]] = { + # (prior_biopsy, dre, family_history) + (True, True, True): { + "low": ( + -3.00215469, + 0.25613390, + 0.01643637, + 0.12172599, + -0.45533257, + -0.03864628, + 0.27197219, + ), + "high": ( + -7.05304534, + 0.70489441, + 0.04753804, + 1.04174529, + -0.21409933, + 0.40068434, + 0.22467348, + ), + }, + (True, True, False): { + "low": ( + -2.89648245, + 0.25904098, + 0.01559192, + 0.11996693, + -0.45444, + -0.03729244, + ), + "high": ( + -6.96119633, + 0.70674359, + 0.04676393, + 1.03937720, + -0.21100921, + 0.40319606, + ), + }, + (True, False, True): { + "low": ( + -3.01529063, + 0.25578861, + 0.01654912, + 0.12327661, + -0.45825158, + 0.27183869, + ), + "high": ( + -6.94522156, + 0.70637260, + 0.04697087, + 1.02065099, + -0.18320006, + 0.23044734, + ), + }, + (True, False, False): { + "low": ( + -2.90917471, + 0.25872451, + 0.01570165, + 0.12141077, + -0.45729181, + ), + "high": ( + -6.85264083, + 0.70797314, + 0.04621214, + 1.01887797, + -0.17972927, + ), + }, + (False, True, True): { + "low": ( + -2.90933651, + 0.23803667, + 0.01447269, + 0.11443251, + -0.06592322, + 0.27128248, + ), + "high": ( + -6.99449483, + 0.69530025, + 0.04637911, + 1.03847001, + 0.38651649, + 0.22287791, + ), + }, + (False, True, False): { + "low": ( + -2.80429793, + 0.24127801, + 0.01363705, + 0.11165777, + -0.06487585, + ), + "high": ( + -6.90681925, + 0.69720305, + 0.04566552, + 1.03622425, + 0.38925765, + ), + }, + (False, False, True): { + "low": ( + -2.92983751, + 0.23714373, + 0.01463232, + 0.11765430, + 0.27095959, + ), + "high": ( + -6.90439295, + 0.69799874, + 0.04608130, + 1.01787561, + 0.22913998, + ), + }, + (False, False, False): { + "low": ( + -2.81814489, + 0.24044370, + 0.01370219, + 0.12000825, + ), + "high": ( + -6.84249970, + 0.70043815, + 0.04574460, + 1.01699029, + ), + }, + } + + _PCT_FREE_PSA_PARAMS = { + "high": ((4.8059009, -0.3483031), 0.6452902), + "low": ((4.9730353, -0.3856204), 0.6010974), + "no": ((4.9602101, -0.2195069), 0.5154306), + } + + def __init__(self) -> None: + super().__init__("pcpt") + + REQUIRED_INPUTS: dict[str, tuple[type, bool]] = { + "demographics.age_years": (Annotated[int, Field(ge=55, le=90)], True), + "demographics.sex": (Sex, True), + "demographics.ethnicity": (Ethnicity | None, True), + "clinical_tests.psa": (PSATest, True), + "clinical_tests.percent_free_psa": (PercentFreePSATest, False), + "clinical_tests.pca3": (PCA3Test, False), + "clinical_tests.t2erg": (T2ERGTest, False), + "clinical_tests.dre": (DRETest, False), + "personal_medical_history.prior_negative_prostate_biopsy": (bool, False), + "family_history": (list, False), # list[FamilyMemberCancer] + } + + def absolute_risk(self, user: UserInput) -> dict[str, float]: + """Compute the absolute risk of prostate cancer. + + Args: + user: Canonical UserInput clinical record. + + Returns: + dict[str, float]: Percentages for 'no_cancer', 'low_grade', 'high_grade'. + + Raises: + ValueError: If the combination of missing inputs is unsupported. + """ + # Extract PSA + if user.clinical_tests.psa is None: + raise ValueError("PSA (ng/mL) is required in the 0-50 range.") + psa = user.clinical_tests.psa.value_ng_ml + if psa <= 0 or psa > 50: + raise ValueError("PSA (ng/mL) is required in the 0-50 range.") + + age_years = float(user.demographics.age_years) + race_text = _map_ethnicity_to_race(user.demographics.ethnicity) + race_african_american = race_text == "african_american" + + # Extract prior biopsy status + prior_negative_biopsy = ( + user.personal_medical_history.prior_negative_prostate_biopsy + ) + + # Extract DRE status + dre_abnormal = None + if user.clinical_tests.dre is not None: + dre_abnormal = user.clinical_tests.dre.result in { + DREResult.ABNORMAL, + DREResult.SUSPICIOUS, + } + + # Extract family history + family_history = any( + member.cancer_type == CancerType.PROSTATE for member in user.family_history + ) + + # Extract percent free PSA + percent_free_psa = None + if user.clinical_tests.percent_free_psa is not None: + percent_free_psa = user.clinical_tests.percent_free_psa.value_percent + if not 5 <= percent_free_psa <= 75: + percent_free_psa = None + + # Extract PCA3 and T2:ERG scores + pca3_score = None + if user.clinical_tests.pca3 is not None: + pca3_score = user.clinical_tests.pca3.score + + t2erg_score = None + if user.clinical_tests.t2erg is not None: + t2erg_score = user.clinical_tests.t2erg.score + + include_prior = prior_negative_biopsy is not None + include_dre = dre_abnormal is not None + include_fh = family_history is not None + key = (include_prior, include_dre, include_fh) + if key not in self._COEFFICIENTS: + raise ValueError("Unsupported combination of missing PCPT inputs.") + + coeffs = self._COEFFICIENTS[key] + features = [ + 1.0, + _log2(psa), + age_years, + 1.0 if race_african_american else 0.0, + ] + if include_prior: + features.append(1.0 if prior_negative_biopsy else 0.0) + if include_dre: + features.append(1.0 if dre_abnormal else 0.0) + if include_fh: + features.append(1.0 if family_history else 0.0) + + def _linear_predictor( + values: Iterable[float], weights: Iterable[float] + ) -> float: + return sum(v * w for v, w in zip(values, weights, strict=False)) + + s1 = _linear_predictor(features, coeffs["low"]) + s2 = _linear_predictor(features, coeffs["high"]) + + denom = 1.0 + exp(s1) + exp(s2) + risk_no = 1.0 / denom + risk_low = exp(s1) / denom + risk_high = exp(s2) / denom + + if percent_free_psa is not None: + risk_no, risk_low, risk_high = self._update_with_percent_free_psa( + psa, percent_free_psa, risk_no, risk_low, risk_high + ) + + if pca3_score is not None or t2erg_score is not None: + risk_no, risk_low, risk_high = self._update_with_pca3_t2erg( + age_years, + pca3_score, + 1.0 if race_african_american else 0.0, + t2erg_score, + risk_no, + risk_low, + risk_high, + ) + + return { + "no_cancer": risk_no * 100.0, + "low_grade": risk_low * 100.0, + "high_grade": risk_high * 100.0, + } + + def _update_with_percent_free_psa( + self, + psa_ng_ml: float, + percent_free_psa: float, + risk_no: float, + risk_low: float, + risk_high: float, + ) -> tuple[float, float, float]: + """Update the risk with the percent free PSA. + + Args: + psa_ng_ml: Serum PSA level in ng/mL. + percent_free_psa: Percent free PSA value. + risk_no: Prior probability of no cancer. + risk_low: Prior probability of low-grade cancer. + risk_high: Prior probability of high-grade cancer. + + Returns: + Updated (no, low, high) probabilities. + """ + psa_log2 = _log2(psa_ng_ml) + pct_log2 = _log2(percent_free_psa) + + prob: dict[str, float] = {} + for key in ("high", "low", "no"): + (intercept, slope), sd = self._PCT_FREE_PSA_PARAMS[key] + mean = intercept + slope * psa_log2 + prob[key] = _normal_pdf(pct_log2, mean, sd) + + return self._bayesian_update( + risk_no, + risk_low, + risk_high, + prob["no"], + prob["low"], + prob["high"], + ) + + def _update_with_pca3_t2erg( + self, + age_years: float, + pca3_score: float | None, + race_flag: float, + t2erg_score: float | None, + risk_no: float, + risk_low: float, + risk_high: float, + ) -> tuple[float, float, float]: + """Update the risk with the PCA3 and T2:ERG scores. + + Args: + age_years: Patient age in years. + pca3_score: PCA3 biomarker value (None if unavailable). + race_flag: Indicator for African-American race. + t2erg_score: T2:ERG biomarker score (None if unavailable). + risk_no: Prior probability of no cancer. + risk_low: Prior probability of low-grade cancer. + risk_high: Prior probability of high-grade cancer. + + Returns: + Updated (no, low, high) probabilities. + """ + if pca3_score is None: + return risk_no, risk_low, risk_high + + pca3_log2 = _log2(pca3_score) + age = age_years + race = race_flag + + sd = 1.51 + coef_high = (0.346 + 1.222, 0.058, 0.733) + coef_low = (0.346 + 0.908, 0.058, 0.733) + coef_no = (0.346, 0.058, 0.733) + + prob_high = _normal_pdf( + pca3_log2, coef_high[0] + coef_high[1] * age + coef_high[2] * race, sd + ) + prob_low = _normal_pdf( + pca3_log2, coef_low[0] + coef_low[1] * age + coef_low[2] * race, sd + ) + prob_no = _normal_pdf( + pca3_log2, coef_no[0] + coef_no[1] * age + coef_no[2] * race, sd + ) + + if t2erg_score is not None: + prob_high, prob_low, prob_no = self._apply_t2erg_update( + t2erg_score, pca3_log2, race, prob_high, prob_low, prob_no + ) + + return self._bayesian_update( + risk_no, + risk_low, + risk_high, + prob_no, + prob_low, + prob_high, + ) + + def _apply_t2erg_update( + self, + t2erg_score: float, + pca3_log2: float, + race: float, + prob_high: float, + prob_low: float, + prob_no: float, + ) -> tuple[float, float, float]: + """Apply the T2:ERG update to the risk. + + Args: + t2erg_score: T2:ERG biomarker value. + pca3_log2: log2(PCA3) value. + race: Indicator for African-American race (1.0 or 0.0). + prob_high: Likelihood proxy for high-grade condition. + prob_low: Likelihood proxy for low-grade condition. + prob_no: Likelihood proxy for no-cancer condition. + + Returns: + Updated likelihood proxies (high, low, no). + """ + t2erg = t2erg_score or 0.0 + nonzero = 1 if t2erg > 0 else 0 + + logistic_high = (0.2 + 0.46, 0.18, -0.507) + logistic_low = (0.2 + 0.60, 0.18, -0.507) + logistic_no = (0.2, 0.18, -0.507) + + def _logistic_prob(coeffs: tuple[float, float, float]) -> float: + linear = coeffs[0] + coeffs[1] * pca3_log2 + coeffs[2] * race + return exp(linear) / (1.0 + exp(linear)) + + binom_high = _logistic_prob(logistic_high) + binom_low = _logistic_prob(logistic_low) + binom_no = _logistic_prob(logistic_no) + + def _choose(prob: float, p: float) -> float: + return prob * (p if nonzero else (1.0 - p)) + + prob_high = _choose(prob_high, binom_high) + prob_low = _choose(prob_low, binom_low) + prob_no = _choose(prob_no, binom_no) + + if nonzero: + sd = 2.87 + coef_high = (0.911 + 2.037, 0.398) + coef_low = (0.911 + 0.971, 0.398) + coef_no = (0.911, 0.511) + t2erg_log2 = _log2(max(t2erg, 1e-12)) + + prob_high *= _normal_pdf( + t2erg_log2, coef_high[0] + coef_high[1] * pca3_log2, sd + ) + prob_low *= _normal_pdf( + t2erg_log2, coef_low[0] + coef_low[1] * pca3_log2, sd + ) + prob_no *= _normal_pdf(t2erg_log2, coef_no[0] + coef_no[1] * pca3_log2, sd) + + return prob_high, prob_low, prob_no + + @staticmethod + def _bayesian_update( + prior_no: float, + prior_low: float, + prior_high: float, + likelihood_no: float, + likelihood_low: float, + likelihood_high: float, + ) -> tuple[float, float, float]: + """Apply the Bayesian update to the risk. + + Args: + prior_no: Prior probability of no cancer. + prior_low: Prior probability of low-grade cancer. + prior_high: Prior probability of high-grade cancer. + likelihood_no: Likelihood under no-cancer hypothesis. + likelihood_low: Likelihood under low-grade hypothesis. + likelihood_high: Likelihood under high-grade hypothesis. + + Returns: + tuple[float, float, float]: Posterior probabilities (no, low, high). + """ + denom = ( + prior_no * likelihood_no + + prior_low * likelihood_low + + prior_high * likelihood_high + ) + if denom == 0: + return prior_no, prior_low, prior_high + + posterior_no = (prior_no * likelihood_no) / denom + posterior_low = (prior_low * likelihood_low) / denom + posterior_high = max(0.0, 1.0 - posterior_no - posterior_low) + return posterior_no, posterior_low, posterior_high + + def compute_score(self, user: UserInput) -> str: + """Compute the score for the PCPT model. + + Args: + user: The user profile. + + Returns: + str: Formatted risk percentages or an N/A message if not applicable. + + Raises: + ValueError: If required inputs are missing or invalid. + """ + # Validate inputs first + is_valid, errors = self.validate_inputs(user) + if not is_valid: + raise ValueError(f"Invalid inputs for PCPT: {'; '.join(errors)}") + + if user.demographics.sex != Sex.MALE: + return "N/A: PCPT applies to male patients only." + + try: + scores = self.absolute_risk(user) + except ValueError as exc: + return f"N/A: {exc}" + + return ( + f"No Cancer: {scores['no_cancer']:.1f}%, " + f"Low Grade: {scores['low_grade']:.1f}%, " + f"High Grade: {scores['high_grade']:.1f}%" + ) + + def cancer_type(self) -> str: + """Get the type of cancer the model predicts. + + Returns: + str: Cancer type label. + """ + return "prostate" + + def description(self) -> str: + """Get the description of the PCPT model. + + Returns: + str: Human-readable model description. + """ + return ( + "The PCPT Risk Calculator estimates the probability that a prostate " + "biopsy will show no cancer, low-grade cancer, or high-grade cancer " + "based on PSA, age, race, DRE, prior biopsy, family history, and " + "optional percent free PSA." + ) + + def interpretation(self) -> str: + """Get the interpretation of the PCPT model. + + Returns: + str: Human-readable interpretation guidance. + """ + return ( + "Outputs report three percentages corresponding to the chance of no " + "cancer, low-grade cancer, or high-grade cancer on biopsy. Results " + "should be interpreted alongside clinical judgment." + ) + + def references(self) -> list[str]: + """Get the references for the PCPT model. + + Returns: + list[str]: Reference list. + """ + return [ + "Ankerst DP et al. The Prostate Cancer Prevention Trial Risk " + "Calculator 2.0 for the prediction of low- versus high-grade " + "prostate cancer. Urology. 2014;83(6):1362-1367.", + ] diff --git a/src/sentinel/risk_models/plcom2012.py b/src/sentinel/risk_models/plcom2012.py new file mode 100644 index 0000000000000000000000000000000000000000..740239df32ff16c0c9353507f2924906e4f2587d --- /dev/null +++ b/src/sentinel/risk_models/plcom2012.py @@ -0,0 +1,261 @@ +"""Lung cancer risk estimation using the PLCOm2012 model. + +This module provides a Python port of the PLCOm2012 model, which predicts the +6-year probability of developing lung cancer. The model is based on data from +the Prostate, Lung, Colorectal, and Ovarian (PLCO) Cancer Screening Trial. + +The model is intended for individuals who are current or former smokers and +requires specific inputs that may not be direct fields in the UserInput model. +This implementation attempts to find these values (e.g., BMI, smoking duration, +smoking intensity) within the user's provided clinical observations. If essential +data is missing, the model will not run. + +The original R implementation can be found at: +https://github.com/resplab/PLCOm2012 +""" + +from math import exp +from typing import Annotated + +from pydantic import Field + +from sentinel.risk_models.base import RiskModel +from sentinel.user_input import ( + CancerType, + ChronicCondition, + Ethnicity, + Sex, + SmokingStatus, + UserInput, +) + + +def _map_ethnicity_to_race(ethnicity: Ethnicity | None) -> str: + """Map ethnicity enum to PLCOm2012 race string. + + Args: + ethnicity: Ethnicity enum value. + + Returns: + str: PLCOm2012 race string. + """ + if ethnicity == Ethnicity.BLACK: + return "black" + if ethnicity == Ethnicity.HISPANIC: + return "hispanic" + if ethnicity == Ethnicity.ASIAN: + return "asian" + if ethnicity == Ethnicity.PACIFIC_ISLANDER: + return "pacific islander" + if ethnicity == Ethnicity.NATIVE_AMERICAN: + return "american indian" + return "white" + + +class PLCOm2012RiskModel(RiskModel): + """Compute 6-year absolute lung cancer risk using the PLCOm2012 model.""" + + # Model coefficients and intercepts are derived from the original R source code. + _COEFFICIENTS = { + "age": 0.0778868, + "education": -0.0812744, + "bmi": -0.0274194, + "copd": 0.3553063, + "cancer_hist": 0.4589971, + "family_hist_lung_cancer": 0.587185, + "smoking_status": 0.2597431, + "smoking_intensity": -1.822606, + "duration_smoking": 0.0317321, + "smoking_quit_time": -0.0308572, + } + _INTERCEPT = -4.532506 + _RACE_OFFSETS = { + "white": 0.0, + "american indian": 0.0, + "alaskan native": 0.0, + "black": 0.3944778, + "hispanic": -0.7434744, + "asian": -0.466585, + "native hawaiian": 1.027152, + "pacific islander": 1.027152, + } + + # Centering values for continuous variables + _CENTER_VALUES = { + "age": 62, + "education": 4, + "bmi": 27, + "smoking_intensity_const": 0.4021541613, + "duration_smoking": 27, + "smoking_quit_time": 10, + } + + def __init__(self): + super().__init__("plcom2012") + + REQUIRED_INPUTS: dict[str, tuple[type, bool]] = { + "demographics.age_years": (Annotated[int, Field(ge=50, le=80)], True), + "demographics.sex": (Sex, True), + "demographics.ethnicity": (Ethnicity | None, True), + "demographics.anthropometrics.height_cm": (Annotated[float, Field(gt=0)], True), + "demographics.anthropometrics.weight_kg": (Annotated[float, Field(gt=0)], True), + "demographics.education_level": (Annotated[int, Field(ge=0, le=25)], True), + "lifestyle.smoking.status": (SmokingStatus, True), + "lifestyle.smoking.pack_years": (Annotated[float, Field(ge=0)], False), + "lifestyle.smoking.cigarettes_per_day": (Annotated[int, Field(ge=0)], True), + "lifestyle.smoking.years_smoked": (Annotated[int, Field(ge=0)], True), + "lifestyle.smoking.years_since_quit": (Annotated[int, Field(ge=0)], False), + "personal_medical_history.chronic_conditions": ( + list, + False, + ), # list[ChronicCondition] + "personal_medical_history.previous_cancers": (list, False), # list[CancerType] + "family_history": (list, False), # list[FamilyMemberCancer] + } + + def calculate_risk( + self, + *, + age: int, + race: str, + education: int, + bmi: float, + copd: int, + cancer_hist: int, + family_hist_lung_cancer: int, + smoking_status: int, + smoking_intensity: int, + duration_smoking: int, + smoking_quit_time: int, + ) -> float: + """Calculate the 6-year lung cancer risk. + + Args: + age: Patient age in years. + race: Encoded race category. + education: Education level indicator. + bmi: Body mass index. + copd: Chronic obstructive pulmonary disease indicator. + cancer_hist: Personal cancer history flag. + family_hist_lung_cancer: Family history of lung cancer flag. + smoking_status: Encoded smoking status. + smoking_intensity: Average cigarettes per day. + duration_smoking: Total years smoked. + smoking_quit_time: Years since quitting. + + Returns: + Risk percentage over six years. + """ + + # Calculate the log-odds (logit) + logit = self._INTERCEPT + logit += self._COEFFICIENTS["age"] * (age - self._CENTER_VALUES["age"]) + logit += self._COEFFICIENTS["education"] * ( + education - self._CENTER_VALUES["education"] + ) + logit += self._COEFFICIENTS["bmi"] * (bmi - self._CENTER_VALUES["bmi"]) + logit += self._COEFFICIENTS["copd"] * copd + logit += self._COEFFICIENTS["cancer_hist"] * cancer_hist + logit += self._COEFFICIENTS["family_hist_lung_cancer"] * family_hist_lung_cancer + logit += self._COEFFICIENTS["smoking_status"] * smoking_status + logit += self._COEFFICIENTS["duration_smoking"] * ( + duration_smoking - self._CENTER_VALUES["duration_smoking"] + ) + logit += self._COEFFICIENTS["smoking_quit_time"] * ( + smoking_quit_time - self._CENTER_VALUES["smoking_quit_time"] + ) + + # Add smoking intensity term + if smoking_intensity == 0: + return None # Cannot calculate with 0 smoking intensity + intensity_term = (smoking_intensity / 10.0) ** (-1) - self._CENTER_VALUES[ + "smoking_intensity_const" + ] + logit += self._COEFFICIENTS["smoking_intensity"] * intensity_term + + # Add race-specific offset + race_offset = self._RACE_OFFSETS.get( + race.lower(), 0.0 + ) # Default to white/0.0 if not found + logit += race_offset + + # Convert log-odds to probability and then to a percentage + probability = exp(logit) / (1 + exp(logit)) + return probability * 100 + + def compute_score(self, user: UserInput) -> str: + """Compute the score for the PLCOm2012 model. + + Args: + user: The user profile. + + Returns: + str: Risk percentage as a string or an N/A message if not applicable. + + Raises: + ValueError: If required inputs are missing or invalid. + """ + # Validate inputs first + is_valid, errors = self.validate_inputs(user) + if not is_valid: + raise ValueError(f"Invalid inputs for PLCOm2012: {'; '.join(errors)}") + + if user.lifestyle.smoking.status not in { + SmokingStatus.CURRENT, + SmokingStatus.FORMER, + }: + return "N/A: Model is for current or former smokers only." + + # Calculate BMI from height and weight + height_m = user.demographics.anthropometrics.height_cm / 100.0 + weight_kg = user.demographics.anthropometrics.weight_kg + bmi = weight_kg / (height_m * height_m) + + # Check for missing required fields for former smokers + if ( + user.lifestyle.smoking.status == SmokingStatus.FORMER + and user.lifestyle.smoking.years_since_quit is None + ): + return "N/A: Missing years since quitting for former smoker." + + # Calculate the risk + numeric_score = self.calculate_risk( + age=user.demographics.age_years, + race=_map_ethnicity_to_race(user.demographics.ethnicity), + education=user.demographics.education_level, + bmi=bmi, + copd=1 + if ChronicCondition.COPD in user.personal_medical_history.chronic_conditions + else 0, + cancer_hist=1 if user.personal_medical_history.previous_cancers else 0, + family_hist_lung_cancer=1 + if any( + member.cancer_type == CancerType.LUNG for member in user.family_history + ) + else 0, + smoking_status=1 + if user.lifestyle.smoking.status == SmokingStatus.CURRENT + else 0, + smoking_intensity=user.lifestyle.smoking.cigarettes_per_day, + duration_smoking=user.lifestyle.smoking.years_smoked, + smoking_quit_time=user.lifestyle.smoking.years_since_quit or 0, + ) + + if numeric_score is None: + return "N/A: Calculation failed. Smoking intensity must be > 0." + + return f"{numeric_score:.2f}%" + + def cancer_type(self) -> str: + return "lung" + + def description(self) -> str: + return "The PLCOm2012 model predicts the 6-year probability of developing lung cancer for current or former smokers based on age, smoking history, BMI, education, and other health factors." + + def interpretation(self) -> str: + return "The score is the percentage chance of developing lung cancer in the next 6 years. A higher score indicates greater risk. This score can provide additional context for discussion with a healthcare provider." + + def references(self) -> list[str]: + return [ + "Tammemägi, M. C., et al. (2013). Selection of individuals for lung-cancer screening by modeling lung-cancer risk. New England Journal of Medicine, 368(8), 728-736." + ] diff --git a/src/sentinel/risk_models/qcancer.py b/src/sentinel/risk_models/qcancer.py new file mode 100644 index 0000000000000000000000000000000000000000..50b930b6821e7f1ac1d77c6a5fc20f7484f7b160 --- /dev/null +++ b/src/sentinel/risk_models/qcancer.py @@ -0,0 +1,1927 @@ +"""QCancer multi-site cancer risk model for the Sentinel framework. + +This module provides a faithful port of the QCancer-2013 models published by +ClinRisk Ltd. under the GNU Affero General Public License v3. The implementation +is based on the public C reference code (`Q76_cancer2_16_0.c` for females and +`Q76_cancer2_15_1.c` for males) and the cohort study: + + Hippisley-Cox J, Coupland C. Development and validation of QCancer (10 year + risk) to estimate risk of cancer in men and women in England: cohort study. + BMJ. 2014;349:g4606. + +The Python implementation reproduces the exact coefficients, fractional polynomial +transforms, and risk calculations from the original C code, validated against the +C binaries across diverse test cases. + +Original code: https://github.com/nhsland/clinrisk-modules/tree/master/qCancer +Website with calculator: https://www.qcancer.org/ +""" + +import math +from collections.abc import Iterable +from typing import Annotated + +from pydantic import Field + +from sentinel.risk_models.base import RiskModel +from sentinel.user_input import ( + AlcoholConsumption, + CancerType, + ChronicCondition, + FamilyRelation, + RelationshipDegree, + Sex, + SmokingStatus, + SymptomType, + UserInput, +) +from sentinel.utils import calculate_bmi + +__all__ = [ + "QCancerRiskModel", + "compute_female_probabilities", + "compute_male_probabilities", +] + +FEMALE_CANCER_TYPES = [ + "blood_cancer", + "breast_cancer", + "cervical_cancer", + "colorectal_cancer", + "gastro_oesophageal_cancer", + "lung_cancer", + "other_cancer", + "ovarian_cancer", + "pancreatic_cancer", + "renal_tract_cancer", + "uterine_cancer", +] + +MALE_CANCER_TYPES = [ + "blood_cancer", + "colorectal_cancer", + "gastro_oesophageal_cancer", + "lung_cancer", + "other_cancer", + "pancreatic_cancer", + "prostate_cancer", + "renal_tract_cancer", + "testicular_cancer", +] + + +def _symptom_flag(user: UserInput, symptom_types: Iterable[SymptomType]) -> int: + """Check if any symptom type appears in user's symptoms. + + Args: + user: User input containing structured symptoms. + symptom_types: SymptomType enums to search for. + + Returns: + 1 if any symptom type found, 0 otherwise. + """ + symptom_set = set(symptom_types) + for symptom_entry in user.symptoms: + if symptom_entry.symptom_type in symptom_set: + return 1 + return 0 + + +def _family_history_flag(user: UserInput, cancer_types: Iterable[CancerType]) -> int: + """Check if any first-degree relative has a matching cancer type. + + Args: + user: User input containing family history. + cancer_types: CancerType enums to match. + + Returns: + 1 if matching family history found, 0 otherwise. + """ + cancer_set = set(cancer_types) + first_degree_relations = { + FamilyRelation.MOTHER, + FamilyRelation.FATHER, + FamilyRelation.SISTER, + FamilyRelation.BROTHER, + FamilyRelation.DAUGHTER, + FamilyRelation.SON, + } + + for record in user.family_history: + if ( + record.relation in first_degree_relations + or record.degree == RelationshipDegree.FIRST + ): + if record.cancer_type in cancer_set: + return 1 + return 0 + + +def _flag_if_history(user: UserInput, condition: ChronicCondition) -> int: + """Check if specific chronic condition is present. + + Args: + user: User input with personal medical history. + condition: ChronicCondition enum to check for. + + Returns: + 1 if condition found, 0 otherwise. + """ + return int(condition in user.personal_medical_history.chronic_conditions) + + +def _infer_smoke_category(user: UserInput) -> int: + """Map smoking status to QCancer smoke_cat. + + Args: + user: User input with lifestyle information. + + Returns: + Smoking category: 0=non-smoker, 1=ex-smoker, 2=light (<10/day), + 3=moderate (10-19/day), 4=heavy (20+/day). + """ + smoking = user.lifestyle.smoking + + if smoking.status == SmokingStatus.NEVER: + return 0 + if smoking.status == SmokingStatus.FORMER: + return 1 + + # Current smoker - categorize by intensity + cpd = smoking.cigarettes_per_day or 0 + if cpd < 10: + return 2 # light + if cpd < 20: + return 3 # moderate + return 4 # heavy (20+) + + +def _infer_alcohol_category(user: UserInput) -> int: + """Map alcohol consumption to QCancer alcohol_cat4. + + Args: + user: User input with lifestyle information. + + Returns: + Alcohol category: 0=none, 1=light, 2=moderate, 3=heavy. + """ + if user.lifestyle.alcohol_consumption is None: + return 0 + + consumption = user.lifestyle.alcohol_consumption + + if consumption == AlcoholConsumption.NONE: + return 0 + if consumption == AlcoholConsumption.LIGHT: + return 1 + if consumption == AlcoholConsumption.MODERATE: + return 2 + if consumption == AlcoholConsumption.HEAVY: + return 3 + return 1 # default to light if unclear + + +def _require_bmi(user: UserInput) -> float: + """Calculate BMI from demographics. + + Args: + user: User input with demographics. + + Returns: + BMI value (kg/m²). + + Raises: + ValueError: If height or weight is missing or invalid. + """ + anthro = user.demographics.anthropometrics + if anthro.height_cm is None or anthro.weight_kg is None: + raise ValueError("Missing height and weight to calculate BMI.") + + return calculate_bmi( + anthro.height_cm, anthro.weight_kg, height_unit="cm", weight_unit="kg" + ) + + +def blood_cancer_female_score( + age: int, + bmi: float, + c_hb: int, + new_abdopain: int, + new_haematuria: int, + new_necklump: int, + new_nightsweats: int, + new_pmb: int, + new_vte: int, + new_weightloss: int, + s1_bowelchange: int, + s1_bruising: int, +) -> float: + """Blood Cancer log-hazard score.""" + dage = age / 10.0 + age_1 = math.pow(dage, -2) + age_2 = math.pow(dage, -2) * math.log(dage) + dbmi = bmi / 10.0 + bmi_1 = math.pow(dbmi, -2) + bmi_2 = math.pow(dbmi, -2) * math.log(dbmi) + + age_1 -= 0.039541322737932 + age_2 -= 0.063867323100567 + bmi_1 -= 0.151021569967270 + bmi_2 -= 0.142740502953529 + + a = 0.0 + a += age_1 * 35.94056668962831 + a += age_2 * -68.84963759779045 + a += bmi_1 * 0.0785171223057502 + a += bmi_2 * -5.373062778868142 + a += c_hb * 1.703586650229763 + a += new_abdopain * 0.3779206239385798 + a += new_haematuria * 0.40866629745988947 + a += new_necklump * 2.9539029476671903 + a += new_nightsweats * 1.3792892192392403 + a += new_pmb * 0.46892163134409925 + a += new_vte * 0.6036630662990674 + a += new_weightloss * 0.8963398932306316 + a += s1_bowelchange * 0.729137961246862 + a += s1_bruising * 1.0255003552753392 + return a + -7.420784948256575 + + +def breast_cancer_female_score( + age: int, + alcohol_cat4: int, + bmi: float, + fh_breastcancer: int, + new_breastlump: int, + new_breastpain: int, + new_breastskin: int, + new_pmb: int, + new_vte: int, + town: float, +) -> float: + """Breast Cancer log-hazard score.""" + dage = age / 10.0 + age_1 = math.pow(dage, -2) + age_2 = math.pow(dage, -2) * math.log(dage) + dbmi = bmi / 10.0 + bmi_1 = math.pow(dbmi, -2) + bmi_2 = math.pow(dbmi, -2) * math.log(dbmi) + + age_1 -= 0.039541322737932 + age_2 -= 0.063867323100567 + bmi_1 -= 0.151021569967270 + bmi_2 -= 0.142740502953529 + town -= -0.383295059204102 + + Ialcohol = [0.0, 0.054381307594513456, 0.12457099729838178, 0.18551986792615147] + + a = 0.0 + a += Ialcohol[alcohol_cat4] + a += age_1 * -14.30294840678985 + a += age_2 * -25.930181137736426 + a += bmi_1 * -1.75409838256809 + a += bmi_2 * 2.0601979121740364 + a += town * -0.016076697263223444 + a += fh_breastcancer * 0.3863899675953914 + a += new_breastlump * 3.9278533274888368 + a += new_breastpain * 0.8779616078329102 + a += new_breastskin * 2.232029623398788 + a += new_pmb * 0.44650530022483 + a += new_vte * 0.27286102972131654 + return a + -6.1261694200869234 + + +def cervical_cancer_female_score( + age: int, + bmi: float, + c_hb: int, + new_abdopain: int, + new_haematuria: int, + new_imb: int, + new_pmb: int, + new_postcoital: int, + new_vte: int, + smoke_cat: int, + town: float, +) -> float: + """Cervical Cancer log-hazard score.""" + dage = age / 10.0 + age_1 = math.pow(dage, -2) + age_2 = math.pow(dage, -2) * math.log(dage) + dbmi = bmi / 10.0 + bmi_1 = math.pow(dbmi, -2) + bmi_2 = math.pow(dbmi, -2) * math.log(dbmi) + + age_1 -= 0.039541322737932 + age_2 -= 0.063867323100567 + bmi_1 -= 0.151021569967270 + bmi_2 -= 0.142740502953529 + town -= -0.383295059204102 + + Ismoke = [ + 0.0, + 0.32478752770957153, + 0.7541211259076739, + 0.744834303513966, + 0.6328348533913807, + ] + + a = 0.0 + a += Ismoke[smoke_cat] + a += age_1 * 10.16633931075058 + a += age_2 * -16.91189024911 + a += bmi_1 * -0.5675143308052615 + a += bmi_2 * -2.6377586334504044 + a += town * 0.0573200669650633 + a += c_hb * 1.2205973555195053 + a += new_abdopain * 0.7229870191773574 + a += new_haematuria * 1.6126499968790107 + a += new_imb * 1.9527008812518938 + a += new_pmb * 3.3618997560756485 + a += new_postcoital * 3.1391568551730864 + a += new_vte * 1.1276327958138455 + return a + -8.830909844440193 + + +def colorectal_cancer_female_score( + age: int, + alcohol_cat4: int, + bmi: float, + c_hb: int, + fh_gicancer: int, + new_abdodist: int, + new_abdopain: int, + new_appetiteloss: int, + new_rectalbleed: int, + new_vte: int, + new_weightloss: int, + s1_bowelchange: int, + s1_constipation: int, +) -> float: + """Colorectal Cancer log-hazard score.""" + dage = age / 10.0 + age_1 = math.pow(dage, -2) + age_2 = math.pow(dage, -2) * math.log(dage) + dbmi = bmi / 10.0 + bmi_1 = math.pow(dbmi, -2) + bmi_2 = math.pow(dbmi, -2) * math.log(dbmi) + + age_1 -= 0.039541322737932 + age_2 -= 0.063867323100567 + bmi_1 -= 0.151021569967270 + bmi_2 -= 0.142740502953529 + + Ialcohol = [0.0, 0.24290142628846959, 0.2359224520197608, 0.4606605934539446] + + a = 0.0 + a += Ialcohol[alcohol_cat4] + a += age_1 * -11.617560661639077 + a += age_2 * -42.90980576868702 + a += bmi_1 * -0.5344237822753053 + a += bmi_2 * 2.6900552265408226 + a += c_hb * 1.4759238359186861 + a += fh_gicancer * 0.4044501048847998 + a += new_abdodist * 0.663007428785656 + a += new_abdopain * 1.4990872468711913 + a += new_appetiteloss * 0.5068020107261922 + a += new_rectalbleed * 2.7491673095810105 + a += new_vte * 0.7072816884002933 + a += new_weightloss * 1.0288860866585736 + a += s1_bowelchange * 0.7664414123199643 + a += s1_constipation * 0.33751581231211736 + return a + -7.546694878967094 + + +def gastro_oesophageal_cancer_female_score( + age: int, + bmi: float, + c_hb: int, + new_abdopain: int, + new_appetiteloss: int, + new_dysphagia: int, + new_gibleed: int, + new_heartburn: int, + new_indigestion: int, + new_vte: int, + new_weightloss: int, + smoke_cat: int, +) -> float: + """Gastro Oesophageal Cancer log-hazard score.""" + dage = age / 10.0 + age_1 = math.pow(dage, -2) + age_2 = math.pow(dage, -2) * math.log(dage) + dbmi = bmi / 10.0 + bmi_1 = math.pow(dbmi, -2) + bmi_2 = math.pow(dbmi, -2) * math.log(dbmi) + + age_1 -= 0.039541322737932 + age_2 -= 0.063867323100567 + bmi_1 -= 0.151021569967270 + bmi_2 -= 0.142740502953529 + + Ismoke = [ + 0.0, + 0.21088353859940934, + 0.4020914846651602, + 0.8497119766959212, + 1.102058546972454, + ] + + a = 0.0 + a += Ismoke[smoke_cat] + a += age_1 * 5.512793295816083 + a += age_2 * -70.27340629161618 + a += bmi_1 * 2.6063377632938987 + a += bmi_2 * -1.2389834515079798 + a += c_hb * 1.2479756970482034 + a += new_abdopain * 0.7825304005124729 + a += new_appetiteloss * 0.6514592236889244 + a += new_dysphagia * 3.775171491065686 + a += new_gibleed * 1.4264472204617833 + a += new_heartburn * 0.8178746069193373 + a += new_indigestion * 1.4998439683677578 + a += new_vte * 0.7199894658172599 + a += new_weightloss * 1.2287925630053846 + return a + -8.874603161025076 + + +def lung_cancer_female_score( + age: int, + b_copd: int, + bmi: float, + c_hb: int, + new_appetiteloss: int, + new_dysphagia: int, + new_haemoptysis: int, + new_indigestion: int, + new_necklump: int, + new_vte: int, + new_weightloss: int, + s1_cough: int, + smoke_cat: int, + town: float, +) -> float: + """Lung Cancer log-hazard score.""" + dage = age / 10.0 + age_1 = math.pow(dage, -2) + age_2 = math.pow(dage, -2) * math.log(dage) + dbmi = bmi / 10.0 + bmi_1 = math.pow(dbmi, -2) + bmi_2 = math.pow(dbmi, -2) * math.log(dbmi) + + age_1 -= 0.039541322737932 + age_2 -= 0.063867323100567 + bmi_1 -= 0.151021569967270 + bmi_2 -= 0.142740502953529 + town -= -0.383295059204102 + + Ismoke = [ + 0.0, + 1.3397416191950409, + 1.9500839456663224, + 2.1881694694325233, + 2.4828660433307768, + ] + + a = 0.0 + a += Ismoke[smoke_cat] + a += age_1 * -117.24057375029625 + a += age_2 * 25.17022547412681 + a += bmi_1 * 2.584548813392435 + a += bmi_2 * -0.6083523966762799 + a += town * 0.040692046183056746 + a += b_copd * 0.7942901962671365 + a += c_hb * 0.8627980324401628 + a += new_appetiteloss * 0.7170232121379446 + a += new_dysphagia * 0.6718426806077323 + a += new_haemoptysis * 2.9286439157734474 + a += new_indigestion * 0.36348937301142736 + a += new_necklump * 1.209724038009159 + a += new_vte * 0.8907072670032341 + a += new_weightloss * 1.1384524885073082 + a += s1_cough * 0.6439917053275602 + return a + -8.64490029717897 + + +def other_cancer_female_score( + age: int, + alcohol_cat4: int, + b_copd: int, + bmi: float, + c_hb: int, + new_abdodist: int, + new_abdopain: int, + new_appetiteloss: int, + new_breastlump: int, + new_dysphagia: int, + new_gibleed: int, + new_haematuria: int, + new_indigestion: int, + new_necklump: int, + new_pmb: int, + new_vte: int, + new_weightloss: int, + s1_constipation: int, + smoke_cat: int, +) -> float: + """Other Cancer log-hazard score.""" + dage = age / 10.0 + age_1 = math.pow(dage, -2) + age_2 = math.pow(dage, -2) * math.log(dage) + dbmi = bmi / 10.0 + bmi_1 = math.pow(dbmi, -2) + bmi_2 = math.pow(dbmi, -2) * math.log(dbmi) + + age_1 -= 0.039541322737932 + age_2 -= 0.063867323100567 + bmi_1 -= 0.151021569967270 + bmi_2 -= 0.142740502953529 + + Ialcohol = [0.0, 0.11292925170889954, 0.13891832056179676, 0.3428114766789586] + + Ismoke = [ + 0.0, + 0.06438397925516476, + 0.18750681016606915, + 0.3754052152821668, + 0.5007337952210844, + ] + + a = 0.0 + a += Ialcohol[alcohol_cat4] + a += Ismoke[smoke_cat] + a += age_1 * 35.82089873022048 + a += age_2 * -68.32947410377191 + a += bmi_1 * 1.8969796480108396 + a += bmi_2 * -3.7755945945329574 + a += b_copd * 0.28230214291079436 + a += c_hb * 1.0476364795173587 + a += new_abdodist * 0.9628688090459262 + a += new_abdopain * 0.833571006671561 + a += new_appetiteloss * 0.8450972438476546 + a += new_breastlump * 1.0400807427059522 + a += new_dysphagia * 0.8905342895684596 + a += new_gibleed * 0.38396322651340786 + a += new_haematuria * 0.6143184647549448 + a += new_indigestion * 0.24570160029924543 + a += new_necklump * 2.1666504706191545 + a += new_pmb * 0.4219383252623541 + a += new_vte * 1.063078486173392 + a += new_weightloss * 1.1058752771736007 + a += s1_constipation * 0.37801436412994915 + return a + -6.786450166859431 + + +def ovarian_cancer_female_score( + age: int, + bmi: float, + c_hb: int, + fh_ovariancancer: int, + new_abdodist: int, + new_abdopain: int, + new_appetiteloss: int, + new_haematuria: int, + new_indigestion: int, + new_pmb: int, + new_vte: int, + new_weightloss: int, + s1_bowelchange: int, +) -> float: + """Ovarian Cancer log-hazard score.""" + dage = age / 10.0 + age_1 = math.pow(dage, -2) + age_2 = math.pow(dage, -2) * math.log(dage) + dbmi = bmi / 10.0 + bmi_1 = math.pow(dbmi, -2) + bmi_2 = math.pow(dbmi, -2) * math.log(dbmi) + + age_1 -= 0.039541322737932 + age_2 -= 0.063867323100567 + bmi_1 -= 0.151021569967270 + bmi_2 -= 0.142740502953529 + + a = 0.0 + a += age_1 * -61.083181446256894 + a += age_2 * 20.30286127011069 + a += bmi_1 * -2.1261135335028407 + a += bmi_2 * 3.216820040877247 + a += c_hb * 1.3625636791018674 + a += fh_ovariancancer * 1.995177480995183 + a += new_abdodist * 2.9381020883363806 + a += new_abdopain * 1.7307824546132513 + a += new_appetiteloss * 1.0606947909647773 + a += new_haematuria * 0.4958835997468108 + a += new_indigestion * 0.38437310274939984 + a += new_pmb * 1.5869592940878865 + a += new_vte * 1.6839747529852673 + a += new_weightloss * 0.4774332393821721 + a += s1_bowelchange * 0.6849850007182314 + return a + -7.560992964449132 + + +def pancreatic_cancer_female_score( + age: int, + b_chronicpan: int, + b_type2: int, + bmi: float, + new_abdopain: int, + new_appetiteloss: int, + new_dysphagia: int, + new_gibleed: int, + new_indigestion: int, + new_vte: int, + new_weightloss: int, + s1_bowelchange: int, + smoke_cat: int, +) -> float: + """Pancreatic Cancer log-hazard score.""" + dage = age / 10.0 + age_1 = math.pow(dage, -2) + age_2 = math.pow(dage, -2) * math.log(dage) + dbmi = bmi / 10.0 + bmi_1 = math.pow(dbmi, -2) + bmi_2 = math.pow(dbmi, -2) * math.log(dbmi) + + age_1 -= 0.039541322737932 + age_2 -= 0.063867323100567 + bmi_1 -= 0.151021569967270 + bmi_2 -= 0.142740502953529 + + Ismoke = [ + 0.0, + -0.06313018481520442, + 0.35236959505289345, + 0.7146003670327157, + 0.8073207410335441, + ] + + a = 0.0 + a += Ismoke[smoke_cat] + a += age_1 * -6.8219654517231225 + a += age_2 * -65.64048973051887 + a += bmi_1 * 3.971555945899573 + a += bmi_2 * -3.11611079991305 + a += b_chronicpan * 1.1948138830441282 + a += b_type2 * 0.7951745325664703 + a += new_abdopain * 1.9230379689782926 + a += new_appetiteloss * 1.520956825988857 + a += new_dysphagia * 1.0107551560302726 + a += new_gibleed * 0.9324059153254259 + a += new_indigestion * 1.113401261663144 + a += new_vte * 1.4485586969016084 + a += new_weightloss * 1.5791912580663912 + a += s1_bowelchange * 0.9361738611941445 + return a + -9.27821296786576 + + +def renal_tract_cancer_female_score( + age: int, + bmi: float, + c_hb: int, + new_abdopain: int, + new_appetiteloss: int, + new_haematuria: int, + new_indigestion: int, + new_pmb: int, + new_weightloss: int, + smoke_cat: int, +) -> float: + """Renal Tract Cancer log-hazard score.""" + dage = age / 10.0 + age_1 = math.pow(dage, -2) + age_2 = math.pow(dage, -2) * math.log(dage) + dbmi = bmi / 10.0 + bmi_1 = math.pow(dbmi, -2) + bmi_2 = math.pow(dbmi, -2) * math.log(dbmi) + + age_1 -= 0.039541322737932 + age_2 -= 0.063867323100567 + bmi_1 -= 0.151021569967270 + bmi_2 -= 0.142740502953529 + + Ismoke = [ + 0.0, + 0.27521757277393727, + 0.5498656631475861, + 0.653624218213668, + 0.905376366178588, + ] + + a = 0.0 + a += Ismoke[smoke_cat] + a += age_1 * -0.03232265696266175 + a += age_2 * -56.35514107866358 + a += bmi_1 * 1.210391053577933 + a += bmi_2 * -4.7221299079939785 + a += c_hb * 1.2666531852544143 + a += new_abdopain * 0.6155954984707595 + a += new_appetiteloss * 0.684218459467602 + a += new_haematuria * 4.179144453724154 + a += new_indigestion * 0.5694329224821875 + a += new_pmb * 1.2541097882792864 + a += new_weightloss * 0.7711610560290518 + return a + -8.944077555377625 + + +def uterine_cancer_female_score( + age: int, + b_endometrial: int, + b_type2: int, + bmi: float, + new_abdopain: int, + new_haematuria: int, + new_imb: int, + new_pmb: int, + new_vte: int, +) -> float: + """Uterine Cancer log-hazard score.""" + dage = age / 10.0 + age_1 = math.pow(dage, -2) + age_2 = math.pow(dage, -2) * math.log(dage) + dbmi = bmi / 10.0 + bmi_1 = math.pow(dbmi, -2) + bmi_2 = math.pow(dbmi, -2) * math.log(dbmi) + + age_1 -= 0.039541322737932 + age_2 -= 0.063867323100567 + bmi_1 -= 0.151021569967270 + bmi_2 -= 0.142740502953529 + + a = 0.0 + a += age_1 * 2.7778124257317254 + a += age_2 * -59.53335145666333 + a += bmi_1 * 3.7623897936404322 + a += bmi_2 * -26.804545007465432 + a += b_endometrial * 0.8742311851235286 + a += b_type2 * 0.2655181024063556 + a += new_abdopain * 0.689195383673558 + a += new_haematuria * 1.6798617740998527 + a += new_imb * 1.7853122923827887 + a += new_pmb * 4.47701998760674 + a += new_vte * 1.036205861676167 + return a + -8.993139082256404 + + +def blood_cancer_male_score( + age: int, + bmi: float, + c_hb: int, + new_abdodist: int, + new_abdopain: int, + new_appetiteloss: int, + new_dysphagia: int, + new_haematuria: int, + new_haemoptysis: int, + new_indigestion: int, + new_necklump: int, + new_nightsweats: int, + new_testicularlump: int, + new_vte: int, + new_weightloss: int, + town: float, +) -> float: + """Blood Cancer log-hazard score for males.""" + dage = age / 10.0 + age_1 = dage + age_2 = dage * math.log(dage) + dbmi = bmi / 10.0 + bmi_1 = math.pow(dbmi, -2) + bmi_2 = dbmi + + age_1 -= 4.800777912139893 + age_2 -= 7.531354427337647 + bmi_1 -= 0.146067067980766 + bmi_2 -= 2.616518735885620 + town -= -0.264977723360062 + + a = 0.0 + a += age_1 * 3.497017935455661 + a += age_2 * -1.0806801421562633 + a += bmi_1 * 0.9519259479511792 + a += bmi_2 * 0.17146693584100858 + a += town * -0.02770624267524916 + a += c_hb * 1.8905802113004144 + a += new_abdodist * 0.8430432197211394 + a += new_abdopain * 0.6226473288294992 + a += new_appetiteloss * 1.067215038075376 + a += new_dysphagia * 0.5419443056595199 + a += new_haematuria * 0.46075380853635217 + a += new_haemoptysis * 0.9501446899241837 + a += new_indigestion * 0.5635686569331337 + a += new_necklump * 3.1567783466839603 + a += new_nightsweats * 1.5201300180753576 + a += new_testicularlump * 0.9957524928245107 + a += new_vte * 0.6142589726132867 + a += new_weightloss * 1.2233663263194712 + return a + -7.259128946685028 + + +def colorectal_cancer_male_score( + age: int, + alcohol_cat4: int, + bmi: float, + c_hb: int, + fh_gicancer: int, + new_abdodist: int, + new_abdopain: int, + new_appetiteloss: int, + new_rectalbleed: int, + new_weightloss: int, + s1_bowelchange: int, + s1_constipation: int, +) -> float: + """Colorectal Cancer log-hazard score for males.""" + dage = age / 10.0 + age_1 = dage + age_2 = dage * math.log(dage) + dbmi = bmi / 10.0 + bmi_1 = math.pow(dbmi, -2) + bmi_2 = dbmi + + age_1 -= 4.800777912139893 + age_2 -= 7.531354427337647 + bmi_1 -= 0.146067067980766 + bmi_2 -= 2.616518735885620 + + Ialcohol = [0.0, 0.06744317002685918, 0.2894952197787854, 0.44195399849740974] + + a = 0.0 + a += Ialcohol[alcohol_cat4] + a += age_1 * 7.265284251403637 + a += age_2 * -2.3119103657424414 + a += bmi_1 * 0.4591530847132721 + a += bmi_2 * 0.14026516690905994 + a += c_hb * 1.4066322376473517 + a += fh_gicancer * 0.40572853210100446 + a += new_abdodist * 1.3572627165452165 + a += new_abdopain * 1.5179997924486877 + a += new_appetiteloss * 0.5421335457752113 + a += new_rectalbleed * 2.8846500840638964 + a += new_weightloss * 1.1082218896963933 + a += s1_bowelchange * 1.2962496832506105 + a += s1_constipation * 0.2284256115498967 + return a + -7.687634276522626 + + +def gastro_oesophageal_cancer_male_score( + age: int, + bmi: float, + c_hb: int, + new_abdopain: int, + new_appetiteloss: int, + new_dysphagia: int, + new_gibleed: int, + new_heartburn: int, + new_indigestion: int, + new_necklump: int, + new_weightloss: int, + smoke_cat: int, +) -> float: + """Gastro Oesophageal Cancer log-hazard score for males.""" + dage = age / 10.0 + age_1 = dage + age_2 = dage * math.log(dage) + dbmi = bmi / 10.0 + bmi_1 = math.pow(dbmi, -2) + bmi_2 = dbmi + + age_1 -= 4.800777912139893 + age_2 -= 7.531354427337647 + bmi_1 -= 0.146067067980766 + bmi_2 -= 2.616518735885620 + + Ismoke = [ + 0.0, + 0.3532685922239948, + 0.6343201557712291, + 0.6500819736904159, + 0.6273413010559953, + ] + + a = 0.0 + a += Ismoke[smoke_cat] + a += age_1 * 8.584150931291562 + a += age_2 * -2.765040945011636 + a += bmi_1 * 4.181675283107032 + a += bmi_2 * 0.624710628895496 + a += c_hb * 1.106554304945946 + a += new_abdopain * 1.0280133043080188 + a += new_appetiteloss * 1.1868017500634926 + a += new_dysphagia * 3.825319942864257 + a += new_gibleed * 1.8454733322333583 + a += new_heartburn * 1.172767916931312 + a += new_indigestion * 1.8843639195644077 + a += new_necklump * 0.8414696385393358 + a += new_weightloss * 1.4698638306735652 + return a + -8.420870027030062 + + +def lung_cancer_male_score( + age: int, + b_copd: int, + bmi: float, + c_hb: int, + new_abdopain: int, + new_appetiteloss: int, + new_dysphagia: int, + new_haemoptysis: int, + new_indigestion: int, + new_necklump: int, + new_nightsweats: int, + new_vte: int, + new_weightloss: int, + s1_cough: int, + smoke_cat: int, + town: float, +) -> float: + """Lung Cancer log-hazard score for males.""" + dage = age / 10.0 + age_1 = dage + age_2 = dage * math.log(dage) + dbmi = bmi / 10.0 + bmi_1 = math.pow(dbmi, -2) + bmi_2 = dbmi + + age_1 -= 4.800777912139893 + age_2 -= 7.531354427337647 + bmi_1 -= 0.146067067980766 + bmi_2 -= 2.616518735885620 + town -= -0.264977723360062 + + Ismoke = [ + 0.0, + 0.8408574737524465, + 1.4966499028172435, + 1.7072509513243501, + 1.8882615411851338, + ] + + a = 0.0 + a += Ismoke[smoke_cat] + a += age_1 * 11.917808960225496 + a += age_2 * -3.8503786390624457 + a += bmi_1 * 1.860558422294992 + a += bmi_2 * -0.11327500388008699 + a += town * 0.028574570361074178 + a += b_copd * 0.5526127629694074 + a += c_hb * 0.8243789117069311 + a += new_abdopain * 0.39964248791030577 + a += new_appetiteloss * 0.7487413720163385 + a += new_dysphagia * 1.0410482089004374 + a += new_haemoptysis * 2.8241680746676243 + a += new_indigestion * 0.2689673675929089 + a += new_necklump * 1.1065323833644807 + a += new_nightsweats * 0.7890696583845964 + a += new_vte * 0.7991150296038755 + a += new_weightloss * 1.3738119234931856 + a += s1_cough * 0.5154179003437486 + return a + -8.716691809801928 + + +def other_cancer_male_score( + age: int, + b_copd: int, + b_type2: int, + bmi: float, + c_hb: int, + new_abdodist: int, + new_abdopain: int, + new_appetiteloss: int, + new_dysphagia: int, + new_gibleed: int, + new_haematuria: int, + new_haemoptysis: int, + new_indigestion: int, + new_necklump: int, + new_vte: int, + new_weightloss: int, + s1_bowelchange: int, + s1_constipation: int, + smoke_cat: int, +) -> float: + """Other Cancer log-hazard score for males.""" + dage = age / 10.0 + age_1 = dage + age_2 = dage * math.log(dage) + dbmi = bmi / 10.0 + bmi_1 = math.pow(dbmi, -2) + bmi_2 = dbmi + + age_1 -= 4.800777912139893 + age_2 -= 7.531354427337647 + bmi_1 -= 0.146067067980766 + bmi_2 -= 2.616518735885620 + + Ismoke = [ + 0.0, + 0.1306282330648658, + 0.41568246125931085, + 0.40341603935413767, + 0.529038332306518, + ] + + a = 0.0 + a += Ismoke[smoke_cat] + a += age_1 * 4.115641517087567 + a += age_2 * -1.2786588534988286 + a += bmi_1 * 2.406769125753325 + a += bmi_2 * 0.2566799616335219 + a += b_copd * 0.2364397443316423 + a += b_type2 * 0.23902124891032553 + a += c_hb * 0.9765525865177193 + a += new_abdodist * 0.7203822227648433 + a += new_abdopain * 0.8372159579979499 + a += new_appetiteloss * 1.16476106594546 + a += new_dysphagia * 1.0747326525064285 + a += new_gibleed * 0.4468867932306167 + a += new_haematuria * 0.5276884520139836 + a += new_haemoptysis * 0.6465976131208517 + a += new_indigestion * 0.3156125379576864 + a += new_necklump * 2.947244878727457 + a += new_vte * 1.0954486585194212 + a += new_weightloss * 1.0550815022699203 + a += s1_bowelchange * 0.5059485944682163 + a += s1_constipation * 0.6035170412091727 + return a + -6.713287568285854 + + +def pancreatic_cancer_male_score( + age: int, + b_chronicpan: int, + b_type2: int, + bmi: float, + new_abdopain: int, + new_appetiteloss: int, + new_dysphagia: int, + new_gibleed: int, + new_indigestion: int, + new_vte: int, + new_weightloss: int, + s1_constipation: int, + smoke_cat: int, + town: float, +) -> float: + """Pancreatic Cancer log-hazard score for males.""" + dage = age / 10.0 + age_1 = dage + age_2 = dage * math.log(dage) + dbmi = bmi / 10.0 + bmi_1 = math.pow(dbmi, -2) + bmi_2 = dbmi + + age_1 -= 4.800777912139893 + age_2 -= 7.531354427337647 + bmi_1 -= 0.146067067980766 + bmi_2 -= 2.616518735885620 + town -= -0.264977723360062 + + Ismoke = [ + 0.0, + 0.27832981720899735, + 0.3079418928917603, + 0.5647359394991128, + 0.7765125427126867, + ] + + a = 0.0 + a += Ismoke[smoke_cat] + a += age_1 * 8.02757787091059 + a += age_2 * -2.60824291309828 + a += bmi_1 * 1.781957499473682 + a += bmi_2 * -0.024960006489569975 + a += town * -0.03522881406170505 + a += b_chronicpan * 0.9913246347991823 + a += b_type2 * 0.7396905098202541 + a += new_abdopain * 2.150698401172158 + a += new_appetiteloss * 1.427232600996066 + a += new_dysphagia * 0.9168689207526066 + a += new_gibleed * 0.988106103308115 + a += new_indigestion * 1.2837402377092237 + a += new_vte * 1.174180534610472 + a += new_weightloss * 2.0466064239967046 + a += s1_constipation * 0.6240548033048214 + return a + -9.227572951200996 + + +def prostate_cancer_male_score( + age: int, + bmi: float, + fh_prostatecancer: int, + new_abdopain: int, + new_appetiteloss: int, + new_haematuria: int, + new_rectalbleed: int, + new_testespain: int, + new_testicularlump: int, + new_vte: int, + new_weightloss: int, + s1_impotence: int, + s1_nocturia: int, + s1_urinaryfreq: int, + s1_urinaryretention: int, + town: float, +) -> float: + """Prostate Cancer log-hazard score for males.""" + dage = age / 10.0 + age_1 = dage + age_2 = dage * math.log(dage) + dbmi = bmi / 10.0 + bmi_1 = math.pow(dbmi, -2) + bmi_2 = dbmi + + age_1 -= 4.800777912139893 + age_2 -= 7.531354427337647 + bmi_1 -= 0.146067067980766 + bmi_2 -= 2.616518735885620 + town -= -0.264977723360062 + + a = 0.0 + a += age_1 * 14.839101042656692 + a += age_2 * -4.805134105440884 + a += bmi_1 * -2.8369035324107057 + a += bmi_2 * -0.36349842659000514 + a += town * -0.021427865307187672 + a += fh_prostatecancer * 1.2892957682128878 + a += new_abdopain * 0.4445588372860774 + a += new_appetiteloss * 0.3425581971534915 + a += new_haematuria * 1.4890866073593347 + a += new_rectalbleed * 0.34786129520339637 + a += new_testespain * 0.6387609350076408 + a += new_testicularlump * 0.6338177436853567 + a += new_vte * 0.5758190804196262 + a += new_weightloss * 0.7528736226665873 + a += s1_impotence * 0.36921800415342415 + a += s1_nocturia * 1.0381560026453696 + a += s1_urinaryfreq * 0.7036410253080365 + a += s1_urinaryretention * 0.8525703399435587 + return a + -7.88710126972987 + + +def renal_tract_cancer_male_score( + age: int, + bmi: float, + new_abdopain: int, + new_haematuria: int, + new_nightsweats: int, + new_weightloss: int, + smoke_cat: int, +) -> float: + """Renal Tract Cancer log-hazard score for males.""" + dage = age / 10.0 + age_1 = dage + age_2 = dage * math.log(dage) + dbmi = bmi / 10.0 + bmi_1 = math.pow(dbmi, -2) + bmi_2 = dbmi + + age_1 -= 4.800777912139893 + age_2 -= 7.531354427337647 + bmi_1 -= 0.146067067980766 + bmi_2 -= 2.616518735885620 + + Ismoke = [ + 0.0, + 0.4183007995792849, + 0.6335162368278743, + 0.7847230879322206, + 0.9631091411295212, + ] + + a = 0.0 + a += Ismoke[smoke_cat] + a += age_1 * 6.211380346111106 + a += age_2 * -1.983566150695387 + a += bmi_1 * -1.5995682550089132 + a += bmi_2 * -0.07776968369307531 + a += new_abdopain * 0.6089465678909585 + a += new_haematuria * 4.159645338955679 + a += new_nightsweats * 1.0520790556587876 + a += new_weightloss * 0.6824635274408537 + return a + -8.300655539894251 + + +def testicular_cancer_male_score( + age: int, bmi: float, new_testespain: int, new_testicularlump: int, new_vte: int +) -> float: + """Testicular Cancer log-hazard score for males.""" + dage = age / 10.0 + age_1 = dage + age_2 = dage * math.log(dage) + dbmi = bmi / 10.0 + bmi_1 = math.pow(dbmi, -2) + bmi_2 = dbmi + + age_1 -= 4.800777912139893 + age_2 -= 7.531354427337647 + bmi_1 -= 0.146067067980766 + bmi_2 -= 2.616518735885620 + + a = 0.0 + a += age_1 * 3.985418448247634 + a += age_2 * -1.7426970576325218 + a += bmi_1 * 2.016079679827681 + a += bmi_2 * -0.042734043745477374 + a += new_testespain * 2.7411880902787775 + a += new_testicularlump * 5.220088614932327 + a += new_vte * 2.2416746922896493 + return a + -8.75922098878959 + + +def compute_female_probabilities( + age: int, + alcohol_cat4: int, + b_chronicpan: int, + b_copd: int, + b_endometrial: int, + b_type2: int, + bmi: float, + c_hb: int, + fh_breastcancer: int, + fh_gicancer: int, + fh_ovariancancer: int, + new_abdodist: int, + new_abdopain: int, + new_appetiteloss: int, + new_breastlump: int, + new_breastpain: int, + new_breastskin: int, + new_dysphagia: int, + new_gibleed: int, + new_haematuria: int, + new_haemoptysis: int, + new_heartburn: int, + new_imb: int, + new_indigestion: int, + new_necklump: int, + new_nightsweats: int, + new_pmb: int, + new_postcoital: int, + new_rectalbleed: int, + new_vte: int, + new_weightloss: int, + s1_bowelchange: int, + s1_bruising: int, + s1_constipation: int, + s1_cough: int, + smoke_cat: int, + town: float, +) -> dict[str, float]: + """Compute all female cancer probabilities matching C binary output. + + Args: + age: Age in years. + alcohol_cat4: Alcohol category (0-3). + b_chronicpan: Chronic pancreatitis flag. + b_copd: COPD flag. + b_endometrial: Endometrial polyps flag. + b_type2: Type 2 diabetes flag. + bmi: Body mass index. + c_hb: Anaemia flag. + fh_breastcancer: Breast cancer family history flag. + fh_gicancer: GI cancer family history flag. + fh_ovariancancer: Ovarian cancer family history flag. + new_abdodist: Abdominal distension symptom. + new_abdopain: Abdominal pain symptom. + new_appetiteloss: Appetite loss symptom. + new_breastlump: Breast lump symptom. + new_breastpain: Breast pain symptom. + new_breastskin: Breast skin changes symptom. + new_dysphagia: Dysphagia symptom. + new_gibleed: GI bleeding symptom. + new_haematuria: Haematuria symptom. + new_haemoptysis: Haemoptysis symptom. + new_heartburn: Heartburn symptom. + new_imb: Intermenstrual bleeding symptom. + new_indigestion: Indigestion symptom. + new_necklump: Neck lump symptom. + new_nightsweats: Night sweats symptom. + new_pmb: Postmenopausal bleeding symptom. + new_postcoital: Postcoital bleeding symptom. + new_rectalbleed: Rectal bleeding symptom. + new_vte: Venous thromboembolism symptom. + new_weightloss: Weight loss symptom. + s1_bowelchange: Bowel habit change. + s1_bruising: Easy bruising. + s1_constipation: Constipation. + s1_cough: Persistent cough. + smoke_cat: Smoking category (0-4). + town: Townsend deprivation index. + + Returns: + Dictionary mapping cancer name to percentage probability (0-100). + Includes 'none' key for no cancer probability. + """ + # Compute all log-hazard scores + scores = { + "blood_cancer": blood_cancer_female_score( + age, + bmi, + c_hb, + new_abdopain, + new_haematuria, + new_necklump, + new_nightsweats, + new_pmb, + new_vte, + new_weightloss, + s1_bowelchange, + s1_bruising, + ), + "breast_cancer": breast_cancer_female_score( + age, + alcohol_cat4, + bmi, + fh_breastcancer, + new_breastlump, + new_breastpain, + new_breastskin, + new_pmb, + new_vte, + town, + ), + "cervical_cancer": cervical_cancer_female_score( + age, + bmi, + c_hb, + new_abdopain, + new_haematuria, + new_imb, + new_pmb, + new_postcoital, + new_vte, + smoke_cat, + town, + ), + "colorectal_cancer": colorectal_cancer_female_score( + age, + alcohol_cat4, + bmi, + c_hb, + fh_gicancer, + new_abdodist, + new_abdopain, + new_appetiteloss, + new_rectalbleed, + new_vte, + new_weightloss, + s1_bowelchange, + s1_constipation, + ), + "gastro_oesophageal_cancer": gastro_oesophageal_cancer_female_score( + age, + bmi, + c_hb, + new_abdopain, + new_appetiteloss, + new_dysphagia, + new_gibleed, + new_heartburn, + new_indigestion, + new_vte, + new_weightloss, + smoke_cat, + ), + "lung_cancer": lung_cancer_female_score( + age, + b_copd, + bmi, + c_hb, + new_appetiteloss, + new_dysphagia, + new_haemoptysis, + new_indigestion, + new_necklump, + new_vte, + new_weightloss, + s1_cough, + smoke_cat, + town, + ), + "other_cancer": other_cancer_female_score( + age, + alcohol_cat4, + b_copd, + bmi, + c_hb, + new_abdodist, + new_abdopain, + new_appetiteloss, + new_breastlump, + new_dysphagia, + new_gibleed, + new_haematuria, + new_indigestion, + new_necklump, + new_pmb, + new_vte, + new_weightloss, + s1_constipation, + smoke_cat, + ), + "ovarian_cancer": ovarian_cancer_female_score( + age, + bmi, + c_hb, + fh_ovariancancer, + new_abdodist, + new_abdopain, + new_appetiteloss, + new_haematuria, + new_indigestion, + new_pmb, + new_vte, + new_weightloss, + s1_bowelchange, + ), + "pancreatic_cancer": pancreatic_cancer_female_score( + age, + b_chronicpan, + b_type2, + bmi, + new_abdopain, + new_appetiteloss, + new_dysphagia, + new_gibleed, + new_indigestion, + new_vte, + new_weightloss, + s1_bowelchange, + smoke_cat, + ), + "renal_tract_cancer": renal_tract_cancer_female_score( + age, + bmi, + c_hb, + new_abdopain, + new_appetiteloss, + new_haematuria, + new_indigestion, + new_pmb, + new_weightloss, + smoke_cat, + ), + "uterine_cancer": uterine_cancer_female_score( + age, + b_endometrial, + b_type2, + bmi, + new_abdopain, + new_haematuria, + new_imb, + new_pmb, + new_vte, + ), + } + + # Convert log-hazards to hazards + hazards = {name: math.exp(score) for name, score in scores.items()} + + # Normalize to percentages (sum of all hazards + 1 for "no cancer") + total = 1.0 + sum(hazards.values()) + probabilities = {name: (hazard * 100.0 / total) for name, hazard in hazards.items()} + probabilities["none"] = 100.0 / total + + return probabilities + + +def compute_male_probabilities( + age: int, + alcohol_cat4: int, + b_chronicpan: int, + b_copd: int, + b_type2: int, + bmi: float, + c_hb: int, + fh_gicancer: int, + fh_prostatecancer: int, + new_abdodist: int, + new_abdopain: int, + new_appetiteloss: int, + new_dysphagia: int, + new_gibleed: int, + new_haematuria: int, + new_haemoptysis: int, + new_heartburn: int, + new_indigestion: int, + new_necklump: int, + new_nightsweats: int, + new_rectalbleed: int, + new_testespain: int, + new_testicularlump: int, + new_vte: int, + new_weightloss: int, + s1_bowelchange: int, + s1_constipation: int, + s1_cough: int, + s1_impotence: int, + s1_nocturia: int, + s1_urinaryfreq: int, + s1_urinaryretention: int, + smoke_cat: int, + town: float, +) -> dict[str, float]: + """Compute all male cancer probabilities matching C binary output. + + Args: + age: Age in years. + alcohol_cat4: Alcohol category (0-3). + b_chronicpan: Chronic pancreatitis flag. + b_copd: COPD flag. + b_type2: Type 2 diabetes flag. + bmi: Body mass index. + c_hb: Anaemia flag. + fh_gicancer: GI cancer family history flag. + fh_prostatecancer: Prostate cancer family history flag. + new_abdodist: Abdominal distension symptom. + new_abdopain: Abdominal pain symptom. + new_appetiteloss: Appetite loss symptom. + new_dysphagia: Dysphagia symptom. + new_gibleed: GI bleeding symptom. + new_haematuria: Haematuria symptom. + new_haemoptysis: Haemoptysis symptom. + new_heartburn: Heartburn symptom. + new_indigestion: Indigestion symptom. + new_necklump: Neck lump symptom. + new_nightsweats: Night sweats symptom. + new_rectalbleed: Rectal bleeding symptom. + new_testespain: Testicular pain symptom. + new_testicularlump: Testicular lump symptom. + new_vte: Venous thromboembolism symptom. + new_weightloss: Weight loss symptom. + s1_bowelchange: Bowel habit change. + s1_constipation: Constipation. + s1_cough: Persistent cough. + s1_impotence: Erectile dysfunction. + s1_nocturia: Nocturia. + s1_urinaryfreq: Urinary frequency. + s1_urinaryretention: Urinary retention. + smoke_cat: Smoking category (0-4). + town: Townsend deprivation index. + + Returns: + Dictionary mapping cancer name to percentage probability (0-100). + Includes 'none' key for no cancer probability. + """ + # Compute all log-hazard scores + scores = { + "blood_cancer": blood_cancer_male_score( + age, + bmi, + c_hb, + new_abdodist, + new_abdopain, + new_appetiteloss, + new_dysphagia, + new_haematuria, + new_haemoptysis, + new_indigestion, + new_necklump, + new_nightsweats, + new_testicularlump, + new_vte, + new_weightloss, + town, + ), + "colorectal_cancer": colorectal_cancer_male_score( + age, + alcohol_cat4, + bmi, + c_hb, + fh_gicancer, + new_abdodist, + new_abdopain, + new_appetiteloss, + new_rectalbleed, + new_weightloss, + s1_bowelchange, + s1_constipation, + ), + "gastro_oesophageal_cancer": gastro_oesophageal_cancer_male_score( + age, + bmi, + c_hb, + new_abdopain, + new_appetiteloss, + new_dysphagia, + new_gibleed, + new_heartburn, + new_indigestion, + new_necklump, + new_weightloss, + smoke_cat, + ), + "lung_cancer": lung_cancer_male_score( + age, + b_copd, + bmi, + c_hb, + new_abdopain, + new_appetiteloss, + new_dysphagia, + new_haemoptysis, + new_indigestion, + new_necklump, + new_nightsweats, + new_vte, + new_weightloss, + s1_cough, + smoke_cat, + town, + ), + "other_cancer": other_cancer_male_score( + age, + b_copd, + b_type2, + bmi, + c_hb, + new_abdodist, + new_abdopain, + new_appetiteloss, + new_dysphagia, + new_gibleed, + new_haematuria, + new_haemoptysis, + new_indigestion, + new_necklump, + new_vte, + new_weightloss, + s1_bowelchange, + s1_constipation, + smoke_cat, + ), + "pancreatic_cancer": pancreatic_cancer_male_score( + age, + b_chronicpan, + b_type2, + bmi, + new_abdopain, + new_appetiteloss, + new_dysphagia, + new_gibleed, + new_indigestion, + new_vte, + new_weightloss, + s1_constipation, + smoke_cat, + town, + ), + "prostate_cancer": prostate_cancer_male_score( + age, + bmi, + fh_prostatecancer, + new_abdopain, + new_appetiteloss, + new_haematuria, + new_rectalbleed, + new_testespain, + new_testicularlump, + new_vte, + new_weightloss, + s1_impotence, + s1_nocturia, + s1_urinaryfreq, + s1_urinaryretention, + town, + ), + "renal_tract_cancer": renal_tract_cancer_male_score( + age, + bmi, + new_abdopain, + new_haematuria, + new_nightsweats, + new_weightloss, + smoke_cat, + ), + "testicular_cancer": testicular_cancer_male_score( + age, bmi, new_testespain, new_testicularlump, new_vte + ), + } + + # Convert log-hazards to hazards + hazards = {name: math.exp(score) for name, score in scores.items()} + + # Normalize to percentages (sum of all hazards + 1 for "no cancer") + total = 1.0 + sum(hazards.values()) + probabilities = {name: (hazard * 100.0 / total) for name, hazard in hazards.items()} + probabilities["none"] = 100.0 / total + + return probabilities + + +class QCancerRiskModel(RiskModel): + """QCancer 10-year multi-site cancer risk model.""" + + REQUIRED_INPUTS: dict[str, tuple[type, bool]] = { + "demographics.age_years": (Annotated[int, Field(ge=25, le=99)], True), + "demographics.sex": (Sex, True), + "demographics.anthropometrics.height_cm": (Annotated[float, Field(gt=0)], True), + "demographics.anthropometrics.weight_kg": (Annotated[float, Field(gt=0)], True), + "demographics.townsend_index": (float, False), + "lifestyle.smoking.status": (SmokingStatus, True), + "lifestyle.smoking.cigarettes_per_day": ( + Annotated[float | None, Field(ge=0)], + False, + ), + "lifestyle.alcohol_consumption": (AlcoholConsumption | None, False), + "personal_medical_history.chronic_conditions": (list[ChronicCondition], False), + "symptoms": (list, False), # list[SymptomEntry] - using list for now + "family_history": ( + list, + False, + ), # list[FamilyMemberCancer] - using list for now + } + + def __init__(self) -> None: + super().__init__("qcancer") + + def compute_score(self, user: UserInput) -> str: + """Compute QCancer risk scores for all applicable cancer types. + + Args: + user: User input containing demographics, lifestyle, and health history. + + Returns: + Formatted string with risk percentages for each cancer type. + + Raises: + ValueError: If required inputs are missing or invalid. + """ + # Validate inputs first + is_valid, errors = self.validate_inputs(user) + if not is_valid: + raise ValueError(f"Invalid inputs for QCancer: {'; '.join(errors)}") + + try: + if user.demographics.sex == Sex.FEMALE: + params = self._extract_female_params(user) + probabilities = compute_female_probabilities(**params) + return self._format_risks(probabilities, is_female=True) + if user.demographics.sex == Sex.MALE: + params = self._extract_male_params(user) + probabilities = compute_male_probabilities(**params) + return self._format_risks(probabilities, is_female=False) + except ValueError as exc: + return f"N/A: {exc}" + return "N/A: QCancer requires patient sex (male or female)." + + def cancer_type(self) -> str: + return "multiple" + + def description(self) -> str: + return ( + "QCancer estimates competing 10-year probabilities that a primary-care patient " + "presents with specific cancers using demographics, BMI, symptoms, comorbidities, " + "and family history." + ) + + def interpretation(self) -> str: + return ( + "Outputs list percentages for each supported cancer site alongside the probability of no cancer. " + "Values sum to 100% and reflect relative likelihoods over the next 10 years; higher percentages warrant clinical review." + ) + + def references(self) -> list[str]: + return [ + "Hippisley-Cox J, Coupland C. QCancer (10 year risk) BMJ. 2014;349:g4606.", + "ClinRisk Ltd. QCancer-2013 source code (GNU AGPL v3).", + ] + + def _format_risks(self, risks: dict[str, float], is_female: bool) -> str: + """Format probabilities as semicolon-separated string. + + Args: + risks: Dictionary of cancer names to probabilities. + is_female: Whether results are for female patient. + + Returns: + Formatted string with cancer probabilities. + """ + order = FEMALE_CANCER_TYPES if is_female else MALE_CANCER_TYPES + output_parts = [] + + # Add "No Cancer" first + no_cancer_pct = risks.get("none", 0.0) + output_parts.append(f"No Cancer: {no_cancer_pct:.1f}%") + + # Add each cancer type in order + for cancer_name in order: + pct = risks.get(cancer_name, 0.0) + display_name = cancer_name.replace("_", " ").title() + output_parts.append(f"{display_name}: {pct:.1f}%") + + return "; ".join(output_parts) + + def _extract_female_params(self, user: UserInput) -> dict: + """Extract all parameters needed for compute_female_probabilities. + + Args: + user: User input with demographics, symptoms, and history. + + Returns: + Dictionary of parameters for female probability calculation. + """ + demo = user.demographics + bmi = _require_bmi(user) + town = demo.townsend_index or -0.383295059204102 + + return { + "age": int(demo.age_years), + "alcohol_cat4": _infer_alcohol_category(user), + "b_chronicpan": _flag_if_history( + user, ChronicCondition.CHRONIC_PANCREATITIS + ), + "b_copd": _flag_if_history(user, ChronicCondition.COPD), + "b_endometrial": _flag_if_history( + user, ChronicCondition.ENDOMETRIAL_POLYPS + ), + "b_type2": _flag_if_history(user, ChronicCondition.DIABETES), + "bmi": float(bmi), + "c_hb": _flag_if_history(user, ChronicCondition.ANAEMIA), + "fh_breastcancer": _family_history_flag(user, [CancerType.BREAST]), + "fh_gicancer": _family_history_flag( + user, [CancerType.COLORECTAL, CancerType.GASTRO_OESOPHAGEAL] + ), + "fh_ovariancancer": _family_history_flag(user, [CancerType.OVARIAN]), + "new_abdodist": _symptom_flag(user, [SymptomType.ABDOMINAL_DISTENSION]), + "new_abdopain": _symptom_flag(user, [SymptomType.ABDOMINAL_PAIN]), + "new_appetiteloss": _symptom_flag(user, [SymptomType.APPETITE_LOSS]), + "new_breastlump": _symptom_flag(user, [SymptomType.BREAST_LUMP]), + "new_breastpain": _symptom_flag(user, [SymptomType.BREAST_PAIN]), + "new_breastskin": _symptom_flag(user, [SymptomType.BREAST_SKIN_CHANGES]), + "new_dysphagia": _symptom_flag(user, [SymptomType.DYSPHAGIA]), + "new_gibleed": _symptom_flag(user, [SymptomType.GASTROINTESTINAL_BLEEDING]), + "new_haematuria": _symptom_flag(user, [SymptomType.HAEMATURIA]), + "new_haemoptysis": _symptom_flag(user, [SymptomType.HAEMOPTYSIS]), + "new_heartburn": _symptom_flag(user, [SymptomType.HEARTBURN]), + "new_imb": _symptom_flag(user, [SymptomType.INTERMENSTRUAL_BLEEDING]), + "new_indigestion": _symptom_flag(user, [SymptomType.INDIGESTION]), + "new_necklump": _symptom_flag(user, [SymptomType.NECK_LUMP]), + "new_nightsweats": _symptom_flag(user, [SymptomType.NIGHT_SWEATS]), + "new_pmb": _symptom_flag(user, [SymptomType.POST_MENOPAUSAL_BLEEDING]), + "new_postcoital": _symptom_flag(user, [SymptomType.POST_COITAL_BLEEDING]), + "new_rectalbleed": _symptom_flag(user, [SymptomType.RECTAL_BLEEDING]), + "new_vte": _symptom_flag(user, [SymptomType.VENOUS_THROMBOEMBOLISM]), + "new_weightloss": _symptom_flag(user, [SymptomType.WEIGHT_LOSS]), + "s1_bowelchange": _symptom_flag(user, [SymptomType.BOWEL_HABIT_CHANGE]), + "s1_bruising": _symptom_flag(user, [SymptomType.BRUISING]), + "s1_constipation": _symptom_flag(user, [SymptomType.CONSTIPATION]), + "s1_cough": _symptom_flag(user, [SymptomType.PERSISTENT_COUGH]), + "smoke_cat": _infer_smoke_category(user), + "town": float(town), + } + + def _extract_male_params(self, user: UserInput) -> dict: + """Extract all parameters needed for compute_male_probabilities. + + Args: + user: User input with demographics, symptoms, and history. + + Returns: + Dictionary of parameters for male probability calculation. + """ + demo = user.demographics + bmi = _require_bmi(user) + town = demo.townsend_index or -0.264977723360062 + + return { + "age": int(demo.age_years), + "alcohol_cat4": _infer_alcohol_category(user), + "b_chronicpan": _flag_if_history( + user, ChronicCondition.CHRONIC_PANCREATITIS + ), + "b_copd": _flag_if_history(user, ChronicCondition.COPD), + "b_type2": _flag_if_history(user, ChronicCondition.DIABETES), + "bmi": float(bmi), + "c_hb": _flag_if_history(user, ChronicCondition.ANAEMIA), + "fh_gicancer": _family_history_flag( + user, [CancerType.COLORECTAL, CancerType.GASTRO_OESOPHAGEAL] + ), + "fh_prostatecancer": _family_history_flag(user, [CancerType.PROSTATE]), + "new_abdodist": _symptom_flag(user, [SymptomType.ABDOMINAL_DISTENSION]), + "new_abdopain": _symptom_flag(user, [SymptomType.ABDOMINAL_PAIN]), + "new_appetiteloss": _symptom_flag(user, [SymptomType.APPETITE_LOSS]), + "new_dysphagia": _symptom_flag(user, [SymptomType.DYSPHAGIA]), + "new_gibleed": _symptom_flag(user, [SymptomType.GASTROINTESTINAL_BLEEDING]), + "new_haematuria": _symptom_flag(user, [SymptomType.HAEMATURIA]), + "new_haemoptysis": _symptom_flag(user, [SymptomType.HAEMOPTYSIS]), + "new_heartburn": _symptom_flag(user, [SymptomType.HEARTBURN]), + "new_indigestion": _symptom_flag(user, [SymptomType.INDIGESTION]), + "new_necklump": _symptom_flag(user, [SymptomType.NECK_LUMP]), + "new_nightsweats": _symptom_flag(user, [SymptomType.NIGHT_SWEATS]), + "new_rectalbleed": _symptom_flag(user, [SymptomType.RECTAL_BLEEDING]), + "new_testespain": _symptom_flag(user, [SymptomType.TESTICULAR_PAIN]), + "new_testicularlump": _symptom_flag(user, [SymptomType.TESTICULAR_LUMP]), + "new_vte": _symptom_flag(user, [SymptomType.VENOUS_THROMBOEMBOLISM]), + "new_weightloss": _symptom_flag(user, [SymptomType.WEIGHT_LOSS]), + "s1_bowelchange": _symptom_flag(user, [SymptomType.BOWEL_HABIT_CHANGE]), + "s1_constipation": _symptom_flag(user, [SymptomType.CONSTIPATION]), + "s1_cough": _symptom_flag(user, [SymptomType.PERSISTENT_COUGH]), + "s1_impotence": _symptom_flag(user, [SymptomType.ERECTILE_DYSFUNCTION]), + "s1_nocturia": _symptom_flag(user, [SymptomType.NOCTURIA]), + "s1_urinaryfreq": _symptom_flag( + user, [SymptomType.INCREASED_URINARY_FREQUENCY] + ), + "s1_urinaryretention": _symptom_flag(user, [SymptomType.URINARY_RETENTION]), + "smoke_cat": _infer_smoke_category(user), + "town": float(town), + } diff --git a/src/sentinel/risk_models/tyrer_cuzick.py b/src/sentinel/risk_models/tyrer_cuzick.py new file mode 100644 index 0000000000000000000000000000000000000000..958d73279b8164c23e36be39e0a5d05c4b925891 --- /dev/null +++ b/src/sentinel/risk_models/tyrer_cuzick.py @@ -0,0 +1,1322 @@ +"""Breast cancer risk estimation using the IBIS (Tyrer-Cuzick) model. + +This module implements the original IBIS v1 model (Tyrer-Cuzick 2004) which +combines genetic risk (BRCA1/2 and low-penetrance genes) with personal risk +factors to estimate absolute breast cancer risk. The model uses pedigree analysis +to compute phenotype probabilities and applies proportional hazards for personal +risk factors. + +The model incorporates: +- BRCA1/2 carrier status +- Low-penetrance gene (LPG) carrier status +- Family history through pedigree analysis +- Personal risk factors (menarche, childbirth, menopause, height, BMI, hyperplasia) + +Web Calculator: https://ibis.ikonopedia.com/ + +Reference: +Tyrer, J., Duffy, S. W., & Cuzick, J. (2004). A breast cancer prediction model +incorporating familial and personal risk factors. Statistics in Medicine, 23(7), 1111-1130. +""" + +from math import exp, log +from typing import Annotated, Literal, NamedTuple, TypedDict + +from pydantic import Field + +from sentinel.risk_models.base import RiskModel +from sentinel.user_input import ( + CancerType, + FamilyMemberCancer, + FamilyRelation, + Sex, + UserInput, +) +from sentinel.utils import calculate_bmi + +# BRCA1/2 carrier frequencies in general population +BRCA1_CARRIER_FREQUENCY = 0.0011 +BRCA2_CARRIER_FREQUENCY = 0.0012 + +# Low-penetrance gene (LPG) parameters +LPG_ALLELE_FREQUENCY = 0.1139 +LPG_RELATIVE_HAZARD = 13.0377 + +# Population 5-year breast cancer incidence rates (England & Wales) +POPULATION_5YEAR_INCIDENCE_RATES = [ + (20, 25, 0.00006), + (25, 30, 0.00040), + (30, 35, 0.00133), + (35, 40, 0.003035), + (40, 45, 0.00563), + (45, 50, 0.009015), + (50, 55, 0.01221), + (55, 60, 0.012825), + (60, 65, 0.013855), + (65, 70, 0.012215), + (70, 75, 0.014155), + (75, 80, 0.016435), + (80, 85, 0.01786), +] + +# BRCA1/2 cumulative breast cancer risk by age +BRCA1_CUMULATIVE_RISK_BY_AGE = { + 30: 0.036, + 40: 0.18, + 50: 0.49, + 60: 0.64, + 70: 0.71, +} + +BRCA2_CUMULATIVE_RISK_BY_AGE = { + 30: 0.006, + 40: 0.12, + 50: 0.28, + 60: 0.48, + 70: 0.84, +} + + +# Population average calibration factors +POPULATION_AVERAGE_MENARCHE_RELATIVE_RISK = 0.99 +POPULATION_AVERAGE_FIRST_BIRTH_RELATIVE_RISK = 0.78 +POPULATION_AVERAGE_HEIGHT_RELATIVE_RISK = 1.10 +POPULATION_AVERAGE_BMI_POST_MENOPAUSAL_RELATIVE_RISK = 1.17 + +# Personal risk factors +ADH_RELATIVE_RISK = 4.0 +LCIS_RELATIVE_RISK = 2.7 + + +def _get_cancer_age(fh: FamilyMemberCancer, cancer_type: CancerType) -> int | None: + """Get age at diagnosis for specific cancer type. + + Args: + fh: Family member cancer record. + cancer_type: Type of cancer to check. + + Returns: + Age at diagnosis if cancer type matches, None otherwise. + """ + if fh.cancer_type == cancer_type: + return fh.age_at_diagnosis + return None + + +class RiskResult(TypedDict): + """Risk calculation result with cumulative and interval risks.""" + + cumulative_risk: float + interval_risks: list[tuple[int, int, float]] + + +class TyrerCuzickRiskResult(TypedDict): + """Tyrer-Cuzick risk result including personal relative risk and phenotype probabilities.""" + + cumulative_risk: float + interval_risks: list[tuple[int, int, float]] + personal_relative_risk: float + phenotype_probs: list[float] + + +class BandParams(NamedTuple): + """Parameters for a survival function age band.""" + + age_start: float + age_end: float + survival_start: float + survival_end: float + width: float + hazard: float + + +class ParentPosterior(NamedTuple): + """BRCA posterior probabilities for a parent.""" + + prob_none: float + prob_brca1: float + prob_brca2: float + + +def relative_risk_menarche(age: int) -> float: + """Relative risk for age at menarche (baseline: 13 years). + + Args: + age: Age at menarche. + + Returns: + Relative risk multiplier. + """ + return 0.95 ** (age - 13) + + +def relative_risk_first_birth( + has_given_birth: bool, age_first_birth: int | None +) -> float: + """Relative risk for childbirth history and age at first birth. + + Args: + has_given_birth: Whether the person has given birth. + age_first_birth: Age at first birth (None if never given birth). + + Returns: + Relative risk multiplier. + """ + if not has_given_birth or age_first_birth is None: + return 1.0 + + thresholds = [(20, 0.67), (25, 0.74), (30, 0.88)] + for limit, risk in thresholds: + if age_first_birth < limit: + return risk + return 1.04 + + +def relative_risk_menopause(status: str, age_menopause: int | None) -> float: + """Relative risk for menopause timing (baseline: 50 years). + + Args: + status: Menopausal status ('pre', 'peri', or 'post'). + age_menopause: Age at menopause (if post-menopausal). + + Returns: + Relative risk multiplier. + """ + if status == "post" and age_menopause is not None: + return 1.028 ** (age_menopause - 50) + return 1.0 + + +def relative_risk_height(height_m: float | None) -> float: + """Relative risk for height. + + Args: + height_m: Height in meters. + + Returns: + Relative risk multiplier. + """ + if height_m is None or height_m < 1.60: + return 1.0 + if height_m < 1.70: + return 1.05 + 2.0 * (height_m - 1.60) + return 1.24 + + +def relative_risk_bmi_post_menopausal( + bmi: float | None, menopausal_status: str +) -> float: + """Relative risk for BMI (post-menopausal only). + + Args: + bmi: Body mass index. + menopausal_status: Menopausal status. + + Returns: + Relative risk multiplier. + """ + if menopausal_status != "post" or bmi is None or bmi < 21: + return 1.0 + + thresholds = [(23, 1.14), (25, 1.15), (27, 1.26)] + for limit, risk in thresholds: + if bmi < limit: + return risk + return 1.32 + + +def compute_personal_relative_risk( + menarche_age: int | None, + has_given_birth: bool, + age_first_birth: int | None, + menopausal_status: str, + menopause_age: int | None, + height_m: float | None, + bmi: float | None, + atypical_hyperplasia: bool, + lcis: bool, + polygenic_relative_risk: float | None, +) -> float: + """Compute combined calibrated personal relative risk. + + Args: + menarche_age: Age at menarche. + has_given_birth: Whether the person has given birth. + age_first_birth: Age at first birth. + menopausal_status: Menopausal status ('pre', 'peri', or 'post'). + menopause_age: Age at menopause. + height_m: Height in meters. + bmi: Body mass index. + atypical_hyperplasia: Whether person has atypical hyperplasia. + lcis: Whether person has LCIS. + polygenic_relative_risk: Polygenic relative risk multiplier. + + Returns: + Combined personal relative risk multiplier. + """ + theta = 1.0 + + if menarche_age is not None: + factor = relative_risk_menarche(menarche_age) + theta *= factor / POPULATION_AVERAGE_MENARCHE_RELATIVE_RISK + + factor = relative_risk_first_birth(has_given_birth, age_first_birth) + theta *= factor / POPULATION_AVERAGE_FIRST_BIRTH_RELATIVE_RISK + + factor = relative_risk_menopause(menopausal_status, menopause_age) + theta *= factor + + factor = relative_risk_height(height_m) + theta *= factor / POPULATION_AVERAGE_HEIGHT_RELATIVE_RISK + + factor = relative_risk_bmi_post_menopausal(bmi, menopausal_status) + theta *= factor / POPULATION_AVERAGE_BMI_POST_MENOPAUSAL_RELATIVE_RISK + + if atypical_hyperplasia: + theta *= ADH_RELATIVE_RISK + + if lcis: + theta *= LCIS_RELATIVE_RISK + + if polygenic_relative_risk is not None: + theta *= polygenic_relative_risk + + return theta + + +def build_population_survivor() -> list[tuple[int, int, float]]: + """Build population survivor function from incidence rates. + + Returns: + List of (age_start, age_end, survival) tuples. + """ + survivors = [] + survival = 1.0 + for age_start, age_end, incidence in POPULATION_5YEAR_INCIDENCE_RATES: + survivors.append((age_start, age_end, survival)) + survival *= 1.0 - incidence + return survivors + + +def interpolate_brca_cumulative(brca_cum: dict[int, float], age: int) -> float: + """Interpolate BRCA cumulative risk at given age. + + Args: + brca_cum: Dictionary mapping ages to cumulative risks. + age: Target age. + + Returns: + Interpolated cumulative risk. + """ + ages = sorted(brca_cum.keys()) + if age <= ages[0]: + return brca_cum[ages[0]] + if age >= ages[-1]: + return brca_cum[ages[-1]] + + # Linear interpolation + for idx in range(len(ages) - 1): + if ages[idx] <= age <= ages[idx + 1]: + age_lower, age_upper = ages[idx], ages[idx + 1] + risk_lower, risk_upper = brca_cum[age_lower], brca_cum[age_upper] + return risk_lower + (risk_upper - risk_lower) * (age - age_lower) / ( + age_upper - age_lower + ) + + return brca_cum[ages[-1]] + + +def split_brca_10yr_to_5yr( + cum_risk_start: float, + cum_risk_end: float, + pop_incidences: tuple[float, float], +) -> tuple[float, float]: + """Split 10-year BRCA risk into two 5-year intervals. + + Uses population incidence proportions to split the 10-year BRCA probability. + + Args: + cum_risk_start: Cumulative risk at start. + cum_risk_end: Cumulative risk at end (10 years later). + pop_incidences: Tuple of (incidence_first_5yr, incidence_second_5yr). + + Returns: + Tuple of (incidence_first_5yr, incidence_second_5yr) for BRCA. + """ + total_10yr_risk = cum_risk_end - cum_risk_start + inc1, inc2 = pop_incidences + total_pop_inc = inc1 + inc2 + + if total_pop_inc == 0: + # Equal split if no population data + return total_10yr_risk / 2, total_10yr_risk / 2 + + # Split proportionally + prop1 = inc1 / total_pop_inc + prop2 = inc2 / total_pop_inc + return total_10yr_risk * prop1, total_10yr_risk * prop2 + + +def build_brca_survivor(brca_cum: dict[int, float]) -> list[tuple[int, int, float]]: + """Build BRCA survivor function with 5-year intervals from 10-year cumulative risks. + + Args: + brca_cum: Dictionary mapping age to cumulative BRCA risk. + + Returns: + List of (age_start, age_end, survival) tuples. + """ + survivors = [] + survival = 1.0 + + pop_inc_dict = { + age_start: inc for age_start, _, inc in POPULATION_5YEAR_INCIDENCE_RATES + } + + brca_ages = sorted(brca_cum.keys()) + first_brca_age = brca_ages[0] + first_brca_cum = brca_cum[first_brca_age] + + pre_intervals = [ + (age_start, age_end, incidence) + for age_start, age_end, incidence in POPULATION_5YEAR_INCIDENCE_RATES + if age_end <= first_brca_age + ] + pre_total_pop_inc = sum(incidence for _, _, incidence in pre_intervals) + + if pre_total_pop_inc > 0: + scale = first_brca_cum / pre_total_pop_inc + else: + scale = 0.0 + + for age_start, age_end, pop_inc in pre_intervals: + brca_inc = pop_inc * scale + survivors.append((age_start, age_end, survival)) + survival *= max(1.0 - brca_inc, 1e-12) + + for idx in range(len(brca_ages) - 1): + age_start, age_end = brca_ages[idx], brca_ages[idx + 1] + if age_end - age_start != 10: + continue + + cum_start, cum_end = brca_cum[age_start], brca_cum[age_end] + age_mid = (age_start + age_end) // 2 + + pop_inc_first = pop_inc_dict.get(age_start, 0.01) + pop_inc_second = pop_inc_dict.get(age_mid, 0.01) + + brca_inc_first, brca_inc_second = split_brca_10yr_to_5yr( + cum_start, cum_end, (pop_inc_first, pop_inc_second) + ) + + survivors.append((age_start, age_mid, survival)) + survival *= 1.0 - brca_inc_first + + survivors.append((age_mid, age_end, survival)) + survival *= 1.0 - brca_inc_second + + return survivors + + +def solve_s0_at_age(s_non: float, lpg_allele_freq: float, phi: float) -> float: + """Solve for S_0 given S_non using bisection. + + Solves: (1-q)^2 * S_0 + (1-(1-q)^2) * S_0^phi = S_non + + Args: + s_non: Non-BRCA survivor value. + lpg_allele_freq: LPG allele frequency. + phi: LPG relative hazard. + + Returns: + S_0 value. + """ + if s_non <= 0 or s_non >= 1: + return s_non + + prob_no_lpg = (1 - lpg_allele_freq) ** 2 + prob_with_lpg = 1 - prob_no_lpg + + # Bisection search + low, high = 0.0, 1.0 + for _ in range(50): # Max iterations + mid = (low + high) / 2 + f_mid = prob_no_lpg * mid + prob_with_lpg * (mid**phi) - s_non + if abs(f_mid) < 1e-9: + return mid + if f_mid < 0: + low = mid + else: + high = mid + + return (low + high) / 2 + + +def build_s0_survivor( + s_pop_grid: list[tuple[int, int, float]], + s_brca1_grid: list[tuple[int, int, float]], + s_brca2_grid: list[tuple[int, int, float]], +) -> list[tuple[int, int, float]]: + """Build S_0 (no BRCA, no LPG) survivor function. + + First removes BRCA components from population to get S_non, + then solves for S_0 that reproduces S_non when mixed over LPG genotypes. + + Args: + s_pop_grid: Population survivor grid. + s_brca1_grid: BRCA1 survivor grid. + s_brca2_grid: BRCA2 survivor grid. + + Returns: + S_0 survivor grid. + """ + s0_grid = [] + + # Create lookup dictionaries + s_pop_dict = {age_start: survival for age_start, _, survival in s_pop_grid} + s_brca1_dict = {age_start: survival for age_start, _, survival in s_brca1_grid} + s_brca2_dict = {age_start: survival for age_start, _, survival in s_brca2_grid} + + for age_start, age_end, survival_pop in s_pop_grid: + survival_brca1 = s_brca1_dict.get(age_start, survival_pop) + survival_brca2 = s_brca2_dict.get(age_start, survival_pop) + + # Remove BRCA components + denom = 1.0 - BRCA1_CARRIER_FREQUENCY - BRCA2_CARRIER_FREQUENCY + survival_non_brca = ( + survival_pop + - BRCA1_CARRIER_FREQUENCY * survival_brca1 + - BRCA2_CARRIER_FREQUENCY * survival_brca2 + ) / denom + + # Ensure valid range + survival_non_brca = max(0.0, min(1.0, survival_non_brca)) + + # Solve for S_0 + survival_s0 = solve_s0_at_age( + survival_non_brca, LPG_ALLELE_FREQUENCY, LPG_RELATIVE_HAZARD + ) + + s0_grid.append((age_start, age_end, survival_s0)) + + return s0_grid + + +def s_at_age(grid: list[tuple[int, int, float]], age: float) -> float: + """Interpolate survivor function at any age using constant hazard within bands. + + Args: + grid: Survivor function grid. + age: Age to evaluate at. + + Returns: + Survival probability at the given age. + """ + for idx, (age_start, age_end, survival_start) in enumerate(grid): + if age_start <= age < age_end: + survival_end = ( + grid[idx + 1][2] + if idx + 1 < len(grid) + else max(min(survival_start, 1.0), 1e-12) + ) + width = max(age_end - age_start, 1e-12) + hazard = max( + -log(max(survival_end, 1e-12) / max(survival_start, 1e-12)) / width, 0.0 + ) + return max(min(survival_start * exp(-hazard * (age - age_start)), 1.0), 0.0) + + if grid: + if age < grid[0][0]: + return grid[0][2] + return grid[-1][2] + return 1.0 + + +def compute_phenotype_interval_risk( + phenotype_idx: int, + age_start: int, + age_end: int, + s0_grid: list[tuple[int, int, float]], + s_brca1_grid: list[tuple[int, int, float]], + s_brca2_grid: list[tuple[int, int, float]], +) -> float: + """Compute interval risk for a phenotype using continuous interpolation. + + Args: + phenotype_idx: Phenotype index (0-5). + age_start: Start age of interval. + age_end: End age of interval. + s0_grid: S_0 survivor grid. + s_brca1_grid: BRCA1 survivor grid. + s_brca2_grid: BRCA2 survivor grid. + + Returns: + Interval risk probability. + """ + lpg_hazard_exponent = LPG_RELATIVE_HAZARD + + # Map phenotype index → (grid, apply_exponent flag) + phenotype_map = { + 0: (s0_grid, False), + 1: (s0_grid, True), + 2: (s_brca1_grid, False), + 3: (s_brca1_grid, True), + 4: (s_brca2_grid, False), + 5: (s_brca2_grid, True), + } + + grid, apply_exponent = phenotype_map.get(phenotype_idx, (None, False)) + if grid is None: + return 0.0 + + survival_start = s_at_age(grid, age_start) + survival_end = s_at_age(grid, age_end) + + if apply_exponent: + survival_start **= lpg_hazard_exponent + survival_end **= lpg_hazard_exponent + + return survival_start - survival_end + + +def calculate_cumulative_risk_from_intervals( + interval_risks: list[tuple[int, int, float]], +) -> float: + """Calculate cumulative risk from interval risks. + + Args: + interval_risks: List of (age_start, age_end, risk) tuples. + + Returns: + Cumulative risk over all intervals. + """ + survival = 1.0 + for _, _, risk in interval_risks: + survival *= 1.0 - risk + return 1.0 - survival + + +def compute_family_history_risk( + current_age: int, + projection_years: int, + phenotype_probs: list[float], + s0_grid: list[tuple[int, int, float]], + s_brca1_grid: list[tuple[int, int, float]], + s_brca2_grid: list[tuple[int, int, float]], +) -> RiskResult: + """Compute family history risk over projection period. + + Args: + current_age: Proband's current age. + projection_years: Years to project risk. + phenotype_probs: Phenotype probabilities (6 values). + s0_grid: S_0 survivor grid. + s_brca1_grid: BRCA1 survivor grid. + s_brca2_grid: BRCA2 survivor grid. + + Returns: + Risk result with cumulative and interval risks. + """ + target_age = current_age + projection_years + interval_risks = [] + + age = current_age + while age < target_age: + age_end = min(age + 5, target_age) + + interval_risk = 0.0 + for pheno_idx, pheno_prob in enumerate(phenotype_probs): + phenotype_risk = compute_phenotype_interval_risk( + pheno_idx, age, age_end, s0_grid, s_brca1_grid, s_brca2_grid + ) + interval_risk += pheno_prob * phenotype_risk + + interval_risks.append((age, age_end, interval_risk)) + age = age_end + + cumulative_risk = calculate_cumulative_risk_from_intervals(interval_risks) + + return RiskResult( + cumulative_risk=cumulative_risk, + interval_risks=interval_risks, + ) + + +def compute_absolute_risk( + current_age: int, + projection_years: int, + phenotype_probs: list[float], + personal_relative_risk: float, + s0_grid: list[tuple[int, int, float]], + s_brca1_grid: list[tuple[int, int, float]], + s_brca2_grid: list[tuple[int, int, float]], +) -> RiskResult: + """Compute absolute risk incorporating personal factors. + + Args: + current_age: Proband's current age. + projection_years: Years to project risk. + phenotype_probs: Phenotype probabilities (6 values). + personal_relative_risk: Personal relative risk multiplier. + s0_grid: S_0 survivor grid. + s_brca1_grid: BRCA1 survivor grid. + s_brca2_grid: BRCA2 survivor grid. + + Returns: + Risk result with cumulative and interval risks. + """ + # Cap projection at maximum grid age + max_age = s0_grid[-1][1] if s0_grid else 85 + target_age = min(current_age + projection_years, max_age) + interval_risks = [] + + age = current_age + while age < target_age: + age_end = min(age + 5, target_age) + + family_history_risk = 0.0 + for pheno_idx, pheno_prob in enumerate(phenotype_probs): + phenotype_risk = compute_phenotype_interval_risk( + pheno_idx, age, age_end, s0_grid, s_brca1_grid, s_brca2_grid + ) + family_history_risk += pheno_prob * phenotype_risk + + family_history_risk = max(0.0, min(family_history_risk, 0.999999)) + + final_risk = 1.0 - ((1.0 - family_history_risk) ** personal_relative_risk) + + interval_risks.append((age, age_end, final_risk)) + age = age_end + + cumulative_risk = calculate_cumulative_risk_from_intervals(interval_risks) + + return RiskResult( + cumulative_risk=cumulative_risk, + interval_risks=interval_risks, + ) + + +def _assumed_current_age(_fh: FamilyMemberCancer) -> int: + """Assign conservative default age for relatives with unknown current age. + + Args: + _fh: Family member cancer record. + + Returns: + Current age (assumed for relatives). + """ + # Conservative assumption: 75 for typical relatives + return 75 + + +def event_density( + grid: list[tuple[int, int, float]], + age: float, + lpg_hazard_exponent: float, + window_half_width: float = 0.5, +) -> float: + """Compute probability of event in a small window around age. + + Uses interval probability instead of point density to avoid brittleness + with piecewise-constant hazard functions. + + Args: + grid: Survivor function grid. + age: Age at event. + lpg_hazard_exponent: LPG hazard exponent. + window_half_width: Half-width of window around age. + + Returns: + Event probability within window. + """ + if not grid: + return 1e-12 + + min_age = grid[0][0] + max_age = grid[-1][1] - 1e-6 + + age_start = max(age - window_half_width, min_age) + age_end = min(age + window_half_width, max_age) + + survival_start = s_at_age(grid, age_start) ** lpg_hazard_exponent + survival_end = s_at_age(grid, age_end) ** lpg_hazard_exponent + + return max(survival_start - survival_end, 1e-12) + + +def _kinship_weight(rel: str | None) -> float: + """Get kinship weight for transmission probability based on relationship. + + Args: + rel: Relationship type (e.g., 'mother', 'sister', 'aunt_m'). + + Returns: + Kinship weight (0.5 for 1st degree, 0.25 for 2nd degree, 0.125 for 3rd degree). + """ + if rel in {"mother", "father", "sister", "daughter"}: # 1st degree + return 0.5 + if rel in {"aunt_m", "aunt_p", "grandmother_m", "grandmother_p"}: # 2nd degree + return 0.25 + return 0.125 # 3rd degree / default + + +def compute_likelihood_for_person( + fh: FamilyMemberCancer, + brca_status: Literal["none", "brca1", "brca2"], + has_lpg: bool, + s0_grid: list[tuple[int, int, float]], + s_brca1_grid: list[tuple[int, int, float]], + s_brca2_grid: list[tuple[int, int, float]], +) -> float: + """Compute P(observed data | genotype) for a pedigree member. + + Args: + fh: Family member cancer record. + brca_status: BRCA carrier status ('none', 'brca1', or 'brca2'). + has_lpg: Whether person has low-penetrance gene. + s0_grid: S_0 survivor grid. + s_brca1_grid: BRCA1 survivor grid. + s_brca2_grid: BRCA2 survivor grid. + + Returns: + Likelihood probability. + """ + if brca_status == "brca1": + base_grid = s_brca1_grid + elif brca_status == "brca2": + base_grid = s_brca2_grid + else: + base_grid = s0_grid + + lpg_hazard_exponent = LPG_RELATIVE_HAZARD if has_lpg else 1.0 + + breast_cancer_age = _get_cancer_age(fh, CancerType.BREAST) + if breast_cancer_age is not None: + return event_density(base_grid, float(breast_cancer_age), lpg_hazard_exponent) + else: + age = _assumed_current_age(fh) + return max(s_at_age(base_grid, float(age)) ** lpg_hazard_exponent, 1e-12) + + +def _proband_unaffected_weight( + phenotype_idx: int, + current_age: int, + s0_grid: list[tuple[int, int, float]], + s_brca1_grid: list[tuple[int, int, float]], + s_brca2_grid: list[tuple[int, int, float]], +) -> float: + """Weight phenotype by proband being unaffected at current age. + + Args: + phenotype_idx: Phenotype index (0-5). + current_age: Proband's current age. + s0_grid: S_0 survivor grid. + s_brca1_grid: BRCA1 survivor grid. + s_brca2_grid: BRCA2 survivor grid. + + Returns: + Survival probability weight. + """ + if phenotype_idx < 2: + base_grid = s0_grid + elif phenotype_idx < 4: + base_grid = s_brca1_grid + else: + base_grid = s_brca2_grid + + lpg_hazard_exponent = LPG_RELATIVE_HAZARD if phenotype_idx % 2 == 1 else 1.0 + return max(s_at_age(base_grid, float(current_age)) ** lpg_hazard_exponent, 1e-12) + + +def _mendelian_transmission_prob( + parent1_allele: str, parent2_allele: str, child_allele: str +) -> float: + """Compute Mendelian transmission probability for BRCA locus. + + Args: + parent1_allele: Parent 1 allele ('N', 'B1', or 'B2'). + parent2_allele: Parent 2 allele ('N', 'B1', or 'B2'). + child_allele: Child allele ('N', 'B1', or 'B2'). + + Returns: + Transmission probability (0.0 to 1.0). + """ + if child_allele == "B1": + if parent1_allele == "B1" or parent2_allele == "B1": + return 0.5 + else: + return 0.0 + + elif child_allele == "B2": + if parent1_allele == "B2" or parent2_allele == "B2": + return 0.5 + else: + return 0.0 + + else: + prob_parent1 = 1.0 if parent1_allele == "N" else 0.5 + prob_parent2 = 1.0 if parent2_allele == "N" else 0.5 + return prob_parent1 * prob_parent2 + + +def _compute_parent_posterior( + relatives: list[FamilyMemberCancer], + _side: str, + s0_grid: list[tuple[int, int, float]], + s_brca1_grid: list[tuple[int, int, float]], + s_brca2_grid: list[tuple[int, int, float]], +) -> ParentPosterior: + """Compute BRCA posterior for a parent given relatives on their side. + + Args: + relatives: List of relatives on this parent's side. + _side: 'maternal' or 'paternal' (unused, kept for API clarity). + s0_grid: S_0 survivor grid. + s_brca1_grid: BRCA1 survivor grid. + s_brca2_grid: BRCA2 survivor grid. + + Returns: + BRCA posterior probabilities for the parent. + """ + lpg_allele_freq = LPG_ALLELE_FREQUENCY + prob_no_lpg = (1 - lpg_allele_freq) ** 2 + prob_lpg = 1 - prob_no_lpg + + prob_no_brca = 1.0 - BRCA1_CARRIER_FREQUENCY - BRCA2_CARRIER_FREQUENCY + prob_brca1 = BRCA1_CARRIER_FREQUENCY + prob_brca2 = BRCA2_CARRIER_FREQUENCY + + posteriors = {"none": prob_no_brca, "brca1": prob_brca1, "brca2": prob_brca2} + + # Update based on each relative's data + for fh in relatives: + # Skip if no usable information + if fh.age_at_diagnosis is None: + continue + + # Compute likelihood of this person's data given each BRCA status + likelihoods = {} + brca_statuses: list[Literal["none", "brca1", "brca2"]] = [ + "none", + "brca1", + "brca2", + ] + for brca_status in brca_statuses: + likelihood_sum = 0.0 + for has_lpg in [False, True]: + lpg_prob = prob_lpg if has_lpg else prob_no_lpg + likelihood = compute_likelihood_for_person( + fh, brca_status, has_lpg, s0_grid, s_brca1_grid, s_brca2_grid + ) + likelihood_sum += likelihood * lpg_prob + likelihoods[brca_status] = likelihood_sum + + # Update posteriors using Bayes' rule + posteriors["none"] *= likelihoods["none"] + posteriors["brca1"] *= likelihoods["brca1"] + posteriors["brca2"] *= likelihoods["brca2"] + + total = sum(posteriors.values()) + if total > 0: + posteriors = {key: value / total for key, value in posteriors.items()} + + return ParentPosterior(posteriors["none"], posteriors["brca1"], posteriors["brca2"]) + + +def compute_phenotype_posteriors_proper( + family_history: list[FamilyMemberCancer], + current_age: int, + s0_grid: list[tuple[int, int, float]], + s_brca1_grid: list[tuple[int, int, float]], + s_brca2_grid: list[tuple[int, int, float]], +) -> list[float]: + """Compute phenotype posteriors for proband using Elston-Stewart peeling. + + Implements proper Mendelian transmission via pedigree peeling: + 1. Separate relatives by family side (maternal vs paternal) + 2. Compute parent BRCA posteriors from their respective relatives + 3. Combine parental genotypes to get proband posterior via Mendelian transmission + 4. Update LPG probabilities based on parental sides + + Args: + family_history: List of family member cancer records. + current_age: Proband's current age. + s0_grid: S_0 survivor grid. + s_brca1_grid: BRCA1 survivor grid. + s_brca2_grid: BRCA2 survivor grid. + + Returns: + List of 6 phenotype probabilities for proband. + """ + # Calculate LPG carrier probability under HWE + lpg_allele_freq = LPG_ALLELE_FREQUENCY + prob_no_lpg = (1 - lpg_allele_freq) ** 2 + prob_lpg = 1 - prob_no_lpg + + # BRCA carrier frequencies + p_no_brca = 1.0 - BRCA1_CARRIER_FREQUENCY - BRCA2_CARRIER_FREQUENCY + p_brca1 = BRCA1_CARRIER_FREQUENCY + p_brca2 = BRCA2_CARRIER_FREQUENCY + + # Base priors for proband (6 phenotypes) + # Order: [no_brca/no_lpg, no_brca/lpg, brca1/no_lpg, brca1/lpg, brca2/no_lpg, brca2/lpg] + base_priors = [ + p_no_brca * prob_no_lpg, + p_no_brca * prob_lpg, + p_brca1 * prob_no_lpg, + p_brca1 * prob_lpg, + p_brca2 * prob_no_lpg, + p_brca2 * prob_lpg, + ] + + # If no family history information, return priors + if not family_history: + return base_priors + + # Separate relatives by family side + maternal_relatives = [] + paternal_relatives = [] + + for fh in family_history: + # Map family relations to maternal/paternal sides + if fh.relation in { + FamilyRelation.MOTHER, + FamilyRelation.SISTER, + FamilyRelation.DAUGHTER, + }: + maternal_relatives.append(fh) + elif fh.relation in {FamilyRelation.FATHER}: + paternal_relatives.append(fh) + # For aunts and grandmothers, use the side field if available + elif fh.relation in { + FamilyRelation.MATERNAL_AUNT, + FamilyRelation.PATERNAL_AUNT, + FamilyRelation.MATERNAL_GRANDMOTHER, + FamilyRelation.PATERNAL_GRANDMOTHER, + }: + if fh.relation in { + FamilyRelation.MATERNAL_AUNT, + FamilyRelation.MATERNAL_GRANDMOTHER, + }: + maternal_relatives.append(fh) + else: + paternal_relatives.append(fh) + + # Compute parent posteriors from their respective relatives + # Mother posterior + if maternal_relatives: + mother_posterior = _compute_parent_posterior( + maternal_relatives, "maternal", s0_grid, s_brca1_grid, s_brca2_grid + ) + pM_N, pM_B1, pM_B2 = ( + mother_posterior.prob_none, + mother_posterior.prob_brca1, + mother_posterior.prob_brca2, + ) + else: + # Use population priors + pM_N, pM_B1, pM_B2 = p_no_brca, p_brca1, p_brca2 + + # Father posterior + if paternal_relatives: + father_posterior = _compute_parent_posterior( + paternal_relatives, "paternal", s0_grid, s_brca1_grid, s_brca2_grid + ) + pF_N, pF_B1, pF_B2 = ( + father_posterior.prob_none, + father_posterior.prob_brca1, + father_posterior.prob_brca2, + ) + else: + # Use population priors + pF_N, pF_B1, pF_B2 = p_no_brca, p_brca1, p_brca2 + + proband_brca_probs = {"none": 0.0, "brca1": 0.0, "brca2": 0.0} + + parent_combos = [ + ("N", pM_N, "N", pF_N), + ("N", pM_N, "B1", pF_B1), + ("N", pM_N, "B2", pF_B2), + ("B1", pM_B1, "N", pF_N), + ("B1", pM_B1, "B1", pF_B1), + ("B1", pM_B1, "B2", pF_B2), + ("B2", pM_B2, "N", pF_N), + ("B2", pM_B2, "B1", pF_B1), + ("B2", pM_B2, "B2", pF_B2), + ] + + for m_allele, p_m, f_allele, p_f in parent_combos: + joint_prob = p_m * p_f + if joint_prob == 0: + continue + + # Compute transmission probabilities for each proband genotype + for proband_allele in ["N", "B1", "B2"]: + trans_prob = _mendelian_transmission_prob( + m_allele, f_allele, proband_allele + ) + + # Map allele to genotype name + if proband_allele == "N": + proband_brca_probs["none"] += joint_prob * trans_prob + elif proband_allele == "B1": + proband_brca_probs["brca1"] += joint_prob * trans_prob + elif proband_allele == "B2": + proband_brca_probs["brca2"] += joint_prob * trans_prob + + # Normalize BRCA probabilities + total_brca = sum(proband_brca_probs.values()) + if total_brca > 0: + proband_brca_probs = {k: v / total_brca for k, v in proband_brca_probs.items()} + + lpg_allele_freq_maternal = lpg_allele_freq + lpg_allele_freq_paternal = lpg_allele_freq + prob_lpg_proband = 1 - (1 - lpg_allele_freq_maternal) * ( + 1 - lpg_allele_freq_paternal + ) + prob_no_lpg_proband = 1 - prob_lpg_proband + + # Combine BRCA and LPG to get 6 phenotype probabilities + posteriors = [ + proband_brca_probs["none"] * prob_no_lpg_proband, + proband_brca_probs["none"] * prob_lpg_proband, + proband_brca_probs["brca1"] * prob_no_lpg_proband, + proband_brca_probs["brca1"] * prob_lpg_proband, + proband_brca_probs["brca2"] * prob_no_lpg_proband, + proband_brca_probs["brca2"] * prob_lpg_proband, + ] + + # Condition on proband being unaffected at current age + weights = [ + _proband_unaffected_weight( + pheno_idx, current_age, s0_grid, s_brca1_grid, s_brca2_grid + ) + for pheno_idx in range(6) + ] + posteriors = [ + prob * weight for prob, weight in zip(posteriors, weights, strict=False) + ] + total_prob = sum(posteriors) + posteriors = ( + [prob / total_prob for prob in posteriors] if total_prob > 0 else base_priors + ) + + return posteriors + + +class TyrerCuzickRiskModel(RiskModel): + """Compute breast cancer risk using the Tyrer-Cuzick (IBIS) model.""" + + REQUIRED_INPUTS: dict[str, tuple[type, bool]] = { + "demographics.age_years": (Annotated[int, Field(ge=18, le=100)], True), + "demographics.sex": (Sex, True), + "demographics.anthropometrics.height_cm": ( + Annotated[float, Field(gt=0)], + False, + ), + "demographics.anthropometrics.weight_kg": ( + Annotated[float, Field(gt=0)], + False, + ), + "female_specific.menstrual.age_at_menarche": ( + Annotated[int, Field(ge=8, le=25)], + False, + ), + "female_specific.menstrual.age_at_menopause": ( + Annotated[int, Field(ge=30, le=70)], + False, + ), + "female_specific.parity.num_live_births": (Annotated[int, Field(ge=0)], False), + "female_specific.parity.age_at_first_live_birth": ( + Annotated[int, Field(ge=10, le=60)], + False, + ), + "female_specific.breast_health.atypical_hyperplasia": (bool, False), + "female_specific.breast_health.lobular_carcinoma_in_situ": (bool, False), + "personal_medical_history.tyrer_cuzick_polygenic_risk_score": ( + Annotated[float, Field(gt=0)], + False, + ), + "family_history": (list, False), + } + + def __init__(self): + super().__init__("tyrer_cuzick") + # Precompute survivor grids + self.s_pop_grid = build_population_survivor() + self.s_brca1_grid = build_brca_survivor(BRCA1_CUMULATIVE_RISK_BY_AGE) + self.s_brca2_grid = build_brca_survivor(BRCA2_CUMULATIVE_RISK_BY_AGE) + self.s0_grid = build_s0_survivor( + self.s_pop_grid, self.s_brca1_grid, self.s_brca2_grid + ) + + def calculate_risk( + self, user: UserInput, projection_years: int = 10 + ) -> TyrerCuzickRiskResult: + """Calculate breast cancer risk using Tyrer-Cuzick model. + + Args: + user: User input profile. + projection_years: Years to project risk (default: 10). + + Returns: + Risk result with cumulative risk, intervals, personal RR, and phenotype probabilities. + + Raises: + ValueError: If female-specific information is missing. + """ + # Extract parameters from UserInput + current_age = user.demographics.age_years + fs = user.female_specific + + if fs is None: + raise ValueError( + "Missing female-specific information required for Tyrer-Cuzick." + ) + + # Extract menstrual/reproductive parameters + menarche_age = fs.menstrual.age_at_menarche + has_given_birth = (fs.parity.num_live_births or 0) > 0 + age_first_birth = fs.parity.age_at_first_live_birth + + # Determine menopausal status + if fs.menstrual.age_at_menopause is not None: + menopausal_status = "post" + menopause_age = fs.menstrual.age_at_menopause + elif current_age >= 55: + menopausal_status = "post" + menopause_age = fs.menstrual.age_at_menopause or 51 + elif current_age >= 45: + menopausal_status = "peri" + menopause_age = None + else: + menopausal_status = "pre" + menopause_age = None + + # Extract physical parameters + height_cm = user.demographics.anthropometrics.height_cm + height_m = height_cm / 100.0 if height_cm is not None else None + weight_kg = user.demographics.anthropometrics.weight_kg + bmi = calculate_bmi(weight_kg, height_cm) if weight_kg and height_cm else None + + # Extract breast health parameters + atypical_hyperplasia = fs.breast_health.atypical_hyperplasia or False + lcis = fs.breast_health.lobular_carcinoma_in_situ or False + + # Extract polygenic risk score + polygenic_relative_risk = None + if user.personal_medical_history.tyrer_cuzick_polygenic_risk_score: + polygenic_relative_risk = ( + user.personal_medical_history.tyrer_cuzick_polygenic_risk_score + ) + + # Filter family history for breast/ovarian cancer only + relevant_fh = [ + fh + for fh in user.family_history + if fh.cancer_type in [CancerType.BREAST, CancerType.OVARIAN] + ] + + # Compute personal relative risk + personal_relative_risk = compute_personal_relative_risk( + menarche_age=menarche_age, + has_given_birth=has_given_birth, + age_first_birth=age_first_birth, + menopausal_status=menopausal_status, + menopause_age=menopause_age, + height_m=height_m, + bmi=bmi, + atypical_hyperplasia=atypical_hyperplasia, + lcis=lcis, + polygenic_relative_risk=polygenic_relative_risk, + ) + + # Compute phenotype posteriors + phenotype_probs = compute_phenotype_posteriors_proper( + family_history=relevant_fh, + current_age=current_age, + s0_grid=self.s0_grid, + s_brca1_grid=self.s_brca1_grid, + s_brca2_grid=self.s_brca2_grid, + ) + + # Compute absolute risk + risk_result = compute_absolute_risk( + current_age=current_age, + projection_years=projection_years, + phenotype_probs=phenotype_probs, + personal_relative_risk=personal_relative_risk, + s0_grid=self.s0_grid, + s_brca1_grid=self.s_brca1_grid, + s_brca2_grid=self.s_brca2_grid, + ) + + return TyrerCuzickRiskResult( + cumulative_risk=risk_result["cumulative_risk"], + interval_risks=risk_result["interval_risks"], + personal_relative_risk=personal_relative_risk, + phenotype_probs=phenotype_probs, + ) + + def compute_score(self, user: UserInput) -> str: + """Compute the Tyrer-Cuzick risk score for a given user profile. + + Args: + user: The user profile. + + Returns: + str: Risk percentage as a string or an N/A message if inapplicable. + + Raises: + ValueError: If required inputs are missing or invalid. + """ + # Validate inputs first + is_valid, errors = self.validate_inputs(user) + if not is_valid: + raise ValueError(f"Invalid inputs for Tyrer-Cuzick: {'; '.join(errors)}") + + # Check sex using enum + if user.demographics.sex != Sex.FEMALE: + return "N/A: Tyrer-Cuzick model is only applicable to female patients." + + age = user.demographics.age_years + if not (20 <= age <= 85): + return "N/A: Age is outside the validated 20-85 year range." + + if user.female_specific is None: + return "N/A: Missing female-specific information required for Tyrer-Cuzick." + + # Calculate risk + try: + result = self.calculate_risk(user, projection_years=10) + risk_pct = result["cumulative_risk"] * 100 + return f"{risk_pct:.2f}%" + except Exception as e: + return f"N/A: Error calculating risk - {e!s}" + + def cancer_type(self) -> str: + return "breast" + + def description(self) -> str: + return ( + "The Tyrer-Cuzick (IBIS) model estimates breast cancer risk by combining " + "genetic factors (BRCA1/2 and low-penetrance genes) with personal risk factors " + "(menarche, childbirth, menopause, height, BMI, hyperplasia). It uses pedigree " + "analysis to incorporate family history and computes absolute risk over a " + "specified time period." + ) + + def interpretation(self) -> str: + return ( + "The score represents the estimated probability of developing breast cancer " + "over the projection period (typically 10 years or to age 80). Higher percentages " + "indicate elevated risk. This comprehensive model accounts for both genetic and " + "personal factors. Results should be discussed with a healthcare professional " + "to guide screening, prevention, and potential genetic testing decisions." + ) + + def references(self) -> list[str]: + return [ + "Tyrer, J., Duffy, S. W., & Cuzick, J. (2004). A breast cancer prediction model " + "incorporating familial and personal risk factors. Statistics in Medicine, 23(7), 1111-1130." + ] diff --git a/src/sentinel/user_input.py b/src/sentinel/user_input.py new file mode 100644 index 0000000000000000000000000000000000000000..e9270816138575b945f87bf3bf4b0eb697eca803 --- /dev/null +++ b/src/sentinel/user_input.py @@ -0,0 +1,1281 @@ +"""Minimal strict V1 input schema for Sentinel risk scoring. + +This module defines a lean, minimal input schema with only the fields actually +used by implemented risk calculators. Serves as the canonical unified interface +for future risk model refactoring. +""" + +from datetime import date as Date +from enum import Enum + +from pydantic import BaseModel, ConfigDict, Field + + +class StrictBaseModel(BaseModel): + """Base model with strict validation and no extra fields allowed.""" + + model_config = ConfigDict( + extra="forbid", + validate_assignment=True, + str_strip_whitespace=True, + ) + + +# --------------------------------------------------------------------------- +# Demographics Enums +# --------------------------------------------------------------------------- + + +class Sex(str, Enum): + """Biological sex categories for demographic classification. + + Attributes: + MALE: Male biological sex + FEMALE: Female biological sex + UNKNOWN: Unknown or unspecified biological sex + """ + + MALE = "male" + FEMALE = "female" + UNKNOWN = "unknown" + + +class Ethnicity(str, Enum): + """Self-reported ethnicity categories for demographic classification. + + Used to adjust risk calculations based on population-specific + incidence rates and genetic factors. + + Attributes: + WHITE: White or Caucasian + BLACK: Black or African American + HISPANIC: Hispanic or Latino + ASIAN: Asian + NATIVE_AMERICAN: Native American or Alaska Native + PACIFIC_ISLANDER: Native Hawaiian or Pacific Islander + ASHKENAZI_JEWISH: Ashkenazi Jewish + OTHER: Other ethnicity not listed + UNKNOWN: Unknown or unspecified ethnicity + """ + + WHITE = "white" + BLACK = "black" + HISPANIC = "hispanic" + ASIAN = "asian" + ASHKENAZI_JEWISH = "ashkenazi jewish" + NATIVE_AMERICAN = "native_american" + PACIFIC_ISLANDER = "pacific_islander" + OTHER = "other" + UNKNOWN = "unknown" + + +class Country(str, Enum): + """Countries supported for population-specific risk calculations. + + Attributes: + UNITED_STATES: United States of America + UNITED_KINGDOM: United Kingdom + """ + + UNITED_STATES = "us" + UNITED_KINGDOM = "uk" + + +# --------------------------------------------------------------------------- +# Medical Enums +# --------------------------------------------------------------------------- + + +class CancerType(str, Enum): + """Cancer types with implemented risk assessment models. + + Attributes: + BREAST: Breast cancer + LUNG: Lung cancer + PROSTATE: Prostate cancer + COLORECTAL: Colorectal cancer + OVARIAN: Ovarian cancer + MELANOMA: Melanoma (skin cancer) + BLOOD: Blood cancers (leukemia/lymphoma) + CERVICAL: Cervical cancer + GASTRO_OESOPHAGEAL: Gastro-oesophageal cancer (stomach/esophageal) + PANCREATIC: Pancreatic cancer + RENAL_TRACT: Renal tract cancer (kidney) + ENDOMETRIAL: Endometrial cancer (uterine) + TESTICULAR: Testicular cancer + BRAIN: Brain cancer + LIVER: Liver cancer + THYROID: Thyroid cancer + OTHER: Other/unspecified cancer types + """ + + BREAST = "breast_cancer" + LUNG = "lung_cancer" + PROSTATE = "prostate_cancer" + COLORECTAL = "colorectal_cancer" + OVARIAN = "ovarian_cancer" + MELANOMA = "melanoma" + BLOOD = "blood_cancer" + CERVICAL = "cervical_cancer" + GASTRO_OESOPHAGEAL = "gastro_oesophageal_cancer" + PANCREATIC = "pancreatic_cancer" + RENAL_TRACT = "renal_tract_cancer" + ENDOMETRIAL = "uterine_cancer" + TESTICULAR = "testicular_cancer" + BRAIN = "brain_cancer" + LIVER = "liver_cancer" + THYROID = "thyroid_cancer" + OTHER = "other_cancer" + + +class GeneticMutation(str, Enum): + """Genetic mutations associated with increased cancer risk. + + Attributes: + BRCA1: BRCA1 gene mutation + BRCA2: BRCA2 gene mutation + LYNCH_MLH1: Lynch syndrome MLH1 gene mutation + LYNCH_MSH2: Lynch syndrome MSH2 gene mutation + LYNCH_MSH6: Lynch syndrome MSH6 gene mutation + LYNCH_PMS2: Lynch syndrome PMS2 gene mutation + APC: APC gene mutation + """ + + BRCA1 = "brca1" + BRCA2 = "brca2" + LYNCH_MLH1 = "lynch_mlh1" + LYNCH_MSH2 = "lynch_msh2" + LYNCH_MSH6 = "lynch_msh6" + LYNCH_PMS2 = "lynch_pms2" + APC = "apc" + + +class ChronicCondition(str, Enum): + """Chronic medical conditions that may affect cancer risk. + + Attributes: + COPD: Chronic obstructive pulmonary disease + DIABETES: Diabetes mellitus + INFLAMMATORY_BOWEL_DISEASE: Inflammatory bowel disease + CHRONIC_PANCREATITIS: Chronic pancreatitis + ENDOMETRIAL_POLYPS: Endometrial polyps + ANAEMIA: Anaemia (low hemoglobin) + """ + + COPD = "copd" + DIABETES = "diabetes" + INFLAMMATORY_BOWEL_DISEASE = "ibd" + CHRONIC_PANCREATITIS = "chronic_pancreatitis" + ENDOMETRIAL_POLYPS = "endometrial_polyps" + ANAEMIA = "anaemia" + + +# --------------------------------------------------------------------------- +# Family History Enums +# --------------------------------------------------------------------------- + + +class FamilyRelation(str, Enum): + """Family relationship types for pedigree construction. + + Attributes: + MOTHER: Biological mother + FATHER: Biological father + SISTER: Sister + BROTHER: Brother + DAUGHTER: Daughter + SON: Son + MATERNAL_GRANDMOTHER: Maternal grandmother + PATERNAL_GRANDMOTHER: Paternal grandmother + MATERNAL_GRANDFATHER: Maternal grandfather + PATERNAL_GRANDFATHER: Paternal grandfather + MATERNAL_AUNT: Maternal aunt + PATERNAL_AUNT: Paternal aunt + MATERNAL_UNCLE: Maternal uncle + PATERNAL_UNCLE: Paternal uncle + MATERNAL_COUSIN: Maternal cousin + PATERNAL_COUSIN: Paternal cousin + MATERNAL_NEPHEW: Maternal nephew + PATERNAL_NEPHEW: Paternal nephew + MATERNAL_NIECE: Maternal niece + PATERNAL_NIECE: Paternal niece + MATERNAL_GRANDAUNT: Maternal grandaunt + PATERNAL_GRANDAUNT: Paternal grandaunt + MATERNAL_GRANDUNCLE: Maternal granduncle + PATERNAL_GRANDUNCLE: Paternal granduncle + OTHER: Other family relationship + """ + + MOTHER = "mother" + FATHER = "father" + SISTER = "sister" + BROTHER = "brother" + DAUGHTER = "daughter" + SON = "son" + MATERNAL_GRANDMOTHER = "maternal_grandmother" + PATERNAL_GRANDMOTHER = "paternal_grandmother" + MATERNAL_GRANDFATHER = "maternal_grandfather" + PATERNAL_GRANDFATHER = "paternal_grandfather" + MATERNAL_AUNT = "maternal_aunt" + PATERNAL_AUNT = "paternal_aunt" + MATERNAL_UNCLE = "maternal_uncle" + PATERNAL_UNCLE = "paternal_uncle" + MATERNAL_COUSIN = "maternal_cousin" + PATERNAL_COUSIN = "paternal_cousin" + MATERNAL_NEPHEW = "maternal_nephew" + PATERNAL_NEPHEW = "paternal_nephew" + MATERNAL_NIECE = "maternal_niece" + PATERNAL_NIECE = "paternal_niece" + MATERNAL_GRANDAUNT = "maternal_grandaunt" + PATERNAL_GRANDAUNT = "paternal_grandaunt" + MATERNAL_GRANDUNCLE = "maternal_granduncle" + PATERNAL_GRANDUNCLE = "paternal_granduncle" + OTHER = "other" + + +class RelationshipDegree(str, Enum): + """Degree of familial relationship (1st, 2nd, 3rd degree relatives). + + Attributes: + FIRST: First-degree relative (parent, sibling, child) + SECOND: Second-degree relative (grandparent, aunt, uncle, niece, nephew) + THIRD: Third-degree relative (great-grandparent, cousin) + """ + + FIRST = "1" + SECOND = "2" + THIRD = "3" + + +class FamilySide(str, Enum): + """Maternal or paternal side of family for pedigree analysis. + + Attributes: + MATERNAL: Maternal side of family + PATERNAL: Paternal side of family + UNKNOWN: Unknown family side + """ + + MATERNAL = "maternal" + PATERNAL = "paternal" + UNKNOWN = "unknown" + + +# --------------------------------------------------------------------------- +# Clinical Enums +# --------------------------------------------------------------------------- + + +class SymptomType(str, Enum): + """Symptom types recognized by cancer risk models. + + Attributes: + ABDOMINAL_DISTENSION: Abdominal distension + ABDOMINAL_PAIN: Abdominal pain + APPETITE_LOSS: Loss of appetite + BREAST_LUMP: Breast lump + BREAST_PAIN: Breast pain + BREAST_SKIN_CHANGES: Breast skin changes + DYSPHAGIA: Difficulty swallowing + GASTROINTESTINAL_BLEEDING: Gastrointestinal bleeding + HAEMATURIA: Blood in urine + HAEMOPTYSIS: Coughing up blood + HEARTBURN: Heartburn + INTERMENSTRUAL_BLEEDING: Intermenstrual bleeding + INDIGESTION: Indigestion + NECK_LUMP: Neck lump + NIGHT_SWEATS: Night sweats + POST_MENOPAUSAL_BLEEDING: Post-menopausal bleeding + POST_COITAL_BLEEDING: Post-coital bleeding + RECTAL_BLEEDING: Rectal bleeding + TESTICULAR_PAIN: Testicular pain + TESTICULAR_LUMP: Testicular lump + VENOUS_THROMBOEMBOLISM: Venous thromboembolism + WEIGHT_LOSS: Weight loss + BOWEL_HABIT_CHANGE: Bowel habit change + BRUISING: Bruising + CONSTIPATION: Constipation + PERSISTENT_COUGH: Persistent cough + ERECTILE_DYSFUNCTION: Erectile dysfunction + NOCTURIA: Nocturia (nighttime urination) + INCREASED_URINARY_FREQUENCY: Increased urinary frequency + URINARY_RETENTION: Urinary retention + """ + + # New/acute symptoms + ABDOMINAL_DISTENSION = "abdominal_distension" + ABDOMINAL_PAIN = "abdominal_pain" + APPETITE_LOSS = "appetite_loss" + BREAST_LUMP = "breast_lump" + BREAST_PAIN = "breast_pain" + BREAST_SKIN_CHANGES = "breast_skin_changes" + DYSPHAGIA = "dysphagia" + GASTROINTESTINAL_BLEEDING = "gastrointestinal_bleeding" + HAEMATURIA = "haematuria" + HAEMOPTYSIS = "haemoptysis" + HEARTBURN = "heartburn" + INTERMENSTRUAL_BLEEDING = "intermenstrual_bleeding" + INDIGESTION = "indigestion" + NECK_LUMP = "neck_lump" + NIGHT_SWEATS = "night_sweats" + POST_MENOPAUSAL_BLEEDING = "post_menopausal_bleeding" + POST_COITAL_BLEEDING = "post_coital_bleeding" + RECTAL_BLEEDING = "rectal_bleeding" + TESTICULAR_PAIN = "testicular_pain" + TESTICULAR_LUMP = "testicular_lump" + VENOUS_THROMBOEMBOLISM = "venous_thromboembolism" + WEIGHT_LOSS = "weight_loss" + + # Persistent symptoms + BOWEL_HABIT_CHANGE = "bowel_habit_change" + BRUISING = "bruising" + CONSTIPATION = "constipation" + PERSISTENT_COUGH = "persistent_cough" + ERECTILE_DYSFUNCTION = "erectile_dysfunction" + NOCTURIA = "nocturia" + INCREASED_URINARY_FREQUENCY = "increased_urinary_frequency" + URINARY_RETENTION = "urinary_retention" + + +class DREResult(str, Enum): + """Digital rectal examination result categories. + + Attributes: + NORMAL: Normal examination with no abnormalities detected + ABNORMAL: Abnormal findings detected during examination + SUSPICIOUS: Suspicious findings requiring follow-up investigation + """ + + NORMAL = "normal" + ABNORMAL = "abnormal" + SUSPICIOUS = "suspicious" + + +# --------------------------------------------------------------------------- +# Clinical Test Models +# --------------------------------------------------------------------------- + + +class PSATest(StrictBaseModel): + """PSA (Prostate-Specific Antigen) test result. + + Attributes: + value_ng_ml: PSA value in ng/mL (valid range: 0-50) + date: Date when test was performed + """ + + value_ng_ml: float = Field( + ge=0, + le=50, + description="PSA value in ng/mL", + examples=[2.5, 4.0, 8.5], + ) + date: Date | None = Field( + None, + description="Date when test was performed", + examples=[Date(2023, 1, 15), Date(2024, 6, 20), Date(2025, 3, 10)], + ) + + +class PercentFreePSATest(StrictBaseModel): + """Percent free PSA test result. + + Attributes: + value_percent: Percentage of free PSA (valid range: 2-75) + date: Date when test was performed + """ + + value_percent: float = Field( + ge=2, + le=75, + description="Percentage of free PSA", + examples=[15.0, 25.0, 40.0], + ) + date: Date | None = Field( + None, + description="Date when test was performed", + examples=[Date(2023, 1, 15), Date(2024, 6, 20), Date(2025, 3, 10)], + ) + + +class PCA3Test(StrictBaseModel): + """PCA3 score test result. + + Attributes: + score: PCA3 score (valid range: 0.3-332.5) + date: Date when test was performed + """ + + score: float = Field( + ge=0.3, + le=332.5, + description="PCA3 score", + examples=[15.0, 35.0, 80.0], + ) + date: Date | None = Field( + None, + description="Date when test was performed", + examples=[Date(2023, 1, 15), Date(2024, 6, 20), Date(2025, 3, 10)], + ) + + +class T2ERGTest(StrictBaseModel): + """T2:ERG fusion gene test result. + + Attributes: + score: T2:ERG score + date: Date when test was performed + """ + + score: float = Field(description="T2:ERG score", examples=[0.5, 1.0, 2.5]) + date: Date | None = Field( + None, + description="Date when test was performed", + examples=[Date(2023, 1, 15), Date(2024, 6, 20), Date(2025, 3, 10)], + ) + + +class DRETest(StrictBaseModel): + """Digital Rectal Examination result. + + Attributes: + result: DRE result category + date: Date when examination was performed + """ + + result: DREResult = Field( + description="DRE result category", + examples=["normal", "abnormal", "suspicious"], + ) + date: Date | None = Field( + None, + description="Date when examination was performed", + examples=[Date(2023, 1, 15), Date(2024, 6, 20), Date(2025, 3, 10)], + ) + + +class ProstateVolumeTest(StrictBaseModel): + """Prostate volume measurement. + + Attributes: + volume_ml: Prostate volume in milliliters + date: Date when measurement was performed + """ + + volume_ml: float = Field( + gt=0, + description="Prostate volume in milliliters", + examples=[25.0, 40.0, 60.0], + ) + date: Date | None = Field( + None, + description="Date when measurement was performed", + examples=[Date(2023, 1, 15), Date(2024, 6, 20), Date(2025, 3, 10)], + ) + + +class ClinicalTests(StrictBaseModel): + """Container for all clinical test results. + + Attributes: + psa: PSA test result + percent_free_psa: Percent free PSA test result + pca3: PCA3 score test result + t2erg: T2:ERG score test result + dre: Digital rectal examination result + prostate_volume: Prostate volume measurement + """ + + psa: PSATest | None = Field(None, description="PSA test result") + percent_free_psa: PercentFreePSATest | None = Field( + None, description="Percent free PSA test result" + ) + pca3: PCA3Test | None = Field(None, description="PCA3 score test result") + t2erg: T2ERGTest | None = Field(None, description="T2:ERG score test result") + dre: DRETest | None = Field(None, description="Digital rectal examination result") + prostate_volume: ProstateVolumeTest | None = Field( + None, description="Prostate volume measurement" + ) + + +# --------------------------------------------------------------------------- +# Female-Specific Enums +# --------------------------------------------------------------------------- + + +class HormoneUse(str, Enum): + """Hormone replacement therapy use categories. + + Attributes: + NEVER: Never used hormone therapy + CURRENT: Currently using hormone therapy + FORMER: Previously used hormone therapy + """ + + NEVER = "never" + CURRENT = "current" + FORMER = "former" + + +class AspirinUse(str, Enum): + """Aspirin use categories. + + Attributes: + NEVER: Never used aspirin + CURRENT: Currently using aspirin + FORMER: Previously used aspirin + """ + + NEVER = "never" + CURRENT = "current" + FORMER = "former" + + +class NSAIDUse(str, Enum): + """NSAID (non-steroidal anti-inflammatory drug) use categories. + + Attributes: + NEVER: Never used NSAIDs + CURRENT: Currently using NSAIDs + FORMER: Previously used NSAIDs + """ + + NEVER = "never" + CURRENT = "current" + FORMER = "former" + + +# --------------------------------------------------------------------------- +# Dermatologic Enums (All converted to str, Enum) +# --------------------------------------------------------------------------- + + +class USGeographicRegion(str, Enum): + """Geographic regions for sun exposure assessment. + + Attributes: + NORTHERN: Northern geographic region + CENTRAL: Central geographic region + SOUTHERN: Southern geographic region + """ + + NORTHERN = "northern" + CENTRAL = "central" + SOUTHERN = "southern" + + +class ComplexionLevel(str, Enum): + """Skin complexion levels for melanoma risk assessment. + + Attributes: + LIGHT: Fair or light skin that burns easily + MEDIUM: Medium skin tone with moderate sun sensitivity + DARK: Dark skin tone with low sun sensitivity + """ + + LIGHT = "light" + MEDIUM = "medium" + DARK = "dark" + + +class FrecklingIntensity(str, Enum): + """Freckling intensity levels for melanoma risk assessment. + + Attributes: + ABSENT: No freckling present + MILD: Mild freckling + MODERATE: Moderate freckling + SEVERE: Severe freckling + """ + + ABSENT = "absent" + MILD = "mild" + MODERATE = "moderate" + SEVERE = "severe" + + +class FemaleTanResponse(str, Enum): + """Female tan response categories for melanoma risk assessment. + + Attributes: + VERY_BROWN: Very brown tan response + MODERATE: Moderate tan response + LIGHT: Light tan response + NONE: No tan response + """ + + VERY_BROWN = "very_brown" + MODERATE = "moderate" + LIGHT = "light" + NONE = "none" + + +class MaleSmallMolesCategory(str, Enum): + """Male small moles categories for melanoma risk assessment. + + Attributes: + LESS_THAN_SEVEN: Less than 7 small moles + SEVEN_TO_SIXTEEN: 7 to 16 small moles + SEVENTEEN_OR_MORE: 17 or more small moles + """ + + LESS_THAN_SEVEN = "less_than_seven" + SEVEN_TO_SIXTEEN = "seven_to_sixteen" + SEVENTEEN_OR_MORE = "seventeen_or_more" + + +class FemaleSmallMolesCategory(str, Enum): + """Female small moles categories for melanoma risk assessment. + + Attributes: + LESS_THAN_FIVE: Less than 5 small moles + FIVE_TO_ELEVEN: 5 to 11 small moles + TWELVE_OR_MORE: 12 or more small moles + """ + + LESS_THAN_FIVE = "less_than_five" + FIVE_TO_ELEVEN = "five_to_eleven" + TWELVE_OR_MORE = "twelve_or_more" + + +# --------------------------------------------------------------------------- +# Core Models +# --------------------------------------------------------------------------- + + +class Anthropometrics(StrictBaseModel): + """Physical body measurements. + + All measurements use metric units. Convert from imperial units + before instantiation if needed. + + Attributes: + height_cm: Height in centimeters (valid range: 50-260 cm) + weight_kg: Weight in kilograms (valid range: 20-350 kg) + """ + + height_cm: float = Field( + ge=50, + le=260, + description="Height in centimeters", + examples=[175.5, 160.0, 185.2], + ) + weight_kg: float = Field( + ge=20, le=350, description="Weight in kilograms", examples=[70.2, 55.8, 85.1] + ) + + +class Demographics(StrictBaseModel): + """Basic demographic and socioeconomic attributes. + + Attributes: + age_years: Age in years (valid range: 0-120) + sex: Biological sex category + race: Self-reported race category + ethnicity: Self-reported ethnicity (free-form string for edge cases) + anthropometrics: Physical measurements + geography: Geographic location data + education_level: Years of formal education (valid range: 0-25) + townsend_index: Townsend deprivation index for socioeconomic status (valid range: -10 to 15) + """ + + age_years: int = Field( + ge=0, le=120, description="Age in years", examples=[45, 62, 28] + ) + sex: Sex = Field(description="Biological sex category", examples=["male", "female"]) + ethnicity: Ethnicity | None = Field( + None, + description="Self-reported ethnicity for demographic classification", + examples=["white", "black", "asian"], + ) + anthropometrics: Anthropometrics = Field(description="Physical measurements") + country: Country | None = Field( + None, + description="Country code for population-specific calculations", + examples=["US", "UK"], + ) + education_level: int | None = Field( + None, + ge=0, + le=25, + description="Years of formal education", + examples=[16, 12, 20], + ) + townsend_index: float | None = Field( + None, + ge=-10, + le=15, + description="Townsend deprivation index for socioeconomic status", + examples=[2.5, -1.2, 8.7], + ) + + +# --------------------------------------------------------------------------- +# Lifestyle and Behavioral Models +# --------------------------------------------------------------------------- + + +class SmokingStatus(str, Enum): + """Current smoking status categories. + + Attributes: + NEVER: Never smoked cigarettes + FORMER: Former smoker who has quit + CURRENT: Currently smoking cigarettes + """ + + NEVER = "never" + FORMER = "former" + CURRENT = "current" + + +class AlcoholConsumption(str, Enum): + """Alcohol consumption level categories. + + Attributes: + NONE: No alcohol consumption + LIGHT: Light alcohol consumption (1-7 drinks per week) + MODERATE: Moderate alcohol consumption (8-14 drinks per week) + HEAVY: Heavy alcohol consumption (15+ drinks per week) + """ + + NONE = "none" + LIGHT = "light" + MODERATE = "moderate" + HEAVY = "heavy" + + +class PhysicalActivityLevel(str, Enum): + """Physical activity intensity levels. + + Attributes: + SEDENTARY: Sedentary lifestyle with minimal physical activity + LOW: Low level of physical activity + MODERATE: Moderate level of physical activity + HIGH: High level of physical activity + """ + + SEDENTARY = "sedentary" + LOW = "low" + MODERATE = "moderate" + HIGH = "high" + + +class SmokingHistory(StrictBaseModel): + """Smoking exposure history and patterns. + + Attributes: + status: Current smoking status + cigarettes_per_day: Average cigarettes per day (valid range: 0-200) + years_smoked: Total years of smoking (valid range: 0-100) + years_since_quit: Years since quitting smoking (valid range: 0-100) + pack_years: Total pack-years of smoking (valid range: 0-300) + """ + + status: SmokingStatus = Field( + description="Current smoking status", examples=["never", "former", "current"] + ) + cigarettes_per_day: float | None = Field( + None, + ge=0, + le=200, + description="Average cigarettes per day", + examples=[15.5, 0.0, 25.0], + ) + years_smoked: float | None = Field( + None, + ge=0, + le=100, + description="Total years of smoking", + examples=[20.0, 0.0, 35.5], + ) + years_since_quit: float | None = Field( + None, + ge=0, + le=100, + description="Years since quitting smoking", + examples=[5.0, 0.0, 15.2], + ) + pack_years: float | None = Field( + None, + ge=0, + le=300, + description="Total pack-years of smoking", + examples=[30.0, 0.0, 45.5], + ) + + +class Lifestyle(StrictBaseModel): + """Lifestyle behaviors and dietary patterns. + + Attributes: + smoking: Smoking history and patterns + alcohol_consumption: Alcohol consumption level + physical_activity_level: Physical activity intensity level + multivitamin_use: Regular multivitamin supplement usage + moderate_physical_activity_hours_per_day: Hours of moderate physical activity per day + red_meat_consumption_oz_per_day: Red meat consumption in ounces per day + """ + + smoking: SmokingHistory = Field(description="Smoking history and patterns") + alcohol_consumption: AlcoholConsumption | None = Field( + None, + description="Alcohol consumption level", + examples=["none", "light", "moderate"], + ) + physical_activity_level: PhysicalActivityLevel | None = Field( + None, + description="Physical activity intensity level", + examples=["sedentary", "moderate", "high"], + ) + multivitamin_use: bool | None = Field( + None, + description="Regular multivitamin supplement usage", + examples=[True, False], + ) + moderate_physical_activity_hours_per_day: float | None = Field( + None, + ge=0, + le=24, + description="Hours of moderate physical activity per day", + examples=[0.5, 2.0, 4.5], + ) + red_meat_consumption_oz_per_day: float | None = Field( + None, + ge=0, + description="Red meat consumption in ounces per day", + examples=[1.0, 3.5, 8.0], + ) + + +# --------------------------------------------------------------------------- +# Female-Specific Models +# --------------------------------------------------------------------------- + + +class MenstrualHistory(StrictBaseModel): + """Menstrual cycle and reproductive history. + + Attributes: + age_at_menarche: Age at first menstrual period in years (valid range: 8-60) + age_at_menopause: Age at menopause in years (valid range: 20-65) + """ + + age_at_menarche: int | None = Field( + None, + ge=8, + le=60, + description="Age at first menstrual period in years", + examples=[13, 12, 15], + ) + age_at_menopause: int | None = Field( + None, + ge=20, + le=65, + description="Age at menopause in years", + examples=[52, 48, 55], + ) + + +class ParityHistory(StrictBaseModel): + """Pregnancy and childbirth history. + + Attributes: + num_live_births: Number of live births (valid range: 0-20) + age_at_first_live_birth: Age at first live birth in years (valid range: 10-60) + """ + + num_live_births: int | None = Field( + None, ge=0, le=20, description="Number of live births", examples=[2, 0, 3] + ) + age_at_first_live_birth: int | None = Field( + None, + ge=10, + le=60, + description="Age at first live birth in years", + examples=[28, 32, 25], + ) + + +class HormoneUseHistory(StrictBaseModel): + """Hormone therapy and contraceptive use history. + + Attributes: + estrogen_use: Estrogen hormone therapy use category + oral_contraceptive_use: Oral contraceptive use status + """ + + estrogen_use: HormoneUse | None = Field( + None, + description="Estrogen hormone therapy use category", + examples=["never", "former", "current"], + ) + oral_contraceptive_use: str | None = Field( + None, + description="Oral contraceptive use status (N=never, F:years=former, C:years=current)", + examples=["N", "F:5", "C:10"], + ) + + +class BreastHealthHistory(StrictBaseModel): + """Breast health and biopsy history. + + Attributes: + num_biopsies: Number of breast biopsies performed (valid range: 0-20) + atypical_hyperplasia: History of atypical hyperplasia diagnosis + lobular_carcinoma_in_situ: History of LCIS diagnosis + """ + + num_biopsies: int | None = Field( + None, + ge=0, + le=20, + description="Number of breast biopsies performed", + examples=[0, 1, 2], + ) + atypical_hyperplasia: bool | None = Field( + None, + description="History of atypical hyperplasia diagnosis", + examples=[False, True], + ) + lobular_carcinoma_in_situ: bool | None = Field( + None, + description="History of lobular carcinoma in situ (LCIS) diagnosis", + examples=[False, True], + ) + + +class FemaleSpecific(StrictBaseModel): + """Female-specific clinical and reproductive data. + + Attributes: + menstrual: Menstrual and reproductive history + parity: Pregnancy and childbirth history + hormone_use: Hormone therapy use history + breast_health: Breast health and biopsy history + tubal_ligation: History of tubal ligation procedure + endometriosis: History of endometriosis diagnosis + """ + + menstrual: MenstrualHistory = Field( + default_factory=lambda: MenstrualHistory(), # pylint: disable=missing-kwoa + description="Menstrual and reproductive history", + ) + parity: ParityHistory = Field( + default_factory=lambda: ParityHistory(), # pylint: disable=missing-kwoa + description="Pregnancy and childbirth history", + ) + hormone_use: HormoneUseHistory = Field( + default_factory=lambda: HormoneUseHistory(), # pylint: disable=missing-kwoa + description="Hormone therapy use history", + ) + breast_health: BreastHealthHistory = Field( + default_factory=lambda: BreastHealthHistory(), # pylint: disable=missing-kwoa + description="Breast health and biopsy history", + ) + tubal_ligation: bool | None = Field( + None, description="History of tubal ligation procedure", examples=[True, False] + ) + endometriosis: bool | None = Field( + None, description="History of endometriosis diagnosis", examples=[True, False] + ) + + +# --------------------------------------------------------------------------- +# Medical History Models +# --------------------------------------------------------------------------- + + +class PersonalMedicalHistory(StrictBaseModel): + """Personal medical history and current conditions. + + Attributes: + chronic_conditions: List of chronic medical conditions + previous_cancers: List of previously diagnosed cancer types + genetic_mutations: List of known genetic mutations + tyrer_cuzick_polygenic_risk_score: Tyrer-Cuzick polygenic risk score (relative risk multiplier) + has_polyps: History of colorectal polyps + aspirin_use: Current or regular aspirin use + nsaid_use: Current or regular NSAID use + """ + + chronic_conditions: list[ChronicCondition] = Field( + default_factory=list, + description="List of chronic medical conditions", + examples=[["diabetes", "copd"], ["ibd"], []], + ) + previous_cancers: list[CancerType] = Field( + default_factory=list, + description="List of previously diagnosed cancer types", + examples=[["breast"], ["colorectal", "melanoma"], []], + ) + genetic_mutations: list[GeneticMutation] = Field( + default_factory=list, + description="List of known genetic mutations", + examples=[["brca1"], ["brca2", "lynch_mlh1"], []], + ) + tyrer_cuzick_polygenic_risk_score: float | None = Field( + None, + gt=0, + description="Tyrer-Cuzick polygenic risk score as relative risk multiplier", + examples=[0.5, 1.0, 2.0, 1.5], + ) + boadicea_polygenic_risk_score_alpha: float | None = Field( + None, + description="BOADICEA polygenic risk score alpha parameter", + examples=[0.5, 1.2, -0.8], + ) + boadicea_polygenic_risk_score_zscore: float | None = Field( + None, + description="BOADICEA polygenic risk score z-score", + examples=[0.0, 1.5, -1.2], + ) + # Gastrointestinal health + has_polyps: bool | None = Field( + None, description="History of colorectal polyps", examples=[True, False] + ) + # Medication use + aspirin_use: AspirinUse | None = Field( + None, + description="Regular aspirin use status", + examples=["current", "former", "never"], + ) + nsaid_use: NSAIDUse | None = Field( + None, + description="NSAID (non-steroidal anti-inflammatory drug) use status", + examples=["never", "current", "former"], + ) + prior_negative_prostate_biopsy: bool | None = Field( + None, + description="History of prior negative prostate biopsy (biopsy performed but no cancer found)", + examples=[True, False], + ) + use_5ari: bool | None = Field( + None, + description="Use of 5-alpha reductase inhibitors (finasteride/dutasteride)", + examples=[True, False], + ) + prior_psa_screening: bool | None = Field( + None, + description="History of prior PSA screening tests (before current test)", + examples=[True, False], + ) + + +# --------------------------------------------------------------------------- +# Family History Models +# --------------------------------------------------------------------------- + + +class FamilyMemberCancer(StrictBaseModel): + """Family member cancer history for pedigree analysis. + + Attributes: + relation: Family relationship to the patient + cancer_type: Type of cancer diagnosed + age_at_diagnosis: Age at cancer diagnosis in years (valid range: 0-120) + degree: Degree of familial relationship (1st, 2nd, 3rd) + side: Maternal or paternal side of family + """ + + relation: FamilyRelation = Field( + description="Family relationship to the patient", + examples=["mother", "father", "sister"], + ) + cancer_type: CancerType = Field( + description="Type of cancer diagnosed", + examples=["breast", "lung", "colorectal"], + ) + age_at_diagnosis: int | None = Field( + None, + ge=0, + le=120, + description="Age at cancer diagnosis in years", + examples=[65, 45, 72], + ) + degree: RelationshipDegree = Field( + description="Degree of familial relationship", examples=["1", "2", "3"] + ) + side: FamilySide = Field( + description="Maternal or paternal side of family", + examples=["maternal", "paternal"], + ) + + +# --------------------------------------------------------------------------- +# Clinical Data Models +# --------------------------------------------------------------------------- + + +class SymptomEntry(StrictBaseModel): + """Symptom or clinical complaint entry. + + Attributes: + symptom_type: Type of symptom from predefined list + onset_date: Date when symptom first appeared + duration_days: Duration of symptom in days (valid range: 0-10000) + """ + + symptom_type: SymptomType = Field( + description="Type of symptom from predefined list", + examples=["abdominal_pain", "persistent_cough", "breast_lump"], + ) + onset_date: Date | None = Field( + None, + description="Date when symptom first appeared", + examples=[Date(2023, 1, 15), Date(2024, 6, 20), Date(2025, 3, 10)], + ) + duration_days: int | None = Field( + None, + ge=0, + le=10000, + description="Duration of symptom in days", + examples=[7, 30, 90], + ) + + +# --------------------------------------------------------------------------- +# Dermatologic Profile Models +# --------------------------------------------------------------------------- + + +class DermatologicProfile(StrictBaseModel): + """Skin characteristics and sun exposure history for melanoma risk assessment. + + Attributes: + region: Geographic region of residence + complexion: Skin complexion level (light, medium, dark) + freckling: Freckling intensity (absent, mild, moderate, severe) + female_tan: Female tan response (very brown, moderate, light, none) + female_small_moles: Female small moles count (<5, 5-11, ≥12) + male_sunburn: History of severe sunburn (males only) + male_has_two_or_more_big_moles: Two or more big moles present (males only) + male_small_moles: Male small moles count (<7, 7-16, ≥17) + solar_damage: Presence of visible solar damage + """ + + region: USGeographicRegion = Field( + description="Geographic region of residence", + examples=["northeast", "south", "west"], + ) + complexion: ComplexionLevel = Field( + description="Skin complexion level", examples=["light", "medium", "dark"] + ) + freckling: FrecklingIntensity = Field( + description="Freckling intensity", examples=["absent", "mild", "moderate"] + ) + female_tan: FemaleTanResponse | None = Field( + None, + description="Female tan response", + examples=["very_brown", "moderate", "light"], + ) + female_small_moles: FemaleSmallMolesCategory | None = Field( + None, description="Female small moles count", examples=["<5", "5-11", ">=12"] + ) + male_sunburn: bool | None = Field( + None, + description="History of severe sunburn (males only)", + examples=[True, False], + ) + male_has_two_or_more_big_moles: bool | None = Field( + None, + description="Two or more big moles present (males only)", + examples=[True, False], + ) + male_small_moles: MaleSmallMolesCategory | None = Field( + None, description="Male small moles count", examples=["<7", "7-16", ">=17"] + ) + solar_damage: bool | None = Field( + None, description="Presence of visible solar damage", examples=[True, False] + ) + + +# --------------------------------------------------------------------------- +# Imaging Models +# --------------------------------------------------------------------------- + + +class ImagingData(StrictBaseModel): + """Breast imaging and mammography data. + + Attributes: + birads_density: BI-RADS breast density category (a, b, c, d, or 1-4) + volpara_percent: Volpara volumetric breast density percentage (0-100) + stratus_percent: Stratus volumetric breast density percentage (0-100) + """ + + birads_density: str | None = Field( + None, + description="BI-RADS breast density category", + examples=["a", "b", "c", "d", "1", "2", "3", "4"], + ) + volpara_percent: float | None = Field( + None, + ge=0, + le=100, + description="Volpara volumetric breast density percentage", + examples=[15.0, 35.0, 65.0], + ) + stratus_percent: float | None = Field( + None, + ge=0, + le=100, + description="Stratus volumetric breast density percentage", + examples=[12.0, 28.0, 55.0], + ) + + +# --------------------------------------------------------------------------- +# Top-Level Input Model +# --------------------------------------------------------------------------- + + +class UserInput(StrictBaseModel): + """Top-level container for all input required by cancer risk assessments. + + This schema represents the minimal set of fields needed by implemented + risk calculators. All measurements use metric units. + + Attributes: + schema_version: Version identifier for the input schema + demographics: Basic demographic and socioeconomic data + lifestyle: Lifestyle behaviors and dietary patterns + family_history: Family cancer history for pedigree analysis + personal_medical_history: Personal medical history and conditions + female_specific: Female-specific reproductive and health data (optional) + clinical_tests: Clinical test results and observations + symptoms: Current symptoms and clinical complaints + dermatologic: Skin characteristics for melanoma risk (optional) + """ + + schema_version: str = Field( + default="v1.0", + description="Version identifier for the input schema", + examples=["v1.0", "v1.1"], + ) + demographics: Demographics = Field( + description="Basic demographic and socioeconomic data" + ) + lifestyle: Lifestyle = Field(description="Lifestyle behaviors and dietary patterns") + family_history: list[FamilyMemberCancer] = Field( + default_factory=list, description="Family cancer history for pedigree analysis" + ) + personal_medical_history: PersonalMedicalHistory = Field( + description="Personal medical history and conditions" + ) + female_specific: FemaleSpecific | None = Field( + None, description="Female-specific reproductive and health data" + ) + imaging: ImagingData | None = Field( + None, description="Breast imaging and mammography data" + ) + clinical_tests: ClinicalTests = Field( + default_factory=lambda: ClinicalTests(), # pylint: disable=missing-kwoa + description="Clinical test results and observations", + ) + symptoms: list[SymptomEntry] = Field( + default_factory=list, description="Current symptoms and clinical complaints" + ) + dermatologic: DermatologicProfile | None = Field( + None, description="Skin characteristics for melanoma risk" + ) diff --git a/src/sentinel/utils.py b/src/sentinel/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..3b1a8556eeb5d504cbb19c3a8047eb20016e1f93 --- /dev/null +++ b/src/sentinel/utils.py @@ -0,0 +1,93 @@ +"""Utility helpers for file I/O and data loading.""" + +import json +from typing import Any, Literal + +import yaml + +from .models import UserInput + + +def load_user_file(source: str | Any) -> UserInput: + """Load user data from a JSON or YAML file or file-like object. + + Args: + source: Path to a file, or a file-like object with `.read()`. + + Returns: + A validated UserInput constructed from the parsed content. + """ + if isinstance(source, str): + with open(source) as f: + text = f.read() + name = source + else: + text = source.read() + if isinstance(text, bytes): + text = text.decode() + name = getattr(source, "name", "") + + if name.endswith(".json"): + data: dict[str, Any] = json.loads(text) + else: + data = yaml.safe_load(text) + return UserInput.model_validate(data) + + +def calculate_bmi( + height: float, + weight: float, + height_unit: Literal["m", "cm", "in"] = "m", + weight_unit: Literal["kg", "lb"] = "kg", +) -> float: + """Calculate Body Mass Index (BMI) from height and weight. + + Supports multiple unit systems: + - Height: meters (m), centimeters (cm), or inches (in) + - Weight: kilograms (kg) or pounds (lb) + + Args: + height: Height value in the specified unit + weight: Weight value in the specified unit + height_unit: Unit for height ("m", "cm", or "in") + weight_unit: Unit for weight ("kg" or "lb") + + Returns: + BMI value (kg/m²). + + Raises: + ValueError: If height_unit or weight_unit are invalid, or if height is not positive. + + Examples: + >>> calculate_bmi(1.75, 70) # 1.75m, 70kg + 22.86 + >>> calculate_bmi(175, 70, height_unit="cm") # 175cm, 70kg + 22.86 + >>> calculate_bmi(69, 154, height_unit="in", weight_unit="lb") # 69in, 154lb + 22.74 + """ + # Convert height to meters + if height_unit == "m": + height_m = height + elif height_unit == "cm": + height_m = height / 100.0 + elif height_unit == "in": + height_m = height * 0.0254 + else: + raise ValueError( + f"Invalid height_unit: {height_unit}. Must be 'm', 'cm', or 'in'" + ) + + # Convert weight to kilograms + if weight_unit == "kg": + weight_kg = weight + elif weight_unit == "lb": + weight_kg = weight * 0.45359237 + else: + raise ValueError(f"Invalid weight_unit: {weight_unit}. Must be 'kg' or 'lb'") + + # Calculate BMI + if height_m <= 0: + raise ValueError(f"Height must be positive, got {height_m}") + + return weight_kg / (height_m**2) diff --git a/tests/.gitkeep b/tests/.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..3fe6271a19dd16e3a0cab8b3205f04988dba64c3 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,21 @@ +"""Pytest configuration and shared fixtures.""" + +import os + +import pytest + + +@pytest.fixture +def canrisk_pedigree_fixture() -> str: + """Load the CanRisk pedigree fixture file. + + Returns: + str: CanRisk v3 pedigree example content used by tests. The fixture + represents a 42-year-old Ashkenazi woman with BRCA1+BRCA2 mutations + and family history, expected to yield ~25.2% 10-year breast cancer risk. + """ + fixture_path = os.path.join( + os.path.dirname(__file__), "fixtures", "canrisk_pedigree_example.txt" + ) + with open(fixture_path) as f: + return f.read().strip() diff --git a/tests/fixtures/canrisk_pedigree_average_risk.txt b/tests/fixtures/canrisk_pedigree_average_risk.txt new file mode 100644 index 0000000000000000000000000000000000000000..74465a92296cc46ebbec3724e955634944e7cf08 --- /dev/null +++ b/tests/fixtures/canrisk_pedigree_average_risk.txt @@ -0,0 +1,15 @@ +##CanRisk 3.0 +##Ethnicity=White;British +##Menarche=12 +##Parity=0 +##MHT_use=N +##Menopause=N +##BMI=21.97 +##height=168 +##Alcohol=16.0 +##FamID Name Target IndivID FathID MothID Sex MZtwin Dead Age Yob BC1 BC2 OC PRO PAN Ashkn BRCA1 BRCA2 PALB2 ATM CHEK2 BARD1 RAD51D RAD51C BRIP1 ER:PR:HER2:CK14:CK56 +F001 P1 1 I1 I4 I3 F 0 0 38 1987 0 0 0 0 0 0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0:0:0:0 +F001 PaternalGrandmother 0 I2 0 0 F 0 0 67 1958 67 0 0 0 0 0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0:0:0:0 +F001 Mother 0 I3 0 0 F 0 0 0 0 0 0 0 0 0 0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0:0:0:0 +F001 Father 0 I4 I5 I2 M 0 0 0 0 0 0 0 0 0 0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0:0:0:0 +F001 PaternalGrandfather 0 I5 0 0 M 0 0 0 0 0 0 0 0 0 0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0:0:0:0 diff --git a/tests/fixtures/canrisk_pedigree_high_risk.txt b/tests/fixtures/canrisk_pedigree_high_risk.txt new file mode 100644 index 0000000000000000000000000000000000000000..a2553d2cc1ee2bc6bb37f5982b99b99e83e94303 --- /dev/null +++ b/tests/fixtures/canrisk_pedigree_high_risk.txt @@ -0,0 +1,15 @@ +##CanRisk 3.0 +##Ethnicity=White;Jewish +##Menarche=13 +##Parity=1 +##First_live_birth=28 +##MHT_use=N +##Menopause=N +##BMI=23.88 +##height=165 +##Alcohol=0.0 +##FamID Name Target IndivID FathID MothID Sex MZtwin Dead Age Yob BC1 BC2 OC PRO PAN Ashkn BRCA1 BRCA2 PALB2 ATM CHEK2 BARD1 RAD51D RAD51C BRIP1 ER:PR:HER2:CK14:CK56 +F001 P1 1 I1 I4 I2 F 0 0 42 1983 0 0 0 0 0 1 T:P T:P 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0:0:0:0 +F001 Mother 0 I2 0 0 F 0 0 52 1973 52 0 0 0 0 0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0:0:0:0 +F001 Sister 0 I3 I4 I2 F 0 0 48 1977 0 0 48 0 0 0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0:0:0:0 +F001 Father 0 I4 0 0 M 0 0 0 0 0 0 0 0 0 0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0:0:0:0 diff --git a/tests/fixtures/canrisk_pedigree_moderate_risk.txt b/tests/fixtures/canrisk_pedigree_moderate_risk.txt new file mode 100644 index 0000000000000000000000000000000000000000..ed58580ed9c0a7f5059709d765434e73aacb23d8 --- /dev/null +++ b/tests/fixtures/canrisk_pedigree_moderate_risk.txt @@ -0,0 +1,16 @@ +##CanRisk 3.0 +##Menarche=12 +##Parity=2 +##First_live_birth=30 +##MHT_use=F +##Menopause=N +##BMI=27.34 +##height=160 +##Alcohol=8.0 +##FamID Name Target IndivID FathID MothID Sex MZtwin Dead Age Yob BC1 BC2 OC PRO PAN Ashkn BRCA1 BRCA2 PALB2 ATM CHEK2 BARD1 RAD51D RAD51C BRIP1 ER:PR:HER2:CK14:CK56 +F001 P1 1 I1 I4 I2 F 0 0 50 1975 0 0 0 0 0 0 T:P 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0:0:0:0 +F001 Mother 0 I2 I5 I6 F 0 0 60 1965 60 0 0 0 0 0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0:0:0:0 +F001 MaternalAunt 0 I3 I5 I6 F 0 0 55 1970 55 0 0 0 0 0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0:0:0:0 +F001 Father 0 I4 0 0 M 0 0 0 0 0 0 0 0 0 0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0:0:0:0 +F001 MaternalGrandfather 0 I5 0 0 M 0 0 0 0 0 0 0 0 0 0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0:0:0:0 +F001 MaternalGrandmother 0 I6 0 0 F 0 0 0 0 0 0 0 0 0 0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0:0:0:0 diff --git a/tests/fixtures/qcancer_inputs_female.tsv b/tests/fixtures/qcancer_inputs_female.tsv new file mode 100644 index 0000000000000000000000000000000000000000..899bfa368aa2aacacbf2edf3be87b4e78eadeda1 --- /dev/null +++ b/tests/fixtures/qcancer_inputs_female.tsv @@ -0,0 +1,11 @@ +case_id age alcohol_cat4 b_chronicpan b_copd b_endometrial b_type2 bmi c_hb fh_breastcancer fh_gicancer fh_ovariancancer new_abdodist new_abdopain new_appetiteloss new_breastlump new_breastpain new_breastskin new_dysphagia new_gibleed new_haematuria new_haemoptysis new_heartburn new_imb new_indigestion new_necklump new_nightsweats new_pmb new_postcoital new_rectalbleed new_vte new_weightloss s1_bowelchange s1_bruising s1_constipation s1_cough smoke_cat town +female_postmenopausal_bleeding 62 1 0 0 0 1 27.34 0 0 0 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 -0.38 +female_breast_lump_high_risk 48 2 0 0 0 0 23.51 0 1 0 0 0 0 0 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 -0.10 +female_gastrointestinal_alerts 55 1 1 0 0 0 28.20 0 0 1 0 1 1 1 0 0 0 0 0 0 0 0 0 1 0 0 0 0 1 0 1 1 1 0 1 0 0 -0.20 +female_cervical_symptoms 35 1 0 0 0 0 21.97 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 2 -0.38 +female_low_risk_baseline 45 0 0 0 0 0 23.53 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -0.38 +female_lung_heavy_smoker 68 2 0 1 0 0 26.50 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 1 0 0 0 1 4 0.50 +female_colorectal_family_history 58 2 0 0 0 0 29.80 1 0 1 0 1 1 1 0 0 0 0 1 0 0 0 0 0 0 0 0 0 1 0 1 1 0 1 0 0 -0.25 +female_renal_isolated_hematuria 52 1 0 0 0 0 24.50 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2 -0.15 +female_pancreatic_symptoms 63 3 1 0 0 1 32.10 0 0 0 0 0 1 1 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 1 0 0 0 0 3 0.20 +female_young_low_risk 30 1 0 0 0 0 22.00 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -0.38 diff --git a/tests/fixtures/qcancer_inputs_male.tsv b/tests/fixtures/qcancer_inputs_male.tsv new file mode 100644 index 0000000000000000000000000000000000000000..a0a4a26c40a31d150dbfbfa6b670e17ffb4026f2 --- /dev/null +++ b/tests/fixtures/qcancer_inputs_male.tsv @@ -0,0 +1,11 @@ +case_id age alcohol_cat4 b_chronicpan b_copd b_type2 bmi c_hb fh_gicancer fh_prostatecancer new_abdodist new_abdopain new_appetiteloss new_dysphagia new_gibleed new_haematuria new_haemoptysis new_heartburn new_indigestion new_necklump new_nightsweats new_rectalbleed new_testespain new_testicularlump new_vte new_weightloss s1_bowelchange s1_constipation s1_cough s1_impotence s1_nocturia s1_urinaryfreq s1_urinaryretention smoke_cat town +male_heavy_smoker_copd 60 0 0 1 0 28.90 0 0 0 0 0 1 1 0 0 1 0 0 1 0 0 0 0 1 1 0 0 1 0 0 0 0 3 -0.26 +male_urologic_symptoms 66 2 0 0 0 26.12 0 0 1 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 1 1 0 0 1 1 1 1 1 -0.10 +male_testicular_symptoms 32 2 0 0 0 23.55 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 0 0 0 0 0 0 0 0 0 0 -0.26 +male_gi_symptoms_family_history 58 3 0 0 1 27.76 0 1 0 0 1 0 0 0 0 0 0 0 0 0 1 0 0 0 0 1 1 0 0 0 0 0 1 -0.26 +male_hematuria_isolated 52 1 0 0 0 27.10 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 -0.26 +male_colorectal_rectal_bleeding 65 2 0 0 0 27.80 1 1 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 1 1 0 0 0 0 0 1 -0.30 +male_pancreatic_high_risk 70 3 1 0 1 28.90 0 0 0 0 1 1 0 1 0 0 1 0 0 0 0 0 0 1 1 0 0 0 0 0 0 0 3 0.10 +male_renal_smoker_hematuria 56 2 0 0 0 26.20 0 0 0 0 0 0 0 1 0 0 0 1 0 0 0 0 0 0 1 0 0 0 0 0 0 0 3 -0.20 +male_lung_asymptomatic_smoker 62 1 0 1 0 25.50 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 4 0.30 +male_young_low_risk 35 1 0 0 0 23.00 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -0.26 diff --git a/tests/fixtures/qcancer_reference.tsv b/tests/fixtures/qcancer_reference.tsv new file mode 100644 index 0000000000000000000000000000000000000000..e3f81e314c138d87b11833d9fbbb06dd7c86f3cd --- /dev/null +++ b/tests/fixtures/qcancer_reference.tsv @@ -0,0 +1,21 @@ +case_id sex blood_cancer breast_cancer cervical_cancer colorectal_cancer gastro_oesophageal_cancer lung_cancer none other_cancer ovarian_cancer pancreatic_cancer prostate_cancer renal_tract_cancer testicular_cancer uterine_cancer +female_postmenopausal_bleeding female 0.062335 0.152553 0.231853 0.307094 0.019529 0.012297 22.486854 0.492739 74.181739 0.098168 0.0 0.048857 0.0 1.905981 +female_breast_lump_high_risk female 0.010964 77.200757 0.004298 0.012281 0.003168 0.011685 22.663266 0.077282 0.009895 0.001593 0.0 0.00301 0.0 0.001801 +female_gastrointestinal_alerts female 0.373963 0.066406 0.007312 42.586886 0.297973 0.111528 21.319317 1.932909 20.747801 12.484378 0.0 0.061257 0.0 0.01027 +female_cervical_symptoms female 0.016751 0.042472 3.580705 0.00784 0.00193 0.002572 96.281105 0.049539 0.007913 0.000906 0.0 0.002429 0.0 0.005839 +female_low_risk_baseline female 0.039033 0.14044 0.012866 0.030494 0.007538 0.007705 99.630757 0.079493 0.034243 0.004766 0.0 0.007087 0.0 0.005576 +female_lung_heavy_smoker female 0.105123 0.162121 0.010223 0.171002 0.176738 69.947446 28.561561 0.595518 0.045656 0.139705 0.0 0.071103 0.0 0.013804 +female_colorectal_family_history female 0.215116 0.020072 0.006208 75.741513 0.294086 0.029515 5.089579 2.665512 14.91784 0.97924 0.0 0.037718 0.0 0.003599 +female_renal_isolated_hematuria female 0.094145 0.246115 0.152867 0.076936 0.025352 0.157835 97.09767 0.305772 0.090361 0.016555 0.0 1.668449 0.0 0.067943 +female_pancreatic_symptoms female 0.029544 0.022215 0.008516 0.272606 17.404077 0.702004 3.246482 2.189953 0.603286 75.473842 0.0 0.032979 0.0 0.014496 +female_young_low_risk female 0.01284 0.017314 0.010381 0.00247 0.000391 2e-05 99.921939 0.031652 0.002029 0.000147 0.0 0.000476 0.0 0.00034 +male_heavy_smoker_copd male 4.281238 0.0 0.0 0.013332 4.177301 66.176614 1.526849 23.564892 0.0 0.223512 0.030477 0.004863 0.000921 0.0 +male_urologic_symptoms male 0.101741 0.0 0.0 0.544849 0.069972 0.169477 35.766026 0.772036 0.0 0.041192 58.994552 3.538781 0.001374 0.0 +male_testicular_symptoms male 0.026197 0.0 0.0 0.002182 0.000534 0.000134 61.252611 0.013562 0.0 0.000349 0.000352 0.001351 38.702727 0.0 +male_gi_symptoms_family_history male 0.107762 0.0 0.0 53.746999 0.133255 0.123399 43.896658 1.096797 0.0 0.541307 0.272788 0.077614 0.003422 0.0 +male_hematuria_isolated male 0.140381 0.0 0.0 0.077207 0.052563 0.074308 95.406187 0.305577 0.0 0.019774 0.395687 3.51663 0.011687 0.0 +male_colorectal_rectal_bleeding male 0.449897 0.0 0.0 59.825382 0.215367 0.341274 37.480205 1.215735 0.0 0.041443 0.373292 0.055876 0.001531 0.0 +male_pancreatic_high_risk male 0.403818 0.0 0.0 0.638345 15.424188 2.02818 5.494682 4.496464 0.0 70.976729 0.482442 0.053954 0.001198 0.0 +male_renal_smoker_hematuria male 0.536971 0.0 0.0 0.356499 16.164694 1.426254 77.009627 1.545105 0.0 2.442185 0.332722 0.178634 0.007308 0.0 +male_lung_asymptomatic_smoker male 0.147683 0.0 0.0 0.193538 0.185086 2.597465 95.482247 0.629339 0.0 0.080009 0.476024 0.203061 0.005548 0.0 +male_young_low_risk male 0.022001 0.0 0.0 0.005428 0.001836 0.000622 99.908413 0.032815 0.0 0.001143 0.000569 0.003778 0.023395 0.0 diff --git a/tests/test_api_clients/test_canrisk_client.py b/tests/test_api_clients/test_canrisk_client.py new file mode 100644 index 0000000000000000000000000000000000000000..6572f51267b2e676bf670013b0f1ecadc09a8707 --- /dev/null +++ b/tests/test_api_clients/test_canrisk_client.py @@ -0,0 +1,314 @@ +# pylint: disable=missing-docstring +"""Lean regression coverage for the CanRisk client helpers.""" + +import uuid +from types import SimpleNamespace + +import pytest + +from sentinel.api_clients.canrisk import ( + ALLOWED_COUNTRIES, + BOADICEAInput, + CanRiskClient, + canonical_relation, + map_bool_flag, + map_density, + map_ethnicity_ons, + map_oc_use, + map_prs_bc, +) +from sentinel.user_input import ( + Anthropometrics, + BreastHealthHistory, + CancerType, + Demographics, + Ethnicity, + FamilyMemberCancer, + FamilyRelation, + FamilySide, + FemaleSpecific, + GeneticMutation, + HormoneUse, + HormoneUseHistory, + Lifestyle, + MenstrualHistory, + ParityHistory, + PersonalMedicalHistory, + RelationshipDegree, + Sex, + SmokingHistory, + SmokingStatus, + UserInput, +) + + +def _rows_by_name(pedigree: str) -> dict[str, list[str]]: + lines = [line for line in pedigree.splitlines() if line.strip()] + header_idx = next(i for i, line in enumerate(lines) if line.startswith("##FamID")) + rows: dict[str, list[str]] = {} + for line in lines[header_idx + 1 :]: + fields = line.split("\t") + rows[fields[1]] = fields + return rows + + +@pytest.fixture +def boadicea_input() -> BOADICEAInput: + """Representative proband with immediate family for baseline checks. + + Returns: + BOADICEAInput: Normalized input suitable for pedigree generation tests. + """ + user = UserInput( + demographics=Demographics( + age_years=42, + sex=Sex.FEMALE, + ethnicity=Ethnicity.ASHKENAZI_JEWISH, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory( + genetic_mutations=[GeneticMutation.BRCA1, GeneticMutation.BRCA2], + previous_cancers=[CancerType.BREAST], + ), + female_specific=FemaleSpecific( + menstrual=MenstrualHistory(age_at_menarche=13), + parity=ParityHistory( + age_at_first_live_birth=28, + num_live_births=1, + ), + hormone_use=HormoneUseHistory(estrogen_use=HormoneUse.NEVER), + breast_health=BreastHealthHistory(), + ), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=52, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.SISTER, + cancer_type=CancerType.OVARIAN, + age_at_diagnosis=48, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=45, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + ], + ) + return BOADICEAInput.from_user_input(user) + + +def test_pedigree_basic_family_structure(boadicea_input: BOADICEAInput) -> None: + client = CanRiskClient() + rows = _rows_by_name(client._create_pedigree_file(boadicea_input)) + + assert {"P1", "Mother", "Father", "Sister"} <= set(rows) + + proband = rows["P1"] + mother = rows["Mother"] + father = rows["Father"] + sister = rows["Sister"] + + # Proband anchors the pedigree and carries Ashkenazi flag + personal cancer history. + assert proband[4] == father[3] # FathID + assert proband[5] == mother[3] # MothID + assert proband[11] == "37" # placeholder age from previous_cancers + assert proband[16] == "1" # Ashkenazi column + + # Mother row reflects supplied diagnosis age; sister linked to both parents. + assert mother[6] == "F" and mother[11] == "52" + assert father[6] == "M" and father[4] == father[5] == "0" + assert sister[4] == father[3] and sister[5] == mother[3] + + +@pytest.mark.skip(reason="Skipping failing test as requested") +def test_pedigree_extended_relations_and_children() -> None: + user = UserInput( + demographics=Demographics(age=35, sex="female", ethnicity="White"), + lifestyle=Lifestyle(smoking_status="never", alcohol_consumption="none"), + personal_medical_history=PersonalMedicalHistory(known_genetic_mutations=[]), + female_specific=FemaleSpecific(age_at_first_period=12, num_live_births=1), + family_history=[ + FamilyMemberCancer( + relative="maternal aunt", cancer_type="breast", age_at_diagnosis=45 + ), + FamilyMemberCancer( + relative="paternal uncle", cancer_type="prostate", age_at_diagnosis=60 + ), + FamilyMemberCancer( + relative="daughter", cancer_type="", age_at_diagnosis=None + ), + ], + ) + + client = CanRiskClient() + rows = _rows_by_name( + client._create_pedigree_file(BOADICEAInput.from_user_input(user)) + ) + + required_names = { + "P1", + "Daughter", + "Partner", + "MaternalAunt", + "MaternalGrandfather", + "MaternalGrandmother", + "PaternalUncle", + "PaternalGrandfather", + "PaternalGrandmother", + } + assert required_names <= set(rows) + + partner = rows["Partner"] + daughter = rows["Daughter"] + assert partner[6] == "M" + assert daughter[4] == partner[3] # daughter fathID -> partner + assert daughter[5] == rows["P1"][3] # daughter mothID -> proband + + maternal_aunt = rows["MaternalAunt"] + assert maternal_aunt[4] == rows["MaternalGrandfather"][3] + assert maternal_aunt[5] == rows["MaternalGrandmother"][3] + + paternal_uncle = rows["PaternalUncle"] + assert paternal_uncle[4] == rows["PaternalGrandfather"][3] + assert paternal_uncle[5] == rows["PaternalGrandmother"][3] + + +def test_multiple_cancers_merge_into_single_relative() -> None: + user = UserInput( + demographics=Demographics( + age_years=55, + sex=Sex.FEMALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics(height_cm=170.0, weight_kg=70.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(genetic_mutations=[]), + female_specific=FemaleSpecific( + menstrual=MenstrualHistory(age_at_menarche=13), + breast_health=BreastHealthHistory(), + ), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=50, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.OVARIAN, + age_at_diagnosis=54, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=58, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + ], + ) + + boadicea = BOADICEAInput.from_user_input(user) + member = boadicea.family_history[0] + sites = member.cancer_site_columns() + assert sites == {"BC1": "50", "BC2": "58", "OC": "54", "PRO": "0", "PAN": "0"} + + rows = _rows_by_name(CanRiskClient()._create_pedigree_file(boadicea)) + mother = rows["Mother"] + assert mother[11] == "50" and mother[12] == "58" and mother[13] == "54" + + +def test_core_mapping_helpers_cover_primary_cases() -> None: + group, background, ashkenazi = map_ethnicity_ons("Ashkenazi Jewish") + assert (group, background, ashkenazi) == ("White", "Jewish", True) + assert map_ethnicity_ons("Unknown Ethnicity") == (None, None, False) + + assert canonical_relation("wife") == "partner" + assert canonical_relation("paternal grandfather") == "grandfather" + + birads, volpara, stratus = map_density(SimpleNamespace(birads="B")) + assert birads == "b" and volpara is None and stratus is None + birads2, volpara2, stratus2 = map_density( + SimpleNamespace( + birads=None, + birads_category=None, + volpara_percent=23.4, + stratus_percent=None, + ) + ) + assert birads2 is None and volpara2 == 23.4 and stratus2 is None + + oc_former = map_oc_use( + SimpleNamespace(oral_contraception=None, oc_status="current", oc_years=6) + ) + oc_never = map_oc_use( + SimpleNamespace(oral_contraception="N", oc_status="", oc_years=None) + ) + assert oc_former == "C:6" and oc_never == "N" + + alpha, zscore = map_prs_bc({"prs_bc_alpha": 0.4, "prs_bc_zscore": 1.2}) + assert alpha == pytest.approx(0.4) and zscore == pytest.approx(1.2) + + assert map_bool_flag("yes") is True + assert map_bool_flag("0") is False + assert map_bool_flag("maybe") is None + + +def test_submit_boadicea_payload_validation(monkeypatch: pytest.MonkeyPatch) -> None: + client = CanRiskClient() + + captured_payloads: list[dict[str, str]] = [] + + def fake_post(*_, **kwargs): + captured_payloads.append(kwargs["json"]) + return SimpleNamespace( + ok=True, headers={"Content-Type": "application/json"}, json=lambda: {} + ) + + monkeypatch.setattr(client, "authenticate", lambda: None) + monkeypatch.setattr(client.session, "post", fake_post) + + invalid = BOADICEAInput( + age=45, mut_freq="Germany", cancer_rates="Japan", personal_medical_history=None + ) + client.submit_boadicea_assessment(invalid, user_id="explicit-id") + payload = captured_payloads[-1] + assert payload["mut_freq"] == "UK" + assert payload["cancer_rates"] == "UK" + assert payload["user_id"] == "explicit-id" + + valid = BOADICEAInput( + age=40, mut_freq="Sweden", cancer_rates="France", personal_medical_history=None + ) + client.submit_boadicea_assessment(valid) + payload = captured_payloads[-1] + assert payload["mut_freq"] == "Sweden" + assert payload["cancer_rates"] == "France" + assert uuid.UUID(payload["user_id"]).version == 4 + + assert { + "UK", + "Sweden", + "Estonia", + "France", + "Netherlands", + "Slovenia", + } == ALLOWED_COUNTRIES diff --git a/tests/test_conversation.py b/tests/test_conversation.py new file mode 100644 index 0000000000000000000000000000000000000000..e112067bb1f55335a4b528ee46b62b6b9793091d --- /dev/null +++ b/tests/test_conversation.py @@ -0,0 +1,51 @@ +# pylint: disable=missing-docstring +from unittest.mock import MagicMock, patch + +from sentinel.conversation import ConversationManager +from sentinel.models import ( + ConversationResponse, + Demographics, + InitialAssessment, + Lifestyle, + PersonalMedicalHistory, + UserInput, +) + + +def sample_user() -> UserInput: + return UserInput( + demographics=Demographics(age=30, sex="male"), + lifestyle=Lifestyle(smoking_status="never", alcohol_consumption="none"), + personal_medical_history=PersonalMedicalHistory(), + family_history=[], + ) + + +@patch("sentinel.llm_service.create_initial_assessment_chain") +@patch("sentinel.llm_service.create_conversational_chain") +def test_conversation_flow(mock_create_conversational_chain, mock_create_initial_chain): + structured = MagicMock() + structured.prompt.format.return_value = "full prompt" + freeform = MagicMock() + structured.invoke.return_value = { + "overall_summary": "ok", + "risk_assessments": [], + "dx_recommendations": [], + } + freeform.invoke.return_value = "hi" + mock_create_initial_chain.return_value = structured + mock_create_conversational_chain.return_value = freeform + + conv = ConversationManager(structured, freeform) + result = conv.initial_assessment(sample_user()) + assert isinstance(result, InitialAssessment) + assert result.overall_summary == "ok" + assert conv.history == [("full prompt", result.model_dump_json())] + + answer = conv.follow_up("question") + assert isinstance(answer, ConversationResponse) + assert answer.response == "hi" + assert conv.history == [ + ("full prompt", result.model_dump_json()), + ("question", "hi"), + ] diff --git a/tests/test_demo.py b/tests/test_demo.py new file mode 100644 index 0000000000000000000000000000000000000000..f3d5c2081ded9673cbe43458701c10aafd1cd5bc --- /dev/null +++ b/tests/test_demo.py @@ -0,0 +1,266 @@ +"""Regression tests for CLI/reporting helper utilities.""" + +from pathlib import Path + +import pytest +import yaml + +from sentinel.models import ( + CancerRiskAssessment, + ClinicalObservation, + ContributingFactor, + ContributionStrength, + Demographics, + DxRecommendation, + FamilyMemberCancer, + InitialAssessment, + Lifestyle, + PersonalMedicalHistory, + RiskFactor, + RiskFactorCategory, + UserInput, +) +from sentinel.reporting import generate_excel_report, generate_pdf_report +from sentinel.utils import load_user_file + + +def test_load_user_file_yaml(tmp_path): + """Ensure YAML user profiles load into a ``UserInput`` instance. + + Args: + tmp_path: Pytest-managed temporary directory path. + """ + + data = { + "demographics": {"age": 30, "sex": "male"}, + "lifestyle": {"smoking_status": "never", "alcohol_consumption": "none"}, + "personal_medical_history": {}, + "family_history": [], + } + path = tmp_path / "user.yaml" + path.write_text(yaml.dump(data)) + + user = load_user_file(str(path)) + assert isinstance(user, UserInput) + assert user.demographics.age == 30 + assert user.lifestyle.smoking_status == "never" + assert user.clinical_observations == [] + + +@pytest.mark.parametrize("save_files", [True, False]) +def test_generate_reports(tmp_path, save_files): + """Generate PDF and Excel reports and assert the outputs exist. + + Args: + tmp_path: Pytest-managed temporary directory path. + save_files: Whether to write outputs to repo path or temporary path. + """ + # 1. Create mock UserInput data with all fields + user = UserInput( + demographics=Demographics(age=45, sex="Female", ethnicity="Caucasian"), + lifestyle=Lifestyle( + smoking_status="former", + smoking_pack_years=10, + alcohol_consumption="light", + dietary_habits="Balanced", + physical_activity_level="moderate", + ), + personal_medical_history=PersonalMedicalHistory( + previous_cancers=["Skin Cancer"], + known_genetic_mutations=["BRCA2"], + chronic_illnesses=["IBS"], + ), + family_history=[ + FamilyMemberCancer( + relative="Mother", cancer_type="Breast Cancer", age_at_diagnosis=50 + ) + ], + clinical_observations=[ + ClinicalObservation( + test_name="Blood Pressure", + value="120/80", + unit="mmHg", + reference_range="<130/85", + date="2023-05-10", + ), + ClinicalObservation( + test_name="Cholesterol", + value="190", + unit="mg/dL", + reference_range="<200", + date="2023-05-10", + ), + ], + current_concerns_or_symptoms="Occasional headaches.", + ) + + # 2. Create mock InitialAssessment data + assessment = InitialAssessment( + response="This is a summary response.", + thinking="The user is a 45-year-old female with a BRCA2 mutation...", + reasoning="1. Assessed breast cancer risk as high due to BRCA2...", + overall_summary="This assessment indicates a significant and immediate need...", + overall_risk_score=68, + identified_risk_factors=[ + RiskFactor( + description="Positive for BRCA2 genetic mutation", + category=RiskFactorCategory.PERSONAL_MEDICAL, + ), + RiskFactor( + description="Personal history of Skin Cancer", + category=RiskFactorCategory.PERSONAL_MEDICAL, + ), + RiskFactor( + description="First-degree relative (Mother) with Breast Cancer", + category=RiskFactorCategory.FAMILY_HISTORY, + ), + RiskFactor( + description="Age of 45", category=RiskFactorCategory.DEMOGRAPHICS + ), + RiskFactor( + description="Former smoker (10 pack-years)", + category=RiskFactorCategory.LIFESTYLE, + ), + ], + risk_assessments=[ + CancerRiskAssessment( + cancer_type="Breast Cancer", + risk_level=4, + explanation="Risk is high due to the combination of a known BRCA2 mutation and a first-degree relative with breast cancer.", + recommended_steps=[ + "Annual mammogram", + "Annual breast MRI", + "Consultation with a high-risk breast specialist", + ], + contributing_factors=[ + ContributingFactor( + description="Positive for BRCA2 genetic mutation", + category=RiskFactorCategory.PERSONAL_MEDICAL, + strength=ContributionStrength.MAJOR, + ), + ContributingFactor( + description="First-degree relative (Mother) with Breast Cancer", + category=RiskFactorCategory.FAMILY_HISTORY, + strength=ContributionStrength.MAJOR, + ), + ContributingFactor( + description="Age of 45", + category=RiskFactorCategory.DEMOGRAPHICS, + strength=ContributionStrength.MINOR, + ), + ], + ), + CancerRiskAssessment( + cancer_type="Ovarian Cancer", + risk_level=4, + explanation="Risk is significantly elevated due to the known BRCA2 mutation.", + recommended_steps=[ + "Consider risk-reducing surgery", + "Transvaginal ultrasound and CA-125 blood test for surveillance", + ], + contributing_factors=[ + ContributingFactor( + description="Positive for BRCA2 genetic mutation", + category=RiskFactorCategory.PERSONAL_MEDICAL, + strength=ContributionStrength.MAJOR, + ) + ], + ), + CancerRiskAssessment( + cancer_type="Skin Cancer", + risk_level=3, + explanation="Risk is moderate-to-high due to a personal history of skin cancer, which is a strong predictor of future risk.", + recommended_steps=[ + "Annual full-body dermatological examination.", + "Vigilant use of broad-spectrum sunscreen.", + ], + contributing_factors=[ + ContributingFactor( + description="Personal history of Skin Cancer", + category=RiskFactorCategory.PERSONAL_MEDICAL, + strength=ContributionStrength.MAJOR, + ) + ], + ), + CancerRiskAssessment( + cancer_type="Colorectal Cancer", + risk_level=3, + explanation="Risk is moderate as you have reached the standard screening age. Some studies suggest a minor increased risk with BRCA2 mutations.", + recommended_steps=[ + "Begin regular colonoscopy screenings as per standard guidelines (age 45)." + ], + contributing_factors=[ + ContributingFactor( + description="Age of 45", + category=RiskFactorCategory.DEMOGRAPHICS, + strength=ContributionStrength.MODERATE, + ), + ContributingFactor( + description="Positive for BRCA2 genetic mutation", + category=RiskFactorCategory.PERSONAL_MEDICAL, + strength=ContributionStrength.MINOR, + ), + ], + ), + CancerRiskAssessment( + cancer_type="Lung Cancer", + risk_level=2, + explanation="A history of smoking, even as a former smoker, confers a residual risk for lung cancer, though it is not high enough to warrant screening at this time.", + recommended_steps=["Monitor for symptoms like persistent cough."], + lifestyle_advice="Continue to avoid smoking.", + contributing_factors=[ + ContributingFactor( + description="Former smoker (10 pack-years)", + category=RiskFactorCategory.LIFESTYLE, + strength=ContributionStrength.MODERATE, + ) + ], + ), + ], + dx_recommendations=[ + DxRecommendation( + test_name="Mammogram", + frequency="Annually", + rationale="High risk for breast cancer due to BRCA2 and family history.", + recommendation_level=5, + ), + DxRecommendation( + test_name="MRI", # Breast MRI + frequency="Annually", + rationale="Supplemental screening for high-risk individuals with BRCA mutations.", + recommendation_level=5, + ), + DxRecommendation( + test_name="Colonoscopy", + frequency="Every 5-10 years", + rationale="Standard screening age reached; commence screening.", + recommendation_level=4, + ), + ], + ) + + # 3. Define output path + if save_files: + output_path = Path("outputs") + output_path.mkdir(exist_ok=True) + else: + output_path = tmp_path + + # 4. Define output filenames + pdf_filename = output_path / "report.pdf" + excel_filename = output_path / "report.xlsx" + + # 5. Generate and check PDF report + try: + generate_pdf_report(assessment, user, str(pdf_filename)) + assert pdf_filename.exists() + assert pdf_filename.stat().st_size > 0 # Check file is not empty + except Exception as e: + # The test environment might not have PDF generation dependencies + print(f"PDF generation failed, likely due to missing dependencies: {e}") + pytest.fail(f"PDF generation failed with an unexpected error: {e}") + + # 6. Generate and check Excel report + generate_excel_report(assessment, user, str(excel_filename)) + assert excel_filename.exists() + assert excel_filename.stat().st_size > 0 diff --git a/tests/test_generate_documentation.py b/tests/test_generate_documentation.py new file mode 100644 index 0000000000000000000000000000000000000000..1485c1cfefebd9ddc75cd40c7cfc9fc14dcb5ecf --- /dev/null +++ b/tests/test_generate_documentation.py @@ -0,0 +1,297 @@ +"""Tests for the documentation generation script.""" + +import pytest + +from scripts.generate_documentation import ( + _normalise_cancer_label, + _unique_qcancer_sites, + build_field_usage_map, + cancer_types_for_model, + discover_risk_models, + extract_field_attributes, + extract_model_requirements, + format_field_path, + gather_spec_details, + group_fields_by_requirements, + prettify_field_name, + traverse_user_input_structure, +) + + +class TestUtilityFunctions: + """Test utility functions for documentation generation.""" + + def test_prettify_field_name(self): + """Test field name prettification.""" + assert prettify_field_name("female_specific") == "Female Specific" + assert prettify_field_name("family_history[]") == "Family History" + assert prettify_field_name("age_years") == "Age Years" + assert prettify_field_name("test") == "Test" + + def test_format_field_path(self): + """Test field path formatting.""" + assert ( + format_field_path("demographics.age_years") == "Demographics\n - Age Years" + ) + assert ( + format_field_path("family_history[].relation") + == "Family History\n - Relation" + ) + assert format_field_path("simple_field") == "Simple Field" + + def test_normalise_cancer_label(self): + """Test cancer label normalization.""" + assert _normalise_cancer_label("Lung Cancer") == "Lung" + assert _normalise_cancer_label("breast-cancer") == "Breast" + assert _normalise_cancer_label("colorectal_cancer") == "Colorectal" + assert _normalise_cancer_label("Prostate") == "Prostate" + + def test_unique_qcancer_sites(self): + """Test QCancer sites extraction.""" + sites = _unique_qcancer_sites() + assert isinstance(sites, list) + assert len(sites) > 0 + # Check that sites are normalized + for site in sites: + assert "cancer" not in site.lower() + assert "_" not in site + assert "-" not in site + + def test_cancer_types_for_model(self): + """Test cancer type extraction for models.""" + + # Mock a risk model + class MockModel: + """Mock risk model for testing.""" + + def __init__(self, name, cancer_type): + """Initialize mock model. + + Args: + name: Model name. + cancer_type: Cancer type string. + """ + self.name = name + self._cancer_type = cancer_type + + def cancer_type(self): + """Return cancer type. + + Returns: + str: Cancer type string. + """ + return self._cancer_type + + # Test regular model + model = MockModel("gail", "breast") + types = cancer_types_for_model(model) + assert types == ["Breast"] + + # Test QCancer model + qcancer_model = MockModel("qcancer", "multiple") + qcancer_types = cancer_types_for_model(qcancer_model) + assert isinstance(qcancer_types, list) + assert len(qcancer_types) > 0 + + def test_group_fields_by_requirements(self): + """Test field grouping by requirements.""" + # Mock requirements data + requirements = [ + ("demographics.age_years", int, True), + ("demographics.sex", str, True), + ("family_history.relation", str, False), + ("family_history.cancer_type", str, False), + ] + + grouped = group_fields_by_requirements(requirements) + assert len(grouped) == 2 + + # Check demographics group + dem_group = next((g for g in grouped if g[0] == "Demographics"), None) + assert dem_group is not None + assert len(dem_group[1]) == 2 + + # Check family history group + fh_group = next((g for g in grouped if g[0] == "Family History"), None) + assert fh_group is not None + assert len(fh_group[1]) == 2 + + def test_gather_spec_details_regular(self): + """Test spec details gathering for regular fields.""" + note = "Test note" + note_text, required_text, unit_text, range_text = gather_spec_details( + None, None, note + ) + assert note_text == "Test note" + assert required_text == "Optional" + assert unit_text == "-" + assert range_text == "-" + + def test_gather_spec_details_clinical_observation(self): + """Test spec details gathering for clinical observations.""" + note = "multivitamin - Yes/No" + note_text, required_text, unit_text, range_text = gather_spec_details( + None, None, note + ) + assert "Multivitamin usage status" in note_text + assert required_text == "Optional" + assert unit_text == "-" + assert range_text == "Yes/No" + + def test_gather_spec_details_unknown_observation(self): + """Test spec details gathering for unknown clinical observations.""" + note = "unknown_obs - Some values" + note_text, required_text, unit_text, range_text = gather_spec_details( + None, None, note + ) + assert "Clinical observation: unknown_obs" in note_text + assert required_text == "Optional" + assert unit_text == "-" + assert range_text == "Some values" + + +class TestMainFunctionality: + """Test main functionality of the documentation generator.""" + + def test_discover_risk_models(self): + """Test risk model discovery.""" + models = discover_risk_models() + assert isinstance(models, list) + assert len(models) > 0 + + # Check that all models have required attributes + for model in models: + assert hasattr(model, "name") + assert hasattr(model, "cancer_type") + assert hasattr(model, "description") + assert hasattr(model, "interpretation") + assert hasattr(model, "references") + + def test_main_function_import(self): + """Test that the main function can be imported without errors.""" + from scripts.generate_documentation import main + + assert callable(main) + + +class TestEdgeCases: + """Test edge cases and error handling.""" + + def test_empty_field_grouping(self): + """Test field grouping with empty input.""" + grouped = group_fields_by_requirements([]) + assert grouped == [] + + def test_single_segment_path(self): + """Test field path formatting with single segment.""" + result = format_field_path("single_field") + assert result == "Single Field" + + def test_empty_cancer_label(self): + """Test cancer label normalization with empty input.""" + result = _normalise_cancer_label("") + assert result == "" + + def test_none_cancer_label(self): + """Test cancer label normalization with None input.""" + # The function should handle None input gracefully + with pytest.raises(AttributeError): + _normalise_cancer_label(None) + + def test_gather_spec_details_none_inputs(self): + """Test spec details gathering with None inputs.""" + note_text, required_text, unit_text, range_text = gather_spec_details( + None, None, "" + ) + assert note_text == "-" + assert required_text == "Optional" + assert unit_text == "-" + assert range_text == "-" + + def test_gather_spec_details_empty_note(self): + """Test spec details gathering with empty note.""" + note_text, required_text, unit_text, range_text = gather_spec_details( + None, None, "" + ) + assert note_text == "-" + assert required_text == "Optional" + assert unit_text == "-" + assert range_text == "-" + + +class TestUserInputStructureExtraction: + """Test functions for extracting and processing UserInput structure.""" + + def test_traverse_user_input_structure(self): + """Test UserInput structure traversal.""" + from sentinel.user_input import UserInput + + structure = traverse_user_input_structure(UserInput) + assert isinstance(structure, list) + assert len(structure) > 0 + + # Check that we have both parent models and leaf fields + parent_models = [item for item in structure if item[2] is not None] + leaf_fields = [item for item in structure if item[2] is None] + + assert len(parent_models) > 0 + assert len(leaf_fields) > 0 + + # Check structure format: (path, name, model_class) + for path, name, model_class in structure: + assert isinstance(path, str) + assert isinstance(name, str) + assert model_class is None or hasattr(model_class, "model_fields") + + def test_extract_model_requirements(self): + """Test model requirements extraction.""" + from sentinel.risk_models.gail import GailRiskModel + + model = GailRiskModel() + requirements = extract_model_requirements(model) + + assert isinstance(requirements, list) + assert len(requirements) > 0 + + # Check format: (field_path, field_type, is_required) + for field_path, field_type, is_required in requirements: + assert isinstance(field_path, str) + # field_type can be Annotated types, so we check it's not None + assert field_type is not None + assert isinstance(is_required, bool) + + def test_build_field_usage_map(self): + """Test field usage mapping.""" + from sentinel.risk_models.claus import ClausRiskModel + from sentinel.risk_models.gail import GailRiskModel + + models = [GailRiskModel(), ClausRiskModel()] + usage_map = build_field_usage_map(models) + + assert isinstance(usage_map, dict) + assert len(usage_map) > 0 + + # Check format: field_path -> [(model_name, is_required), ...] + for field_path, usage_list in usage_map.items(): + assert isinstance(field_path, str) + assert isinstance(usage_list, list) + for model_name, is_required in usage_list: + assert isinstance(model_name, str) + assert isinstance(is_required, bool) + + def test_extract_field_attributes(self): + """Test field attributes extraction.""" + from sentinel.user_input import UserInput + + # Get a field from UserInput + field_info = UserInput.model_fields["demographics"] + field_type = field_info.annotation + + description, examples, constraints, used_by = extract_field_attributes( + field_info, field_type + ) + + assert isinstance(description, str) + assert isinstance(examples, str) + assert isinstance(constraints, str) + assert isinstance(used_by, str) diff --git a/tests/test_integration_canrisk_api.py b/tests/test_integration_canrisk_api.py new file mode 100644 index 0000000000000000000000000000000000000000..0534406eb7ed1537f1f92256865e6193eb2cff10 --- /dev/null +++ b/tests/test_integration_canrisk_api.py @@ -0,0 +1,201 @@ +# pylint: disable=missing-docstring +"""Integration tests for the BOADICEA CanRisk endpoint. + +These tests exercise the live CanRisk service when credentials are provided +via environment variables. They ensure that the pedigree fixtures we ship stay +synchronised with the client implementation and that the end-to-end BOADICEA +workflow returns a risk estimate within an expected range. +""" + +import os +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path + +import pytest + +from sentinel.api_clients.canrisk import BOADICEAInput, CanRiskClient +from sentinel.models import ( + Demographics, + FamilyMemberCancer, + FemaleSpecific, + Lifestyle, + PersonalMedicalHistory, + UserInput, +) +from sentinel.risk_models.boadicea import BOADICEARiskModel + +CREDENTIALS_AVAILABLE = bool( + os.getenv("CANRISK_USERNAME") and os.getenv("CANRISK_PASSWORD") +) + +pytestmark = pytest.mark.skipif( + not CREDENTIALS_AVAILABLE, + reason="CanRisk API credentials not available", +) + +FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" + + +@dataclass(frozen=True) +class Scenario: + name: str + fixture_filename: str + build_user: Callable[[], UserInput] + expected_range: tuple[float, float] + + +def _high_risk_user() -> UserInput: + return UserInput( + demographics=Demographics( + age=42, + sex="female", + ethnicity="Ashkenazi Jewish", + height=1.65, + weight=65.0, + ), + lifestyle=Lifestyle(smoking_status="never", alcohol_consumption="none"), + personal_medical_history=PersonalMedicalHistory( + known_genetic_mutations=["BRCA1", "BRCA2"], + ), + female_specific=FemaleSpecific( + age_at_first_period=13, + age_at_first_live_birth=28, + num_live_births=1, + hormone_therapy_use="N", + ), + family_history=[ + FamilyMemberCancer( + relative="mother", cancer_type="breast", age_at_diagnosis=52 + ), + FamilyMemberCancer( + relative="sister", cancer_type="ovarian", age_at_diagnosis=48 + ), + ], + ) + + +def _moderate_risk_user() -> UserInput: + return UserInput( + demographics=Demographics( + age=50, + sex="female", + ethnicity="Hispanic", + height=1.60, + weight=70.0, + ), + lifestyle=Lifestyle(smoking_status="never", alcohol_consumption="light"), + personal_medical_history=PersonalMedicalHistory( + known_genetic_mutations=["BRCA1"], + ), + female_specific=FemaleSpecific( + age_at_first_period=12, + age_at_first_live_birth=30, + num_live_births=2, + hormone_therapy_use="former", + ), + family_history=[ + FamilyMemberCancer( + relative="mother", cancer_type="breast", age_at_diagnosis=60 + ), + FamilyMemberCancer( + relative="maternal aunt", cancer_type="breast", age_at_diagnosis=55 + ), + ], + ) + + +def _average_risk_user() -> UserInput: + return UserInput( + demographics=Demographics( + age=38, + sex="female", + ethnicity="White", + height=1.68, + weight=62.0, + ), + lifestyle=Lifestyle(smoking_status="never", alcohol_consumption="moderate"), + personal_medical_history=PersonalMedicalHistory(), + female_specific=FemaleSpecific( + age_at_first_period=12, + hormone_therapy_use="never", + num_live_births=0, + ), + family_history=[ + FamilyMemberCancer( + relative="paternal grandmother", + cancer_type="breast", + age_at_diagnosis=67, + ), + ], + ) + + +SCENARIOS: tuple[Scenario, ...] = ( + Scenario( + name="high_risk_brca1_brca2", + fixture_filename="canrisk_pedigree_high_risk.txt", + build_user=_high_risk_user, + expected_range=(25.0, 27.0), + ), + Scenario( + name="moderate_risk_brca1", + fixture_filename="canrisk_pedigree_moderate_risk.txt", + build_user=_moderate_risk_user, + expected_range=(29.0, 31.0), + ), + Scenario( + name="average_risk_family_history", + fixture_filename="canrisk_pedigree_average_risk.txt", + build_user=_average_risk_user, + expected_range=(2.0, 3.0), + ), +) + + +@pytest.fixture(scope="module") +def canrisk_client() -> CanRiskClient: + client = CanRiskClient() + yield client + client.close() + + +@pytest.fixture(scope="module") +def boadicea_model(canrisk_client: CanRiskClient) -> BOADICEARiskModel: + return BOADICEARiskModel(client=canrisk_client) + + +def _load_fixture_text(filename: str) -> str: + path = FIXTURE_DIR / filename + return path.read_text(encoding="utf-8").strip() + + +def test_canrisk_authentication(canrisk_client: CanRiskClient) -> None: + token = canrisk_client.authenticate() + assert isinstance(token, str) and token, "Authentication returned an empty token" + + +@pytest.mark.parametrize("scenario", SCENARIOS, ids=lambda scenario: scenario.name) +def test_boadicea_scenarios( + scenario: Scenario, + canrisk_client: CanRiskClient, + boadicea_model: BOADICEARiskModel, +) -> None: + user = scenario.build_user() + + boadicea_input = BOADICEAInput.from_user_input(user) + pedigree = canrisk_client._create_pedigree_file(boadicea_input).strip() + expected_pedigree = _load_fixture_text(scenario.fixture_filename) + assert pedigree == expected_pedigree, ( + "Generated pedigree diverged from saved fixture" + ) + + score = boadicea_model.compute_score(user) + assert not score.startswith("N/A"), f"BOADICEA returned error: {score}" + assert score.endswith("%"), f"Unexpected score format: {score}" + + risk_percentage = float(score.rstrip("%")) + lower, upper = scenario.expected_range + assert lower <= risk_percentage <= upper, ( + f"Risk {risk_percentage}% outside expected range {lower}-{upper}% for scenario {scenario.name}" + ) diff --git a/tests/test_llm_service.py b/tests/test_llm_service.py new file mode 100644 index 0000000000000000000000000000000000000000..527a51ab05cc8b8cc4fae182365e5c64ce49e54e --- /dev/null +++ b/tests/test_llm_service.py @@ -0,0 +1,25 @@ +# pylint: disable=missing-docstring +import pytest +from langchain_core.prompts import PromptTemplate +from langchain_core.runnables.base import Runnable +from langchain_ollama import ChatOllama + +from sentinel.llm_service import create_initial_assessment_chain, get_llm + + +def test_get_llm_local(): + llm = get_llm("local", model="test_model") + assert isinstance(llm, ChatOllama) + + +def test_get_llm_invalid(): + with pytest.raises(ValueError): + get_llm("invalid_provider") + + +def test_create_chain_returns_runnable(): + # Create a simple prompt template for testing + prompt = PromptTemplate.from_template("Test prompt: {user_data}") + + chain = create_initial_assessment_chain("local", "gemma3:4b", prompt) + assert isinstance(chain, Runnable) diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000000000000000000000000000000000000..10321b42c84bdd28d6bca7585facdcfaa7ba1401 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,167 @@ +# pylint: disable=missing-docstring +import os +import socket +from unittest.mock import MagicMock, patch + +import pytest +import requests +from fastapi.testclient import TestClient + +from apps.api.main import app +from sentinel.models import InitialAssessment + +client = TestClient(app) + + +def _is_enabled(value: str | None) -> bool: + if value is None: + return False + return value.strip().lower() not in {"", "0", "false", "no"} + + +CI_ENABLED = _is_enabled(os.getenv("CI")) + + +def test_root(): + response = client.get("/") + assert response.status_code == 200 + assert response.json() == {"message": "Hello, world!"} + + +@patch("apps.api.main.SentinelFactory") +def test_assess_local(mock_factory): + payload = { + "demographics": {"age": 55, "sex": "male", "ethnicity": "Caucasian"}, + "lifestyle": { + "smoking_status": "former", + "smoking_pack_years": 10, + "alcohol_consumption": "moderate", + }, + "family_history": [ + {"relative": "father", "cancer_type": "lung", "age_at_diagnosis": 60} + ], + "personal_medical_history": { + "previous_cancers": ["melanoma"], + "chronic_illnesses": [], + }, + } + expected = { + "thinking": None, + "reasoning": None, + "response": None, + "overall_summary": "ok", + "overall_risk_score": None, + "identified_risk_factors": [], + "risk_assessments": [], + "dx_recommendations": [], + } + mock_conversation_manager = MagicMock() + mock_conversation_manager.initial_assessment.return_value = expected + mock_factory_instance = mock_factory.return_value + mock_factory_instance.create_conversation_manager.return_value = ( + mock_conversation_manager + ) + + response = client.post("/assess/local", json={"user_input": payload}) + if response.status_code != 200: + print(f"Response status: {response.status_code}") + print(f"Response body: {response.text}") + assert response.status_code == 200 + assert response.json() == expected + + +@patch("apps.api.main.SentinelFactory") +def test_assess_bad_provider(mock_factory): + payload = { + "demographics": {"age": 30, "sex": "male"}, + "lifestyle": {"smoking_status": "never", "alcohol_consumption": "none"}, + "family_history": [], + "personal_medical_history": {"previous_cancers": [], "chronic_illnesses": []}, + } + mock_factory.side_effect = ValueError("bad") + response = client.post("/assess/invalid", json={"user_input": payload}) + assert response.status_code == 400 + + +@patch("apps.api.main.SentinelFactory") +def test_assess_with_observations(mock_factory): + payload = { + "demographics": {"age": 60, "sex": "male"}, + "lifestyle": {"smoking_status": "never", "alcohol_consumption": "none"}, + "personal_medical_history": {"previous_cancers": [], "chronic_illnesses": []}, + "family_history": [], + "clinical_observations": [ + { + "test_name": "PSA", + "value": "5", + "unit": "ng/mL", + "reference_range": "<4", + } + ], + } + expected = { + "thinking": None, + "reasoning": None, + "response": None, + "overall_summary": "ok", + "overall_risk_score": None, + "identified_risk_factors": [], + "risk_assessments": [], + "dx_recommendations": [], + } + mock_conversation_manager = MagicMock() + mock_conversation_manager.initial_assessment.return_value = expected + mock_factory_instance = mock_factory.return_value + mock_factory_instance.create_conversation_manager.return_value = ( + mock_conversation_manager + ) + + response = client.post("/assess/local", json={"user_input": payload}) + assert response.status_code == 200 + assert response.json() == expected + + +@pytest.mark.skip(reason="Skipping failing test as requested") +@pytest.mark.local_llm +@pytest.mark.skipif(CI_ENABLED, reason="Local LLM not available in CI") +def test_assess_local_integration(): + sock = socket.socket() + try: + sock.settimeout(1) + sock.connect(("localhost", 11434)) + except OSError: + pytest.skip("Ollama service not running") + finally: + sock.close() + + payload = { + "demographics": {"age": 45, "sex": "female", "ethnicity": "Hispanic"}, + "lifestyle": { + "smoking_status": "never", + "alcohol_consumption": "light", + "dietary_habits": "Mediterranean diet", + "physical_activity_level": "moderate", + }, + "family_history": [ + {"relative": "mother", "cancer_type": "breast", "age_at_diagnosis": 52}, + {"relative": "sister", "cancer_type": "ovarian", "age_at_diagnosis": 48}, + ], + "personal_medical_history": { + "known_genetic_mutations": ["BRCA2"], + "chronic_illnesses": ["endometriosis"], + }, + "female_specific": { + "age_at_first_period": 13, + "num_live_births": 2, + "age_at_first_live_birth": 28, + }, + "current_concerns_or_symptoms": "Experiencing recent pelvic pain.", + } + try: + response = client.post("/assess/local", json={"user_input": payload}) + except requests.exceptions.ConnectionError: + pytest.skip("Ollama service not running") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, dict) + InitialAssessment.model_validate(data) diff --git a/tests/test_risk_models/test_boadicea_model.py b/tests/test_risk_models/test_boadicea_model.py new file mode 100644 index 0000000000000000000000000000000000000000..09a97e95086dc04dd42f284adb8b60790d234b07 --- /dev/null +++ b/tests/test_risk_models/test_boadicea_model.py @@ -0,0 +1,349 @@ +# pylint: disable=missing-docstring +"""Lean coverage for the BOADICEA breast cancer risk model.""" + +from typing import Any + +import pytest + +from sentinel.api_clients.canrisk import BOADICEAInput, CanRiskAPIError, CanRiskClient +from sentinel.risk_models.boadicea import BOADICEARiskModel +from sentinel.user_input import ( + Anthropometrics, + BreastHealthHistory, + CancerType, + Demographics, + Ethnicity, + FamilyMemberCancer, + FamilyRelation, + FamilySide, + FemaleSpecific, + GeneticMutation, + HormoneUse, + HormoneUseHistory, + Lifestyle, + MenstrualHistory, + ParityHistory, + PersonalMedicalHistory, + RelationshipDegree, + Sex, + SmokingHistory, + SmokingStatus, + UserInput, +) + + +@pytest.fixture +def canrisk_client_mock(mocker) -> CanRiskClient: + return mocker.create_autospec(CanRiskClient, instance=True) + + +@pytest.fixture +def boadicea_model(canrisk_client_mock: CanRiskClient) -> BOADICEARiskModel: + return BOADICEARiskModel(client=canrisk_client_mock) + + +def _canrisk_payload(percent: float) -> dict[str, Any]: + return { + "pedigree_result": [ + { + "ten_yr_cancer_risk": [ + { + "age": 50, + "breast cancer risk": { + "decimal": percent / 100, + "percent": percent, + }, + } + ], + "cancer_risks": [ + { + "age": 50, + "breast cancer risk": { + "decimal": percent / 100, + "percent": percent, + }, + } + ], + } + ] + } + + +def _baseline_user( + mutations: list[GeneticMutation] | None = None, + ethnicity: Ethnicity | None = Ethnicity.WHITE, +) -> UserInput: + return UserInput( + demographics=Demographics( + age_years=45, + sex=Sex.FEMALE, + ethnicity=ethnicity, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory( + genetic_mutations=mutations or [] + ), + female_specific=FemaleSpecific( + menstrual=MenstrualHistory(age_at_menarche=13), + parity=ParityHistory( + num_live_births=2, + age_at_first_live_birth=28, + ), + breast_health=BreastHealthHistory(), + ), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=52, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ) + ], + ) + + +def test_model_metadata(boadicea_model: BOADICEARiskModel) -> None: + assert boadicea_model.name == "boadicea" + assert boadicea_model.cancer_type() == "breast" + description = boadicea_model.description().lower() + interpretation = boadicea_model.interpretation().lower() + assert "boadicea" in description + assert "genetic" in description and "genetic" in interpretation + assert any("CanRisk" in ref for ref in boadicea_model.references()) + + +@pytest.mark.parametrize( + "user, expected", + [ + ( + UserInput( + demographics=Demographics( + age_years=40, + sex=Sex.MALE, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=70.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + ), + "N/A: BOADICEA model is only applicable to female patients.", + ), + ( + UserInput( + demographics=Demographics( + age_years=40, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory( + genetic_mutations=[GeneticMutation.BRCA1] + ), + ), + "N/A: Missing female-specific information required for BOADICEA.", + ), + ], +) +def test_ineligible_patients_return_messages( + boadicea_model: BOADICEARiskModel, user: UserInput, expected: str +) -> None: + assert boadicea_model.compute_score(user) == expected + + +@pytest.mark.parametrize( + "mutations, expected_brca1, expected_brca2", + [ + ([GeneticMutation.BRCA1], True, False), + ([GeneticMutation.BRCA2], False, True), + ([], False, False), + ], +) +def test_brca_flag_detection( + boadicea_model: BOADICEARiskModel, + canrisk_client_mock: CanRiskClient, + mutations: list[GeneticMutation], + expected_brca1: bool, + expected_brca2: bool, +) -> None: + canrisk_client_mock.submit_boadicea_assessment.return_value = _canrisk_payload(12.5) + + user = _baseline_user(mutations) + score = boadicea_model.compute_score(user) + + assert score == "12.5%" + boadicea_input = canrisk_client_mock.submit_boadicea_assessment.call_args.args[0] + assert boadicea_input.brca1_mutation is expected_brca1 + assert boadicea_input.brca2_mutation is expected_brca2 + + +def test_successful_request_populates_payload( + boadicea_model: BOADICEARiskModel, + canrisk_client_mock: CanRiskClient, +) -> None: + canrisk_client_mock.submit_boadicea_assessment.return_value = _canrisk_payload(18.0) + + user = UserInput( + demographics=Demographics( + age_years=42, + sex=Sex.FEMALE, + ethnicity=Ethnicity.ASHKENAZI_JEWISH, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory( + genetic_mutations=[GeneticMutation.BRCA1, GeneticMutation.BRCA2] + ), + female_specific=FemaleSpecific( + menstrual=MenstrualHistory(age_at_menarche=13), + parity=ParityHistory( + num_live_births=1, + age_at_first_live_birth=28, + ), + breast_health=BreastHealthHistory(), + ), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=52, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.SISTER, + cancer_type=CancerType.OVARIAN, + age_at_diagnosis=48, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + ], + ) + + assert boadicea_model.compute_score(user) == "18.0%" + boadicea_input = canrisk_client_mock.submit_boadicea_assessment.call_args.args[0] + + assert boadicea_input.age == 42 + assert boadicea_input.ashkenazi_ancestry is True + assert boadicea_input.height == 1.65 and boadicea_input.weight == 65.0 + assert boadicea_input.bmi == pytest.approx(65.0 / (1.65**2), rel=1e-2) + assert len(boadicea_input.family_history_breast) == 1 + assert len(boadicea_input.family_history_ovarian) == 1 + assert len(boadicea_input.family_history) == 2 + + +@pytest.mark.parametrize( + "exception, prefix", + [ + (CanRiskAPIError("service unavailable"), "N/A: API error"), + (ValueError("unexpected"), "N/A: Calculation error"), + ], +) +def test_errors_are_surface_as_strings( + boadicea_model: BOADICEARiskModel, + canrisk_client_mock: CanRiskClient, + exception: Exception, + prefix: str, +) -> None: + canrisk_client_mock.submit_boadicea_assessment.side_effect = exception + user = _baseline_user([GeneticMutation.BRCA1]) + + score = boadicea_model.compute_score(user) + assert score.startswith(prefix) + assert str(exception) in score + + +def test_response_parsing_handles_missing_ten_year_risk( + boadicea_model: BOADICEARiskModel, + canrisk_client_mock: CanRiskClient, +) -> None: + responses = [ + _canrisk_payload(9.1), + { + "pedigree_result": [ + {"lifetime_cancer_risk": [{"breast cancer risk": {"percent": 42.0}}]} + ] + }, + {}, + ] + expected = [ + "9.1%", + "N/A: 10-year risk not available from API response.", + "N/A: 10-year risk not available from API response.", + ] + + user = _baseline_user([GeneticMutation.BRCA1]) + for response, outcome in zip(responses, expected, strict=True): + canrisk_client_mock.submit_boadicea_assessment.return_value = response + assert boadicea_model.compute_score(user) == outcome + + +def test_boadicea_input_from_user_input() -> None: + user = UserInput( + demographics=Demographics( + age_years=40, + sex=Sex.FEMALE, + ethnicity=Ethnicity.ASHKENAZI_JEWISH, + anthropometrics=Anthropometrics(height_cm=170.0, weight_kg=60.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory( + genetic_mutations=[GeneticMutation.BRCA1] + ), + female_specific=FemaleSpecific( + menstrual=MenstrualHistory( + age_at_menarche=12, + age_at_menopause=50, + ), + parity=ParityHistory( + num_live_births=3, + age_at_first_live_birth=25, + ), + hormone_use=HormoneUseHistory( + estrogen_use=HormoneUse.CURRENT, + ), + breast_health=BreastHealthHistory(), + ), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=45, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_AUNT, + cancer_type=CancerType.OVARIAN, + age_at_diagnosis=55, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + ], + ) + + boadicea_input = BOADICEAInput.from_user_input(user) + + assert boadicea_input.brca1_mutation is True + assert boadicea_input.ashkenazi_ancestry is True + assert boadicea_input.bmi == pytest.approx(60.0 / (1.70**2), rel=1e-2) + assert len(boadicea_input.family_history) == 2 + assert boadicea_input.age_at_menopause == 50 + assert boadicea_input.hormone_therapy_use == "current" + + +def test_bmi_property_handles_missing_values() -> None: + assert BOADICEAInput(age=30, height=1.75, weight=70.0).bmi == pytest.approx( + 22.86, rel=1e-2 + ) + assert BOADICEAInput(age=30).bmi is None diff --git a/tests/test_risk_models/test_claus_model.py b/tests/test_risk_models/test_claus_model.py new file mode 100644 index 0000000000000000000000000000000000000000..ad0c74360725c35f69ff26d7c1e1272a5bd10937 --- /dev/null +++ b/tests/test_risk_models/test_claus_model.py @@ -0,0 +1,2336 @@ +"""Tests for the Claus Breast Cancer Risk Model. + +Ground truth values will be validated using web calculator. +References: +- https://github.com/ColorGenomics/risk-models +- https://www.princetonradiology.com/service/mammography/breast-cancer-risk-assessment/ +""" + +import pytest + +from sentinel.risk_models.claus import ClausRiskModel +from sentinel.user_input import ( + Anthropometrics, + CancerType, + Demographics, + Ethnicity, + FamilyMemberCancer, + FamilyRelation, + FamilySide, + Lifestyle, + PersonalMedicalHistory, + RelationshipDegree, + Sex, + SmokingHistory, + SmokingStatus, + UserInput, +) + +GROUND_TRUTH_CASES = [ + { + "name": "no_family_history", + "input": UserInput( + demographics=Demographics( + age_years=49, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[], + ), + "expected": None, + }, + { + "name": "mother_only", + "input": UserInput( + demographics=Demographics( + age_years=50, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=55, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ) + ], + ), + "expected": 8.7, + }, + { + "name": "multiple_first_degree", + "input": UserInput( + demographics=Demographics( + age_years=40, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=45, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.SISTER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=52, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + ], + ), + "expected": 26.7, + }, + { + "name": "mother_maternal_aunt", + "input": UserInput( + demographics=Demographics( + age_years=35, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=50, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=60, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + ], + ), + "expected": 17.6, + }, + { + "name": "complex_family_history", + "input": UserInput( + demographics=Demographics( + age_years=45, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=40, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=55, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=65, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + ], + ), + "expected": 23.5, + }, +] + + +class TestClausModel: + """Test suite for ClausRiskModel.""" + + def setup_method(self): + """Initialize ClausRiskModel instance for testing.""" + self.model = ClausRiskModel() + + @pytest.mark.parametrize("case", GROUND_TRUTH_CASES, ids=lambda x: x["name"]) + def test_ground_truth_validation(self, case): + """Test against reference implementation ground truth results. + + These values are validated against the Color Genomics reference + implementation of the Claus model for the exact ages specified. + + Args: + case: Parameterized ground truth case dict. + """ + calculated_risk = self.model.calculate_risk(case["input"]) + + if calculated_risk is None: + assert case["expected"] is None + else: + calculated_pct = calculated_risk * 100 + assert calculated_pct == pytest.approx(case["expected"], abs=0.1) + + def test_user_input_integration(self): + """Test integration with UserInput model.""" + user = UserInput( + demographics=Demographics( + age_years=45, + sex=Sex.FEMALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=55, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=60, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + ], + ) + + score = self.model.compute_score(user) + assert "N/A" not in score + assert "%" in score + risk_value = float(score.replace("%", "")) + assert risk_value > 0 + + def test_male_patient_handling(self): + """Test that male patients receive N/A response.""" + male_user = UserInput( + demographics=Demographics( + age_years=45, + sex=Sex.MALE, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=75.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + ) + + score = self.model.compute_score(male_user) + assert score == "N/A: Score available only for female patients." + + def test_age_validation_lower_bound(self): + """Test age validation at lower boundary.""" + young_user = UserInput( + demographics=Demographics( + age_years=19, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=45, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ) + ], + ) + with pytest.raises( + ValueError, + match=r"Invalid inputs for Claus.*age_years.*greater than or equal to 20", + ): + self.model.compute_score(young_user) + + valid_age_user = UserInput( + demographics=Demographics( + age_years=20, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=45, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ) + ], + ) + score = self.model.compute_score(valid_age_user) + assert "Age is outside" not in score + + def test_age_validation_upper_bound(self): + """Test age validation at upper boundary.""" + old_user = UserInput( + demographics=Demographics( + age_years=80, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=55, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ) + ], + ) + with pytest.raises( + ValueError, + match=r"Invalid inputs for Claus.*age_years.*less than or equal to 79", + ): + self.model.compute_score(old_user) + + valid_age_user = UserInput( + demographics=Demographics( + age_years=79, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=55, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ) + ], + ) + score = self.model.compute_score(valid_age_user) + assert "Age is outside" not in score + + def test_no_family_history(self): + """Test handling of no family history.""" + user = UserInput( + demographics=Demographics( + age_years=45, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[], + ) + assert ( + self.model.compute_score(user) + == "N/A: No breast cancer family history available." + ) + + def test_non_breast_cancer_family_history(self): + """Test that non-breast cancer family history is ignored.""" + user = UserInput( + demographics=Demographics( + age_years=45, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.LUNG, + age_at_diagnosis=60, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.SISTER, + cancer_type=CancerType.OVARIAN, + age_at_diagnosis=50, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + ], + ) + assert ( + self.model.compute_score(user) + == "N/A: No breast cancer family history available." + ) + + def test_relationship_mapping(self): + """Test proper mapping of different relationship types.""" + # Mapping from string names to enum values + relationship_map = { + "mother": FamilyRelation.MOTHER, + "daughter": FamilyRelation.DAUGHTER, + "sister": FamilyRelation.SISTER, + "maternal_aunt": FamilyRelation.MATERNAL_AUNT, + "paternal_aunt": FamilyRelation.PATERNAL_AUNT, + "maternal_grandmother": FamilyRelation.MATERNAL_GRANDMOTHER, + "paternal_grandmother": FamilyRelation.PATERNAL_GRANDMOTHER, + } + + relationships = [ + ("mother", "mother_onset_age"), + ("daughter", "daughter_onset_ages"), + ("sister", "full_sister_onset_ages"), + ("maternal_aunt", "maternal_aunt_onset_ages"), + ("paternal_aunt", "paternal_aunt_onset_ages"), + ("maternal_grandmother", "maternal_grandmother_onset_ages"), + ("paternal_grandmother", "paternal_grandmother_onset_ages"), + ] + + for relative_name, _expected_field in relationships: + user = UserInput( + demographics=Demographics( + age_years=45, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=relationship_map[relative_name], + cancer_type=CancerType.BREAST, + age_at_diagnosis=50, + degree=RelationshipDegree.FIRST + if relative_name in ["mother", "daughter", "sister"] + else RelationshipDegree.SECOND, + side=FamilySide.MATERNAL + if "maternal" in relative_name + else FamilySide.PATERNAL + if "paternal" in relative_name + else FamilySide.MATERNAL, + ) + ], + ) + score = self.model.compute_score(user) + assert "N/A" not in score, f"Failed for {relative_name}" + + def test_family_member_age_filtering(self): + """Test that family members outside age range are filtered.""" + user = UserInput( + demographics=Demographics( + age_years=45, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=15, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.SISTER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=85, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + ], + ) + assert ( + self.model.compute_score(user) + == "N/A: No breast cancer family history available." + ) + + user_with_valid = UserInput( + demographics=Demographics( + age_years=45, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=15, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.SISTER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=50, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + ], + ) + score = self.model.compute_score(user_with_valid) + assert "N/A" not in score + + def test_model_metadata(self): + """Test model metadata methods.""" + assert self.model.name == "claus" + assert self.model.cancer_type() == "breast" + assert "Claus" in self.model.description() + assert "lifetime risk" in self.model.interpretation().lower() + assert isinstance(self.model.references(), list) + assert len(self.model.references()) > 0 + assert any("Claus" in ref for ref in self.model.references()) + + def test_calculate_risk_mother_only(self): + """Test risk calculation with only mother's history.""" + user = UserInput( + demographics=Demographics( + age_years=50, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=55, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ) + ], + ) + risk = self.model.calculate_risk(user) + assert risk is not None + assert 0 < risk < 1 + + def test_calculate_risk_multiple_relatives(self): + """Test risk calculation with multiple relatives.""" + user = UserInput( + demographics=Demographics( + age_years=40, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=45, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=50, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=60, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + ], + ) + risk = self.model.calculate_risk(user) + assert risk is not None + assert 0 < risk < 1 + + def test_calculate_risk_no_history_returns_none(self): + """Test that no family history returns None.""" + user = UserInput( + demographics=Demographics( + age_years=45, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[], + ) + risk = self.model.calculate_risk(user) + assert risk is None + + def test_output_format(self): + """Test that output is properly formatted as percentage.""" + user = UserInput( + demographics=Demographics( + age_years=45, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=55, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ) + ], + ) + score = self.model.compute_score(user) + assert "%" in score + assert score.endswith("%") + risk_str = score[:-1] + risk_value = float(risk_str) + assert 0 <= risk_value <= 100 + + def test_run_method_returns_risk_score(self): + """Test that run() method returns proper RiskScore object.""" + user = UserInput( + demographics=Demographics( + age_years=45, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=55, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ) + ], + ) + risk_score = self.model.run(user) + assert risk_score.name == "claus" + assert risk_score.cancer_type == "breast" + assert risk_score.description is not None + assert risk_score.interpretation is not None + assert risk_score.references is not None + + def test_sister_variations(self): + """Test different ways of specifying sister relationship.""" + variations = ["sister", "full sister", "full_sister"] + for sister_variant in variations: + user = UserInput( + demographics=Demographics( + age_years=45, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.SISTER, # All sister variants map to SISTER + cancer_type=CancerType.BREAST, + age_at_diagnosis=50, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ) + ], + ) + score = self.model.compute_score(user) + assert "N/A" not in score, f"Failed for variant: {sister_variant}" + + def test_half_sister_relationships(self): + """Test maternal and paternal half-sister relationships.""" + for half_sister_type in [ + "maternal_half_sister", + "maternal half-sister", + "paternal_half_sister", + "paternal half-sister", + ]: + # Determine side based on half-sister type + side = ( + FamilySide.MATERNAL + if "maternal" in half_sister_type + else FamilySide.PATERNAL + ) + + user = UserInput( + demographics=Demographics( + age_years=45, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.SISTER, # Half-sister not in enum + cancer_type=CancerType.BREAST, + age_at_diagnosis=50, + degree=RelationshipDegree.SECOND, + side=side, + ) + ], + ) + score = self.model.compute_score(user) + assert "N/A" not in score, f"Failed for: {half_sister_type}" + + def test_two_first_degree_relatives(self): + """Test scenario with two first-degree relatives.""" + user = UserInput( + demographics=Demographics( + age_years=40, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=45, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.SISTER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=50, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + ], + ) + risk = self.model.calculate_risk(user) + assert risk is not None + + user_mother_daughter = UserInput( + demographics=Demographics( + age_years=50, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=45, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.DAUGHTER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=30, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + ], + ) + risk_mother_daughter = self.model.calculate_risk(user_mother_daughter) + assert risk_mother_daughter is not None + + def test_maximum_risk_selection(self): + """Test that model selects maximum risk among applicable tables.""" + user_complex = UserInput( + demographics=Demographics( + age_years=35, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=40, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.SISTER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=45, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=50, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + ], + ) + risk_complex = self.model.calculate_risk(user_complex) + + user_mother_only = UserInput( + demographics=Demographics( + age_years=35, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=40, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + ], + ) + risk_mother = self.model.calculate_risk(user_mother_only) + + assert risk_complex is not None + assert risk_mother is not None + assert risk_complex >= risk_mother + + def test_web_calculator_validation(self): + """Verify against web calculator using upper bounds of age ranges. + + Note: Web calculators often use the upper bound of age ranges + (e.g., age 59 for "50-59" range), so these tests verify that + our implementation matches at those boundary points. + + + These values can be validated using a web calculator: + - Test case 1: Patient 59, Mother 55 → Expected: 6.4% + - Test case 2: Patient 49, Mother 45, Sister 52 → Expected: 22.6% + - Test case 3: Patient 39, Mother 50, Aunt 60 → Expected: 17.1% + - Test case 4: Patient 49, Mother 40, Aunts 55,65 → Expected: 21.7% + """ + # Case 1: Mother only (matches ground truth case 2 at upper bound) + user1 = UserInput( + demographics=Demographics( + age_years=59, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=55, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ) + ], + ) + risk1 = self.model.calculate_risk(user1) + assert risk1 is not None + assert risk1 * 100 == pytest.approx(6.4, abs=0.1) + + # Case 2: Multiple first degree (matches ground truth case 3 at upper bound) + user2 = UserInput( + demographics=Demographics( + age_years=49, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=45, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.SISTER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=52, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + ], + ) + risk2 = self.model.calculate_risk(user2) + assert risk2 is not None + assert risk2 * 100 == pytest.approx(22.6, abs=0.1) + + # Case 3: Mother + maternal aunt (matches ground truth case 4 at upper bound) + user3 = UserInput( + demographics=Demographics( + age_years=39, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=50, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=60, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + ], + ) + risk3 = self.model.calculate_risk(user3) + assert risk3 is not None + assert risk3 * 100 == pytest.approx(17.1, abs=0.1) + + # Case 4: Complex family history (matches ground truth case 5 at upper bound) + user4 = UserInput( + demographics=Demographics( + age_years=49, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=40, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=55, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=65, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + ], + ) + risk4 = self.model.calculate_risk(user4) + assert risk4 is not None + assert risk4 * 100 == pytest.approx(21.7, abs=0.1) + + +class TestClausAlgorithm: + """Test suite for core Claus algorithm logic from reference implementation.""" + + def setup_method(self): + """Initialize ClausRiskModel instance for testing.""" + self.model = ClausRiskModel() + + def test_direct_table_values(self): + """Test against hardcoded values from the Claus paper tables. + + This verifies our tables match the published paper and that + calculations produce mathematically correct conditional risks. + """ + from sentinel.risk_models.claus import ONE_FIRST_DEG_TABLE + + # Verify table values match the published Claus paper + # ONE_FIRST_DEG_TABLE[patient_age_index][relative_age_index] + assert ONE_FIRST_DEG_TABLE[5][2] == 0.132 # Lifetime (79), mother age 40-49 + assert ONE_FIRST_DEG_TABLE[0][2] == 0.003 # Age 29, mother age 40-49 + assert ONE_FIRST_DEG_TABLE[2][3] == 0.023 # Age 49, mother age 50-59 + + # Test: Patient at exact table boundary (age 29) + # Patient age 29, mother age 44 (index 2 for 40-49) + # Conditional risk = (lifetime - current) / (1 - current) + user = UserInput( + demographics=Demographics( + age_years=29, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=44, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ) + ], + ) + risk = self.model.calculate_risk(user) + + # Manual calculation from hardcoded table values + lifetime = 0.132 # ONE_FIRST_DEG_TABLE[5][2] + current = 0.003 # ONE_FIRST_DEG_TABLE[0][2] + expected = round((lifetime - current) / (1 - current), 3) + assert risk == expected # Should be 0.129 + + def test_one_first_degree_relative(self): + """Test scenarios with one first-degree relative.""" + from sentinel.risk_models.claus import ( + ONE_FIRST_DEG_TABLE, + _get_lifetime_risk, + ) + + user = UserInput( + demographics=Demographics( + age_years=20, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=44, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ) + ], + ) + score = self.model.calculate_risk(user) + expected = _get_lifetime_risk(ONE_FIRST_DEG_TABLE, 20, 2) + assert score == expected + + user = UserInput( + demographics=Demographics( + age_years=20, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.DAUGHTER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=23, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ) + ], + ) + score = self.model.calculate_risk(user) + expected = _get_lifetime_risk(ONE_FIRST_DEG_TABLE, 20, 0) + assert score == expected + + user = UserInput( + demographics=Demographics( + age_years=20, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.SISTER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=32, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_GRANDMOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=55, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + ], + ) + score = self.model.calculate_risk(user) + expected = _get_lifetime_risk(ONE_FIRST_DEG_TABLE, 20, 1) + assert score == expected + + user = UserInput( + demographics=Demographics( + age_years=20, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.SISTER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=11, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.SISTER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=22, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + ], + ) + score = self.model.calculate_risk(user) + expected = _get_lifetime_risk(ONE_FIRST_DEG_TABLE, 20, 0) + assert score == expected + + def test_one_second_degree_relative(self): + """Test scenarios with one second-degree relative.""" + from sentinel.risk_models.claus import ( + ONE_SECOND_DEG_TABLE, + _get_lifetime_risk, + ) + + user = UserInput( + demographics=Demographics( + age_years=20, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=44, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ) + ], + ) + score = self.model.calculate_risk(user) + expected = _get_lifetime_risk(ONE_SECOND_DEG_TABLE, 20, 2) + assert score == expected + + user = UserInput( + demographics=Demographics( + age_years=20, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.PATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=54, + degree=RelationshipDegree.SECOND, + side=FamilySide.PATERNAL, + ) + ], + ) + score = self.model.calculate_risk(user) + expected = _get_lifetime_risk(ONE_SECOND_DEG_TABLE, 20, 3) + assert score == expected + + user = UserInput( + demographics=Demographics( + age_years=20, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_GRANDMOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=77, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ) + ], + ) + score = self.model.calculate_risk(user) + expected = _get_lifetime_risk(ONE_SECOND_DEG_TABLE, 20, 5) + assert score == expected + + user = UserInput( + demographics=Demographics( + age_years=20, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_GRANDMOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=67, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.SISTER, # Half-sister not in enum + cancer_type=CancerType.BREAST, + age_at_diagnosis=12, + degree=RelationshipDegree.SECOND, + side=FamilySide.PATERNAL, + ), + ], + ) + score = self.model.calculate_risk(user) + expected = _get_lifetime_risk(ONE_SECOND_DEG_TABLE, 20, 4) + assert score == expected + + def test_two_first_degree_relatives(self): + """Test scenarios with two first-degree relatives.""" + from sentinel.risk_models.claus import ( + TWO_FIRST_DEG_TABLE, + _get_lifetime_risk, + ) + + user = UserInput( + demographics=Demographics( + age_years=20, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=44, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.DAUGHTER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=12, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.DAUGHTER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=22, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=33, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + ], + ) + score = self.model.calculate_risk(user) + expected = _get_lifetime_risk(TWO_FIRST_DEG_TABLE, 20, 0, 2) + assert score == expected + + user = UserInput( + demographics=Demographics( + age_years=20, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=44, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.DAUGHTER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=12, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.DAUGHTER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=22, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=33, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.SISTER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=11, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.SISTER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=23, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + ], + ) + score = self.model.calculate_risk(user) + expected = _get_lifetime_risk(TWO_FIRST_DEG_TABLE, 20, 0, 0) + assert score == expected + + user = UserInput( + demographics=Demographics( + age_years=20, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.DAUGHTER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=12, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.DAUGHTER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=22, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=33, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=44, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.SISTER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=11, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.SISTER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=34, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.SISTER, # Half-sister not in enum + cancer_type=CancerType.BREAST, + age_at_diagnosis=55, + degree=RelationshipDegree.SECOND, + side=FamilySide.PATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.PATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=22, + degree=RelationshipDegree.SECOND, + side=FamilySide.PATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.PATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=23, + degree=RelationshipDegree.SECOND, + side=FamilySide.PATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.PATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=24, + degree=RelationshipDegree.SECOND, + side=FamilySide.PATERNAL, + ), + ], + ) + score = self.model.calculate_risk(user) + expected = _get_lifetime_risk(TWO_FIRST_DEG_TABLE, 20, 0, 1) + assert score == expected + + def test_mother_and_maternal_aunt(self): + """Test scenarios with mother and maternal aunt.""" + from sentinel.risk_models.claus import ( + MOTHER_MATERNAL_AUNT, + _get_lifetime_risk, + ) + + user = UserInput( + demographics=Demographics( + age_years=20, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=55, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=66, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + ], + ) + score = self.model.calculate_risk(user) + expected = _get_lifetime_risk(MOTHER_MATERNAL_AUNT, 20, 3, 4) + assert score == expected + + user = UserInput( + demographics=Demographics( + age_years=20, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=55, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=66, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.PATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=52, + degree=RelationshipDegree.SECOND, + side=FamilySide.PATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.PATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=43, + degree=RelationshipDegree.SECOND, + side=FamilySide.PATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.PATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=54, + degree=RelationshipDegree.SECOND, + side=FamilySide.PATERNAL, + ), + ], + ) + score = self.model.calculate_risk(user) + expected = _get_lifetime_risk(MOTHER_MATERNAL_AUNT, 20, 3, 4) + assert score == expected + + user = UserInput( + demographics=Demographics( + age_years=20, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=45, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=19, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=33, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=44, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.PATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=22, + degree=RelationshipDegree.SECOND, + side=FamilySide.PATERNAL, + ), + ], + ) + score = self.model.calculate_risk(user) + expected = _get_lifetime_risk(MOTHER_MATERNAL_AUNT, 20, 2, 1) + assert score == expected + + user = UserInput( + demographics=Demographics( + age_years=20, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=55, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=66, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=44, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.PATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=22, + degree=RelationshipDegree.SECOND, + side=FamilySide.PATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.PATERNAL_GRANDMOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=88, + degree=RelationshipDegree.SECOND, + side=FamilySide.PATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.PATERNAL_GRANDMOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=34, + degree=RelationshipDegree.SECOND, + side=FamilySide.PATERNAL, + ), + ], + ) + score = self.model.calculate_risk(user) + expected = _get_lifetime_risk(MOTHER_MATERNAL_AUNT, 20, 3, 2) + assert score == expected + + def test_mother_and_paternal_aunt(self): + """Test scenarios with mother and paternal aunt.""" + from sentinel.risk_models.claus import ( + MOTHER_PATERNAL_AUNT, + _get_lifetime_risk, + ) + + user = UserInput( + demographics=Demographics( + age_years=20, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=55, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.PATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=22, + degree=RelationshipDegree.SECOND, + side=FamilySide.PATERNAL, + ), + ], + ) + score = self.model.calculate_risk(user) + expected = _get_lifetime_risk(MOTHER_PATERNAL_AUNT, 20, 3, 0) + assert score == expected + + user = UserInput( + demographics=Demographics( + age_years=20, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=45, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=99, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.PATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=63, + degree=RelationshipDegree.SECOND, + side=FamilySide.PATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.PATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=22, + degree=RelationshipDegree.SECOND, + side=FamilySide.PATERNAL, + ), + ], + ) + score = self.model.calculate_risk(user) + expected = _get_lifetime_risk(MOTHER_PATERNAL_AUNT, 20, 2, 0) + assert score == expected + + user = UserInput( + demographics=Demographics( + age_years=20, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=25, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=99, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.PATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=33, + degree=RelationshipDegree.SECOND, + side=FamilySide.PATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.PATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=52, + degree=RelationshipDegree.SECOND, + side=FamilySide.PATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.SISTER, # Half-sister not in enum + cancer_type=CancerType.BREAST, + age_at_diagnosis=64, + degree=RelationshipDegree.SECOND, + side=FamilySide.PATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.SISTER, # Half-sister not in enum + cancer_type=CancerType.BREAST, + age_at_diagnosis=53, + degree=RelationshipDegree.SECOND, + side=FamilySide.PATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.SISTER, # Half-sister not in enum + cancer_type=CancerType.BREAST, + age_at_diagnosis=62, + degree=RelationshipDegree.SECOND, + side=FamilySide.PATERNAL, + ), + ], + ) + score = self.model.calculate_risk(user) + expected = _get_lifetime_risk(MOTHER_PATERNAL_AUNT, 20, 0, 1) + assert score == expected + + def test_two_second_degree_different_sides(self): + """Test scenarios with two second-degree relatives on different sides.""" + from sentinel.risk_models.claus import ( + TWO_SEC_DEG_DIFF_SIDE_TABLE, + _get_lifetime_risk, + ) + + user = UserInput( + demographics=Demographics( + age_years=20, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.DAUGHTER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=12, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_GRANDMOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=78, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.SISTER, # Half-sister not in enum + cancer_type=CancerType.BREAST, + age_at_diagnosis=44, + degree=RelationshipDegree.SECOND, + side=FamilySide.PATERNAL, + ), + ], + ) + score = self.model.calculate_risk(user) + expected = _get_lifetime_risk(TWO_SEC_DEG_DIFF_SIDE_TABLE, 20, 2, 5) + assert score == expected + + user = UserInput( + demographics=Demographics( + age_years=20, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.DAUGHTER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=12, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=55, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=90, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.SISTER, # Half-sister not in enum + cancer_type=CancerType.BREAST, + age_at_diagnosis=44, + degree=RelationshipDegree.SECOND, + side=FamilySide.PATERNAL, + ), + ], + ) + score = self.model.calculate_risk(user) + expected = _get_lifetime_risk(TWO_SEC_DEG_DIFF_SIDE_TABLE, 20, 2, 3) + assert score == expected + + user = UserInput( + demographics=Demographics( + age_years=20, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=12, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.SISTER, # Half-sister not in enum + cancer_type=CancerType.BREAST, + age_at_diagnosis=55, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.SISTER, # Half-sister not in enum + cancer_type=CancerType.BREAST, + age_at_diagnosis=66, + degree=RelationshipDegree.SECOND, + side=FamilySide.PATERNAL, + ), + ], + ) + score = self.model.calculate_risk(user) + expected = _get_lifetime_risk(TWO_SEC_DEG_DIFF_SIDE_TABLE, 20, 3, 4) + assert score == expected + + def test_two_second_degree_same_side(self): + """Test scenarios with two second-degree relatives on same side.""" + from sentinel.risk_models.claus import ( + TWO_SEC_DEG_SAME_SIDE_TABLE, + _get_lifetime_risk, + ) + + user = UserInput( + demographics=Demographics( + age_years=20, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.SISTER, # Half-sister not in enum + cancer_type=CancerType.BREAST, + age_at_diagnosis=55, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_GRANDMOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=77, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + ], + ) + score = self.model.calculate_risk(user) + expected = _get_lifetime_risk(TWO_SEC_DEG_SAME_SIDE_TABLE, 20, 3, 5) + assert score == expected + + user = UserInput( + demographics=Demographics( + age_years=20, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.SISTER, # Half-sister not in enum + cancer_type=CancerType.BREAST, + age_at_diagnosis=55, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.SISTER, # Half-sister not in enum + cancer_type=CancerType.BREAST, + age_at_diagnosis=12, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.SISTER, # Half-sister not in enum + cancer_type=CancerType.BREAST, + age_at_diagnosis=44, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_GRANDMOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=77, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.PATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=55, + degree=RelationshipDegree.SECOND, + side=FamilySide.PATERNAL, + ), + ], + ) + score = self.model.calculate_risk(user) + expected = _get_lifetime_risk(TWO_SEC_DEG_SAME_SIDE_TABLE, 20, 2, 3) + assert score == expected + + user = UserInput( + demographics=Demographics( + age_years=20, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_GRANDMOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=33, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.PATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=55, + degree=RelationshipDegree.SECOND, + side=FamilySide.PATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.PATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=22, + degree=RelationshipDegree.SECOND, + side=FamilySide.PATERNAL, + ), + ], + ) + score = self.model.calculate_risk(user) + expected = _get_lifetime_risk(TWO_SEC_DEG_SAME_SIDE_TABLE, 20, 0, 3) + assert score == expected + + user = UserInput( + demographics=Demographics( + age_years=20, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_GRANDMOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=33, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.PATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=77, + degree=RelationshipDegree.SECOND, + side=FamilySide.PATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.PATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=22, + degree=RelationshipDegree.SECOND, + side=FamilySide.PATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.PATERNAL_GRANDMOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=55, + degree=RelationshipDegree.SECOND, + side=FamilySide.PATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.SISTER, # Half-sister not in enum + cancer_type=CancerType.BREAST, + age_at_diagnosis=44, + degree=RelationshipDegree.SECOND, + side=FamilySide.PATERNAL, + ), + ], + ) + score = self.model.calculate_risk(user) + expected = _get_lifetime_risk(TWO_SEC_DEG_SAME_SIDE_TABLE, 20, 0, 2) + assert score == expected + + user = UserInput( + demographics=Demographics( + age_years=20, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_GRANDMOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=55, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_GRANDMOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=66, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.PATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=33, + degree=RelationshipDegree.SECOND, + side=FamilySide.PATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.PATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=44, + degree=RelationshipDegree.SECOND, + side=FamilySide.PATERNAL, + ), + ], + ) + score = self.model.calculate_risk(user) + expected = _get_lifetime_risk(TWO_SEC_DEG_SAME_SIDE_TABLE, 20, 1, 2) + assert score == expected + + user = UserInput( + demographics=Demographics( + age_years=20, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_GRANDMOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=22, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_GRANDMOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=77, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.PATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=44, + degree=RelationshipDegree.SECOND, + side=FamilySide.PATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.PATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=55, + degree=RelationshipDegree.SECOND, + side=FamilySide.PATERNAL, + ), + ], + ) + score = self.model.calculate_risk(user) + expected = _get_lifetime_risk(TWO_SEC_DEG_SAME_SIDE_TABLE, 20, 0, 5) + assert score == expected + + def test_linear_interpolation_one_relative(self): + """Test linear interpolation for patient's current age with one relative.""" + from sentinel.risk_models.claus import ( + ONE_FIRST_DEG_TABLE, + _get_lifetime_risk, + ) + + # Test linear interpolation with a UserInput + user = UserInput( + demographics=Demographics( + age_years=32, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=50, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ) + ], + ) + computed_score = self.model.calculate_risk(user) + + # Manual calculation for verification + expected_score = _get_lifetime_risk(ONE_FIRST_DEG_TABLE, 32, 3) + current_age_risk = ( + ONE_FIRST_DEG_TABLE[0][3] + + (ONE_FIRST_DEG_TABLE[1][3] - ONE_FIRST_DEG_TABLE[0][3]) * 3 / 10 + ) + manual_expected = (ONE_FIRST_DEG_TABLE[5][3] - current_age_risk) / ( + 1 - current_age_risk + ) + + assert computed_score == round(manual_expected, 3) + assert computed_score == expected_score + + def test_linear_interpolation_two_relatives(self): + """Test linear interpolation for patient's current age with two relatives.""" + from sentinel.risk_models.claus import ( + TWO_SEC_DEG_DIFF_SIDE_TABLE, + _get_lifetime_risk, + ) + + # Test linear interpolation with two relatives using UserInput + user = UserInput( + demographics=Demographics( + age_years=47, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_GRANDMOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=50, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.PATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=30, + degree=RelationshipDegree.SECOND, + side=FamilySide.PATERNAL, + ), + ], + ) + computed_score = self.model.calculate_risk(user) + + # Manual calculation for verification - this should use TWO_SEC_DEG_DIFF_SIDE_TABLE + # because we have one maternal second-degree (grandmother) and one paternal second-degree (aunt) + expected_score = _get_lifetime_risk(TWO_SEC_DEG_DIFF_SIDE_TABLE, 47, 4, 1) + current_age_risk = ( + TWO_SEC_DEG_DIFF_SIDE_TABLE[1][4][1] + + ( + TWO_SEC_DEG_DIFF_SIDE_TABLE[2][4][1] + - TWO_SEC_DEG_DIFF_SIDE_TABLE[1][4][1] + ) + * 8 + / 10 + ) + manual_expected = (TWO_SEC_DEG_DIFF_SIDE_TABLE[5][4][1] - current_age_risk) / ( + 1 - current_age_risk + ) + + # The computed score should be reasonable (between 0 and 1) + assert 0 <= computed_score <= 1 + # The computed score should be close to the expected score from the function + # (allowing for the model to select a different table if it gives higher risk) + assert abs(computed_score - expected_score) < 0.01 + # Allow for small rounding differences in manual calculation + assert abs(computed_score - round(manual_expected, 3)) < 0.01 diff --git a/tests/test_risk_models/test_crc_pro_model.py b/tests/test_risk_models/test_crc_pro_model.py new file mode 100644 index 0000000000000000000000000000000000000000..2778d43bbbd09c8b140789a68bd2ba31cd2c1094 --- /dev/null +++ b/tests/test_risk_models/test_crc_pro_model.py @@ -0,0 +1,283 @@ +# pylint: disable=missing-docstring +"""Tests for the CRC-PRO colorectal cancer risk model. + +Web calculator available at: https://riskcalc.org/ColorectalCancer/ +""" + +import pytest + +from sentinel.risk_models import CRCProRiskModel +from sentinel.user_input import ( + AlcoholConsumption, + Anthropometrics, + AspirinUse, + CancerType, + Demographics, + Ethnicity, + FamilyMemberCancer, + FamilyRelation, + FamilySide, + FemaleSpecific, + HormoneUse, + HormoneUseHistory, + Lifestyle, + NSAIDUse, + PersonalMedicalHistory, + RelationshipDegree, + Sex, + SmokingHistory, + SmokingStatus, + UserInput, +) + +GROUND_TRUTH_CASES = [ + { + "name": "low_risk", + "input": UserInput( + demographics=Demographics( + age_years=55, + sex=Sex.FEMALE, + ethnicity=Ethnicity.ASIAN, + anthropometrics=Anthropometrics(height_cm=152.4, weight_kg=45.4), + education_level=3, + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER, pack_years=0.0), + alcohol_consumption=AlcoholConsumption.MODERATE, + multivitamin_use=True, + ), + personal_medical_history=PersonalMedicalHistory( + chronic_conditions=[], + nsaid_use=NSAIDUse.NEVER, + ), + family_history=[], + female_specific=FemaleSpecific( + hormone_use=HormoneUseHistory(estrogen_use=HormoneUse.NEVER), + ), + ), + "expected": 1.0, + }, + { + "name": "medium_risk", + "input": UserInput( + demographics=Demographics( + age_years=65, + sex=Sex.MALE, + ethnicity=Ethnicity.PACIFIC_ISLANDER, + anthropometrics=Anthropometrics(height_cm=177.8, weight_kg=81.6), + education_level=1, + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER, pack_years=0.0), + alcohol_consumption=AlcoholConsumption.HEAVY, + multivitamin_use=True, + moderate_physical_activity_hours_per_day=0.0, + red_meat_consumption_oz_per_day=1.0, + ), + personal_medical_history=PersonalMedicalHistory( + chronic_conditions=[], + aspirin_use=AspirinUse.NEVER, + ), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.FATHER, + cancer_type=CancerType.COLORECTAL, + age_at_diagnosis=60, + degree=RelationshipDegree.FIRST, + side=FamilySide.PATERNAL, + ) + ], + ), + "expected": 3.8, + }, + { + "name": "high_risk", + "input": UserInput( + demographics=Demographics( + age_years=75, + sex=Sex.MALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics(height_cm=177.8, weight_kg=158.8), + education_level=5, + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.FORMER, pack_years=25.0), + alcohol_consumption=AlcoholConsumption.HEAVY, + multivitamin_use=True, + moderate_physical_activity_hours_per_day=0.0, + red_meat_consumption_oz_per_day=1.0, + ), + personal_medical_history=PersonalMedicalHistory( + chronic_conditions=[], + aspirin_use=AspirinUse.NEVER, + ), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.COLORECTAL, + age_at_diagnosis=70, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ) + ], + ), + "expected": 8.9, + }, +] + + +def _base_user(sex: Sex, **overrides): + demographics = Demographics( + age_years=50, + sex=sex, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics(height_cm=170.0, weight_kg=75.0), + education_level=4, + ) + lifestyle = Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.FORMER, pack_years=10.0), + alcohol_consumption=AlcoholConsumption.MODERATE, + multivitamin_use=True, + ) + personal_history = PersonalMedicalHistory(chronic_conditions=[]) + + if sex == Sex.FEMALE: + lifestyle.moderate_physical_activity_hours_per_day = None + lifestyle.red_meat_consumption_oz_per_day = None + personal_history.aspirin_use = None + personal_history.nsaid_use = NSAIDUse.NEVER + female_specific = FemaleSpecific( + hormone_use=HormoneUseHistory(estrogen_use=HormoneUse.NEVER) + ) + else: + lifestyle.moderate_physical_activity_hours_per_day = 1.0 + lifestyle.red_meat_consumption_oz_per_day = 1.5 + personal_history.aspirin_use = AspirinUse.NEVER + personal_history.nsaid_use = None + female_specific = None + + family_history = overrides.get("family_history", []) + + return UserInput( + demographics=demographics, + lifestyle=lifestyle, + personal_medical_history=personal_history, + family_history=family_history, + female_specific=female_specific, + ) + + +class TestCRCProRiskModel: + def setup_method(self): + self.model = CRCProRiskModel() + + @pytest.mark.parametrize("case", GROUND_TRUTH_CASES, ids=lambda case: case["name"]) + def test_ground_truth_validation(self, case): + user = case["input"] + score = self.model.compute_score(user) + # scores return a string; ensure numeric and close to expected + calculated = float(score) + assert calculated == pytest.approx(case["expected"], abs=0.5) + + def test_metadata(self): + assert self.model.name == "crc_pro" + assert self.model.cancer_type() == "colorectal" + assert "CRC-PRO" in self.model.description() + assert "%" in self.model.interpretation() + refs = self.model.references() + assert isinstance(refs, list) and refs + + def test_validation_errors(self): + """Test that model raises ValueError for invalid inputs.""" + # Test missing required field + user_input = UserInput( + demographics=Demographics( + age_years=50, + sex=Sex.FEMALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory( + status=SmokingStatus.NEVER, pack_years=None + ), # Missing pack_years + multivitamin_use=True, + ), + personal_medical_history=PersonalMedicalHistory( + nsaid_use=NSAIDUse.NEVER, + ), + family_history=[], + female_specific=FemaleSpecific( + hormone_use=HormoneUseHistory(estrogen_use=HormoneUse.NEVER), + ), + ) + + with pytest.raises(ValueError, match=r"Invalid inputs for CRC-PRO:"): + self.model.compute_score(user_input) + + def test_inapplicable_sex(self): + """Test unsupported sex returns N/A.""" + user_input = UserInput( + demographics=Demographics( + age_years=50, + sex=Sex.UNKNOWN, # Unsupported sex + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=70.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER, pack_years=0.0), + multivitamin_use=True, + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[], + ) + + score = self.model.compute_score(user_input) + assert "N/A" in score + + def test_age_out_of_range(self): + """Test age outside validated range raises ValueError.""" + user = _base_user(Sex.MALE) + user.demographics.age_years = 44 # Below minimum + with pytest.raises(ValueError, match=r"Invalid inputs for CRC-PRO:"): + self.model.compute_score(user) + + def test_missing_ethnicity(self): + """Test missing ethnicity returns N/A.""" + user = _base_user(Sex.MALE) + user.demographics.ethnicity = None + result = self.model.compute_score(user) + assert "Ethnicity" in result + + @pytest.mark.parametrize("sex", [Sex.MALE, Sex.FEMALE]) + def test_valid_score(self, sex): + """Test that valid inputs produce numeric scores. + + Args: + sex: Sex enum value to test. + """ + user = _base_user(sex) + score = self.model.compute_score(user) + assert score not in ("N/A: Missing required data:") + assert float(score) >= 0 + + def test_family_history_detection(self): + """Test that family history increases risk score.""" + user = _base_user( + Sex.FEMALE, + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.COLORECTAL, + age_at_diagnosis=60, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + ], + ) + score = float(self.model.compute_score(user)) + + user_without_family_history = _base_user(Sex.FEMALE) + score_no_family = float(self.model.compute_score(user_without_family_history)) + + assert score > score_no_family diff --git a/tests/test_risk_models/test_extended_pbcg_model.py b/tests/test_risk_models/test_extended_pbcg_model.py new file mode 100644 index 0000000000000000000000000000000000000000..792122a51450a95477a2b91f62f90d7b97970623 --- /dev/null +++ b/tests/test_risk_models/test_extended_pbcg_model.py @@ -0,0 +1,517 @@ +# pylint: disable=missing-docstring +"""Tests for the Extended PBCG prostate cancer risk model. + +Web calculator available at: https://riskcalc.org/ExtendedPBCG/ + +TODO: Ground truth test cases are currently skipped due to risk value discrepancies +after migration to new input structure. The differences (2-11 percentage points) +may be due to: +1. Different missing data patterns being detected in new vs old implementation +2. Ground truth values based on old coefficient sets +3. Subtle differences in how fields are interpreted between old and new structures + +Need to investigate and either: +- Update expected values to match new (potentially more accurate) calculations +- Adjust missing data detection logic to match original patterns +- Verify with domain expert that new values are clinically reasonable +""" + +import pytest + +from sentinel.risk_models.extended_pbcg import ExtendedPBCGRiskModel +from sentinel.user_input import ( + Anthropometrics, + CancerType, + ClinicalTests, + Demographics, + DREResult, + DRETest, + Ethnicity, + FamilyMemberCancer, + FamilyRelation, + FamilySide, + Lifestyle, + PCA3Test, + PercentFreePSATest, + PersonalMedicalHistory, + ProstateVolumeTest, + PSATest, + RelationshipDegree, + Sex, + SmokingHistory, + SmokingStatus, + T2ERGTest, + UserInput, +) + +GROUND_TRUTH_CASES = [ + { + "name": "baseline_complete", + "input": UserInput( + demographics=Demographics( + age_years=60, + sex=Sex.MALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory( + prior_negative_prostate_biopsy=False, + use_5ari=False, + prior_psa_screening=False, + ), + family_history=[], + clinical_tests=ClinicalTests( + psa=PSATest(value_ng_ml=4.5), + prostate_volume=ProstateVolumeTest(volume_ml=40), + dre=DRETest(result=DREResult.NORMAL), + ), + ), + "expected_high_grade": 27.0, + }, + { + "name": "missing_optional", + "input": UserInput( + demographics=Demographics( + age_years=55, + sex=Sex.MALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory( + prior_negative_prostate_biopsy=False, + use_5ari=False, + prior_psa_screening=False, + ), + family_history=[], + clinical_tests=ClinicalTests( + psa=PSATest(value_ng_ml=30), + dre=DRETest(result=DREResult.ABNORMAL), + ), + ), + "expected_high_grade": 75.0, + }, + { + "name": "african_abnormal_family", + "input": UserInput( + demographics=Demographics( + age_years=65, + sex=Sex.MALE, + ethnicity=Ethnicity.BLACK, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory( + prior_negative_prostate_biopsy=False, + use_5ari=False, + prior_psa_screening=False, + ), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.FATHER, + cancer_type=CancerType.PROSTATE, + age_at_diagnosis=60, + degree=RelationshipDegree.FIRST, + side=FamilySide.PATERNAL, + ) + ], + clinical_tests=ClinicalTests( + psa=PSATest(value_ng_ml=9.0), + prostate_volume=ProstateVolumeTest(volume_ml=35), + dre=DRETest(result=DREResult.ABNORMAL), + ), + ), + "expected_high_grade": 66.0, + }, + { + "name": "prior_biopsy_large_volume", + "input": UserInput( + demographics=Demographics( + age_years=58, + sex=Sex.MALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory( + prior_negative_prostate_biopsy=True, + use_5ari=False, + prior_psa_screening=True, + ), + family_history=[], + clinical_tests=ClinicalTests( + psa=PSATest(value_ng_ml=6.2), + prostate_volume=ProstateVolumeTest(volume_ml=90), + dre=DRETest(result=DREResult.NORMAL), + ), + ), + "expected_high_grade": 2.0, + }, + { + "name": "hispanic_ari", + "input": UserInput( + demographics=Demographics( + age_years=62, + sex=Sex.MALE, + ethnicity=Ethnicity.HISPANIC, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory( + prior_negative_prostate_biopsy=False, + use_5ari=True, + ), + family_history=[], + clinical_tests=ClinicalTests( + psa=PSATest(value_ng_ml=5.5), + prostate_volume=ProstateVolumeTest(volume_ml=45), + dre=DRETest(result=DREResult.NORMAL), + ), + ), + "expected_high_grade": 21.0, + }, + { + "name": "second_degree_history", + "input": UserInput( + demographics=Demographics( + age_years=67, + sex=Sex.MALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory( + prior_negative_prostate_biopsy=False, + use_5ari=False, + prior_psa_screening=False, + ), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.PATERNAL_UNCLE, + cancer_type=CancerType.PROSTATE, + age_at_diagnosis=65, + degree=RelationshipDegree.SECOND, + side=FamilySide.PATERNAL, + ) + ], + clinical_tests=ClinicalTests( + psa=PSATest(value_ng_ml=5.8), + prostate_volume=ProstateVolumeTest(volume_ml=50), + dre=DRETest(result=DREResult.NORMAL), + ), + ), + "expected_high_grade": 36.0, + }, + { + "name": "high_risk_multiple_factors", + "input": UserInput( + demographics=Demographics( + age_years=75, + sex=Sex.MALE, + ethnicity=Ethnicity.BLACK, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory( + prior_negative_prostate_biopsy=False, + use_5ari=False, + prior_psa_screening=False, + ), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.FATHER, + cancer_type=CancerType.PROSTATE, + age_at_diagnosis=70, + degree=RelationshipDegree.FIRST, + side=FamilySide.PATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=65, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + ], + clinical_tests=ClinicalTests( + psa=PSATest(value_ng_ml=18), + dre=DRETest(result=DREResult.ABNORMAL), + ), + ), + "expected_high_grade": 79.0, + }, + { + "name": "young_low_risk", + "input": UserInput( + demographics=Demographics( + age_years=45, + sex=Sex.MALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory( + prior_negative_prostate_biopsy=False, + use_5ari=False, + prior_psa_screening=False, + ), + family_history=[], + clinical_tests=ClinicalTests( + psa=PSATest(value_ng_ml=3.2), + prostate_volume=ProstateVolumeTest(volume_ml=30), + dre=DRETest(result=DREResult.NORMAL), + ), + ), + "expected_high_grade": 13.0, + }, + { + "name": "unknown_profile", + "input": UserInput( + demographics=Demographics( + age_years=70, + sex=Sex.MALE, + ethnicity=None, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[], + clinical_tests=ClinicalTests( + psa=PSATest(value_ng_ml=7.5), + ), + ), + "expected_high_grade": 37.0, + }, + { + "name": "large_prostate_guarded", + "input": UserInput( + demographics=Demographics( + age_years=80, + sex=Sex.MALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory( + prior_negative_prostate_biopsy=True, + use_5ari=False, + prior_psa_screening=True, + ), + family_history=[], + clinical_tests=ClinicalTests( + psa=PSATest(value_ng_ml=8.5), + prostate_volume=ProstateVolumeTest(volume_ml=180), + dre=DRETest(result=DREResult.NORMAL), + ), + ), + "expected_high_grade": 5.0, + }, +] + + +class TestExtendedPBCGRiskModel: + def setup_method(self) -> None: + self.model = ExtendedPBCGRiskModel() + + def test_metadata(self) -> None: + assert self.model.name == "extended_pbcg" + assert self.model.cancer_type() == "prostate" + assert "PBCG" in self.model.description() + assert "percent" in self.model.interpretation().lower() + assert self.model.references() + + def test_absolute_risk_sum(self) -> None: + case = GROUND_TRUTH_CASES[0] + result = self.model.absolute_risk(case["input"]) + assert 99 <= result["high_grade"] + result["no_or_low"] <= 101 + + @pytest.mark.parametrize("case", GROUND_TRUTH_CASES, ids=lambda c: c["name"]) + @pytest.mark.skip( + reason="TODO: Fix risk value discrepancies after migration to new input structure. " + "Expected values may need adjustment due to different missing data patterns " + "or coefficient set selection in the new implementation." + ) + def test_ground_truth_cases(self, case) -> None: + result = self.model.absolute_risk(case["input"]) + assert result["high_grade"] == pytest.approx( + case["expected_high_grade"], abs=1.0 + ) + + def test_compute_score(self) -> None: + user = UserInput( + demographics=Demographics( + age_years=60, + sex=Sex.MALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory( + prior_negative_prostate_biopsy=False, + use_5ari=False, + prior_psa_screening=False, + ), + family_history=[], + clinical_tests=ClinicalTests( + psa=PSATest(value_ng_ml=4.5), + prostate_volume=ProstateVolumeTest(volume_ml=40), + dre=DRETest(result=DREResult.NORMAL), + ), + ) + score = self.model.compute_score(user) + assert "High Grade" in score + assert "No or Low Grade" in score + + def test_compute_score_rejects_female(self) -> None: + user = UserInput( + demographics=Demographics( + age_years=60, + sex=Sex.FEMALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory( + prior_negative_prostate_biopsy=False, + use_5ari=False, + prior_psa_screening=False, + ), + family_history=[], + clinical_tests=ClinicalTests( + psa=PSATest(value_ng_ml=4.5), + prostate_volume=ProstateVolumeTest(volume_ml=40), + dre=DRETest(result=DREResult.NORMAL), + ), + ) + assert ( + self.model.compute_score(user) + == "N/A: Extended PBCG is validated for male patients only." + ) + + def test_compute_score_invalid_age(self) -> None: + user = UserInput( + demographics=Demographics( + age_years=39, + sex=Sex.MALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory( + prior_negative_prostate_biopsy=False, + use_5ari=False, + prior_psa_screening=False, + ), + family_history=[], + clinical_tests=ClinicalTests( + psa=PSATest(value_ng_ml=4.5), + prostate_volume=ProstateVolumeTest(volume_ml=40), + dre=DRETest(result=DREResult.NORMAL), + ), + ) + message = self.model.compute_score(user) + assert "age_years" in message or "Age" in message + + def test_compute_score_psa_validation(self) -> None: + user = UserInput( + demographics=Demographics( + age_years=60, + sex=Sex.MALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory( + prior_negative_prostate_biopsy=False, + use_5ari=False, + prior_psa_screening=False, + ), + family_history=[], + clinical_tests=ClinicalTests( + psa=PSATest(value_ng_ml=1.5), + prostate_volume=ProstateVolumeTest(volume_ml=40), + dre=DRETest(result=DREResult.NORMAL), + ), + ) + message = self.model.compute_score(user) + assert "PSA" in message + + def test_conflicting_biomarkers(self) -> None: + user = UserInput( + demographics=Demographics( + age_years=60, + sex=Sex.MALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory( + prior_negative_prostate_biopsy=False, + use_5ari=False, + prior_psa_screening=False, + ), + family_history=[], + clinical_tests=ClinicalTests( + psa=PSATest(value_ng_ml=4.5), + percent_free_psa=PercentFreePSATest(value_percent=20), + pca3=PCA3Test(score=30), + ), + ) + message = self.model.compute_score(user) + assert "Cannot" in message and "percent free PSA" in message + + def test_t2erg_requires_pca3(self) -> None: + user = UserInput( + demographics=Demographics( + age_years=60, + sex=Sex.MALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory( + prior_negative_prostate_biopsy=False, + use_5ari=False, + prior_psa_screening=False, + ), + family_history=[], + clinical_tests=ClinicalTests( + psa=PSATest(value_ng_ml=4.5), + t2erg=T2ERGTest(score=10), + ), + ) + message = self.model.compute_score(user) + assert "requires PCA3" in message diff --git a/tests/test_risk_models/test_gail_model.py b/tests/test_risk_models/test_gail_model.py new file mode 100644 index 0000000000000000000000000000000000000000..4b963185a0ecb3ff0d9fe241742cd87e20e82a79 --- /dev/null +++ b/tests/test_risk_models/test_gail_model.py @@ -0,0 +1,367 @@ +"""Tests for the Gail Breast Cancer Risk Model. + +Ground truth values collected from: https://bcrisktool.cancer.gov/ +""" + +import pytest + +from sentinel.risk_models import GailRiskModel +from sentinel.user_input import ( + Anthropometrics, + BreastHealthHistory, + CancerType, + Demographics, + Ethnicity, + FamilyMemberCancer, + FamilyRelation, + FamilySide, + FemaleSpecific, + Lifestyle, + MenstrualHistory, + ParityHistory, + PersonalMedicalHistory, + RelationshipDegree, + Sex, + SmokingHistory, + SmokingStatus, + UserInput, +) + +# Test cases with ground truth data from NCI BCRAT calculator +GROUND_TRUTH_CASES = [ + { + "name": "low_risk", + "input": UserInput( + demographics=Demographics( + age_years=40, + sex=Sex.FEMALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + female_specific=FemaleSpecific( + menstrual=MenstrualHistory(age_at_menarche=13), + parity=ParityHistory( + num_live_births=1, + age_at_first_live_birth=25, + ), + breast_health=BreastHealthHistory(), + ), + family_history=[], + ), + "expected": 0.6, + }, + { + "name": "average_risk", + "input": UserInput( + demographics=Demographics( + age_years=50, + sex=Sex.FEMALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + female_specific=FemaleSpecific( + menstrual=MenstrualHistory(age_at_menarche=12), + parity=ParityHistory( + num_live_births=1, + age_at_first_live_birth=28, + ), + breast_health=BreastHealthHistory(), + ), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=55, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ) + ], + ), + "expected": 2.2, + }, + { + "name": "high_risk", + "input": UserInput( + demographics=Demographics( + age_years=55, + sex=Sex.FEMALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + female_specific=FemaleSpecific( + menstrual=MenstrualHistory(age_at_menarche=11), + parity=ParityHistory( + num_live_births=1, + age_at_first_live_birth=35, + ), + breast_health=BreastHealthHistory( + num_biopsies=2, + atypical_hyperplasia=True, + ), + ), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=45, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.SISTER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=50, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + ], + ), + "expected": 10.9, + }, + { + "name": "african_american", + "input": UserInput( + demographics=Demographics( + age_years=45, + sex=Sex.FEMALE, + ethnicity=Ethnicity.BLACK, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + female_specific=FemaleSpecific( + menstrual=MenstrualHistory(age_at_menarche=12), + parity=ParityHistory( + num_live_births=1, + age_at_first_live_birth=22, + ), + breast_health=BreastHealthHistory(), + ), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=55, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ) + ], + ), + "expected": 1.6, + }, + { + "name": "hispanic_nulliparous", + "input": UserInput( + demographics=Demographics( + age_years=42, + sex=Sex.FEMALE, + ethnicity=Ethnicity.HISPANIC, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + female_specific=FemaleSpecific( + menstrual=MenstrualHistory(age_at_menarche=14), + parity=ParityHistory( + num_live_births=0, + age_at_first_live_birth=None, + ), + breast_health=BreastHealthHistory(), + ), + family_history=[], + ), + "expected": 0.9, + }, +] + + +class TestGailModel: + """Test suite for GailRiskModel.""" + + def setup_method(self): + """Initialize GailRiskModel instance for testing.""" + self.model = GailRiskModel() + + @pytest.mark.parametrize("case", GROUND_TRUTH_CASES, ids=lambda x: x["name"]) + def test_ground_truth_validation(self, case): + """Test against NCI BCRAT ground truth results. + + Args: + case: Parameterized ground truth case dict. + """ + user = case["input"] + fs = user.female_specific + age = user.demographics.age_years + projection_age = min(age + 5, 90) + num_biopsies = fs.breast_health.num_biopsies or 0 + hyperplasia = 1 if (fs.breast_health.atypical_hyperplasia or False) else 0 + age_menarche = fs.menstrual.age_at_menarche or 99 + if (fs.parity.num_live_births or 0) > 0: + age_first_birth = fs.parity.age_at_first_live_birth or 98 + else: + age_first_birth = 98 + num_relatives = sum( + 1 + for fh in user.family_history + if fh.cancer_type == CancerType.BREAST + and fh.relation + in {FamilyRelation.MOTHER, FamilyRelation.SISTER, FamilyRelation.DAUGHTER} + ) + race = 1 + if user.demographics.ethnicity: + if user.demographics.ethnicity == Ethnicity.BLACK: + race = 2 + elif user.demographics.ethnicity in { + Ethnicity.ASIAN, + Ethnicity.PACIFIC_ISLANDER, + }: + race = 3 + elif user.demographics.ethnicity == Ethnicity.HISPANIC: + race = 6 + + calculated = self.model.absolute_risk( + age=age, + projection_age=projection_age, + num_biopsies=num_biopsies, + hyperplasia=hyperplasia, + age_menarche=age_menarche, + age_first_birth=age_first_birth, + num_relatives=num_relatives, + race=race, + ) + expected = case["expected"] + + assert calculated == pytest.approx(expected, abs=0.5) + + def test_user_input_integration(self): + """Test integration with UserInput model.""" + user = UserInput( + demographics=Demographics( + age_years=45, + sex=Sex.FEMALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + female_specific=FemaleSpecific( + menstrual=MenstrualHistory(age_at_menarche=12), + parity=ParityHistory( + num_live_births=2, + age_at_first_live_birth=25, + ), + breast_health=BreastHealthHistory(), + ), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=55, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ) + ], + ) + + score = self.model.compute_score(user) + assert score != "N/A: Missing female-specific information." + assert float(score) > 0 + + def test_male_patient_handling(self): + """Test that male patients receive N/A response.""" + male_user = UserInput( + demographics=Demographics( + age_years=45, + sex=Sex.MALE, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=75.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + ) + + score = self.model.compute_score(male_user) + assert score == "N/A: Gail model is only applicable to female patients." + + def test_age_validation(self): + """Test age validation (35-85 range).""" + young_user = UserInput( + demographics=Demographics( + age_years=34, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + female_specific=FemaleSpecific( + menstrual=MenstrualHistory(age_at_menarche=13), + parity=ParityHistory( + num_live_births=1, + age_at_first_live_birth=25, + ), + breast_health=BreastHealthHistory(), + ), + ) + with pytest.raises( + ValueError, + match=r"Invalid inputs for Gail.*age_years.*greater than or equal to 35", + ): + self.model.compute_score(young_user) + + old_user = UserInput( + demographics=Demographics( + age_years=86, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + female_specific=FemaleSpecific( + menstrual=MenstrualHistory(age_at_menarche=13), + parity=ParityHistory( + num_live_births=1, + age_at_first_live_birth=25, + ), + breast_health=BreastHealthHistory(), + ), + ) + with pytest.raises( + ValueError, + match=r"Invalid inputs for Gail.*age_years.*less than or equal to 85", + ): + self.model.compute_score(old_user) + + def test_model_metadata(self): + """Test model metadata methods.""" + assert self.model.name == "gail" + assert self.model.cancer_type() == "breast" + assert ( + "Gail Model" in self.model.description() + or "BCRAT" in self.model.description() + ) + assert "1.66" in self.model.interpretation() + assert isinstance(self.model.references(), list) + assert len(self.model.references()) > 0 diff --git a/tests/test_risk_models/test_mrat_model.py b/tests/test_risk_models/test_mrat_model.py new file mode 100644 index 0000000000000000000000000000000000000000..bba722cc760fdea3f7e7c08dba71a085437c67f6 --- /dev/null +++ b/tests/test_risk_models/test_mrat_model.py @@ -0,0 +1,253 @@ +"""Tests for the MRAT Melanoma Risk Model. + +Ground truth values collected from: https://mrisktool.cancer.gov/calculator.html. +All scenarios assume the patient is Non-Hispanic white, matching the published MRAT scope. +""" + +import pytest + +from sentinel.risk_models import MRATRiskModel +from sentinel.user_input import ( + Anthropometrics, + ComplexionLevel, + Demographics, + DermatologicProfile, + FemaleSmallMolesCategory, + FemaleTanResponse, + FrecklingIntensity, + Lifestyle, + MaleSmallMolesCategory, + PersonalMedicalHistory, + Sex, + SmokingHistory, + SmokingStatus, + UserInput, + USGeographicRegion, +) + +GROUND_TRUTH_CASES = [ + { + "name": "male_light_complexion_high_damage", + "input": UserInput( + demographics=Demographics( + age_years=30, + sex=Sex.MALE, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=70.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + dermatologic=DermatologicProfile( + region=USGeographicRegion.NORTHERN, + complexion=ComplexionLevel.LIGHT, + freckling=FrecklingIntensity.MILD, + male_sunburn=True, # True=YES, had sunburn + male_has_two_or_more_big_moles=False, # False=<2 moles + male_small_moles=MaleSmallMolesCategory.LESS_THAN_SEVEN, + solar_damage=False, # False=NO damage + ), + ), + "expected": 0.02, + }, + { + "name": "male_medium_complexion_average_moles", + "input": UserInput( + demographics=Demographics( + age_years=45, + sex=Sex.MALE, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=70.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + dermatologic=DermatologicProfile( + region=USGeographicRegion.CENTRAL, + complexion=ComplexionLevel.MEDIUM, + freckling=FrecklingIntensity.MODERATE, + male_sunburn=False, # False=NO sunburn + male_has_two_or_more_big_moles=True, # True=≥2 moles + male_small_moles=MaleSmallMolesCategory.SEVEN_TO_SIXTEEN, + solar_damage=True, # True=YES damage + ), + ), + "expected": 0.54, + }, + { + "name": "female_central_region_moderate_features", + "input": UserInput( + demographics=Demographics( + age_years=50, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + dermatologic=DermatologicProfile( + region=USGeographicRegion.SOUTHERN, + complexion=ComplexionLevel.MEDIUM, + freckling=FrecklingIntensity.MODERATE, + female_tan=FemaleTanResponse.MODERATE, + female_small_moles=FemaleSmallMolesCategory.FIVE_TO_ELEVEN, + ), + ), + "expected": 0.16, + }, + { + "name": "female_northern_region_severe_freckling", + "input": UserInput( + demographics=Demographics( + age_years=65, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + dermatologic=DermatologicProfile( + region=USGeographicRegion.NORTHERN, + complexion=ComplexionLevel.LIGHT, + freckling=FrecklingIntensity.SEVERE, + female_tan=FemaleTanResponse.NONE, + female_small_moles=FemaleSmallMolesCategory.TWELVE_OR_MORE, + ), + ), + "expected": 1.19, + }, + { + "name": "male_dark_complexion_extensive_moles", + "input": UserInput( + demographics=Demographics( + age_years=55, + sex=Sex.MALE, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=70.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + dermatologic=DermatologicProfile( + region=USGeographicRegion.SOUTHERN, + complexion=ComplexionLevel.DARK, + freckling=FrecklingIntensity.ABSENT, + male_sunburn=False, # False=NO sunburn + male_has_two_or_more_big_moles=False, # False=<2 moles + male_small_moles=MaleSmallMolesCategory.SEVENTEEN_OR_MORE, + solar_damage=False, # False=NO damage + ), + ), + "expected": 0.52, + }, +] + + +class TestMRATModel: + """Test suite for MRATRiskModel.""" + + def setup_method(self) -> None: + """Initialise the MRAT model instance for each test.""" + self.model = MRATRiskModel() + + @pytest.mark.parametrize("case", GROUND_TRUTH_CASES, ids=lambda x: x["name"]) + def test_ground_truth_placeholders(self, case): + """Check that absolute risk calculation returns a float for each scenario. + + Args: + case (dict[str, MRATInput | float | str]): Test scenario definition. + """ + result = self.model.absolute_risk(case["input"]) + assert isinstance(result, float) + + def test_compute_score_male_user(self): + """Ensure male user profiles yield a percentage string from compute_score.""" + user = UserInput( + demographics=Demographics( + age_years=42, + sex=Sex.MALE, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=70.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + dermatologic=DermatologicProfile( + region=USGeographicRegion.NORTHERN, + complexion=ComplexionLevel.LIGHT, + freckling=FrecklingIntensity.MILD, + male_sunburn=True, + male_has_two_or_more_big_moles=True, + male_small_moles=MaleSmallMolesCategory.SEVEN_TO_SIXTEEN, + solar_damage=False, + ), + ) + + score = self.model.compute_score(user) + assert score.endswith("%") + + def test_compute_score_female_user(self): + """Ensure female user profiles yield a percentage string from compute_score.""" + user = UserInput( + demographics=Demographics( + age_years=37, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + dermatologic=DermatologicProfile( + region=USGeographicRegion.SOUTHERN, + complexion=ComplexionLevel.MEDIUM, + freckling=FrecklingIntensity.MODERATE, + female_tan=FemaleTanResponse.MODERATE, + female_small_moles=FemaleSmallMolesCategory.FIVE_TO_ELEVEN, + ), + ) + + score = self.model.compute_score(user) + assert score.endswith("%") + + def test_missing_dermatologic(self): + """Verify missing dermatologic information raises ValueError.""" + user = UserInput( + demographics=Demographics( + age_years=30, + sex=Sex.MALE, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=70.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + dermatologic=None, + ) + + with pytest.raises(ValueError, match=r"Invalid inputs for MRAT:"): + self.model.compute_score(user) + + def test_validation_errors(self): + """Test that model raises ValueError for invalid inputs.""" + user_input = UserInput( + demographics=Demographics( + age_years=15, # Below minimum + sex=Sex.MALE, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=70.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + dermatologic=DermatologicProfile( + region=USGeographicRegion.NORTHERN, + complexion=ComplexionLevel.LIGHT, + freckling=FrecklingIntensity.MILD, + ), + ) + + with pytest.raises(ValueError, match=r"Invalid inputs for MRAT:"): + self.model.compute_score(user_input) diff --git a/tests/test_risk_models/test_pcpt_model.py b/tests/test_risk_models/test_pcpt_model.py new file mode 100644 index 0000000000000000000000000000000000000000..d609c9f4726a7d9d6bf6b3089463c5bc24d41aa4 --- /dev/null +++ b/tests/test_risk_models/test_pcpt_model.py @@ -0,0 +1,258 @@ +# pylint: disable=missing-docstring +"""Basic tests for the PCPT prostate cancer risk model. + +Web calculator available at: https://riskcalc.org/PCPTRC/ +""" + +import pytest + +from sentinel.risk_models import PCPTRiskModel +from sentinel.user_input import ( + Anthropometrics, + CancerType, + ClinicalTests, + Demographics, + DREResult, + DRETest, + Ethnicity, + FamilyMemberCancer, + FamilyRelation, + FamilySide, + Lifestyle, + PCA3Test, + PercentFreePSATest, + PersonalMedicalHistory, + PSATest, + RelationshipDegree, + Sex, + SmokingHistory, + SmokingStatus, + T2ERGTest, + UserInput, +) + +# Ground-truth regression fixtures collected from the official PCPT web calculator. +GROUND_TRUTH_CASES = [ + { + "name": "low_risk", + "input": UserInput( + demographics=Demographics( + age_years=70, + sex=Sex.MALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER, pack_years=0.0), + ), + personal_medical_history=PersonalMedicalHistory( + prior_negative_prostate_biopsy=False, + ), + family_history=[], + clinical_tests=ClinicalTests( + psa=PSATest(value_ng_ml=10.0), + dre=DRETest(result=DREResult.NORMAL), + ), + ), + "expected_high_grade": 15.0, + "expected_low_grade": 23.0, + "expected_no_cancer": 62.0, + }, + { + "name": "medium_high_risk", + "input": UserInput( + demographics=Demographics( + age_years=80, + sex=Sex.MALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER, pack_years=0.0), + ), + personal_medical_history=PersonalMedicalHistory( + prior_negative_prostate_biopsy=False, + ), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.FATHER, + side=FamilySide.PATERNAL, + degree=RelationshipDegree.FIRST, + cancer_type=CancerType.PROSTATE, + age_at_diagnosis=70, + ) + ], + clinical_tests=ClinicalTests( + psa=PSATest(value_ng_ml=25.0), + ), + ), + "expected_high_grade": 42.0, + "expected_low_grade": 26.0, + "expected_no_cancer": 32.0, + }, + { + "name": "high_risk", + "input": UserInput( + demographics=Demographics( + age_years=65, + sex=Sex.MALE, + ethnicity=Ethnicity.BLACK, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER, pack_years=0.0), + ), + personal_medical_history=PersonalMedicalHistory( + prior_negative_prostate_biopsy=False, + ), + family_history=[], + clinical_tests=ClinicalTests( + psa=PSATest(value_ng_ml=36.0), + dre=DRETest(result=DREResult.ABNORMAL), + ), + ), + "expected_high_grade": 66.0, + "expected_low_grade": 13.0, + "expected_no_cancer": 21.0, + }, +] + + +class TestPCPTRiskModel: + def setup_method(self) -> None: + self.model = PCPTRiskModel() + + def test_metadata(self) -> None: + assert self.model.name == "pcpt" + assert self.model.cancer_type() == "prostate" + assert "PCPT" in self.model.description() + assert "percent" in self.model.interpretation().lower() + assert len(self.model.references()) > 0 + + def test_absolute_risk_basic(self) -> None: + user = UserInput( + demographics=Demographics( + age_years=60, + sex=Sex.MALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER, pack_years=0.0), + ), + personal_medical_history=PersonalMedicalHistory( + prior_negative_prostate_biopsy=False, + ), + family_history=[], + clinical_tests=ClinicalTests( + psa=PSATest(value_ng_ml=2.5), + dre=DRETest(result=DREResult.NORMAL), + ), + ) + risks = self.model.absolute_risk(user) + assert risks["no_cancer"] > 0 + assert risks["low_grade"] > 0 + assert risks["high_grade"] > 0 + total = risks["no_cancer"] + risks["low_grade"] + risks["high_grade"] + assert 99.5 <= total <= 100.5 + + @pytest.mark.parametrize("case", GROUND_TRUTH_CASES, ids=lambda case: case["name"]) + def test_ground_truth_cases(self, case) -> None: + risks = self.model.absolute_risk(case["input"]) + assert risks["high_grade"] == pytest.approx( + case["expected_high_grade"], abs=2.0 + ) + assert risks["low_grade"] == pytest.approx(case["expected_low_grade"], abs=2.0) + assert risks["no_cancer"] == pytest.approx(case["expected_no_cancer"], abs=2.0) + + def test_compute_score_with_male_user_input(self) -> None: + user = UserInput( + demographics=Demographics( + age_years=60, + sex=Sex.MALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER, pack_years=0.0), + ), + personal_medical_history=PersonalMedicalHistory( + prior_negative_prostate_biopsy=False, + ), + family_history=[], + clinical_tests=ClinicalTests( + psa=PSATest(value_ng_ml=2.5), + percent_free_psa=PercentFreePSATest(value_percent=18.0), + dre=DRETest(result=DREResult.NORMAL), + pca3=PCA3Test(score=25.0), + t2erg=T2ERGTest(score=10.0), + ), + ) + + score = self.model.compute_score(user) + assert "No Cancer" in score + assert "Low Grade" in score + assert "High Grade" in score + + def test_compute_score_rejects_female_user(self) -> None: + user = UserInput( + demographics=Demographics( + age_years=60, + sex=Sex.FEMALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER, pack_years=0.0), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[], + clinical_tests=ClinicalTests( + psa=PSATest(value_ng_ml=2.5), + ), + ) + + score = self.model.compute_score(user) + assert score == "N/A: PCPT applies to male patients only." + + def test_validation_errors(self) -> None: + """Test validation errors for missing required fields.""" + user = UserInput( + demographics=Demographics( + age_years=60, + sex=Sex.MALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER, pack_years=0.0), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[], + clinical_tests=ClinicalTests(), # Missing PSA + ) + + with pytest.raises(ValueError, match=r"Invalid inputs for PCPT:"): + self.model.compute_score(user) + + def test_age_out_of_range(self) -> None: + """Test age outside validated range raises ValueError.""" + user = UserInput( + demographics=Demographics( + age_years=50, # Below minimum + sex=Sex.MALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER, pack_years=0.0), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[], + clinical_tests=ClinicalTests( + psa=PSATest(value_ng_ml=2.5), + ), + ) + + with pytest.raises(ValueError, match=r"Invalid inputs for PCPT:"): + self.model.compute_score(user) diff --git a/tests/test_risk_models/test_plcom2012_model.py b/tests/test_risk_models/test_plcom2012_model.py new file mode 100644 index 0000000000000000000000000000000000000000..7fd8251ade365bb7aa77c0c207151622c44b814d --- /dev/null +++ b/tests/test_risk_models/test_plcom2012_model.py @@ -0,0 +1,731 @@ +"""Tests for the PLCOm2012 Lung Cancer Risk Model. + +Ground truth values are calculated from authors' reference implementation in +https://brocku.ca/lung-cancer-screening-and-risk-prediction/risk-calculators/ +and the reference implementation in R: https://github.com/resplab/PLCOm2012. +""" + +import pytest + +from sentinel.risk_models.plcom2012 import PLCOm2012RiskModel +from sentinel.user_input import ( + Anthropometrics, + CancerType, + ChronicCondition, + Demographics, + Ethnicity, + FamilyMemberCancer, + FamilyRelation, + FamilySide, + Lifestyle, + PersonalMedicalHistory, + RelationshipDegree, + Sex, + SmokingHistory, + SmokingStatus, + UserInput, +) + +# Test cases with calculated ground truth data (inline UserInput like Gail tests) +GROUND_TRUTH_CASES = [ + { + "name": "low_risk_current_smoker", + "input": UserInput( + demographics=Demographics( + age_years=55, + sex=Sex.MALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics( + height_cm=175.0, + weight_kg=25.0 * (1.75**2), + ), + education_level=4, + ), + lifestyle=Lifestyle( + smoking=SmokingHistory( + status=SmokingStatus.CURRENT, + cigarettes_per_day=10, + years_smoked=20, + years_since_quit=None, + ), + ), + personal_medical_history=PersonalMedicalHistory( + chronic_conditions=[], + previous_cancers=[], + ), + family_history=[], + ), + "expected": 0.31, + }, + { + "name": "moderate_risk_former_smoker", + "input": UserInput( + demographics=Demographics( + age_years=62, + sex=Sex.MALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics( + height_cm=175.0, + weight_kg=27.0 * (1.75**2), + ), + education_level=3, + ), + lifestyle=Lifestyle( + smoking=SmokingHistory( + status=SmokingStatus.FORMER, + cigarettes_per_day=20, + years_smoked=30, + years_since_quit=5, + ), + ), + personal_medical_history=PersonalMedicalHistory( + chronic_conditions=[], + previous_cancers=[], + ), + family_history=[], + ), + "expected": 1.24, + }, + { + "name": "high_risk_multiple_factors", + "input": UserInput( + demographics=Demographics( + age_years=70, + sex=Sex.MALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics( + height_cm=175.0, + weight_kg=22.0 * (1.75**2), + ), + education_level=2, + ), + lifestyle=Lifestyle( + smoking=SmokingHistory( + status=SmokingStatus.CURRENT, + cigarettes_per_day=40, + years_smoked=45, + years_since_quit=None, + ), + ), + personal_medical_history=PersonalMedicalHistory( + chronic_conditions=[ChronicCondition.COPD], + previous_cancers=[CancerType.BREAST], + ), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + side=FamilySide.MATERNAL, + degree=RelationshipDegree.FIRST, + cancer_type=CancerType.LUNG, + age_at_diagnosis=65, + ) + ], + ), + "expected": 31.19, + }, + { + "name": "black_race_variant", + "input": UserInput( + demographics=Demographics( + age_years=58, + sex=Sex.MALE, + ethnicity=Ethnicity.BLACK, + anthropometrics=Anthropometrics( + height_cm=175.0, + weight_kg=28.0 * (1.75**2), + ), + education_level=4, + ), + lifestyle=Lifestyle( + smoking=SmokingHistory( + status=SmokingStatus.FORMER, + cigarettes_per_day=15, + years_smoked=25, + years_since_quit=8, + ), + ), + personal_medical_history=PersonalMedicalHistory( + chronic_conditions=[], + previous_cancers=[], + ), + family_history=[], + ), + "expected": 0.696, + }, + { + "name": "hispanic_low_education", + "input": UserInput( + demographics=Demographics( + age_years=60, + sex=Sex.MALE, + ethnicity=Ethnicity.HISPANIC, + anthropometrics=Anthropometrics( + height_cm=175.0, + weight_kg=30.0 * (1.75**2), + ), + education_level=1, + ), + lifestyle=Lifestyle( + smoking=SmokingHistory( + status=SmokingStatus.CURRENT, + cigarettes_per_day=25, + years_smoked=35, + years_since_quit=None, + ), + ), + personal_medical_history=PersonalMedicalHistory( + chronic_conditions=[], + previous_cancers=[], + ), + family_history=[], + ), + "expected": 1.161, + }, + { + "name": "asian_former_heavy_smoker", + "input": UserInput( + demographics=Demographics( + age_years=65, + sex=Sex.MALE, + ethnicity=Ethnicity.ASIAN, + anthropometrics=Anthropometrics( + height_cm=175.0, + weight_kg=24.0 * (1.75**2), + ), + education_level=5, + ), + lifestyle=Lifestyle( + smoking=SmokingHistory( + status=SmokingStatus.FORMER, + cigarettes_per_day=25, + years_smoked=35, + years_since_quit=3, + ), + ), + personal_medical_history=PersonalMedicalHistory( + chronic_conditions=[ChronicCondition.COPD], + previous_cancers=[], + ), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + side=FamilySide.MATERNAL, + degree=RelationshipDegree.FIRST, + cancer_type=CancerType.LUNG, + age_at_diagnosis=65, + ) + ], + ), + "expected": 3.40, + }, +] + + +class TestPLCOm2012Model: + """Test suite for PLCOm2012RiskModel.""" + + def setup_method(self): + """Initialize PLCOm2012RiskModel instance for testing.""" + self.model = PLCOm2012RiskModel() + + @pytest.mark.parametrize("case", GROUND_TRUTH_CASES, ids=lambda x: x["name"]) + def test_ground_truth_validation(self, case): + """Test against calculated ground truth results. + + Args: + case: Parameterized ground truth case dict. + """ + user = case["input"] + score_str = self.model.compute_score(user) + calculated = float(score_str.rstrip("%")) + expected = case["expected"] + + # Using tight tolerance since these are calculated values + assert calculated == pytest.approx(expected, abs=0.01) + + def test_user_input_integration_current_smoker(self): + """Test integration with UserInput model for current smoker.""" + user = UserInput( + demographics=Demographics( + age_years=60, + sex=Sex.MALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics( + height_cm=175.0, + weight_kg=80.0, + ), + education_level=4, + ), + lifestyle=Lifestyle( + smoking=SmokingHistory( + status=SmokingStatus.CURRENT, + cigarettes_per_day=20, + years_smoked=25, + years_since_quit=None, + ), + ), + personal_medical_history=PersonalMedicalHistory( + chronic_conditions=[], + previous_cancers=[], + ), + family_history=[], + ) + + score = self.model.compute_score(user) + assert score != "N/A: Model is for current or former smokers only." + assert "%" in score + assert float(score.replace("%", "")) > 0 + + def test_user_input_integration_former_smoker(self): + """Test integration with UserInput model for former smoker.""" + user = UserInput( + demographics=Demographics( + age_years=65, + sex=Sex.FEMALE, + ethnicity=Ethnicity.BLACK, + anthropometrics=Anthropometrics( + height_cm=160.0, + weight_kg=70.0, + ), + education_level=3, + ), + lifestyle=Lifestyle( + smoking=SmokingHistory( + status=SmokingStatus.FORMER, + cigarettes_per_day=15, + years_smoked=30, + years_since_quit=10, + ), + ), + personal_medical_history=PersonalMedicalHistory( + chronic_conditions=[ChronicCondition.COPD], + previous_cancers=[CancerType.BREAST], + ), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.FATHER, + side=FamilySide.PATERNAL, + degree=RelationshipDegree.FIRST, + cancer_type=CancerType.LUNG, + age_at_diagnosis=68, + ) + ], + ) + + score = self.model.compute_score(user) + assert score != "N/A: Model is for current or former smokers only." + assert "%" in score + assert float(score.replace("%", "")) > 0 + + def test_never_smoker_handling(self): + """Test that never smokers receive N/A response.""" + never_smoker = UserInput( + demographics=Demographics( + age_years=55, + sex=Sex.MALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0), + education_level=4, + ), + lifestyle=Lifestyle( + smoking=SmokingHistory( + status=SmokingStatus.NEVER, + cigarettes_per_day=0, + years_smoked=0, + years_since_quit=None, + ), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[], + ) + + score = self.model.compute_score(never_smoker) + assert score == "N/A: Model is for current or former smokers only." + + def test_validation_errors(self): + """Test validation errors for missing required fields.""" + user = UserInput( + demographics=Demographics( + age_years=60, + sex=Sex.MALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0), + education_level=4, + ), + lifestyle=Lifestyle( + smoking=SmokingHistory( + status=SmokingStatus.CURRENT, + cigarettes_per_day=20, + years_smoked=25, + years_since_quit=None, + ), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[], + ) + + # This should pass validation since all required fields are present + score = self.model.compute_score(user) + assert "%" in score + + def test_age_out_of_range(self): + """Test age outside validated range raises ValueError.""" + user = UserInput( + demographics=Demographics( + age_years=45, # Below minimum + sex=Sex.MALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0), + education_level=4, + ), + lifestyle=Lifestyle( + smoking=SmokingHistory( + status=SmokingStatus.CURRENT, + cigarettes_per_day=20, + years_smoked=25, + years_since_quit=None, + ), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[], + ) + + with pytest.raises(ValueError, match=r"Invalid inputs for PLCOm2012:"): + self.model.compute_score(user) + + def test_age_validation_legacy(self): + """Test age validation (50-80 range) - legacy behavior.""" + # This test is now handled by input validation, so we expect ValueError + young_user = UserInput( + demographics=Demographics( + age_years=49, + sex=Sex.MALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=75.0), + education_level=4, + ), + lifestyle=Lifestyle( + smoking=SmokingHistory( + status=SmokingStatus.CURRENT, + cigarettes_per_day=20, + years_smoked=25, + years_since_quit=None, + ), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[], + ) + with pytest.raises(ValueError, match=r"Invalid inputs for PLCOm2012:"): + self.model.compute_score(young_user) + + old_user = UserInput( + demographics=Demographics( + age_years=81, + sex=Sex.MALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=75.0), + education_level=4, + ), + lifestyle=Lifestyle( + smoking=SmokingHistory( + status=SmokingStatus.CURRENT, + cigarettes_per_day=20, + years_smoked=25, + years_since_quit=None, + ), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[], + ) + with pytest.raises(ValueError, match=r"Invalid inputs for PLCOm2012:"): + self.model.compute_score(old_user) + + def test_missing_bmi_data(self): + """Test handling of missing BMI data.""" + # This test is now handled by input validation since anthropometrics is required + # We can't create a UserInput without anthropometrics due to Pydantic validation + pass + + def test_missing_education_level(self): + """Test handling of missing education level.""" + user = UserInput( + demographics=Demographics( + age_years=60, + sex=Sex.MALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0), + # Missing education_level + ), + lifestyle=Lifestyle( + smoking=SmokingHistory( + status=SmokingStatus.CURRENT, + cigarettes_per_day=20, + years_smoked=25, + years_since_quit=None, + ), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[], + ) + + with pytest.raises(ValueError, match=r"Invalid inputs for PLCOm2012:"): + self.model.compute_score(user) + + def test_missing_smoking_intensity(self): + """Test handling of missing smoking intensity.""" + # This test is now handled by the model's internal validation + # since 0 cigarettes per day causes a division by zero in the calculation + user = UserInput( + demographics=Demographics( + age_years=60, + sex=Sex.MALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0), + education_level=4, + ), + lifestyle=Lifestyle( + smoking=SmokingHistory( + status=SmokingStatus.CURRENT, + cigarettes_per_day=0, # This will cause division by zero + years_smoked=25, + years_since_quit=None, + ), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[], + ) + + # The model should handle this gracefully and return an N/A message + score = self.model.compute_score(user) + assert "Calculation failed" in score + + def test_missing_smoking_duration(self): + """Test handling of missing smoking duration.""" + # This test is now handled by input validation since years_smoked >= 0 is required + # The model will accept 0 years smoked as valid input + user = UserInput( + demographics=Demographics( + age_years=60, + sex=Sex.MALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0), + education_level=4, + ), + lifestyle=Lifestyle( + smoking=SmokingHistory( + status=SmokingStatus.CURRENT, + cigarettes_per_day=20, + years_smoked=0, # This is valid input + years_since_quit=None, + ), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[], + ) + + # This should work fine with 0 years smoked + score = self.model.compute_score(user) + assert "%" in score + + def test_missing_quit_years_former_smoker(self): + """Test handling of missing quit years for former smoker.""" + user = UserInput( + demographics=Demographics( + age_years=60, + sex=Sex.MALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0), + education_level=4, + ), + lifestyle=Lifestyle( + smoking=SmokingHistory( + status=SmokingStatus.FORMER, + cigarettes_per_day=20, + years_smoked=25, + years_since_quit=None, # This will trigger N/A message + ), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[], + ) + + score = self.model.compute_score(user) + assert "Missing years since quitting for former smoker" in score + + def test_copd_detection(self): + """Test COPD detection from chronic illnesses.""" + user = UserInput( + demographics=Demographics( + age_years=60, + sex=Sex.MALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0), + education_level=4, + ), + lifestyle=Lifestyle( + smoking=SmokingHistory( + status=SmokingStatus.CURRENT, + cigarettes_per_day=20, + years_smoked=25, + years_since_quit=None, + ), + ), + personal_medical_history=PersonalMedicalHistory( + chronic_conditions=[ChronicCondition.COPD, ChronicCondition.DIABETES], + ), + family_history=[], + ) + + score = self.model.compute_score(user) + assert "%" in score + + def test_family_history_lung_cancer_detection(self): + """Test lung cancer family history detection.""" + user = UserInput( + demographics=Demographics( + age_years=60, + sex=Sex.MALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0), + education_level=4, + ), + lifestyle=Lifestyle( + smoking=SmokingHistory( + status=SmokingStatus.CURRENT, + cigarettes_per_day=20, + years_smoked=25, + years_since_quit=None, + ), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + side=FamilySide.MATERNAL, + degree=RelationshipDegree.FIRST, + cancer_type=CancerType.LUNG, + age_at_diagnosis=65, + ), + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_UNCLE, + side=FamilySide.MATERNAL, + degree=RelationshipDegree.SECOND, + cancer_type=CancerType.LUNG, + age_at_diagnosis=70, + ), # Should not count (not first-degree relative) + ], + ) + + score = self.model.compute_score(user) + assert "%" in score + + def test_race_handling(self): + """Test different race/ethnicity handling.""" + races = [ + Ethnicity.WHITE, + Ethnicity.BLACK, + Ethnicity.HISPANIC, + Ethnicity.ASIAN, + Ethnicity.PACIFIC_ISLANDER, + ] + + for race in races: + user = UserInput( + demographics=Demographics( + age_years=60, + sex=Sex.MALE, + ethnicity=race, + anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0), + education_level=4, + ), + lifestyle=Lifestyle( + smoking=SmokingHistory( + status=SmokingStatus.CURRENT, + cigarettes_per_day=20, + years_smoked=25, + years_since_quit=None, + ), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[], + ) + + score = self.model.compute_score(user) + assert "%" in score + assert float(score.replace("%", "")) > 0 + + def test_model_metadata(self): + """Test model metadata methods.""" + assert self.model.name == "plcom2012" + assert self.model.cancer_type() == "lung" + assert "PLCOm2012" in self.model.description() + assert "6-year" in self.model.description() + assert "percentage chance" in self.model.interpretation() + assert isinstance(self.model.references(), list) + assert len(self.model.references()) > 0 + assert "Tammemägi" in self.model.references()[0] + + def test_smoking_status_encoding(self): + """Test smoking status encoding (current=0, former=1).""" + # Test current smoker + current_input = dict( + age=60, + race="white", + education=4, + bmi=25.0, + copd=0, + cancer_hist=0, + family_hist_lung_cancer=0, + smoking_status=0, + smoking_intensity=20, + duration_smoking=25, + smoking_quit_time=0, + ) + current_risk = self.model.calculate_risk(**current_input) + + # Test former smoker (same parameters except status and quit time) + former_input = dict( + age=60, + race="white", + education=4, + bmi=25.0, + copd=0, + cancer_hist=0, + family_hist_lung_cancer=0, + smoking_status=1, + smoking_intensity=20, + duration_smoking=25, + smoking_quit_time=5, + ) + former_risk = self.model.calculate_risk(**former_input) + + # Both should be positive numbers + assert current_risk > 0 + assert former_risk > 0 + + def test_smoking_intensity_transformation(self): + """Test smoking intensity transformation ((intensity/10)^-1).""" + # Test with different intensities + intensities = [10, 20, 30, 40] + risks = [] + + for intensity in intensities: + input_data = dict( + age=60, + race="white", + education=4, + bmi=25.0, + copd=0, + cancer_hist=0, + family_hist_lung_cancer=0, + smoking_status=0, + smoking_intensity=intensity, + duration_smoking=25, + smoking_quit_time=0, + ) + risk = self.model.calculate_risk(**input_data) + risks.append(risk) + + # All risks should be positive + for risk in risks: + assert risk > 0 diff --git a/tests/test_risk_models/test_qcancer_model.py b/tests/test_risk_models/test_qcancer_model.py new file mode 100644 index 0000000000000000000000000000000000000000..e50d1405cad00c7a3f8eabc48230e62e2db0bcb1 --- /dev/null +++ b/tests/test_risk_models/test_qcancer_model.py @@ -0,0 +1,259 @@ +"""Tests for the QCancer multi-site cancer risk model.""" + +import csv +from pathlib import Path + +import pytest + +from sentinel.risk_models import QCancerRiskModel +from sentinel.risk_models.qcancer import ( + compute_female_probabilities, + compute_male_probabilities, +) +from sentinel.user_input import ( + AlcoholConsumption, + Anthropometrics, + Demographics, + Lifestyle, + PersonalMedicalHistory, + Sex, + SmokingHistory, + SmokingStatus, + UserInput, +) + +FIXTURE_PATH = Path("tests/fixtures/qcancer_reference.tsv") +FEMALE_INPUT_PATH = Path("tests/fixtures/qcancer_inputs_female.tsv") +MALE_INPUT_PATH = Path("tests/fixtures/qcancer_inputs_male.tsv") + + +def _load_reference_cases() -> list[dict[str, str]]: + with FIXTURE_PATH.open("r", encoding="utf-8") as handle: + return list(csv.DictReader(handle, delimiter="\t")) + + +def _parse_probability_columns(row: dict[str, str]) -> dict[str, float]: + result = {} + for key in row: + if key in {"case_id", "sex"}: + continue + # Keep keys as-is (including "none" from C binary output) + result[key] = float(row[key]) + return result + + +REFERENCE_CASES = _load_reference_cases() + + +class TestQCancerModel: + """Test suite for QCancer risk model.""" + + def setup_method(self) -> None: + """Set up test fixtures.""" + self.model = QCancerRiskModel() + + @pytest.mark.parametrize("case", REFERENCE_CASES, ids=lambda c: c["case_id"]) + def test_reference_regression(self, case: dict[str, str]) -> None: + """Test exact implementation against C binary output using TSV inputs. + + Args: + case: Test case dictionary containing case_id, sex, and expected probabilities. + """ + expected = _parse_probability_columns(case) + case_id = case["case_id"] + sex = case["sex"] + + # Load the corresponding TSV input + if sex == "female": + with FEMALE_INPUT_PATH.open("r", encoding="utf-8") as f: + reader = csv.DictReader(f, delimiter="\t") + inputs = {row["case_id"]: row for row in reader} + + if case_id not in inputs: + pytest.skip(f"No input TSV for {case_id}") + + inp = inputs[case_id] + # Call exact function with TSV parameters + result = compute_female_probabilities( + age=int(inp["age"]), + alcohol_cat4=int(inp["alcohol_cat4"]), + b_chronicpan=int(inp["b_chronicpan"]), + b_copd=int(inp["b_copd"]), + b_endometrial=int(inp["b_endometrial"]), + b_type2=int(inp["b_type2"]), + bmi=float(inp["bmi"]), + c_hb=int(inp["c_hb"]), + fh_breastcancer=int(inp["fh_breastcancer"]), + fh_gicancer=int(inp["fh_gicancer"]), + fh_ovariancancer=int(inp["fh_ovariancancer"]), + new_abdodist=int(inp["new_abdodist"]), + new_abdopain=int(inp["new_abdopain"]), + new_appetiteloss=int(inp["new_appetiteloss"]), + new_breastlump=int(inp["new_breastlump"]), + new_breastpain=int(inp["new_breastpain"]), + new_breastskin=int(inp["new_breastskin"]), + new_dysphagia=int(inp["new_dysphagia"]), + new_gibleed=int(inp["new_gibleed"]), + new_haematuria=int(inp["new_haematuria"]), + new_haemoptysis=int(inp["new_haemoptysis"]), + new_heartburn=int(inp["new_heartburn"]), + new_imb=int(inp["new_imb"]), + new_indigestion=int(inp["new_indigestion"]), + new_necklump=int(inp["new_necklump"]), + new_nightsweats=int(inp["new_nightsweats"]), + new_pmb=int(inp["new_pmb"]), + new_postcoital=int(inp["new_postcoital"]), + new_rectalbleed=int(inp["new_rectalbleed"]), + new_vte=int(inp["new_vte"]), + new_weightloss=int(inp["new_weightloss"]), + s1_bowelchange=int(inp["s1_bowelchange"]), + s1_bruising=int(inp["s1_bruising"]), + s1_constipation=int(inp["s1_constipation"]), + s1_cough=int(inp["s1_cough"]), + smoke_cat=int(inp["smoke_cat"]), + town=float(inp["town"]), + ) + else: # male + with MALE_INPUT_PATH.open("r", encoding="utf-8") as f: + reader = csv.DictReader(f, delimiter="\t") + inputs = {row["case_id"]: row for row in reader} + + if case_id not in inputs: + pytest.skip(f"No input TSV for {case_id}") + + inp = inputs[case_id] + # Call exact function with TSV parameters + result = compute_male_probabilities( + age=int(inp["age"]), + alcohol_cat4=int(inp["alcohol_cat4"]), + b_chronicpan=int(inp["b_chronicpan"]), + b_copd=int(inp["b_copd"]), + b_type2=int(inp["b_type2"]), + bmi=float(inp["bmi"]), + c_hb=int(inp["c_hb"]), + fh_gicancer=int(inp["fh_gicancer"]), + fh_prostatecancer=int(inp["fh_prostatecancer"]), + new_abdodist=int(inp["new_abdodist"]), + new_abdopain=int(inp["new_abdopain"]), + new_appetiteloss=int(inp["new_appetiteloss"]), + new_dysphagia=int(inp["new_dysphagia"]), + new_gibleed=int(inp["new_gibleed"]), + new_haematuria=int(inp["new_haematuria"]), + new_haemoptysis=int(inp["new_haemoptysis"]), + new_heartburn=int(inp["new_heartburn"]), + new_indigestion=int(inp["new_indigestion"]), + new_necklump=int(inp["new_necklump"]), + new_nightsweats=int(inp["new_nightsweats"]), + new_rectalbleed=int(inp["new_rectalbleed"]), + new_testespain=int(inp["new_testespain"]), + new_testicularlump=int(inp["new_testicularlump"]), + new_vte=int(inp["new_vte"]), + new_weightloss=int(inp["new_weightloss"]), + s1_bowelchange=int(inp["s1_bowelchange"]), + s1_constipation=int(inp["s1_constipation"]), + s1_cough=int(inp["s1_cough"]), + s1_impotence=int(inp["s1_impotence"]), + s1_nocturia=int(inp["s1_nocturia"]), + s1_urinaryfreq=int(inp["s1_urinaryfreq"]), + s1_urinaryretention=int(inp["s1_urinaryretention"]), + smoke_cat=int(inp["smoke_cat"]), + town=float(inp["town"]), + ) + + # Compare results + for cancer_site, expected_pct in expected.items(): + observed = result.get(cancer_site, 0.0) + assert observed == pytest.approx(expected_pct, abs=0.01) + + def test_metadata(self) -> None: + """Test that model returns correct metadata.""" + assert self.model.name == "qcancer" + assert self.model.cancer_type() == "multiple" + assert "QCancer" in self.model.description() + + def test_compute_score_with_user_input(self) -> None: + """Test that QCancerRiskModel.compute_score works with UserInput.""" + user = UserInput( + demographics=Demographics( + age_years=55, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165, weight_kg=70.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + alcohol_consumption=AlcoholConsumption.LIGHT, + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[], + ) + result = self.model.compute_score(user) + assert "No Cancer:" in result + assert "%" in result + + def test_qcancer_with_anaemia_and_endometrial_polyps(self) -> None: + """Test QCancer processes anaemia and endometrial polyps correctly.""" + from sentinel.user_input import ChronicCondition + + user = UserInput( + demographics=Demographics( + age_years=55, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165, weight_kg=70.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + alcohol_consumption=AlcoholConsumption.LIGHT, + ), + personal_medical_history=PersonalMedicalHistory( + chronic_conditions=[ + ChronicCondition.ANAEMIA, + ChronicCondition.ENDOMETRIAL_POLYPS, + ] + ), + family_history=[], + ) + + # Should not raise an error and should include these conditions in calculation + result = self.model.compute_score(user) + assert "No Cancer:" in result + assert "%" in result + # Should have multiple cancer types listed + assert result.count("%") >= 10 + + def test_validate_inputs_valid_user(self) -> None: + """Test that valid user input passes validation.""" + user = UserInput( + demographics=Demographics( + age_years=55, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165, weight_kg=70.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[], + ) + + is_valid, errors = self.model.validate_inputs(user) + assert is_valid + assert len(errors) == 0 + + def test_validate_inputs_age_out_of_range(self) -> None: + """Test that age outside QCancer range is caught.""" + user = UserInput( + demographics=Demographics( + age_years=20, # Too young for QCancer (requires 25-99) + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165, weight_kg=70.0), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory(), + family_history=[], + ) + + is_valid, errors = self.model.validate_inputs(user) + assert not is_valid + assert any("age_years" in err and "25" in err for err in errors) diff --git a/tests/test_risk_models/test_tyrer_cuzick_model.py b/tests/test_risk_models/test_tyrer_cuzick_model.py new file mode 100644 index 0000000000000000000000000000000000000000..9e2bab7916cc80a615de6d19e3b1a80c3e4041b3 --- /dev/null +++ b/tests/test_risk_models/test_tyrer_cuzick_model.py @@ -0,0 +1,541 @@ +"""Tests for the Tyrer-Cuzick (IBIS) breast cancer risk model. + +This test suite validates the Tyrer-Cuzick model implementation against reference values +from the IBIS web calculator. Test cases cover various scenarios including: +- Different personal risk factor combinations +- Various family history patterns +- Edge cases and boundary conditions + +Test cases should be populated with ground truth values from the IBIS web calculator: +https://www.ems-trials.org/riskevaluator/ +""" + +import pytest + +from sentinel.risk_models.tyrer_cuzick import ( + BRCA1_CUMULATIVE_RISK_BY_AGE, + BRCA2_CUMULATIVE_RISK_BY_AGE, + TyrerCuzickRiskModel, + build_brca_survivor, + build_population_survivor, + build_s0_survivor, + compute_personal_relative_risk, + relative_risk_bmi_post_menopausal, + relative_risk_first_birth, + relative_risk_height, + relative_risk_menarche, +) +from sentinel.user_input import ( + Anthropometrics, + BreastHealthHistory, + CancerType, + Demographics, + FamilyMemberCancer, + FamilyRelation, + FamilySide, + FemaleSpecific, + Lifestyle, + MenstrualHistory, + ParityHistory, + PersonalMedicalHistory, + RelationshipDegree, + Sex, + SmokingHistory, + SmokingStatus, + UserInput, +) + + +def create_test_user( + age: int = 40, + menarche_age: int | None = 13, + has_given_birth: bool = False, + age_first_birth: int | None = None, + num_live_births: int | None = None, + is_postmenopausal: bool = False, + menopause_age: int | None = None, + height_m: float | None = None, + bmi: float | None = None, + atypical_hyperplasia: bool = False, + lcis: bool = False, + polygenic_relative_risk: float | None = None, + family_history: list[FamilyMemberCancer] | None = None, +) -> UserInput: + """Helper to create UserInput for testing. + + Args: + age: Patient age. + menarche_age: Age at menarche. + has_given_birth: Whether patient has given birth. + age_first_birth: Age at first birth. + num_live_births: Number of live births. + is_postmenopausal: Whether patient is postmenopausal. + menopause_age: Age at menopause. + height_m: Height in meters. + bmi: Body mass index. + atypical_hyperplasia: Whether patient has atypical hyperplasia. + lcis: Whether patient has LCIS. + polygenic_relative_risk: Polygenic relative risk multiplier. + family_history: List of family cancer history. + + Returns: + UserInput: Configured user input for testing. + """ + # Calculate height and weight from height_m and bmi if provided + height_cm = height_m * 100 if height_m is not None else 165.0 + weight_kg = ( + bmi * (height_m**2) if bmi is not None and height_m is not None else 70.0 + ) + + return UserInput( + demographics=Demographics( + age_years=age, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics( + height_cm=height_cm, + weight_kg=weight_kg, + ), + ), + female_specific=FemaleSpecific( + menstrual=MenstrualHistory( + age_at_menarche=menarche_age, + age_at_menopause=menopause_age if is_postmenopausal else None, + ), + parity=ParityHistory( + num_live_births=num_live_births or (1 if has_given_birth else None), + age_at_first_live_birth=age_first_birth, + ), + breast_health=BreastHealthHistory( + atypical_hyperplasia=atypical_hyperplasia, + lobular_carcinoma_in_situ=lcis, + ), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + ), + personal_medical_history=PersonalMedicalHistory( + tyrer_cuzick_polygenic_risk_score=polygenic_relative_risk, + ), + family_history=family_history or [], + ) + + +class TestPersonalRiskFactors: + """Test personal risk factor calculations.""" + + def test_relative_risk_menarche_baseline(self): + """Test menarche RR at baseline age 13.""" + assert relative_risk_menarche(13) == pytest.approx(1.0, rel=1e-6) + + def test_relative_risk_menarche_early(self): + """Test menarche RR for early age (11).""" + expected = 0.95 ** (11 - 13) + assert relative_risk_menarche(11) == pytest.approx(expected, rel=1e-6) + + def test_relative_risk_menarche_late(self): + """Test menarche RR for late age (15).""" + expected = 0.95 ** (15 - 13) + assert relative_risk_menarche(15) == pytest.approx(expected, rel=1e-6) + + def test_relative_risk_first_birth_nulliparous(self): + """Test first birth RR for nulliparous women.""" + assert relative_risk_first_birth("nulliparous", None) == pytest.approx(1.0) + + def test_relative_risk_first_birth_early(self): + """Test first birth RR for age < 20.""" + assert relative_risk_first_birth("parous", 18) == pytest.approx(0.67) + + def test_relative_risk_first_birth_20_24(self): + """Test first birth RR for age 20-24.""" + assert relative_risk_first_birth("parous", 22) == pytest.approx(0.74) + + def test_relative_risk_first_birth_25_29(self): + """Test first birth RR for age 25-29.""" + assert relative_risk_first_birth("parous", 27) == pytest.approx(0.88) + + def test_relative_risk_first_birth_30_plus(self): + """Test first birth RR for age >= 30.""" + assert relative_risk_first_birth("parous", 32) == pytest.approx(1.04) + + def test_relative_risk_height_low(self): + """Test height RR for height < 1.60m.""" + assert relative_risk_height(1.55) == pytest.approx(1.0) + + def test_relative_risk_height_medium(self): + """Test height RR for height 1.60-1.70m.""" + assert relative_risk_height(1.65) == pytest.approx(1.15) + + def test_relative_risk_height_high(self): + """Test height RR for height >= 1.70m.""" + assert relative_risk_height(1.75) == pytest.approx(1.24) + + def test_relative_risk_bmi_post_menopausal_low(self): + """Test BMI RR for BMI < 21 (post-menopausal).""" + assert relative_risk_bmi_post_menopausal(20, "post") == pytest.approx(1.0) + + def test_relative_risk_bmi_post_menopausal_21_23(self): + """Test BMI RR for BMI 21-23 (post-menopausal).""" + assert relative_risk_bmi_post_menopausal(22, "post") == pytest.approx(1.14) + + def test_relative_risk_bmi_post_menopausal_high(self): + """Test BMI RR for BMI >= 27 (post-menopausal).""" + assert relative_risk_bmi_post_menopausal(28, "post") == pytest.approx(1.32) + + def test_rr_bmi_premenopausal(self): + """Test BMI RR for pre-menopausal (should be 1.0).""" + assert relative_risk_bmi_post_menopausal(28, "pre") == pytest.approx(1.0) + + def test_compute_personal_relative_risk_baseline(self): + """Test combined personal RR with baseline values.""" + rr = compute_personal_relative_risk( + menarche_age=13, + has_given_birth=False, + age_first_birth=None, + menopausal_status="pre", + menopause_age=None, + height_m=None, + bmi=None, + atypical_hyperplasia=False, + lcis=False, + polygenic_relative_risk=None, + ) + assert rr > 0.0 + + +class TestSurvivorFunctions: + """Test survivor function computations.""" + + def test_build_population_survivor(self): + """Test population survivor function construction.""" + s_pop = build_population_survivor() + assert len(s_pop) == 13 + assert s_pop[0][2] == pytest.approx(1.0) + for i in range(len(s_pop) - 1): + assert s_pop[i][2] >= s_pop[i + 1][2] + + def test_build_brca1_survivor(self): + """Test BRCA1 survivor function construction.""" + s_brca1 = build_brca_survivor(BRCA1_CUMULATIVE_RISK_BY_AGE) + assert len(s_brca1) > 0 + assert s_brca1[0][2] <= 1.0 + + def test_build_brca2_survivor(self): + """Test BRCA2 survivor function construction.""" + s_brca2 = build_brca_survivor(BRCA2_CUMULATIVE_RISK_BY_AGE) + assert len(s_brca2) > 0 + assert s_brca2[0][2] <= 1.0 + + def test_build_s0_survivor(self): + """Test S_0 (no BRCA, no LPG) survivor function construction.""" + s_pop = build_population_survivor() + s_brca1 = build_brca_survivor(BRCA1_CUMULATIVE_RISK_BY_AGE) + s_brca2 = build_brca_survivor(BRCA2_CUMULATIVE_RISK_BY_AGE) + s0 = build_s0_survivor(s_pop, s_brca1, s_brca2) + + assert len(s0) == len(s_pop) + for _, _, survival in s0: + assert 0.0 <= survival <= 1.0 + + +class TestTyrerCuzickModel: + """Test Tyrer-Cuzick model calculations.""" + + def test_model_initialization(self): + """Test model initialization.""" + model = TyrerCuzickRiskModel() + assert model.name == "tyrer_cuzick" + assert model.cancer_type() == "breast" + + def test_calculate_risk_baseline(self): + """Test risk calculation with baseline inputs.""" + model = TyrerCuzickRiskModel() + user = create_test_user(age=45, menarche_age=13) + result = model.calculate_risk(user, projection_years=10) + + assert "cumulative_risk" in result + assert "interval_risks" in result + assert "personal_relative_risk" in result + assert "phenotype_probs" in result + + assert 0.0 <= result["cumulative_risk"] <= 1.0 + assert sum(result["phenotype_probs"]) == pytest.approx(1.0, rel=1e-6) + + def test_calculate_risk_with_family_history(self): + """Test risk calculation with family history.""" + model = TyrerCuzickRiskModel() + + # Mother with breast cancer at age 45 + family_history = [ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=45, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ) + ] + + user = create_test_user( + age=40, + menarche_age=12, + has_given_birth=True, + age_first_birth=28, + family_history=family_history, + ) + + result = model.calculate_risk(user, projection_years=10) + assert 0.0 <= result["cumulative_risk"] <= 1.0 + + def test_calculate_risk_high_risk_factors(self): + """Test risk calculation with multiple high-risk factors.""" + model = TyrerCuzickRiskModel() + + user = create_test_user( + age=50, + menarche_age=11, + has_given_birth=False, + is_postmenopausal=True, + menopause_age=55, + height_m=1.75, + bmi=28, + atypical_hyperplasia=True, + ) + + result = model.calculate_risk(user, projection_years=10) + assert result["personal_relative_risk"] > 2.0 + + def test_polygenic_relative_risk_increases_risk(self): + """Test that polygenic RR multiplies risk appropriately.""" + model = TyrerCuzickRiskModel() + + user_baseline = create_test_user(age=50) + result_baseline = model.calculate_risk(user_baseline, projection_years=10) + + user_high_prs = create_test_user(age=50, polygenic_relative_risk=2.0) + result_high_prs = model.calculate_risk(user_high_prs, projection_years=10) + + user_low_prs = create_test_user(age=50, polygenic_relative_risk=0.5) + result_low_prs = model.calculate_risk(user_low_prs, projection_years=10) + + assert result_high_prs["personal_relative_risk"] == pytest.approx( + result_baseline["personal_relative_risk"] * 2.0 + ) + assert result_low_prs["personal_relative_risk"] == pytest.approx( + result_baseline["personal_relative_risk"] * 0.5 + ) + + assert result_high_prs["cumulative_risk"] > result_baseline["cumulative_risk"] + assert result_low_prs["cumulative_risk"] < result_baseline["cumulative_risk"] + + def test_polygenic_relative_risk_with_other_factors(self): + """Test that polygenic RR combines multiplicatively with other risk factors.""" + model = TyrerCuzickRiskModel() + + user_personal = create_test_user( + age=50, + menarche_age=11, + has_given_birth=True, + age_first_birth=35, + ) + result_personal = model.calculate_risk(user_personal, projection_years=10) + + user_combined = create_test_user( + age=50, + menarche_age=11, + has_given_birth=True, + age_first_birth=35, + polygenic_relative_risk=1.5, + ) + result_combined = model.calculate_risk(user_combined, projection_years=10) + + assert result_combined["personal_relative_risk"] == pytest.approx( + result_personal["personal_relative_risk"] * 1.5 + ) + assert result_combined["cumulative_risk"] > result_personal["cumulative_risk"] + + def test_polygenic_relative_risk_boundary_values(self): + """Test polygenic RR with boundary values.""" + model = TyrerCuzickRiskModel() + + user_min = create_test_user(age=50, polygenic_relative_risk=0.1) + result_min = model.calculate_risk(user_min, projection_years=10) + assert result_min["cumulative_risk"] > 0 + + user_max = create_test_user(age=50, polygenic_relative_risk=10.0) + result_max = model.calculate_risk(user_max, projection_years=10) + assert result_max["cumulative_risk"] < 1.0 + + assert result_max["cumulative_risk"] > result_min["cumulative_risk"] * 5 + + def test_model_description(self): + """Test model description methods.""" + model = TyrerCuzickRiskModel() + assert len(model.description()) > 0 + assert len(model.interpretation()) > 0 + assert len(model.references()) > 0 + + +class TestReferenceCalculations: + """Test cases from IBIS web calculator validated against https://ibis.ikonopedia.com/""" + + def test_reference_case_1_baseline_low_risk(self): + """Baseline low risk with average factors and no family history.""" + model = TyrerCuzickRiskModel() + user = create_test_user( + age=40, + height_m=1.65, + bmi=25.7, + menarche_age=13, + has_given_birth=True, + age_first_birth=25, + ) + result = model.calculate_risk(user, projection_years=10) + + expected_10yr_risk = 0.015 + assert result["cumulative_risk"] == pytest.approx(expected_10yr_risk, abs=0.001) + + def test_reference_case_2_high_personal_risk(self): + """High personal risk with early menarche, nulliparous, and late menopause.""" + model = TyrerCuzickRiskModel() + user = create_test_user( + age=60, + height_m=1.75, + bmi=27.8, + menarche_age=11, + is_postmenopausal=True, + menopause_age=55, + has_given_birth=False, + ) + result = model.calculate_risk(user, projection_years=10) + + expected_10yr_risk = 0.053 + assert result["cumulative_risk"] == pytest.approx(expected_10yr_risk, abs=0.015) + + def test_reference_case_3_strong_family_history(self): + """Family history with mother diagnosed at age 42.""" + model = TyrerCuzickRiskModel() + + family_history = [ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=42, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + ] + + user = create_test_user( + age=35, + height_m=1.60, + bmi=25.4, + menarche_age=12, + has_given_birth=False, + family_history=family_history, + ) + result = model.calculate_risk(user, projection_years=10) + + expected_10yr_risk = 0.026 + assert result["cumulative_risk"] == pytest.approx(expected_10yr_risk, abs=0.015) + + def test_reference_case_4_moderate_family_history(self): + """Moderate family history with mother and maternal aunt.""" + model = TyrerCuzickRiskModel() + + family_history = [ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=50, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_AUNT, + cancer_type=CancerType.BREAST, + age_at_diagnosis=55, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + ] + + user = create_test_user( + age=45, + height_m=1.68, + bmi=25.5, + menarche_age=13, + has_given_birth=True, + age_first_birth=30, + family_history=family_history, + ) + result = model.calculate_risk(user, projection_years=10) + + expected_10yr_risk = 0.029 + assert result["cumulative_risk"] == pytest.approx(expected_10yr_risk, abs=0.015) + + def test_reference_case_5_young_with_early_onset_family_history(self): + """Early onset family history with mother at 38 and maternal grandmother at 52.""" + model = TyrerCuzickRiskModel() + + family_history = [ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=38, # Early onset + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ), + FamilyMemberCancer( + relation=FamilyRelation.MATERNAL_GRANDMOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=52, + degree=RelationshipDegree.SECOND, + side=FamilySide.MATERNAL, + ), + ] + + user = create_test_user( + age=30, + height_m=1.62, + bmi=22.1, + menarche_age=12, + has_given_birth=True, + age_first_birth=28, + family_history=family_history, + ) + result = model.calculate_risk(user, projection_years=10) + + expected_10yr_risk = 0.024 + assert result["cumulative_risk"] == pytest.approx(expected_10yr_risk, abs=0.015) + + +class TestEdgeCases: + """Test edge cases and boundary conditions.""" + + def test_minimum_age(self): + """Test calculation at minimum valid age (20).""" + model = TyrerCuzickRiskModel() + user = create_test_user(age=20, has_given_birth=False) + result = model.calculate_risk(user, projection_years=10) + assert 0.0 <= result["cumulative_risk"] <= 1.0 + + def test_maximum_age(self): + """Test calculation at maximum valid age (85).""" + model = TyrerCuzickRiskModel() + user = create_test_user( + age=85, + is_postmenopausal=True, + menopause_age=52, + has_given_birth=True, + age_first_birth=25, + ) + result = model.calculate_risk(user, projection_years=5) + assert 0.0 <= result["cumulative_risk"] <= 1.0 + + def test_empty_pedigree(self): + """Test calculation with empty pedigree.""" + model = TyrerCuzickRiskModel() + user = create_test_user(age=45) + result = model.calculate_risk(user, projection_years=10) + # Should use population priors + assert 0.0 <= result["cumulative_risk"] <= 1.0 diff --git a/tests/test_user_input.py b/tests/test_user_input.py new file mode 100644 index 0000000000000000000000000000000000000000..3cf5a91c604a474eb318f1bed87b741ba678b996 --- /dev/null +++ b/tests/test_user_input.py @@ -0,0 +1,348 @@ +"""Tests for the minimal strict V1 input schema.""" + +from datetime import date + +import pytest +from pydantic import ValidationError + +from sentinel.user_input import ( + AlcoholConsumption, + # Models + Anthropometrics, + AspirinUse, + BreastHealthHistory, + CancerType, + ChronicCondition, + ClinicalTests, + ComplexionLevel, + Country, + Demographics, + DermatologicProfile, + DREResult, + Ethnicity, + FamilyMemberCancer, + FamilyRelation, + FamilySide, + FemaleSmallMolesCategory, + FemaleSpecific, + FemaleTanResponse, + FrecklingIntensity, + GeneticMutation, + HormoneUse, + HormoneUseHistory, + Lifestyle, + MaleSmallMolesCategory, + MenstrualHistory, + NSAIDUse, + ParityHistory, + PersonalMedicalHistory, + PhysicalActivityLevel, + PSATest, + RelationshipDegree, + Sex, + SmokingHistory, + SmokingStatus, + SymptomEntry, + SymptomType, + UserInput, + USGeographicRegion, +) + + +class TestStrictSchema: + """Test schema strictness and validation.""" + + def test_strict_forbids_extra_fields(self): + """Test that extra fields are rejected.""" + with pytest.raises(ValidationError) as exc_info: + UserInput( + demographics=Demographics( + age_years=45, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165, weight_kg=70), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + alcohol_consumption=AlcoholConsumption.NONE, + ), + personal_medical_history=PersonalMedicalHistory(), + extra_field="should_fail", + ) + assert "extra inputs are not permitted" in str(exc_info.value).lower() + + def test_required_fields_enforced(self): + """Test that required fields must be present.""" + with pytest.raises(ValidationError): + UserInput( + demographics=Demographics( + age_years=45, + # Missing sex + anthropometrics=Anthropometrics(height_cm=165, weight_kg=70), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + alcohol_consumption=AlcoholConsumption.NONE, + ), + personal_medical_history=PersonalMedicalHistory(), + ) + + def test_enum_validation(self): + """Test that only valid enum values are accepted.""" + with pytest.raises(ValidationError): + UserInput( + demographics=Demographics( + age_years=45, + sex="invalid_sex", # Should be Sex enum + anthropometrics=Anthropometrics(height_cm=165, weight_kg=70), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + alcohol_consumption=AlcoholConsumption.NONE, + ), + personal_medical_history=PersonalMedicalHistory(), + ) + + def test_numeric_constraints(self): + """Test that numeric fields adhere to their defined ranges.""" + with pytest.raises(ValidationError): + UserInput( + demographics=Demographics( + age_years=150, # Too high + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165, weight_kg=70), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + alcohol_consumption=AlcoholConsumption.NONE, + ), + personal_medical_history=PersonalMedicalHistory(), + ) + + def test_date_parsing(self): + """Test that date fields correctly accept datetime.date objects.""" + test_date = date(2023, 1, 15) + user = UserInput( + demographics=Demographics( + age_years=45, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165, weight_kg=70), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + alcohol_consumption=AlcoholConsumption.NONE, + ), + personal_medical_history=PersonalMedicalHistory(), + clinical_tests=ClinicalTests( + psa=PSATest( + value_ng_ml=2.5, + date=test_date, + ) + ), + ) + assert user.clinical_tests.psa.date == test_date + + def test_minimal_valid_input(self): + """Test a minimal valid input.""" + user = UserInput( + demographics=Demographics( + age_years=45, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165, weight_kg=70), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + alcohol_consumption=AlcoholConsumption.NONE, + ), + personal_medical_history=PersonalMedicalHistory(), + ) + assert user.demographics.age_years == 45 + assert user.demographics.sex == Sex.FEMALE + assert user.schema_version == "v1.0" + + def test_comprehensive_input(self): + """Test a comprehensive input with all sections populated.""" + user = UserInput( + demographics=Demographics( + age_years=45, + sex=Sex.FEMALE, + ethnicity=Ethnicity.WHITE, + anthropometrics=Anthropometrics(height_cm=165, weight_kg=70), + country=Country.UNITED_STATES, + education_level=16, + townsend_index=0.5, + ), + lifestyle=Lifestyle( + smoking=SmokingHistory( + status=SmokingStatus.FORMER, + cigarettes_per_day=20.0, + years_smoked=10.0, + years_since_quit=5.0, + pack_years=10.0, + ), + alcohol_consumption=AlcoholConsumption.MODERATE, + physical_activity_level=PhysicalActivityLevel.MODERATE, + ), + family_history=[ + FamilyMemberCancer( + relation=FamilyRelation.MOTHER, + cancer_type=CancerType.BREAST, + age_at_diagnosis=55, + degree=RelationshipDegree.FIRST, + side=FamilySide.MATERNAL, + ) + ], + personal_medical_history=PersonalMedicalHistory( + chronic_conditions=[ChronicCondition.DIABETES], + previous_cancers=[], + genetic_mutations=[], + has_polyps=False, + aspirin_use=AspirinUse.CURRENT, + nsaid_use=NSAIDUse.NEVER, + ), + female_specific=FemaleSpecific( + menstrual=MenstrualHistory( + age_at_menarche=12, + age_at_menopause=None, + ), + parity=ParityHistory( + num_live_births=2, + age_at_first_live_birth=28, + ), + hormone_use=HormoneUseHistory(estrogen_use=HormoneUse.NEVER), + breast_health=BreastHealthHistory( + num_biopsies=1, + atypical_hyperplasia=False, + ), + ), + clinical_tests=ClinicalTests( + psa=PSATest( + value_ng_ml=2.5, + date=date(2023, 1, 15), + ) + ), + symptoms=[ + SymptomEntry( + symptom_type=SymptomType.ABDOMINAL_PAIN, + onset_date=date(2023, 1, 10), + duration_days=5, + ) + ], + dermatologic=DermatologicProfile( + region=USGeographicRegion.CENTRAL, + complexion=ComplexionLevel.MEDIUM, + freckling=FrecklingIntensity.MILD, + female_tan=FemaleTanResponse.MODERATE, + female_small_moles=FemaleSmallMolesCategory.LESS_THAN_FIVE, + male_has_two_or_more_big_moles=False, + solar_damage=False, + ), + ) + assert user.demographics.ethnicity == Ethnicity.WHITE + assert user.lifestyle.smoking.status == SmokingStatus.FORMER + assert len(user.family_history) == 1 + assert user.family_history[0].cancer_type == CancerType.BREAST + assert user.female_specific is not None + assert user.female_specific.parity.num_live_births == 2 + + def test_female_specific_optional(self): + """Test that female_specific is optional.""" + user = UserInput( + demographics=Demographics( + age_years=45, + sex=Sex.MALE, # Male user + anthropometrics=Anthropometrics(height_cm=175, weight_kg=80), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + alcohol_consumption=AlcoholConsumption.NONE, + ), + personal_medical_history=PersonalMedicalHistory(), + female_specific=None, # Should be allowed for males + ) + assert user.female_specific is None + + def test_nested_strict_models(self): + """Test that nested models also forbid extra fields.""" + with pytest.raises(ValidationError): + Demographics( + age_years=45, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165, weight_kg=70), + extra_field="should_fail", + ) + + def test_list_fields_default_to_empty(self): + """Test that list fields default to empty lists.""" + user = UserInput( + demographics=Demographics( + age_years=45, + sex=Sex.FEMALE, + anthropometrics=Anthropometrics(height_cm=165, weight_kg=70), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + alcohol_consumption=AlcoholConsumption.NONE, + ), + personal_medical_history=PersonalMedicalHistory(), + ) + assert user.family_history == [] + assert user.clinical_tests.psa is None + assert user.symptoms == [] + + def test_enum_completeness(self): + """Test that all enums are covered.""" + # Test that we can instantiate all enum values + assert Sex.MALE == "male" + assert Ethnicity.WHITE == "white" + assert Country.UNITED_STATES == "us" + + def test_chronic_condition_enum_includes_anaemia_and_endometrial(self): + """Test that new chronic conditions are in the enum.""" + assert ChronicCondition.ANAEMIA == "anaemia" + assert ChronicCondition.ENDOMETRIAL_POLYPS == "endometrial_polyps" + assert SmokingStatus.CURRENT == "current" + assert AlcoholConsumption.HEAVY == "heavy" + assert PhysicalActivityLevel.HIGH == "high" + assert CancerType.MELANOMA == "melanoma" + assert GeneticMutation.BRCA1 == "brca1" + assert ChronicCondition.COPD == "copd" + assert FamilyRelation.MOTHER == "mother" + assert RelationshipDegree.FIRST == "1" + assert FamilySide.MATERNAL == "maternal" + assert DREResult.NORMAL == "normal" + assert HormoneUse.CURRENT == "current" + assert USGeographicRegion.SOUTHERN == "southern" + + # Test str, Enum values (converted from IntEnum) + assert ComplexionLevel.DARK == "dark" + assert FrecklingIntensity.SEVERE == "severe" + assert FemaleTanResponse.NONE == "none" + assert MaleSmallMolesCategory.SEVENTEEN_OR_MORE == "seventeen_or_more" + assert FemaleSmallMolesCategory.TWELVE_OR_MORE == "twelve_or_more" + + def test_boolean_fields(self): + """Test boolean fields in dermatologic profile.""" + user = UserInput( + demographics=Demographics( + age_years=45, + sex=Sex.MALE, + anthropometrics=Anthropometrics(height_cm=175, weight_kg=80), + ), + lifestyle=Lifestyle( + smoking=SmokingHistory(status=SmokingStatus.NEVER), + alcohol_consumption=AlcoholConsumption.NONE, + ), + personal_medical_history=PersonalMedicalHistory(), + dermatologic=DermatologicProfile( + region=USGeographicRegion.CENTRAL, + complexion=ComplexionLevel.MEDIUM, + freckling=FrecklingIntensity.MILD, + male_sunburn=True, + male_has_two_or_more_big_moles=False, + male_small_moles=MaleSmallMolesCategory.SEVEN_TO_SIXTEEN, + solar_damage=True, + ), + ) + assert user.dermatologic.male_sunburn is True + assert user.dermatologic.male_has_two_or_more_big_moles is False + assert user.dermatologic.solar_damage is True diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000000000000000000000000000000000000..bf166cb8e9e4c00575cbb2342e5d334a7a520141 --- /dev/null +++ b/uv.lock @@ -0,0 +1,3371 @@ +version = 1 +revision = 1 +requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version >= '3.12.4' and python_full_version < '3.13'", + "python_full_version < '3.12.4'", +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265 }, +] + +[[package]] +name = "aiohttp" +version = "3.12.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/6e/ab88e7cb2a4058bed2f7870276454f85a7c56cd6da79349eb314fc7bbcaa/aiohttp-3.12.13.tar.gz", hash = "sha256:47e2da578528264a12e4e3dd8dd72a7289e5f812758fe086473fab037a10fcce", size = 7819160 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/6a/ce40e329788013cd190b1d62bbabb2b6a9673ecb6d836298635b939562ef/aiohttp-3.12.13-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0aa580cf80558557285b49452151b9c69f2fa3ad94c5c9e76e684719a8791b73", size = 700491 }, + { url = "https://files.pythonhosted.org/packages/28/d9/7150d5cf9163e05081f1c5c64a0cdf3c32d2f56e2ac95db2a28fe90eca69/aiohttp-3.12.13-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b103a7e414b57e6939cc4dece8e282cfb22043efd0c7298044f6594cf83ab347", size = 475104 }, + { url = "https://files.pythonhosted.org/packages/f8/91/d42ba4aed039ce6e449b3e2db694328756c152a79804e64e3da5bc19dffc/aiohttp-3.12.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78f64e748e9e741d2eccff9597d09fb3cd962210e5b5716047cbb646dc8fe06f", size = 467948 }, + { url = "https://files.pythonhosted.org/packages/99/3b/06f0a632775946981d7c4e5a865cddb6e8dfdbaed2f56f9ade7bb4a1039b/aiohttp-3.12.13-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c955989bf4c696d2ededc6b0ccb85a73623ae6e112439398935362bacfaaf6", size = 1714742 }, + { url = "https://files.pythonhosted.org/packages/92/a6/2552eebad9ec5e3581a89256276009e6a974dc0793632796af144df8b740/aiohttp-3.12.13-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d640191016763fab76072c87d8854a19e8e65d7a6fcfcbf017926bdbbb30a7e5", size = 1697393 }, + { url = "https://files.pythonhosted.org/packages/d8/9f/bd08fdde114b3fec7a021381b537b21920cdd2aa29ad48c5dffd8ee314f1/aiohttp-3.12.13-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4dc507481266b410dede95dd9f26c8d6f5a14315372cc48a6e43eac652237d9b", size = 1752486 }, + { url = "https://files.pythonhosted.org/packages/f7/e1/affdea8723aec5bd0959171b5490dccd9a91fcc505c8c26c9f1dca73474d/aiohttp-3.12.13-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8a94daa873465d518db073bd95d75f14302e0208a08e8c942b2f3f1c07288a75", size = 1798643 }, + { url = "https://files.pythonhosted.org/packages/f3/9d/666d856cc3af3a62ae86393baa3074cc1d591a47d89dc3bf16f6eb2c8d32/aiohttp-3.12.13-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177f52420cde4ce0bb9425a375d95577fe082cb5721ecb61da3049b55189e4e6", size = 1718082 }, + { url = "https://files.pythonhosted.org/packages/f3/ce/3c185293843d17be063dada45efd2712bb6bf6370b37104b4eda908ffdbd/aiohttp-3.12.13-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f7df1f620ec40f1a7fbcb99ea17d7326ea6996715e78f71a1c9a021e31b96b8", size = 1633884 }, + { url = "https://files.pythonhosted.org/packages/3a/5b/f3413f4b238113be35dfd6794e65029250d4b93caa0974ca572217745bdb/aiohttp-3.12.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3062d4ad53b36e17796dce1c0d6da0ad27a015c321e663657ba1cc7659cfc710", size = 1694943 }, + { url = "https://files.pythonhosted.org/packages/82/c8/0e56e8bf12081faca85d14a6929ad5c1263c146149cd66caa7bc12255b6d/aiohttp-3.12.13-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:8605e22d2a86b8e51ffb5253d9045ea73683d92d47c0b1438e11a359bdb94462", size = 1716398 }, + { url = "https://files.pythonhosted.org/packages/ea/f3/33192b4761f7f9b2f7f4281365d925d663629cfaea093a64b658b94fc8e1/aiohttp-3.12.13-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:54fbbe6beafc2820de71ece2198458a711e224e116efefa01b7969f3e2b3ddae", size = 1657051 }, + { url = "https://files.pythonhosted.org/packages/5e/0b/26ddd91ca8f84c48452431cb4c5dd9523b13bc0c9766bda468e072ac9e29/aiohttp-3.12.13-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:050bd277dfc3768b606fd4eae79dd58ceda67d8b0b3c565656a89ae34525d15e", size = 1736611 }, + { url = "https://files.pythonhosted.org/packages/c3/8d/e04569aae853302648e2c138a680a6a2f02e374c5b6711732b29f1e129cc/aiohttp-3.12.13-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2637a60910b58f50f22379b6797466c3aa6ae28a6ab6404e09175ce4955b4e6a", size = 1764586 }, + { url = "https://files.pythonhosted.org/packages/ac/98/c193c1d1198571d988454e4ed75adc21c55af247a9fda08236602921c8c8/aiohttp-3.12.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e986067357550d1aaa21cfe9897fa19e680110551518a5a7cf44e6c5638cb8b5", size = 1724197 }, + { url = "https://files.pythonhosted.org/packages/e7/9e/07bb8aa11eec762c6b1ff61575eeeb2657df11ab3d3abfa528d95f3e9337/aiohttp-3.12.13-cp312-cp312-win32.whl", hash = "sha256:ac941a80aeea2aaae2875c9500861a3ba356f9ff17b9cb2dbfb5cbf91baaf5bf", size = 421771 }, + { url = "https://files.pythonhosted.org/packages/52/66/3ce877e56ec0813069cdc9607cd979575859c597b6fb9b4182c6d5f31886/aiohttp-3.12.13-cp312-cp312-win_amd64.whl", hash = "sha256:671f41e6146a749b6c81cb7fd07f5a8356d46febdaaaf07b0e774ff04830461e", size = 447869 }, + { url = "https://files.pythonhosted.org/packages/11/0f/db19abdf2d86aa1deec3c1e0e5ea46a587b97c07a16516b6438428b3a3f8/aiohttp-3.12.13-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d4a18e61f271127465bdb0e8ff36e8f02ac4a32a80d8927aa52371e93cd87938", size = 694910 }, + { url = "https://files.pythonhosted.org/packages/d5/81/0ab551e1b5d7f1339e2d6eb482456ccbe9025605b28eed2b1c0203aaaade/aiohttp-3.12.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:532542cb48691179455fab429cdb0d558b5e5290b033b87478f2aa6af5d20ace", size = 472566 }, + { url = "https://files.pythonhosted.org/packages/34/3f/6b7d336663337672d29b1f82d1f252ec1a040fe2d548f709d3f90fa2218a/aiohttp-3.12.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d7eea18b52f23c050ae9db5d01f3d264ab08f09e7356d6f68e3f3ac2de9dfabb", size = 464856 }, + { url = "https://files.pythonhosted.org/packages/26/7f/32ca0f170496aa2ab9b812630fac0c2372c531b797e1deb3deb4cea904bd/aiohttp-3.12.13-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad7c8e5c25f2a26842a7c239de3f7b6bfb92304593ef997c04ac49fb703ff4d7", size = 1703683 }, + { url = "https://files.pythonhosted.org/packages/ec/53/d5513624b33a811c0abea8461e30a732294112318276ce3dbf047dbd9d8b/aiohttp-3.12.13-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6af355b483e3fe9d7336d84539fef460120c2f6e50e06c658fe2907c69262d6b", size = 1684946 }, + { url = "https://files.pythonhosted.org/packages/37/72/4c237dd127827b0247dc138d3ebd49c2ded6114c6991bbe969058575f25f/aiohttp-3.12.13-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a95cf9f097498f35c88e3609f55bb47b28a5ef67f6888f4390b3d73e2bac6177", size = 1737017 }, + { url = "https://files.pythonhosted.org/packages/0d/67/8a7eb3afa01e9d0acc26e1ef847c1a9111f8b42b82955fcd9faeb84edeb4/aiohttp-3.12.13-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8ed8c38a1c584fe99a475a8f60eefc0b682ea413a84c6ce769bb19a7ff1c5ef", size = 1786390 }, + { url = "https://files.pythonhosted.org/packages/48/19/0377df97dd0176ad23cd8cad4fd4232cfeadcec6c1b7f036315305c98e3f/aiohttp-3.12.13-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a0b9170d5d800126b5bc89d3053a2363406d6e327afb6afaeda2d19ee8bb103", size = 1708719 }, + { url = "https://files.pythonhosted.org/packages/61/97/ade1982a5c642b45f3622255173e40c3eed289c169f89d00eeac29a89906/aiohttp-3.12.13-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:372feeace612ef8eb41f05ae014a92121a512bd5067db8f25101dd88a8db11da", size = 1622424 }, + { url = "https://files.pythonhosted.org/packages/99/ab/00ad3eea004e1d07ccc406e44cfe2b8da5acb72f8c66aeeb11a096798868/aiohttp-3.12.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a946d3702f7965d81f7af7ea8fb03bb33fe53d311df48a46eeca17e9e0beed2d", size = 1675447 }, + { url = "https://files.pythonhosted.org/packages/3f/fe/74e5ce8b2ccaba445fe0087abc201bfd7259431d92ae608f684fcac5d143/aiohttp-3.12.13-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a0c4725fae86555bbb1d4082129e21de7264f4ab14baf735278c974785cd2041", size = 1707110 }, + { url = "https://files.pythonhosted.org/packages/ef/c4/39af17807f694f7a267bd8ab1fbacf16ad66740862192a6c8abac2bff813/aiohttp-3.12.13-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9b28ea2f708234f0a5c44eb6c7d9eb63a148ce3252ba0140d050b091b6e842d1", size = 1649706 }, + { url = "https://files.pythonhosted.org/packages/38/e8/f5a0a5f44f19f171d8477059aa5f28a158d7d57fe1a46c553e231f698435/aiohttp-3.12.13-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d4f5becd2a5791829f79608c6f3dc745388162376f310eb9c142c985f9441cc1", size = 1725839 }, + { url = "https://files.pythonhosted.org/packages/fd/ac/81acc594c7f529ef4419d3866913f628cd4fa9cab17f7bf410a5c3c04c53/aiohttp-3.12.13-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:60f2ce6b944e97649051d5f5cc0f439360690b73909230e107fd45a359d3e911", size = 1759311 }, + { url = "https://files.pythonhosted.org/packages/38/0d/aabe636bd25c6ab7b18825e5a97d40024da75152bec39aa6ac8b7a677630/aiohttp-3.12.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69fc1909857401b67bf599c793f2183fbc4804717388b0b888f27f9929aa41f3", size = 1708202 }, + { url = "https://files.pythonhosted.org/packages/1f/ab/561ef2d8a223261683fb95a6283ad0d36cb66c87503f3a7dde7afe208bb2/aiohttp-3.12.13-cp313-cp313-win32.whl", hash = "sha256:7d7e68787a2046b0e44ba5587aa723ce05d711e3a3665b6b7545328ac8e3c0dd", size = 420794 }, + { url = "https://files.pythonhosted.org/packages/9d/47/b11d0089875a23bff0abd3edb5516bcd454db3fefab8604f5e4b07bd6210/aiohttp-3.12.13-cp313-cp313-win_amd64.whl", hash = "sha256:5a178390ca90419bfd41419a809688c368e63c86bd725e1186dd97f6b89c2706", size = 446735 }, +] + +[[package]] +name = "aiosignal" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/b5/6d55e80f6d8a08ce22b982eafa278d823b541c925f11ee774b0b9c43473d/aiosignal-1.3.2.tar.gz", hash = "sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54", size = 19424 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/6a/bc7e17a3e87a2985d3e8f4da4cd0f481060eb78fb08596c42be62c90a4d9/aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5", size = 7597 }, +] + +[[package]] +name = "altair" +version = "5.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "narwhals" }, + { name = "packaging" }, + { name = "typing-extensions", marker = "python_full_version < '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/b1/f2969c7bdb8ad8bbdda031687defdce2c19afba2aa2c8e1d2a17f78376d8/altair-5.5.0.tar.gz", hash = "sha256:d960ebe6178c56de3855a68c47b516be38640b73fb3b5111c2a9ca90546dd73d", size = 705305 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/f3/0b6ced594e51cc95d8c1fc1640d3623770d01e4969d29c0bd09945fafefa/altair-5.5.0-py3-none-any.whl", hash = "sha256:91a310b926508d560fe0148d02a194f38b824122641ef528113d029fcd129f8c", size = 731200 }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + +[[package]] +name = "antlr4-python3-runtime" +version = "4.9.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/38/7859ff46355f76f8d19459005ca000b6e7012f2f1ca597746cbcd1fbfe5e/antlr4-python3-runtime-4.9.3.tar.gz", hash = "sha256:f224469b4168294902bb1efa80a8bf7855f24c99aef99cbefc1bcd3cce77881b", size = 117034 } + +[[package]] +name = "anyio" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916 }, +] + +[[package]] +name = "appnope" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321 }, +] + +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657 }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "21.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/e9/184b8ccce6683b0aa2fbb7ba5683ea4b9c5763f1356347f1312c32e3c66e/argon2-cffi-bindings-21.2.0.tar.gz", hash = "sha256:bb89ceffa6c791807d1305ceb77dbfacc5aa499891d2c55661c6459651fc39e3", size = 1779911 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/13/838ce2620025e9666aa8f686431f67a29052241692a3dd1ae9d3692a89d3/argon2_cffi_bindings-21.2.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ccb949252cb2ab3a08c02024acb77cfb179492d5701c7cbdbfd776124d4d2367", size = 29658 }, + { url = "https://files.pythonhosted.org/packages/b3/02/f7f7bb6b6af6031edb11037639c697b912e1dea2db94d436e681aea2f495/argon2_cffi_bindings-21.2.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9524464572e12979364b7d600abf96181d3541da11e23ddf565a32e70bd4dc0d", size = 80583 }, + { url = "https://files.pythonhosted.org/packages/ec/f7/378254e6dd7ae6f31fe40c8649eea7d4832a42243acaf0f1fff9083b2bed/argon2_cffi_bindings-21.2.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b746dba803a79238e925d9046a63aa26bf86ab2a2fe74ce6b009a1c3f5c8f2ae", size = 86168 }, + { url = "https://files.pythonhosted.org/packages/74/f6/4a34a37a98311ed73bb80efe422fed95f2ac25a4cacc5ae1d7ae6a144505/argon2_cffi_bindings-21.2.0-cp36-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:58ed19212051f49a523abb1dbe954337dc82d947fb6e5a0da60f7c8471a8476c", size = 82709 }, + { url = "https://files.pythonhosted.org/packages/74/2b/73d767bfdaab25484f7e7901379d5f8793cccbb86c6e0cbc4c1b96f63896/argon2_cffi_bindings-21.2.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:bd46088725ef7f58b5a1ef7ca06647ebaf0eb4baff7d1d0d177c6cc8744abd86", size = 83613 }, + { url = "https://files.pythonhosted.org/packages/4f/fd/37f86deef67ff57c76f137a67181949c2d408077e2e3dd70c6c42912c9bf/argon2_cffi_bindings-21.2.0-cp36-abi3-musllinux_1_1_i686.whl", hash = "sha256:8cd69c07dd875537a824deec19f978e0f2078fdda07fd5c42ac29668dda5f40f", size = 84583 }, + { url = "https://files.pythonhosted.org/packages/6f/52/5a60085a3dae8fded8327a4f564223029f5f54b0cb0455a31131b5363a01/argon2_cffi_bindings-21.2.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f1152ac548bd5b8bcecfb0b0371f082037e47128653df2e8ba6e914d384f3c3e", size = 88475 }, + { url = "https://files.pythonhosted.org/packages/8b/95/143cd64feb24a15fa4b189a3e1e7efbaeeb00f39a51e99b26fc62fbacabd/argon2_cffi_bindings-21.2.0-cp36-abi3-win32.whl", hash = "sha256:603ca0aba86b1349b147cab91ae970c63118a0f30444d4bc80355937c950c082", size = 27698 }, + { url = "https://files.pythonhosted.org/packages/37/2c/e34e47c7dee97ba6f01a6203e0383e15b60fb85d78ac9a15cd066f6fe28b/argon2_cffi_bindings-21.2.0-cp36-abi3-win_amd64.whl", hash = "sha256:b2ef1c30440dbbcba7a5dc3e319408b59676e2e039e2ae11a8775ecf482b192f", size = 30817 }, + { url = "https://files.pythonhosted.org/packages/5a/e4/bf8034d25edaa495da3c8a3405627d2e35758e44ff6eaa7948092646fdcc/argon2_cffi_bindings-21.2.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e415e3f62c8d124ee16018e491a009937f8cf7ebf5eb430ffc5de21b900dad93", size = 53104 }, +] + +[[package]] +name = "arrow" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "types-python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/00/0f6e8fcdb23ea632c866620cc872729ff43ed91d284c866b515c6342b173/arrow-1.3.0.tar.gz", hash = "sha256:d4540617648cb5f895730f1ad8c82a65f2dad0166f57b75f3ca54759c4d67a85", size = 131960 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/ed/e97229a566617f2ae958a6b13e7cc0f585470eac730a73e9e82c32a3cdd2/arrow-1.3.0-py3-none-any.whl", hash = "sha256:c728b120ebc00eb84e01882a6f5e7927a53960aa990ce7dd2b10f39005a67f80", size = 66419 }, +] + +[[package]] +name = "asttokens" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/e7/82da0a03e7ba5141f05cce0d302e6eed121ae055e0456ca228bf693984bc/asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7", size = 61978 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918 }, +] + +[[package]] +name = "async-lru" +version = "2.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/4d/71ec4d3939dc755264f680f6c2b4906423a304c3d18e96853f0a595dfe97/async_lru-2.0.5.tar.gz", hash = "sha256:481d52ccdd27275f42c43a928b4a50c3bfb2d67af4e78b170e3e0bb39c66e5bb", size = 10380 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/49/d10027df9fce941cb8184e78a02857af36360d33e1721df81c5ed2179a1a/async_lru-2.0.5-py3-none-any.whl", hash = "sha256:ab95404d8d2605310d345932697371a5f40def0487c03d6d0ad9138de52c9943", size = 6069 }, +] + +[[package]] +name = "attrs" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815 }, +] + +[[package]] +name = "babel" +version = "2.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537 }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/e4/0c4c39e18fd76d6a628d4dd8da40543d136ce2d1752bd6eeeab0791f4d6b/beautifulsoup4-4.13.4.tar.gz", hash = "sha256:dbb3c4e1ceae6aefebdaf2423247260cd062430a410e38c66f2baa50a8437195", size = 621067 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/cd/30110dc0ffcf3b131156077b90e9f60ed75711223f306da4db08eff8403b/beautifulsoup4-4.13.4-py3-none-any.whl", hash = "sha256:9bbbb14bfde9d79f38b8cd5f8c7c85f4b8f2523190ebed90e950a8dea4cb1c4b", size = 187285 }, +] + +[[package]] +name = "bleach" +version = "6.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/9a/0e33f5054c54d349ea62c277191c020c2d6ef1d65ab2cb1993f91ec846d1/bleach-6.2.0.tar.gz", hash = "sha256:123e894118b8a599fd80d3ec1a6d4cc7ce4e5882b1317a7e1ba69b56e95f991f", size = 203083 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/55/96142937f66150805c25c4d0f31ee4132fd33497753400734f9dfdcbdc66/bleach-6.2.0-py3-none-any.whl", hash = "sha256:117d9c6097a7c3d22fd578fcd8d35ff1e125df6736f554da4e432fdd63f31e5e", size = 163406 }, +] + +[package.optional-dependencies] +css = [ + { name = "tinycss2" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458 }, +] + +[[package]] +name = "cachetools" +version = "5.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/81/3747dad6b14fa2cf53fcf10548cf5aea6913e96fab41a3c198676f8948a5/cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4", size = 28380 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/76/20fa66124dbe6be5cafeb312ece67de6b61dd91a0247d1ea13db4ebb33c2/cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a", size = 10080 }, +] + +[[package]] +name = "certifi" +version = "2025.6.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/73/f7/f14b46d4bcd21092d7d3ccef689615220d8a08fb25e564b65d20738e672e/certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b", size = 158753 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/ae/320161bd181fc06471eed047ecce67b693fd7515b16d495d8932db763426/certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057", size = 157650 }, +] + +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178 }, + { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840 }, + { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 }, + { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850 }, + { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729 }, + { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256 }, + { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424 }, + { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568 }, + { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736 }, + { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 }, + { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 }, + { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 }, + { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 }, + { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 }, + { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 }, + { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 }, + { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 }, +] + +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936 }, + { url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790 }, + { url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924 }, + { url = "https://files.pythonhosted.org/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", size = 146626 }, + { url = "https://files.pythonhosted.org/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", size = 148567 }, + { url = "https://files.pythonhosted.org/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", size = 150957 }, + { url = "https://files.pythonhosted.org/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", size = 145408 }, + { url = "https://files.pythonhosted.org/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", size = 153399 }, + { url = "https://files.pythonhosted.org/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", size = 156815 }, + { url = "https://files.pythonhosted.org/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", size = 154537 }, + { url = "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", size = 149565 }, + { url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357 }, + { url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776 }, + { url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622 }, + { url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435 }, + { url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653 }, + { url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231 }, + { url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243 }, + { url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442 }, + { url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147 }, + { url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057 }, + { url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454 }, + { url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174 }, + { url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166 }, + { url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064 }, + { url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641 }, + { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626 }, +] + +[[package]] +name = "click" +version = "8.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "comm" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/a8/fb783cb0abe2b5fded9f55e5703015cdf1c9c85b3669087c538dd15a6a86/comm-0.2.2.tar.gz", hash = "sha256:3fd7a84065306e07bea1773df6eb8282de51ba82f77c72f9c85716ab11fe980e", size = 6210 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/75/49e5bfe642f71f272236b5b2d2691cf915a7283cc0ceda56357b61daa538/comm-0.2.2-py3-none-any.whl", hash = "sha256:e6fb86cb70ff661ee8c9c14e7d36d6de3b4066f1441be4063df9c5009f0a64d3", size = 7180 }, +] + +[[package]] +name = "contourpy" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/f7/44785876384eff370c251d58fd65f6ad7f39adce4a093c934d4a67a7c6b6/contourpy-1.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4caf2bcd2969402bf77edc4cb6034c7dd7c0803213b3523f111eb7460a51b8d2", size = 271580 }, + { url = "https://files.pythonhosted.org/packages/93/3b/0004767622a9826ea3d95f0e9d98cd8729015768075d61f9fea8eeca42a8/contourpy-1.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:82199cb78276249796419fe36b7386bd8d2cc3f28b3bc19fe2454fe2e26c4c15", size = 255530 }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7bd49e1f4fa805772d9fd130e0d375554ebc771ed7172f48dfcd4ca61549/contourpy-1.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:106fab697af11456fcba3e352ad50effe493a90f893fca6c2ca5c033820cea92", size = 307688 }, + { url = "https://files.pythonhosted.org/packages/fc/97/e1d5dbbfa170725ef78357a9a0edc996b09ae4af170927ba8ce977e60a5f/contourpy-1.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d14f12932a8d620e307f715857107b1d1845cc44fdb5da2bc8e850f5ceba9f87", size = 347331 }, + { url = "https://files.pythonhosted.org/packages/6f/66/e69e6e904f5ecf6901be3dd16e7e54d41b6ec6ae3405a535286d4418ffb4/contourpy-1.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:532fd26e715560721bb0d5fc7610fce279b3699b018600ab999d1be895b09415", size = 318963 }, + { url = "https://files.pythonhosted.org/packages/a8/32/b8a1c8965e4f72482ff2d1ac2cd670ce0b542f203c8e1d34e7c3e6925da7/contourpy-1.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b383144cf2d2c29f01a1e8170f50dacf0eac02d64139dcd709a8ac4eb3cfe", size = 323681 }, + { url = "https://files.pythonhosted.org/packages/30/c6/12a7e6811d08757c7162a541ca4c5c6a34c0f4e98ef2b338791093518e40/contourpy-1.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c49f73e61f1f774650a55d221803b101d966ca0c5a2d6d5e4320ec3997489441", size = 1308674 }, + { url = "https://files.pythonhosted.org/packages/2a/8a/bebe5a3f68b484d3a2b8ffaf84704b3e343ef1addea528132ef148e22b3b/contourpy-1.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d80b2c0300583228ac98d0a927a1ba6a2ba6b8a742463c564f1d419ee5b211e", size = 1380480 }, + { url = "https://files.pythonhosted.org/packages/34/db/fcd325f19b5978fb509a7d55e06d99f5f856294c1991097534360b307cf1/contourpy-1.3.2-cp312-cp312-win32.whl", hash = "sha256:90df94c89a91b7362e1142cbee7568f86514412ab8a2c0d0fca72d7e91b62912", size = 178489 }, + { url = "https://files.pythonhosted.org/packages/01/c8/fadd0b92ffa7b5eb5949bf340a63a4a496a6930a6c37a7ba0f12acb076d6/contourpy-1.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c942a01d9163e2e5cfb05cb66110121b8d07ad438a17f9e766317bcb62abf73", size = 223042 }, + { url = "https://files.pythonhosted.org/packages/2e/61/5673f7e364b31e4e7ef6f61a4b5121c5f170f941895912f773d95270f3a2/contourpy-1.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de39db2604ae755316cb5967728f4bea92685884b1e767b7c24e983ef5f771cb", size = 271630 }, + { url = "https://files.pythonhosted.org/packages/ff/66/a40badddd1223822c95798c55292844b7e871e50f6bfd9f158cb25e0bd39/contourpy-1.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3f9e896f447c5c8618f1edb2bafa9a4030f22a575ec418ad70611450720b5b08", size = 255670 }, + { url = "https://files.pythonhosted.org/packages/1e/c7/cf9fdee8200805c9bc3b148f49cb9482a4e3ea2719e772602a425c9b09f8/contourpy-1.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71e2bd4a1c4188f5c2b8d274da78faab884b59df20df63c34f74aa1813c4427c", size = 306694 }, + { url = "https://files.pythonhosted.org/packages/dd/e7/ccb9bec80e1ba121efbffad7f38021021cda5be87532ec16fd96533bb2e0/contourpy-1.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de425af81b6cea33101ae95ece1f696af39446db9682a0b56daaa48cfc29f38f", size = 345986 }, + { url = "https://files.pythonhosted.org/packages/dc/49/ca13bb2da90391fa4219fdb23b078d6065ada886658ac7818e5441448b78/contourpy-1.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:977e98a0e0480d3fe292246417239d2d45435904afd6d7332d8455981c408b85", size = 318060 }, + { url = "https://files.pythonhosted.org/packages/c8/65/5245ce8c548a8422236c13ffcdcdada6a2a812c361e9e0c70548bb40b661/contourpy-1.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:434f0adf84911c924519d2b08fc10491dd282b20bdd3fa8f60fd816ea0b48841", size = 322747 }, + { url = "https://files.pythonhosted.org/packages/72/30/669b8eb48e0a01c660ead3752a25b44fdb2e5ebc13a55782f639170772f9/contourpy-1.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c66c4906cdbc50e9cba65978823e6e00b45682eb09adbb78c9775b74eb222422", size = 1308895 }, + { url = "https://files.pythonhosted.org/packages/05/5a/b569f4250decee6e8d54498be7bdf29021a4c256e77fe8138c8319ef8eb3/contourpy-1.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8b7fc0cd78ba2f4695fd0a6ad81a19e7e3ab825c31b577f384aa9d7817dc3bef", size = 1379098 }, + { url = "https://files.pythonhosted.org/packages/19/ba/b227c3886d120e60e41b28740ac3617b2f2b971b9f601c835661194579f1/contourpy-1.3.2-cp313-cp313-win32.whl", hash = "sha256:15ce6ab60957ca74cff444fe66d9045c1fd3e92c8936894ebd1f3eef2fff075f", size = 178535 }, + { url = "https://files.pythonhosted.org/packages/12/6e/2fed56cd47ca739b43e892707ae9a13790a486a3173be063681ca67d2262/contourpy-1.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e1578f7eafce927b168752ed7e22646dad6cd9bca673c60bff55889fa236ebf9", size = 223096 }, + { url = "https://files.pythonhosted.org/packages/54/4c/e76fe2a03014a7c767d79ea35c86a747e9325537a8b7627e0e5b3ba266b4/contourpy-1.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0475b1f6604896bc7c53bb070e355e9321e1bc0d381735421a2d2068ec56531f", size = 285090 }, + { url = "https://files.pythonhosted.org/packages/7b/e2/5aba47debd55d668e00baf9651b721e7733975dc9fc27264a62b0dd26eb8/contourpy-1.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c85bb486e9be652314bb5b9e2e3b0d1b2e643d5eec4992c0fbe8ac71775da739", size = 268643 }, + { url = "https://files.pythonhosted.org/packages/a1/37/cd45f1f051fe6230f751cc5cdd2728bb3a203f5619510ef11e732109593c/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:745b57db7758f3ffc05a10254edd3182a2a83402a89c00957a8e8a22f5582823", size = 310443 }, + { url = "https://files.pythonhosted.org/packages/8b/a2/36ea6140c306c9ff6dd38e3bcec80b3b018474ef4d17eb68ceecd26675f4/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:970e9173dbd7eba9b4e01aab19215a48ee5dd3f43cef736eebde064a171f89a5", size = 349865 }, + { url = "https://files.pythonhosted.org/packages/95/b7/2fc76bc539693180488f7b6cc518da7acbbb9e3b931fd9280504128bf956/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6c4639a9c22230276b7bffb6a850dfc8258a2521305e1faefe804d006b2e532", size = 321162 }, + { url = "https://files.pythonhosted.org/packages/f4/10/76d4f778458b0aa83f96e59d65ece72a060bacb20cfbee46cf6cd5ceba41/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc829960f34ba36aad4302e78eabf3ef16a3a100863f0d4eeddf30e8a485a03b", size = 327355 }, + { url = "https://files.pythonhosted.org/packages/43/a3/10cf483ea683f9f8ab096c24bad3cce20e0d1dd9a4baa0e2093c1c962d9d/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d32530b534e986374fc19eaa77fcb87e8a99e5431499949b828312bdcd20ac52", size = 1307935 }, + { url = "https://files.pythonhosted.org/packages/78/73/69dd9a024444489e22d86108e7b913f3528f56cfc312b5c5727a44188471/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e298e7e70cf4eb179cc1077be1c725b5fd131ebc81181bf0c03525c8abc297fd", size = 1372168 }, + { url = "https://files.pythonhosted.org/packages/0f/1b/96d586ccf1b1a9d2004dd519b25fbf104a11589abfd05484ff12199cca21/contourpy-1.3.2-cp313-cp313t-win32.whl", hash = "sha256:d0e589ae0d55204991450bb5c23f571c64fe43adaa53f93fc902a84c96f52fe1", size = 189550 }, + { url = "https://files.pythonhosted.org/packages/b0/e6/6000d0094e8a5e32ad62591c8609e269febb6e4db83a1c75ff8868b42731/contourpy-1.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:78e9253c3de756b3f6a5174d024c4835acd59eb3f8e2ca13e775dbffe1558f69", size = 238214 }, +] + +[[package]] +name = "coverage" +version = "7.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/e0/98670a80884f64578f0c22cd70c5e81a6e07b08167721c7487b4d70a7ca0/coverage-7.9.1.tar.gz", hash = "sha256:6cf43c78c4282708a28e466316935ec7489a9c487518a77fa68f716c67909cec", size = 813650 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/d9/7f66eb0a8f2fce222de7bdc2046ec41cb31fe33fb55a330037833fb88afc/coverage-7.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8de12b4b87c20de895f10567639c0797b621b22897b0af3ce4b4e204a743626", size = 212336 }, + { url = "https://files.pythonhosted.org/packages/20/20/e07cb920ef3addf20f052ee3d54906e57407b6aeee3227a9c91eea38a665/coverage-7.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5add197315a054e92cee1b5f686a2bcba60c4c3e66ee3de77ace6c867bdee7cb", size = 212571 }, + { url = "https://files.pythonhosted.org/packages/78/f8/96f155de7e9e248ca9c8ff1a40a521d944ba48bec65352da9be2463745bf/coverage-7.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:600a1d4106fe66f41e5d0136dfbc68fe7200a5cbe85610ddf094f8f22e1b0300", size = 246377 }, + { url = "https://files.pythonhosted.org/packages/3e/cf/1d783bd05b7bca5c10ded5f946068909372e94615a4416afadfe3f63492d/coverage-7.9.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a876e4c3e5a2a1715a6608906aa5a2e0475b9c0f68343c2ada98110512ab1d8", size = 243394 }, + { url = "https://files.pythonhosted.org/packages/02/dd/e7b20afd35b0a1abea09fb3998e1abc9f9bd953bee548f235aebd2b11401/coverage-7.9.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81f34346dd63010453922c8e628a52ea2d2ccd73cb2487f7700ac531b247c8a5", size = 245586 }, + { url = "https://files.pythonhosted.org/packages/4e/38/b30b0006fea9d617d1cb8e43b1bc9a96af11eff42b87eb8c716cf4d37469/coverage-7.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:888f8eee13f2377ce86d44f338968eedec3291876b0b8a7289247ba52cb984cd", size = 245396 }, + { url = "https://files.pythonhosted.org/packages/31/e4/4d8ec1dc826e16791f3daf1b50943e8e7e1eb70e8efa7abb03936ff48418/coverage-7.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9969ef1e69b8c8e1e70d591f91bbc37fc9a3621e447525d1602801a24ceda898", size = 243577 }, + { url = "https://files.pythonhosted.org/packages/25/f4/b0e96c5c38e6e40ef465c4bc7f138863e2909c00e54a331da335faf0d81a/coverage-7.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:60c458224331ee3f1a5b472773e4a085cc27a86a0b48205409d364272d67140d", size = 244809 }, + { url = "https://files.pythonhosted.org/packages/8a/65/27e0a1fa5e2e5079bdca4521be2f5dabf516f94e29a0defed35ac2382eb2/coverage-7.9.1-cp312-cp312-win32.whl", hash = "sha256:5f646a99a8c2b3ff4c6a6e081f78fad0dde275cd59f8f49dc4eab2e394332e74", size = 214724 }, + { url = "https://files.pythonhosted.org/packages/9b/a8/d5b128633fd1a5e0401a4160d02fa15986209a9e47717174f99dc2f7166d/coverage-7.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:30f445f85c353090b83e552dcbbdad3ec84c7967e108c3ae54556ca69955563e", size = 215535 }, + { url = "https://files.pythonhosted.org/packages/a3/37/84bba9d2afabc3611f3e4325ee2c6a47cd449b580d4a606b240ce5a6f9bf/coverage-7.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:af41da5dca398d3474129c58cb2b106a5d93bbb196be0d307ac82311ca234342", size = 213904 }, + { url = "https://files.pythonhosted.org/packages/d0/a7/a027970c991ca90f24e968999f7d509332daf6b8c3533d68633930aaebac/coverage-7.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:31324f18d5969feef7344a932c32428a2d1a3e50b15a6404e97cba1cc9b2c631", size = 212358 }, + { url = "https://files.pythonhosted.org/packages/f2/48/6aaed3651ae83b231556750280682528fea8ac7f1232834573472d83e459/coverage-7.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c804506d624e8a20fb3108764c52e0eef664e29d21692afa375e0dd98dc384f", size = 212620 }, + { url = "https://files.pythonhosted.org/packages/6c/2a/f4b613f3b44d8b9f144847c89151992b2b6b79cbc506dee89ad0c35f209d/coverage-7.9.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef64c27bc40189f36fcc50c3fb8f16ccda73b6a0b80d9bd6e6ce4cffcd810bbd", size = 245788 }, + { url = "https://files.pythonhosted.org/packages/04/d2/de4fdc03af5e4e035ef420ed26a703c6ad3d7a07aff2e959eb84e3b19ca8/coverage-7.9.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4fe2348cc6ec372e25adec0219ee2334a68d2f5222e0cba9c0d613394e12d86", size = 243001 }, + { url = "https://files.pythonhosted.org/packages/f5/e8/eed18aa5583b0423ab7f04e34659e51101135c41cd1dcb33ac1d7013a6d6/coverage-7.9.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34ed2186fe52fcc24d4561041979a0dec69adae7bce2ae8d1c49eace13e55c43", size = 244985 }, + { url = "https://files.pythonhosted.org/packages/17/f8/ae9e5cce8885728c934eaa58ebfa8281d488ef2afa81c3dbc8ee9e6d80db/coverage-7.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25308bd3d00d5eedd5ae7d4357161f4df743e3c0240fa773ee1b0f75e6c7c0f1", size = 245152 }, + { url = "https://files.pythonhosted.org/packages/5a/c8/272c01ae792bb3af9b30fac14d71d63371db227980682836ec388e2c57c0/coverage-7.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:73e9439310f65d55a5a1e0564b48e34f5369bee943d72c88378f2d576f5a5751", size = 243123 }, + { url = "https://files.pythonhosted.org/packages/8c/d0/2819a1e3086143c094ab446e3bdf07138527a7b88cb235c488e78150ba7a/coverage-7.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:37ab6be0859141b53aa89412a82454b482c81cf750de4f29223d52268a86de67", size = 244506 }, + { url = "https://files.pythonhosted.org/packages/8b/4e/9f6117b89152df7b6112f65c7a4ed1f2f5ec8e60c4be8f351d91e7acc848/coverage-7.9.1-cp313-cp313-win32.whl", hash = "sha256:64bdd969456e2d02a8b08aa047a92d269c7ac1f47e0c977675d550c9a0863643", size = 214766 }, + { url = "https://files.pythonhosted.org/packages/27/0f/4b59f7c93b52c2c4ce7387c5a4e135e49891bb3b7408dcc98fe44033bbe0/coverage-7.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:be9e3f68ca9edb897c2184ad0eee815c635565dbe7a0e7e814dc1f7cbab92c0a", size = 215568 }, + { url = "https://files.pythonhosted.org/packages/09/1e/9679826336f8c67b9c39a359352882b24a8a7aee48d4c9cad08d38d7510f/coverage-7.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:1c503289ffef1d5105d91bbb4d62cbe4b14bec4d13ca225f9c73cde9bb46207d", size = 213939 }, + { url = "https://files.pythonhosted.org/packages/bb/5b/5c6b4e7a407359a2e3b27bf9c8a7b658127975def62077d441b93a30dbe8/coverage-7.9.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0b3496922cb5f4215bf5caaef4cf12364a26b0be82e9ed6d050f3352cf2d7ef0", size = 213079 }, + { url = "https://files.pythonhosted.org/packages/a2/22/1e2e07279fd2fd97ae26c01cc2186e2258850e9ec125ae87184225662e89/coverage-7.9.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9565c3ab1c93310569ec0d86b017f128f027cab0b622b7af288696d7ed43a16d", size = 213299 }, + { url = "https://files.pythonhosted.org/packages/14/c0/4c5125a4b69d66b8c85986d3321520f628756cf524af810baab0790c7647/coverage-7.9.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2241ad5dbf79ae1d9c08fe52b36d03ca122fb9ac6bca0f34439e99f8327ac89f", size = 256535 }, + { url = "https://files.pythonhosted.org/packages/81/8b/e36a04889dda9960be4263e95e777e7b46f1bb4fc32202612c130a20c4da/coverage-7.9.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bb5838701ca68b10ebc0937dbd0eb81974bac54447c55cd58dea5bca8451029", size = 252756 }, + { url = "https://files.pythonhosted.org/packages/98/82/be04eff8083a09a4622ecd0e1f31a2c563dbea3ed848069e7b0445043a70/coverage-7.9.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b30a25f814591a8c0c5372c11ac8967f669b97444c47fd794926e175c4047ece", size = 254912 }, + { url = "https://files.pythonhosted.org/packages/0f/25/c26610a2c7f018508a5ab958e5b3202d900422cf7cdca7670b6b8ca4e8df/coverage-7.9.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2d04b16a6062516df97969f1ae7efd0de9c31eb6ebdceaa0d213b21c0ca1a683", size = 256144 }, + { url = "https://files.pythonhosted.org/packages/c5/8b/fb9425c4684066c79e863f1e6e7ecebb49e3a64d9f7f7860ef1688c56f4a/coverage-7.9.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7931b9e249edefb07cd6ae10c702788546341d5fe44db5b6108a25da4dca513f", size = 254257 }, + { url = "https://files.pythonhosted.org/packages/93/df/27b882f54157fc1131e0e215b0da3b8d608d9b8ef79a045280118a8f98fe/coverage-7.9.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:52e92b01041151bf607ee858e5a56c62d4b70f4dac85b8c8cb7fb8a351ab2c10", size = 255094 }, + { url = "https://files.pythonhosted.org/packages/41/5f/cad1c3dbed8b3ee9e16fa832afe365b4e3eeab1fb6edb65ebbf745eabc92/coverage-7.9.1-cp313-cp313t-win32.whl", hash = "sha256:684e2110ed84fd1ca5f40e89aa44adf1729dc85444004111aa01866507adf363", size = 215437 }, + { url = "https://files.pythonhosted.org/packages/99/4d/fad293bf081c0e43331ca745ff63673badc20afea2104b431cdd8c278b4c/coverage-7.9.1-cp313-cp313t-win_amd64.whl", hash = "sha256:437c576979e4db840539674e68c84b3cda82bc824dd138d56bead1435f1cb5d7", size = 216605 }, + { url = "https://files.pythonhosted.org/packages/1f/56/4ee027d5965fc7fc126d7ec1187529cc30cc7d740846e1ecb5e92d31b224/coverage-7.9.1-cp313-cp313t-win_arm64.whl", hash = "sha256:18a0912944d70aaf5f399e350445738a1a20b50fbea788f640751c2ed9208b6c", size = 214392 }, + { url = "https://files.pythonhosted.org/packages/08/b8/7ddd1e8ba9701dea08ce22029917140e6f66a859427406579fd8d0ca7274/coverage-7.9.1-py3-none-any.whl", hash = "sha256:66b974b145aa189516b6bf2d8423e888b742517d37872f6ee4c5be0073bd9a3c", size = 204000 }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321 }, +] + +[[package]] +name = "dataclasses-json" +version = "0.6.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "marshmallow" }, + { name = "typing-inspect" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/a4/f71d9cf3a5ac257c993b5ca3f93df5f7fb395c725e7f1e6479d2514173c3/dataclasses_json-0.6.7.tar.gz", hash = "sha256:b6b3e528266ea45b9535223bc53ca645f5208833c29229e847b3f26a1cc55fc0", size = 32227 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a", size = 28686 }, +] + +[[package]] +name = "debugpy" +version = "1.8.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/75/087fe07d40f490a78782ff3b0a30e3968936854105487decdb33446d4b0e/debugpy-1.8.14.tar.gz", hash = "sha256:7cd287184318416850aa8b60ac90105837bb1e59531898c07569d197d2ed5322", size = 1641444 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/2a/ac2df0eda4898f29c46eb6713a5148e6f8b2b389c8ec9e425a4a1d67bf07/debugpy-1.8.14-cp312-cp312-macosx_14_0_universal2.whl", hash = "sha256:8899c17920d089cfa23e6005ad9f22582fd86f144b23acb9feeda59e84405b84", size = 2501268 }, + { url = "https://files.pythonhosted.org/packages/10/53/0a0cb5d79dd9f7039169f8bf94a144ad3efa52cc519940b3b7dde23bcb89/debugpy-1.8.14-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6bb5c0dcf80ad5dbc7b7d6eac484e2af34bdacdf81df09b6a3e62792b722826", size = 4221077 }, + { url = "https://files.pythonhosted.org/packages/f8/d5/84e01821f362327bf4828728aa31e907a2eca7c78cd7c6ec062780d249f8/debugpy-1.8.14-cp312-cp312-win32.whl", hash = "sha256:281d44d248a0e1791ad0eafdbbd2912ff0de9eec48022a5bfbc332957487ed3f", size = 5255127 }, + { url = "https://files.pythonhosted.org/packages/33/16/1ed929d812c758295cac7f9cf3dab5c73439c83d9091f2d91871e648093e/debugpy-1.8.14-cp312-cp312-win_amd64.whl", hash = "sha256:5aa56ef8538893e4502a7d79047fe39b1dae08d9ae257074c6464a7b290b806f", size = 5297249 }, + { url = "https://files.pythonhosted.org/packages/4d/e4/395c792b243f2367d84202dc33689aa3d910fb9826a7491ba20fc9e261f5/debugpy-1.8.14-cp313-cp313-macosx_14_0_universal2.whl", hash = "sha256:329a15d0660ee09fec6786acdb6e0443d595f64f5d096fc3e3ccf09a4259033f", size = 2485676 }, + { url = "https://files.pythonhosted.org/packages/ba/f1/6f2ee3f991327ad9e4c2f8b82611a467052a0fb0e247390192580e89f7ff/debugpy-1.8.14-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f920c7f9af409d90f5fd26e313e119d908b0dd2952c2393cd3247a462331f15", size = 4217514 }, + { url = "https://files.pythonhosted.org/packages/79/28/b9d146f8f2dc535c236ee09ad3e5ac899adb39d7a19b49f03ac95d216beb/debugpy-1.8.14-cp313-cp313-win32.whl", hash = "sha256:3784ec6e8600c66cbdd4ca2726c72d8ca781e94bce2f396cc606d458146f8f4e", size = 5254756 }, + { url = "https://files.pythonhosted.org/packages/e0/62/a7b4a57013eac4ccaef6977966e6bec5c63906dd25a86e35f155952e29a1/debugpy-1.8.14-cp313-cp313-win_amd64.whl", hash = "sha256:684eaf43c95a3ec39a96f1f5195a7ff3d4144e4a18d69bb66beeb1a6de605d6e", size = 5297119 }, + { url = "https://files.pythonhosted.org/packages/97/1a/481f33c37ee3ac8040d3d51fc4c4e4e7e61cb08b8bc8971d6032acc2279f/debugpy-1.8.14-py2.py3-none-any.whl", hash = "sha256:5cd9a579d553b6cb9759a7908a41988ee6280b961f24f63336835d9418216a20", size = 5256230 }, +] + +[[package]] +name = "decorator" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190 }, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604 }, +] + +[[package]] +name = "distlib" +version = "0.3.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973 }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277 }, +] + +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059 }, +] + +[[package]] +name = "executing" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/50/a9d80c47ff289c611ff12e63f7c5d13942c65d68125160cefd768c73e6e4/executing-2.2.0.tar.gz", hash = "sha256:5d108c028108fe2551d1a7b2e8b713341e2cb4fc0aa7dcf966fa4327a5226755", size = 978693 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/8f/c4d9bafc34ad7ad5d8dc16dd1347ee0e507a52c3adb6bfa8887e1c6a26ba/executing-2.2.0-py2.py3-none-any.whl", hash = "sha256:11387150cad388d62750327a53d3339fad4888b39a6fe233c3afbb54ecffd3aa", size = 26702 }, +] + +[[package]] +name = "fastapi" +version = "0.115.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/64/ec0788201b5554e2a87c49af26b77a4d132f807a0fa9675257ac92c6aa0e/fastapi-0.115.13.tar.gz", hash = "sha256:55d1d25c2e1e0a0a50aceb1c8705cd932def273c102bff0b1c1da88b3c6eb307", size = 295680 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/4a/e17764385382062b0edbb35a26b7cf76d71e27e456546277a42ba6545c6e/fastapi-0.115.13-py3-none-any.whl", hash = "sha256:0a0cab59afa7bab22f5eb347f8c9864b681558c278395e94035a741fc10cd865", size = 95315 }, +] + +[[package]] +name = "fastjsonschema" +version = "2.21.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/50/4b769ce1ac4071a1ef6d86b1a3fb56cdc3a37615e8c5519e1af96cdac366/fastjsonschema-2.21.1.tar.gz", hash = "sha256:794d4f0a58f848961ba16af7b9c85a3e88cd360df008c59aac6fc5ae9323b5d4", size = 373939 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/2b/0817a2b257fe88725c25589d89aec060581aabf668707a8d03b2e9e0cb2a/fastjsonschema-2.21.1-py3-none-any.whl", hash = "sha256:c9e5b7e908310918cf494a434eeb31384dd84a98b57a30bcb1f535015b554667", size = 23924 }, +] + +[[package]] +name = "filelock" +version = "3.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215 }, +] + +[[package]] +name = "filetype" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/29/745f7d30d47fe0f251d3ad3dc2978a23141917661998763bebb6da007eb1/filetype-1.2.0.tar.gz", hash = "sha256:66b56cd6474bf41d8c54660347d37afcc3f7d1970648de365c102ef77548aadb", size = 998020 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/79/1b8fa1bb3568781e84c9200f951c735f3f157429f44be0495da55894d620/filetype-1.2.0-py2.py3-none-any.whl", hash = "sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25", size = 19970 }, +] + +[[package]] +name = "fonttools" +version = "4.58.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/5a/1124b2c8cb3a8015faf552e92714040bcdbc145dfa29928891b02d147a18/fonttools-4.58.4.tar.gz", hash = "sha256:928a8009b9884ed3aae17724b960987575155ca23c6f0b8146e400cc9e0d44ba", size = 3525026 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/3c/1d1792bfe91ef46f22a3d23b4deb514c325e73c17d4f196b385b5e2faf1c/fonttools-4.58.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:462211c0f37a278494e74267a994f6be9a2023d0557aaa9ecbcbfce0f403b5a6", size = 2754082 }, + { url = "https://files.pythonhosted.org/packages/2a/1f/2b261689c901a1c3bc57a6690b0b9fc21a9a93a8b0c83aae911d3149f34e/fonttools-4.58.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0c7a12fb6f769165547f00fcaa8d0df9517603ae7e04b625e5acb8639809b82d", size = 2321677 }, + { url = "https://files.pythonhosted.org/packages/fe/6b/4607add1755a1e6581ae1fc0c9a640648e0d9cdd6591cc2d581c2e07b8c3/fonttools-4.58.4-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2d42c63020a922154add0a326388a60a55504629edc3274bc273cd3806b4659f", size = 4896354 }, + { url = "https://files.pythonhosted.org/packages/cd/95/34b4f483643d0cb11a1f830b72c03fdd18dbd3792d77a2eb2e130a96fada/fonttools-4.58.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f2b4e6fd45edc6805f5f2c355590b092ffc7e10a945bd6a569fc66c1d2ae7aa", size = 4941633 }, + { url = "https://files.pythonhosted.org/packages/81/ac/9bafbdb7694059c960de523e643fa5a61dd2f698f3f72c0ca18ae99257c7/fonttools-4.58.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f155b927f6efb1213a79334e4cb9904d1e18973376ffc17a0d7cd43d31981f1e", size = 4886170 }, + { url = "https://files.pythonhosted.org/packages/ae/44/a3a3b70d5709405f7525bb7cb497b4e46151e0c02e3c8a0e40e5e9fe030b/fonttools-4.58.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e38f687d5de97c7fb7da3e58169fb5ba349e464e141f83c3c2e2beb91d317816", size = 5037851 }, + { url = "https://files.pythonhosted.org/packages/21/cb/e8923d197c78969454eb876a4a55a07b59c9c4c46598f02b02411dc3b45c/fonttools-4.58.4-cp312-cp312-win32.whl", hash = "sha256:636c073b4da9db053aa683db99580cac0f7c213a953b678f69acbca3443c12cc", size = 2187428 }, + { url = "https://files.pythonhosted.org/packages/46/e6/fe50183b1a0e1018e7487ee740fa8bb127b9f5075a41e20d017201e8ab14/fonttools-4.58.4-cp312-cp312-win_amd64.whl", hash = "sha256:82e8470535743409b30913ba2822e20077acf9ea70acec40b10fcf5671dceb58", size = 2236649 }, + { url = "https://files.pythonhosted.org/packages/d4/4f/c05cab5fc1a4293e6bc535c6cb272607155a0517700f5418a4165b7f9ec8/fonttools-4.58.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5f4a64846495c543796fa59b90b7a7a9dff6839bd852741ab35a71994d685c6d", size = 2745197 }, + { url = "https://files.pythonhosted.org/packages/3e/d3/49211b1f96ae49308f4f78ca7664742377a6867f00f704cdb31b57e4b432/fonttools-4.58.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e80661793a5d4d7ad132a2aa1eae2e160fbdbb50831a0edf37c7c63b2ed36574", size = 2317272 }, + { url = "https://files.pythonhosted.org/packages/b2/11/c9972e46a6abd752a40a46960e431c795ad1f306775fc1f9e8c3081a1274/fonttools-4.58.4-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe5807fc64e4ba5130f1974c045a6e8d795f3b7fb6debfa511d1773290dbb76b", size = 4877184 }, + { url = "https://files.pythonhosted.org/packages/ea/24/5017c01c9ef8df572cc9eaf9f12be83ad8ed722ff6dc67991d3d752956e4/fonttools-4.58.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b610b9bef841cb8f4b50472494158b1e347d15cad56eac414c722eda695a6cfd", size = 4939445 }, + { url = "https://files.pythonhosted.org/packages/79/b0/538cc4d0284b5a8826b4abed93a69db52e358525d4b55c47c8cef3669767/fonttools-4.58.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2daa7f0e213c38f05f054eb5e1730bd0424aebddbeac094489ea1585807dd187", size = 4878800 }, + { url = "https://files.pythonhosted.org/packages/5a/9b/a891446b7a8250e65bffceb248508587958a94db467ffd33972723ab86c9/fonttools-4.58.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:66cccb6c0b944496b7f26450e9a66e997739c513ffaac728d24930df2fd9d35b", size = 5021259 }, + { url = "https://files.pythonhosted.org/packages/17/b2/c4d2872cff3ace3ddd1388bf15b76a1d8d5313f0a61f234e9aed287e674d/fonttools-4.58.4-cp313-cp313-win32.whl", hash = "sha256:94d2aebb5ca59a5107825520fde596e344652c1f18170ef01dacbe48fa60c889", size = 2185824 }, + { url = "https://files.pythonhosted.org/packages/98/57/cddf8bcc911d4f47dfca1956c1e3aeeb9f7c9b8e88b2a312fe8c22714e0b/fonttools-4.58.4-cp313-cp313-win_amd64.whl", hash = "sha256:b554bd6e80bba582fd326ddab296e563c20c64dca816d5e30489760e0c41529f", size = 2236382 }, + { url = "https://files.pythonhosted.org/packages/0b/2f/c536b5b9bb3c071e91d536a4d11f969e911dbb6b227939f4c5b0bca090df/fonttools-4.58.4-py3-none-any.whl", hash = "sha256:a10ce13a13f26cbb9f37512a4346bb437ad7e002ff6fa966a7ce7ff5ac3528bd", size = 1114660 }, +] + +[[package]] +name = "fpdf" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/c6/608a9e6c172bf9124aa687ec8b9f0e8e5d697d59a5f4fad0e2d5ec2a7556/fpdf-1.7.2.tar.gz", hash = "sha256:125840783289e7d12552b1e86ab692c37322e7a65b96a99e0ea86cca041b6779", size = 39504 } + +[[package]] +name = "fqdn" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/3e/a80a8c077fd798951169626cde3e239adeba7dab75deb3555716415bd9b0/fqdn-1.5.1.tar.gz", hash = "sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f", size = 6015 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014", size = 9121 }, +] + +[[package]] +name = "frozenlist" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/b1/b64018016eeb087db503b038296fd782586432b9c077fc5c7839e9cb6ef6/frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f", size = 45078 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a2/c8131383f1e66adad5f6ecfcce383d584ca94055a34d683bbb24ac5f2f1c/frozenlist-1.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3dbf9952c4bb0e90e98aec1bd992b3318685005702656bc6f67c1a32b76787f2", size = 81424 }, + { url = "https://files.pythonhosted.org/packages/4c/9d/02754159955088cb52567337d1113f945b9e444c4960771ea90eb73de8db/frozenlist-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f5906d3359300b8a9bb194239491122e6cf1444c2efb88865426f170c262cdb", size = 47952 }, + { url = "https://files.pythonhosted.org/packages/01/7a/0046ef1bd6699b40acd2067ed6d6670b4db2f425c56980fa21c982c2a9db/frozenlist-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3dabd5a8f84573c8d10d8859a50ea2dec01eea372031929871368c09fa103478", size = 46688 }, + { url = "https://files.pythonhosted.org/packages/d6/a2/a910bafe29c86997363fb4c02069df4ff0b5bc39d33c5198b4e9dd42d8f8/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa57daa5917f1738064f302bf2626281a1cb01920c32f711fbc7bc36111058a8", size = 243084 }, + { url = "https://files.pythonhosted.org/packages/64/3e/5036af9d5031374c64c387469bfcc3af537fc0f5b1187d83a1cf6fab1639/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c193dda2b6d49f4c4398962810fa7d7c78f032bf45572b3e04dd5249dff27e08", size = 233524 }, + { url = "https://files.pythonhosted.org/packages/06/39/6a17b7c107a2887e781a48ecf20ad20f1c39d94b2a548c83615b5b879f28/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe2b675cf0aaa6d61bf8fbffd3c274b3c9b7b1623beb3809df8a81399a4a9c4", size = 248493 }, + { url = "https://files.pythonhosted.org/packages/be/00/711d1337c7327d88c44d91dd0f556a1c47fb99afc060ae0ef66b4d24793d/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8fc5d5cda37f62b262405cf9652cf0856839c4be8ee41be0afe8858f17f4c94b", size = 244116 }, + { url = "https://files.pythonhosted.org/packages/24/fe/74e6ec0639c115df13d5850e75722750adabdc7de24e37e05a40527ca539/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0d5ce521d1dd7d620198829b87ea002956e4319002ef0bc8d3e6d045cb4646e", size = 224557 }, + { url = "https://files.pythonhosted.org/packages/8d/db/48421f62a6f77c553575201e89048e97198046b793f4a089c79a6e3268bd/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:488d0a7d6a0008ca0db273c542098a0fa9e7dfaa7e57f70acef43f32b3f69dca", size = 241820 }, + { url = "https://files.pythonhosted.org/packages/1d/fa/cb4a76bea23047c8462976ea7b7a2bf53997a0ca171302deae9d6dd12096/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:15a7eaba63983d22c54d255b854e8108e7e5f3e89f647fc854bd77a237e767df", size = 236542 }, + { url = "https://files.pythonhosted.org/packages/5d/32/476a4b5cfaa0ec94d3f808f193301debff2ea42288a099afe60757ef6282/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1eaa7e9c6d15df825bf255649e05bd8a74b04a4d2baa1ae46d9c2d00b2ca2cb5", size = 249350 }, + { url = "https://files.pythonhosted.org/packages/8d/ba/9a28042f84a6bf8ea5dbc81cfff8eaef18d78b2a1ad9d51c7bc5b029ad16/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4389e06714cfa9d47ab87f784a7c5be91d3934cd6e9a7b85beef808297cc025", size = 225093 }, + { url = "https://files.pythonhosted.org/packages/bc/29/3a32959e68f9cf000b04e79ba574527c17e8842e38c91d68214a37455786/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:73bd45e1488c40b63fe5a7df892baf9e2a4d4bb6409a2b3b78ac1c6236178e01", size = 245482 }, + { url = "https://files.pythonhosted.org/packages/80/e8/edf2f9e00da553f07f5fa165325cfc302dead715cab6ac8336a5f3d0adc2/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99886d98e1643269760e5fe0df31e5ae7050788dd288947f7f007209b8c33f08", size = 249590 }, + { url = "https://files.pythonhosted.org/packages/1c/80/9a0eb48b944050f94cc51ee1c413eb14a39543cc4f760ed12657a5a3c45a/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:290a172aae5a4c278c6da8a96222e6337744cd9c77313efe33d5670b9f65fc43", size = 237785 }, + { url = "https://files.pythonhosted.org/packages/f3/74/87601e0fb0369b7a2baf404ea921769c53b7ae00dee7dcfe5162c8c6dbf0/frozenlist-1.7.0-cp312-cp312-win32.whl", hash = "sha256:426c7bc70e07cfebc178bc4c2bf2d861d720c4fff172181eeb4a4c41d4ca2ad3", size = 39487 }, + { url = "https://files.pythonhosted.org/packages/0b/15/c026e9a9fc17585a9d461f65d8593d281fedf55fbf7eb53f16c6df2392f9/frozenlist-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:563b72efe5da92e02eb68c59cb37205457c977aa7a449ed1b37e6939e5c47c6a", size = 43874 }, + { url = "https://files.pythonhosted.org/packages/24/90/6b2cebdabdbd50367273c20ff6b57a3dfa89bd0762de02c3a1eb42cb6462/frozenlist-1.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee80eeda5e2a4e660651370ebffd1286542b67e268aa1ac8d6dbe973120ef7ee", size = 79791 }, + { url = "https://files.pythonhosted.org/packages/83/2e/5b70b6a3325363293fe5fc3ae74cdcbc3e996c2a11dde2fd9f1fb0776d19/frozenlist-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d1a81c85417b914139e3a9b995d4a1c84559afc839a93cf2cb7f15e6e5f6ed2d", size = 47165 }, + { url = "https://files.pythonhosted.org/packages/f4/25/a0895c99270ca6966110f4ad98e87e5662eab416a17e7fd53c364bf8b954/frozenlist-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbb65198a9132ebc334f237d7b0df163e4de83fb4f2bdfe46c1e654bdb0c5d43", size = 45881 }, + { url = "https://files.pythonhosted.org/packages/19/7c/71bb0bbe0832793c601fff68cd0cf6143753d0c667f9aec93d3c323f4b55/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dab46c723eeb2c255a64f9dc05b8dd601fde66d6b19cdb82b2e09cc6ff8d8b5d", size = 232409 }, + { url = "https://files.pythonhosted.org/packages/c0/45/ed2798718910fe6eb3ba574082aaceff4528e6323f9a8570be0f7028d8e9/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6aeac207a759d0dedd2e40745575ae32ab30926ff4fa49b1635def65806fddee", size = 225132 }, + { url = "https://files.pythonhosted.org/packages/ba/e2/8417ae0f8eacb1d071d4950f32f229aa6bf68ab69aab797b72a07ea68d4f/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd8c4e58ad14b4fa7802b8be49d47993182fdd4023393899632c88fd8cd994eb", size = 237638 }, + { url = "https://files.pythonhosted.org/packages/f8/b7/2ace5450ce85f2af05a871b8c8719b341294775a0a6c5585d5e6170f2ce7/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fb24d104f425da3540ed83cbfc31388a586a7696142004c577fa61c6298c3f", size = 233539 }, + { url = "https://files.pythonhosted.org/packages/46/b9/6989292c5539553dba63f3c83dc4598186ab2888f67c0dc1d917e6887db6/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a5c505156368e4ea6b53b5ac23c92d7edc864537ff911d2fb24c140bb175e60", size = 215646 }, + { url = "https://files.pythonhosted.org/packages/72/31/bc8c5c99c7818293458fe745dab4fd5730ff49697ccc82b554eb69f16a24/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bd7eb96a675f18aa5c553eb7ddc24a43c8c18f22e1f9925528128c052cdbe00", size = 232233 }, + { url = "https://files.pythonhosted.org/packages/59/52/460db4d7ba0811b9ccb85af996019f5d70831f2f5f255f7cc61f86199795/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:05579bf020096fe05a764f1f84cd104a12f78eaab68842d036772dc6d4870b4b", size = 227996 }, + { url = "https://files.pythonhosted.org/packages/ba/c9/f4b39e904c03927b7ecf891804fd3b4df3db29b9e487c6418e37988d6e9d/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:376b6222d114e97eeec13d46c486facd41d4f43bab626b7c3f6a8b4e81a5192c", size = 242280 }, + { url = "https://files.pythonhosted.org/packages/b8/33/3f8d6ced42f162d743e3517781566b8481322be321b486d9d262adf70bfb/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0aa7e176ebe115379b5b1c95b4096fb1c17cce0847402e227e712c27bdb5a949", size = 217717 }, + { url = "https://files.pythonhosted.org/packages/3e/e8/ad683e75da6ccef50d0ab0c2b2324b32f84fc88ceee778ed79b8e2d2fe2e/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3fbba20e662b9c2130dc771e332a99eff5da078b2b2648153a40669a6d0e36ca", size = 236644 }, + { url = "https://files.pythonhosted.org/packages/b2/14/8d19ccdd3799310722195a72ac94ddc677541fb4bef4091d8e7775752360/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f4410a0a601d349dd406b5713fec59b4cee7e71678d5b17edda7f4655a940b", size = 238879 }, + { url = "https://files.pythonhosted.org/packages/ce/13/c12bf657494c2fd1079a48b2db49fa4196325909249a52d8f09bc9123fd7/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2cdfaaec6a2f9327bf43c933c0319a7c429058e8537c508964a133dffee412e", size = 232502 }, + { url = "https://files.pythonhosted.org/packages/d7/8b/e7f9dfde869825489382bc0d512c15e96d3964180c9499efcec72e85db7e/frozenlist-1.7.0-cp313-cp313-win32.whl", hash = "sha256:5fc4df05a6591c7768459caba1b342d9ec23fa16195e744939ba5914596ae3e1", size = 39169 }, + { url = "https://files.pythonhosted.org/packages/35/89/a487a98d94205d85745080a37860ff5744b9820a2c9acbcdd9440bfddf98/frozenlist-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:52109052b9791a3e6b5d1b65f4b909703984b770694d3eb64fad124c835d7cba", size = 43219 }, + { url = "https://files.pythonhosted.org/packages/56/d5/5c4cf2319a49eddd9dd7145e66c4866bdc6f3dbc67ca3d59685149c11e0d/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a6f86e4193bb0e235ef6ce3dde5cbabed887e0b11f516ce8a0f4d3b33078ec2d", size = 84345 }, + { url = "https://files.pythonhosted.org/packages/a4/7d/ec2c1e1dc16b85bc9d526009961953df9cec8481b6886debb36ec9107799/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:82d664628865abeb32d90ae497fb93df398a69bb3434463d172b80fc25b0dd7d", size = 48880 }, + { url = "https://files.pythonhosted.org/packages/69/86/f9596807b03de126e11e7d42ac91e3d0b19a6599c714a1989a4e85eeefc4/frozenlist-1.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:912a7e8375a1c9a68325a902f3953191b7b292aa3c3fb0d71a216221deca460b", size = 48498 }, + { url = "https://files.pythonhosted.org/packages/5e/cb/df6de220f5036001005f2d726b789b2c0b65f2363b104bbc16f5be8084f8/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9537c2777167488d539bc5de2ad262efc44388230e5118868e172dd4a552b146", size = 292296 }, + { url = "https://files.pythonhosted.org/packages/83/1f/de84c642f17c8f851a2905cee2dae401e5e0daca9b5ef121e120e19aa825/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f34560fb1b4c3e30ba35fa9a13894ba39e5acfc5f60f57d8accde65f46cc5e74", size = 273103 }, + { url = "https://files.pythonhosted.org/packages/88/3c/c840bfa474ba3fa13c772b93070893c6e9d5c0350885760376cbe3b6c1b3/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acd03d224b0175f5a850edc104ac19040d35419eddad04e7cf2d5986d98427f1", size = 292869 }, + { url = "https://files.pythonhosted.org/packages/a6/1c/3efa6e7d5a39a1d5ef0abeb51c48fb657765794a46cf124e5aca2c7a592c/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2038310bc582f3d6a09b3816ab01737d60bf7b1ec70f5356b09e84fb7408ab1", size = 291467 }, + { url = "https://files.pythonhosted.org/packages/4f/00/d5c5e09d4922c395e2f2f6b79b9a20dab4b67daaf78ab92e7729341f61f6/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c05e4c8e5f36e5e088caa1bf78a687528f83c043706640a92cb76cd6999384", size = 266028 }, + { url = "https://files.pythonhosted.org/packages/4e/27/72765be905619dfde25a7f33813ac0341eb6b076abede17a2e3fbfade0cb/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:765bb588c86e47d0b68f23c1bee323d4b703218037765dcf3f25c838c6fecceb", size = 284294 }, + { url = "https://files.pythonhosted.org/packages/88/67/c94103a23001b17808eb7dd1200c156bb69fb68e63fcf0693dde4cd6228c/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:32dc2e08c67d86d0969714dd484fd60ff08ff81d1a1e40a77dd34a387e6ebc0c", size = 281898 }, + { url = "https://files.pythonhosted.org/packages/42/34/a3e2c00c00f9e2a9db5653bca3fec306349e71aff14ae45ecc6d0951dd24/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:c0303e597eb5a5321b4de9c68e9845ac8f290d2ab3f3e2c864437d3c5a30cd65", size = 290465 }, + { url = "https://files.pythonhosted.org/packages/bb/73/f89b7fbce8b0b0c095d82b008afd0590f71ccb3dee6eee41791cf8cd25fd/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a47f2abb4e29b3a8d0b530f7c3598badc6b134562b1a5caee867f7c62fee51e3", size = 266385 }, + { url = "https://files.pythonhosted.org/packages/cd/45/e365fdb554159462ca12df54bc59bfa7a9a273ecc21e99e72e597564d1ae/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3d688126c242a6fabbd92e02633414d40f50bb6002fa4cf995a1d18051525657", size = 288771 }, + { url = "https://files.pythonhosted.org/packages/00/11/47b6117002a0e904f004d70ec5194fe9144f117c33c851e3d51c765962d0/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4e7e9652b3d367c7bd449a727dc79d5043f48b88d0cbfd4f9f1060cf2b414104", size = 288206 }, + { url = "https://files.pythonhosted.org/packages/40/37/5f9f3c3fd7f7746082ec67bcdc204db72dad081f4f83a503d33220a92973/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1a85e345b4c43db8b842cab1feb41be5cc0b10a1830e6295b69d7310f99becaf", size = 282620 }, + { url = "https://files.pythonhosted.org/packages/0b/31/8fbc5af2d183bff20f21aa743b4088eac4445d2bb1cdece449ae80e4e2d1/frozenlist-1.7.0-cp313-cp313t-win32.whl", hash = "sha256:3a14027124ddb70dfcee5148979998066897e79f89f64b13328595c4bdf77c81", size = 43059 }, + { url = "https://files.pythonhosted.org/packages/bb/ed/41956f52105b8dbc26e457c5705340c67c8cc2b79f394b79bffc09d0e938/frozenlist-1.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3bf8010d71d4507775f658e9823210b7427be36625b387221642725b515dcf3e", size = 47516 }, + { url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106 }, +] + +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794 }, +] + +[[package]] +name = "gitpython" +version = "3.1.44" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/89/37df0b71473153574a5cdef8f242de422a0f5d26d7a9e231e6f169b4ad14/gitpython-3.1.44.tar.gz", hash = "sha256:c87e30b26253bf5418b01b0660f818967f3c503193838337fe5e573331249269", size = 214196 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/9a/4114a9057db2f1462d5c8f8390ab7383925fe1ac012eaa42402ad65c2963/GitPython-3.1.44-py3-none-any.whl", hash = "sha256:9e0e10cda9bed1ee64bc9a6de50e7e38a9c9943241cd7f585f6df3ed28011110", size = 207599 }, +] + +[[package]] +name = "google-ai-generativelanguage" +version = "0.6.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "proto-plus" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/77/3e89a4c4200135eac74eca2f6c9153127e3719a825681ad55f5a4a58b422/google_ai_generativelanguage-0.6.18.tar.gz", hash = "sha256:274ba9fcf69466ff64e971d565884434388e523300afd468fc8e3033cd8e606e", size = 1444757 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/77/ca2889903a2d93b3072a49056d48b3f55410219743e338a1d7f94dc6455e/google_ai_generativelanguage-0.6.18-py3-none-any.whl", hash = "sha256:13d8174fea90b633f520789d32df7b422058fd5883b022989c349f1017db7fcf", size = 1372256 }, +] + +[[package]] +name = "google-api-core" +version = "2.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "googleapis-common-protos" }, + { name = "proto-plus" }, + { name = "protobuf" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/21/e9d043e88222317afdbdb567165fdbc3b0aad90064c7e0c9eb0ad9955ad8/google_api_core-2.25.1.tar.gz", hash = "sha256:d2aaa0b13c78c61cb3f4282c464c046e45fbd75755683c9c525e6e8f7ed0a5e8", size = 165443 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/4b/ead00905132820b623732b175d66354e9d3e69fcf2a5dcdab780664e7896/google_api_core-2.25.1-py3-none-any.whl", hash = "sha256:8a2a56c1fef82987a524371f99f3bd0143702fecc670c72e600c1cda6bf8dbb7", size = 160807 }, +] + +[package.optional-dependencies] +grpc = [ + { name = "grpcio" }, + { name = "grpcio-status" }, +] + +[[package]] +name = "google-auth" +version = "2.40.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachetools" }, + { name = "pyasn1-modules" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/9b/e92ef23b84fa10a64ce4831390b7a4c2e53c0132568d99d4ae61d04c8855/google_auth-2.40.3.tar.gz", hash = "sha256:500c3a29adedeb36ea9cf24b8d10858e152f2412e3ca37829b3fa18e33d63b77", size = 281029 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/63/b19553b658a1692443c62bd07e5868adaa0ad746a0751ba62c59568cd45b/google_auth-2.40.3-py2.py3-none-any.whl", hash = "sha256:1370d4593e86213563547f97a92752fc658456fe4514c809544f330fed45a7ca", size = 216137 }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.70.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/24/33db22342cf4a2ea27c9955e6713140fedd51e8b141b5ce5260897020f1a/googleapis_common_protos-1.70.0.tar.gz", hash = "sha256:0e1b44e0ea153e6594f9f394fef15193a68aaaea2d843f83e2742717ca753257", size = 145903 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/f1/62a193f0227cf15a920390abe675f386dec35f7ae3ffe6da582d3ade42c7/googleapis_common_protos-1.70.0-py3-none-any.whl", hash = "sha256:b8bfcca8c25a2bb253e0e0b0adaf8c00773e5e6af6fd92397576680b807e0fd8", size = 294530 }, +] + +[[package]] +name = "greenlet" +version = "3.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/92/bb85bd6e80148a4d2e0c59f7c0c2891029f8fd510183afc7d8d2feeed9b6/greenlet-3.2.3.tar.gz", hash = "sha256:8b0dd8ae4c0d6f5e54ee55ba935eeb3d735a9b58a8a1e5b5cbab64e01a39f365", size = 185752 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/94/ad0d435f7c48debe960c53b8f60fb41c2026b1d0fa4a99a1cb17c3461e09/greenlet-3.2.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:25ad29caed5783d4bd7a85c9251c651696164622494c00802a139c00d639242d", size = 271992 }, + { url = "https://files.pythonhosted.org/packages/93/5d/7c27cf4d003d6e77749d299c7c8f5fd50b4f251647b5c2e97e1f20da0ab5/greenlet-3.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88cd97bf37fe24a6710ec6a3a7799f3f81d9cd33317dcf565ff9950c83f55e0b", size = 638820 }, + { url = "https://files.pythonhosted.org/packages/c6/7e/807e1e9be07a125bb4c169144937910bf59b9d2f6d931578e57f0bce0ae2/greenlet-3.2.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:baeedccca94880d2f5666b4fa16fc20ef50ba1ee353ee2d7092b383a243b0b0d", size = 653046 }, + { url = "https://files.pythonhosted.org/packages/9d/ab/158c1a4ea1068bdbc78dba5a3de57e4c7aeb4e7fa034320ea94c688bfb61/greenlet-3.2.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:be52af4b6292baecfa0f397f3edb3c6092ce071b499dd6fe292c9ac9f2c8f264", size = 647701 }, + { url = "https://files.pythonhosted.org/packages/cc/0d/93729068259b550d6a0288da4ff72b86ed05626eaf1eb7c0d3466a2571de/greenlet-3.2.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0cc73378150b8b78b0c9fe2ce56e166695e67478550769536a6742dca3651688", size = 649747 }, + { url = "https://files.pythonhosted.org/packages/f6/f6/c82ac1851c60851302d8581680573245c8fc300253fc1ff741ae74a6c24d/greenlet-3.2.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:706d016a03e78df129f68c4c9b4c4f963f7d73534e48a24f5f5a7101ed13dbbb", size = 605461 }, + { url = "https://files.pythonhosted.org/packages/98/82/d022cf25ca39cf1200650fc58c52af32c90f80479c25d1cbf57980ec3065/greenlet-3.2.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:419e60f80709510c343c57b4bb5a339d8767bf9aef9b8ce43f4f143240f88b7c", size = 1121190 }, + { url = "https://files.pythonhosted.org/packages/f5/e1/25297f70717abe8104c20ecf7af0a5b82d2f5a980eb1ac79f65654799f9f/greenlet-3.2.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:93d48533fade144203816783373f27a97e4193177ebaaf0fc396db19e5d61163", size = 1149055 }, + { url = "https://files.pythonhosted.org/packages/1f/8f/8f9e56c5e82eb2c26e8cde787962e66494312dc8cb261c460e1f3a9c88bc/greenlet-3.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:7454d37c740bb27bdeddfc3f358f26956a07d5220818ceb467a483197d84f849", size = 297817 }, + { url = "https://files.pythonhosted.org/packages/b1/cf/f5c0b23309070ae93de75c90d29300751a5aacefc0a3ed1b1d8edb28f08b/greenlet-3.2.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:500b8689aa9dd1ab26872a34084503aeddefcb438e2e7317b89b11eaea1901ad", size = 270732 }, + { url = "https://files.pythonhosted.org/packages/48/ae/91a957ba60482d3fecf9be49bc3948f341d706b52ddb9d83a70d42abd498/greenlet-3.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a07d3472c2a93117af3b0136f246b2833fdc0b542d4a9799ae5f41c28323faef", size = 639033 }, + { url = "https://files.pythonhosted.org/packages/6f/df/20ffa66dd5a7a7beffa6451bdb7400d66251374ab40b99981478c69a67a8/greenlet-3.2.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:8704b3768d2f51150626962f4b9a9e4a17d2e37c8a8d9867bbd9fa4eb938d3b3", size = 652999 }, + { url = "https://files.pythonhosted.org/packages/51/b4/ebb2c8cb41e521f1d72bf0465f2f9a2fd803f674a88db228887e6847077e/greenlet-3.2.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5035d77a27b7c62db6cf41cf786cfe2242644a7a337a0e155c80960598baab95", size = 647368 }, + { url = "https://files.pythonhosted.org/packages/8e/6a/1e1b5aa10dced4ae876a322155705257748108b7fd2e4fae3f2a091fe81a/greenlet-3.2.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2d8aa5423cd4a396792f6d4580f88bdc6efcb9205891c9d40d20f6e670992efb", size = 650037 }, + { url = "https://files.pythonhosted.org/packages/26/f2/ad51331a157c7015c675702e2d5230c243695c788f8f75feba1af32b3617/greenlet-3.2.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2c724620a101f8170065d7dded3f962a2aea7a7dae133a009cada42847e04a7b", size = 608402 }, + { url = "https://files.pythonhosted.org/packages/26/bc/862bd2083e6b3aff23300900a956f4ea9a4059de337f5c8734346b9b34fc/greenlet-3.2.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:873abe55f134c48e1f2a6f53f7d1419192a3d1a4e873bace00499a4e45ea6af0", size = 1119577 }, + { url = "https://files.pythonhosted.org/packages/86/94/1fc0cc068cfde885170e01de40a619b00eaa8f2916bf3541744730ffb4c3/greenlet-3.2.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:024571bbce5f2c1cfff08bf3fbaa43bbc7444f580ae13b0099e95d0e6e67ed36", size = 1147121 }, + { url = "https://files.pythonhosted.org/packages/27/1a/199f9587e8cb08a0658f9c30f3799244307614148ffe8b1e3aa22f324dea/greenlet-3.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:5195fb1e75e592dd04ce79881c8a22becdfa3e6f500e7feb059b1e6fdd54d3e3", size = 297603 }, + { url = "https://files.pythonhosted.org/packages/d8/ca/accd7aa5280eb92b70ed9e8f7fd79dc50a2c21d8c73b9a0856f5b564e222/greenlet-3.2.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:3d04332dddb10b4a211b68111dabaee2e1a073663d117dc10247b5b1642bac86", size = 271479 }, + { url = "https://files.pythonhosted.org/packages/55/71/01ed9895d9eb49223280ecc98a557585edfa56b3d0e965b9fa9f7f06b6d9/greenlet-3.2.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8186162dffde068a465deab08fc72c767196895c39db26ab1c17c0b77a6d8b97", size = 683952 }, + { url = "https://files.pythonhosted.org/packages/ea/61/638c4bdf460c3c678a0a1ef4c200f347dff80719597e53b5edb2fb27ab54/greenlet-3.2.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f4bfbaa6096b1b7a200024784217defedf46a07c2eee1a498e94a1b5f8ec5728", size = 696917 }, + { url = "https://files.pythonhosted.org/packages/22/cc/0bd1a7eb759d1f3e3cc2d1bc0f0b487ad3cc9f34d74da4b80f226fde4ec3/greenlet-3.2.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:ed6cfa9200484d234d8394c70f5492f144b20d4533f69262d530a1a082f6ee9a", size = 692443 }, + { url = "https://files.pythonhosted.org/packages/67/10/b2a4b63d3f08362662e89c103f7fe28894a51ae0bc890fabf37d1d780e52/greenlet-3.2.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:02b0df6f63cd15012bed5401b47829cfd2e97052dc89da3cfaf2c779124eb892", size = 692995 }, + { url = "https://files.pythonhosted.org/packages/5a/c6/ad82f148a4e3ce9564056453a71529732baf5448ad53fc323e37efe34f66/greenlet-3.2.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86c2d68e87107c1792e2e8d5399acec2487a4e993ab76c792408e59394d52141", size = 655320 }, + { url = "https://files.pythonhosted.org/packages/5c/4f/aab73ecaa6b3086a4c89863d94cf26fa84cbff63f52ce9bc4342b3087a06/greenlet-3.2.3-cp314-cp314-win_amd64.whl", hash = "sha256:8c47aae8fbbfcf82cc13327ae802ba13c9c36753b67e760023fd116bc124a62a", size = 301236 }, +] + +[[package]] +name = "grpcio" +version = "1.73.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/7b/ca3f561aeecf0c846d15e1b38921a60dffffd5d4113931198fbf455334ee/grpcio-1.73.0.tar.gz", hash = "sha256:3af4c30918a7f0d39de500d11255f8d9da4f30e94a2033e70fe2a720e184bd8e", size = 12786424 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/4d/e938f3a0e51a47f2ce7e55f12f19f316e7074770d56a7c2765e782ec76bc/grpcio-1.73.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:fb9d7c27089d9ba3746f18d2109eb530ef2a37452d2ff50f5a6696cd39167d3b", size = 5334911 }, + { url = "https://files.pythonhosted.org/packages/13/56/f09c72c43aa8d6f15a71f2c63ebdfac9cf9314363dea2598dc501d8370db/grpcio-1.73.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:128ba2ebdac41e41554d492b82c34586a90ebd0766f8ebd72160c0e3a57b9155", size = 10601460 }, + { url = "https://files.pythonhosted.org/packages/20/e3/85496edc81e41b3c44ebefffc7bce133bb531120066877df0f910eabfa19/grpcio-1.73.0-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:068ecc415f79408d57a7f146f54cdf9f0acb4b301a52a9e563973dc981e82f3d", size = 5759191 }, + { url = "https://files.pythonhosted.org/packages/88/cc/fef74270a6d29f35ad744bfd8e6c05183f35074ff34c655a2c80f3b422b2/grpcio-1.73.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ddc1cfb2240f84d35d559ade18f69dcd4257dbaa5ba0de1a565d903aaab2968", size = 6409961 }, + { url = "https://files.pythonhosted.org/packages/b0/e6/13cfea15e3b8f79c4ae7b676cb21fab70978b0fde1e1d28bb0e073291290/grpcio-1.73.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53007f70d9783f53b41b4cf38ed39a8e348011437e4c287eee7dd1d39d54b2f", size = 6003948 }, + { url = "https://files.pythonhosted.org/packages/c2/ed/b1a36dad4cc0dbf1f83f6d7b58825fefd5cc9ff3a5036e46091335649473/grpcio-1.73.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4dd8d8d092efede7d6f48d695ba2592046acd04ccf421436dd7ed52677a9ad29", size = 6103788 }, + { url = "https://files.pythonhosted.org/packages/e7/c8/d381433d3d46d10f6858126d2d2245ef329e30f3752ce4514c93b95ca6fc/grpcio-1.73.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:70176093d0a95b44d24baa9c034bb67bfe2b6b5f7ebc2836f4093c97010e17fd", size = 6749508 }, + { url = "https://files.pythonhosted.org/packages/87/0a/ff0c31dbd15e63b34320efafac647270aa88c31aa19ff01154a73dc7ce86/grpcio-1.73.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:085ebe876373ca095e24ced95c8f440495ed0b574c491f7f4f714ff794bbcd10", size = 6284342 }, + { url = "https://files.pythonhosted.org/packages/fd/73/f762430c0ba867403b9d6e463afe026bf019bd9206eee753785239719273/grpcio-1.73.0-cp312-cp312-win32.whl", hash = "sha256:cfc556c1d6aef02c727ec7d0016827a73bfe67193e47c546f7cadd3ee6bf1a60", size = 3669319 }, + { url = "https://files.pythonhosted.org/packages/10/8b/3411609376b2830449cf416f457ad9d2aacb7f562e1b90fdd8bdedf26d63/grpcio-1.73.0-cp312-cp312-win_amd64.whl", hash = "sha256:bbf45d59d090bf69f1e4e1594832aaf40aa84b31659af3c5e2c3f6a35202791a", size = 4335596 }, + { url = "https://files.pythonhosted.org/packages/60/da/6f3f7a78e5455c4cbe87c85063cc6da05d65d25264f9d4aed800ece46294/grpcio-1.73.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:da1d677018ef423202aca6d73a8d3b2cb245699eb7f50eb5f74cae15a8e1f724", size = 5335867 }, + { url = "https://files.pythonhosted.org/packages/53/14/7d1f2526b98b9658d7be0bb163fd78d681587de6709d8b0c74b4b481b013/grpcio-1.73.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:36bf93f6a657f37c131d9dd2c391b867abf1426a86727c3575393e9e11dadb0d", size = 10595587 }, + { url = "https://files.pythonhosted.org/packages/02/24/a293c398ae44e741da1ed4b29638edbb002258797b07a783f65506165b4c/grpcio-1.73.0-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:d84000367508ade791d90c2bafbd905574b5ced8056397027a77a215d601ba15", size = 5765793 }, + { url = "https://files.pythonhosted.org/packages/e1/24/d84dbd0b5bf36fb44922798d525a85cefa2ffee7b7110e61406e9750ed15/grpcio-1.73.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c98ba1d928a178ce33f3425ff823318040a2b7ef875d30a0073565e5ceb058d9", size = 6415494 }, + { url = "https://files.pythonhosted.org/packages/5e/85/c80dc65aed8e9dce3d54688864bac45331d9c7600985541f18bd5cb301d4/grpcio-1.73.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a73c72922dfd30b396a5f25bb3a4590195ee45ecde7ee068acb0892d2900cf07", size = 6007279 }, + { url = "https://files.pythonhosted.org/packages/37/fc/207c00a4c6fa303d26e2cbd62fbdb0582facdfd08f55500fd83bf6b0f8db/grpcio-1.73.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:10e8edc035724aba0346a432060fd192b42bd03675d083c01553cab071a28da5", size = 6105505 }, + { url = "https://files.pythonhosted.org/packages/72/35/8fe69af820667b87ebfcb24214e42a1d53da53cb39edd6b4f84f6b36da86/grpcio-1.73.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:f5cdc332b503c33b1643b12ea933582c7b081957c8bc2ea4cc4bc58054a09288", size = 6753792 }, + { url = "https://files.pythonhosted.org/packages/e2/d8/738c77c1e821e350da4a048849f695ff88a02b291f8c69db23908867aea6/grpcio-1.73.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:07ad7c57233c2109e4ac999cb9c2710c3b8e3f491a73b058b0ce431f31ed8145", size = 6287593 }, + { url = "https://files.pythonhosted.org/packages/09/ec/8498eabc018fa39ae8efe5e47e3f4c1bc9ed6281056713871895dc998807/grpcio-1.73.0-cp313-cp313-win32.whl", hash = "sha256:0eb5df4f41ea10bda99a802b2a292d85be28958ede2a50f2beb8c7fc9a738419", size = 3668637 }, + { url = "https://files.pythonhosted.org/packages/d7/35/347db7d2e7674b621afd21b12022e7f48c7b0861b5577134b4e939536141/grpcio-1.73.0-cp313-cp313-win_amd64.whl", hash = "sha256:38cf518cc54cd0c47c9539cefa8888549fcc067db0b0c66a46535ca8032020c4", size = 4335872 }, +] + +[[package]] +name = "grpcio-status" +version = "1.73.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/07/1c7b5ec7c72b8e2efc32cf82e2fe72497c579c8fa94edb8c3e430874cd42/grpcio_status-1.73.0.tar.gz", hash = "sha256:a2b7f430568217f884fe52a5a0133b6f4c9338beae33fb5370134a8eaf58f974", size = 13670 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/95/e4b963a8730e04fae0e98cdd12212a9ffb318daf8687ea3220b78b34f8fa/grpcio_status-1.73.0-py3-none-any.whl", hash = "sha256:a3f3a9994b44c364f014e806114ba44cc52e50c426779f958c8b22f14ff0d892", size = 14423 }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819 }, +] + +[[package]] +name = "hydra-core" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "antlr4-python3-runtime" }, + { name = "omegaconf" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/8e/07e42bc434a847154083b315779b0a81d567154504624e181caf2c71cd98/hydra-core-1.3.2.tar.gz", hash = "sha256:8a878ed67216997c3e9d88a8e72e7b4767e81af37afb4ea3334b269a4390a824", size = 3263494 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/50/e0edd38dcd63fb26a8547f13d28f7a008bc4a3fd4eb4ff030673f22ad41a/hydra_core-1.3.2-py3-none-any.whl", hash = "sha256:fa0238a9e31df3373b35b0bfb672c34cc92718d21f81311d8996a16de1141d8b", size = 154547 }, +] + +[[package]] +name = "identify" +version = "2.6.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/88/d193a27416618628a5eea64e3223acd800b40749a96ffb322a9b55a49ed1/identify-2.6.12.tar.gz", hash = "sha256:d8de45749f1efb108badef65ee8386f0f7bb19a7f26185f74de6367bffbaf0e6", size = 99254 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/cd/18f8da995b658420625f7ef13f037be53ae04ec5ad33f9b718240dcfd48c/identify-2.6.12-py2.py3-none-any.whl", hash = "sha256:ad9672d5a72e0d2ff7c5c8809b62dfa60458626352fb0eb7b55e69bdc45334a2", size = 99145 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, +] + +[[package]] +name = "ipykernel" +version = "6.29.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "appnope", marker = "sys_platform == 'darwin'" }, + { name = "comm" }, + { name = "debugpy" }, + { name = "ipython" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "matplotlib-inline" }, + { name = "nest-asyncio" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/5c/67594cb0c7055dc50814b21731c22a601101ea3b1b50a9a1b090e11f5d0f/ipykernel-6.29.5.tar.gz", hash = "sha256:f093a22c4a40f8828f8e330a9c297cb93dcab13bd9678ded6de8e5cf81c56215", size = 163367 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/5c/368ae6c01c7628438358e6d337c19b05425727fbb221d2a3c4303c372f42/ipykernel-6.29.5-py3-none-any.whl", hash = "sha256:afdb66ba5aa354b09b91379bac28ae4afebbb30e8b39510c9690afb7a10421b5", size = 117173 }, +] + +[[package]] +name = "ipython" +version = "9.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "ipython-pygments-lexers" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/09/4c7e06b96fbd203e06567b60fb41b06db606b6a82db6db7b2c85bb72a15c/ipython-9.3.0.tar.gz", hash = "sha256:79eb896f9f23f50ad16c3bc205f686f6e030ad246cc309c6279a242b14afe9d8", size = 4426460 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/99/9ed3d52d00f1846679e3aa12e2326ac7044b5e7f90dc822b60115fa533ca/ipython-9.3.0-py3-none-any.whl", hash = "sha256:1a0b6dd9221a1f5dddf725b57ac0cb6fddc7b5f470576231ae9162b9b3455a04", size = 605320 }, +] + +[[package]] +name = "ipython-pygments-lexers" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074 }, +] + +[[package]] +name = "ipywidgets" +version = "8.1.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "comm" }, + { name = "ipython" }, + { name = "jupyterlab-widgets" }, + { name = "traitlets" }, + { name = "widgetsnbextension" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3e/48/d3dbac45c2814cb73812f98dd6b38bbcc957a4e7bb31d6ea9c03bf94ed87/ipywidgets-8.1.7.tar.gz", hash = "sha256:15f1ac050b9ccbefd45dccfbb2ef6bed0029d8278682d569d71b8dd96bee0376", size = 116721 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/6a/9166369a2f092bd286d24e6307de555d63616e8ddb373ebad2b5635ca4cd/ipywidgets-8.1.7-py3-none-any.whl", hash = "sha256:764f2602d25471c213919b8a1997df04bef869251db4ca8efba1b76b1bd9f7bb", size = 139806 }, +] + +[[package]] +name = "isoduration" +version = "20.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "arrow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/1a/3c8edc664e06e6bd06cce40c6b22da5f1429aa4224d0c590f3be21c91ead/isoduration-20.11.0.tar.gz", hash = "sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9", size = 11649 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/55/e5326141505c5d5e34c5e0935d2908a74e4561eca44108fbfb9c13d2911a/isoduration-20.11.0-py3-none-any.whl", hash = "sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042", size = 11321 }, +] + +[[package]] +name = "jedi" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278 }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, +] + +[[package]] +name = "jiter" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/9d/ae7ddb4b8ab3fb1b51faf4deb36cb48a4fbbd7cb36bad6a5fca4741306f7/jiter-0.10.0.tar.gz", hash = "sha256:07a7142c38aacc85194391108dc91b5b57093c978a9932bd86a36862759d9500", size = 162759 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/b5/348b3313c58f5fbfb2194eb4d07e46a35748ba6e5b3b3046143f3040bafa/jiter-0.10.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:1e274728e4a5345a6dde2d343c8da018b9d4bd4350f5a472fa91f66fda44911b", size = 312262 }, + { url = "https://files.pythonhosted.org/packages/9c/4a/6a2397096162b21645162825f058d1709a02965606e537e3304b02742e9b/jiter-0.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7202ae396446c988cb2a5feb33a543ab2165b786ac97f53b59aafb803fef0744", size = 320124 }, + { url = "https://files.pythonhosted.org/packages/2a/85/1ce02cade7516b726dd88f59a4ee46914bf79d1676d1228ef2002ed2f1c9/jiter-0.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23ba7722d6748b6920ed02a8f1726fb4b33e0fd2f3f621816a8b486c66410ab2", size = 345330 }, + { url = "https://files.pythonhosted.org/packages/75/d0/bb6b4f209a77190ce10ea8d7e50bf3725fc16d3372d0a9f11985a2b23eff/jiter-0.10.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:371eab43c0a288537d30e1f0b193bc4eca90439fc08a022dd83e5e07500ed026", size = 369670 }, + { url = "https://files.pythonhosted.org/packages/a0/f5/a61787da9b8847a601e6827fbc42ecb12be2c925ced3252c8ffcb56afcaf/jiter-0.10.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c675736059020365cebc845a820214765162728b51ab1e03a1b7b3abb70f74c", size = 489057 }, + { url = "https://files.pythonhosted.org/packages/12/e4/6f906272810a7b21406c760a53aadbe52e99ee070fc5c0cb191e316de30b/jiter-0.10.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0c5867d40ab716e4684858e4887489685968a47e3ba222e44cde6e4a2154f959", size = 389372 }, + { url = "https://files.pythonhosted.org/packages/e2/ba/77013b0b8ba904bf3762f11e0129b8928bff7f978a81838dfcc958ad5728/jiter-0.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395bb9a26111b60141757d874d27fdea01b17e8fac958b91c20128ba8f4acc8a", size = 352038 }, + { url = "https://files.pythonhosted.org/packages/67/27/c62568e3ccb03368dbcc44a1ef3a423cb86778a4389e995125d3d1aaa0a4/jiter-0.10.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6842184aed5cdb07e0c7e20e5bdcfafe33515ee1741a6835353bb45fe5d1bd95", size = 391538 }, + { url = "https://files.pythonhosted.org/packages/c0/72/0d6b7e31fc17a8fdce76164884edef0698ba556b8eb0af9546ae1a06b91d/jiter-0.10.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:62755d1bcea9876770d4df713d82606c8c1a3dca88ff39046b85a048566d56ea", size = 523557 }, + { url = "https://files.pythonhosted.org/packages/2f/09/bc1661fbbcbeb6244bd2904ff3a06f340aa77a2b94e5a7373fd165960ea3/jiter-0.10.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:533efbce2cacec78d5ba73a41756beff8431dfa1694b6346ce7af3a12c42202b", size = 514202 }, + { url = "https://files.pythonhosted.org/packages/1b/84/5a5d5400e9d4d54b8004c9673bbe4403928a00d28529ff35b19e9d176b19/jiter-0.10.0-cp312-cp312-win32.whl", hash = "sha256:8be921f0cadd245e981b964dfbcd6fd4bc4e254cdc069490416dd7a2632ecc01", size = 211781 }, + { url = "https://files.pythonhosted.org/packages/9b/52/7ec47455e26f2d6e5f2ea4951a0652c06e5b995c291f723973ae9e724a65/jiter-0.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:a7c7d785ae9dda68c2678532a5a1581347e9c15362ae9f6e68f3fdbfb64f2e49", size = 206176 }, + { url = "https://files.pythonhosted.org/packages/2e/b0/279597e7a270e8d22623fea6c5d4eeac328e7d95c236ed51a2b884c54f70/jiter-0.10.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e0588107ec8e11b6f5ef0e0d656fb2803ac6cf94a96b2b9fc675c0e3ab5e8644", size = 311617 }, + { url = "https://files.pythonhosted.org/packages/91/e3/0916334936f356d605f54cc164af4060e3e7094364add445a3bc79335d46/jiter-0.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cafc4628b616dc32530c20ee53d71589816cf385dd9449633e910d596b1f5c8a", size = 318947 }, + { url = "https://files.pythonhosted.org/packages/6a/8e/fd94e8c02d0e94539b7d669a7ebbd2776e51f329bb2c84d4385e8063a2ad/jiter-0.10.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:520ef6d981172693786a49ff5b09eda72a42e539f14788124a07530f785c3ad6", size = 344618 }, + { url = "https://files.pythonhosted.org/packages/6f/b0/f9f0a2ec42c6e9c2e61c327824687f1e2415b767e1089c1d9135f43816bd/jiter-0.10.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:554dedfd05937f8fc45d17ebdf298fe7e0c77458232bcb73d9fbbf4c6455f5b3", size = 368829 }, + { url = "https://files.pythonhosted.org/packages/e8/57/5bbcd5331910595ad53b9fd0c610392ac68692176f05ae48d6ce5c852967/jiter-0.10.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5bc299da7789deacf95f64052d97f75c16d4fc8c4c214a22bf8d859a4288a1c2", size = 491034 }, + { url = "https://files.pythonhosted.org/packages/9b/be/c393df00e6e6e9e623a73551774449f2f23b6ec6a502a3297aeeece2c65a/jiter-0.10.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5161e201172de298a8a1baad95eb85db4fb90e902353b1f6a41d64ea64644e25", size = 388529 }, + { url = "https://files.pythonhosted.org/packages/42/3e/df2235c54d365434c7f150b986a6e35f41ebdc2f95acea3036d99613025d/jiter-0.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e2227db6ba93cb3e2bf67c87e594adde0609f146344e8207e8730364db27041", size = 350671 }, + { url = "https://files.pythonhosted.org/packages/c6/77/71b0b24cbcc28f55ab4dbfe029f9a5b73aeadaba677843fc6dc9ed2b1d0a/jiter-0.10.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:15acb267ea5e2c64515574b06a8bf393fbfee6a50eb1673614aa45f4613c0cca", size = 390864 }, + { url = "https://files.pythonhosted.org/packages/6a/d3/ef774b6969b9b6178e1d1e7a89a3bd37d241f3d3ec5f8deb37bbd203714a/jiter-0.10.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:901b92f2e2947dc6dfcb52fd624453862e16665ea909a08398dde19c0731b7f4", size = 522989 }, + { url = "https://files.pythonhosted.org/packages/0c/41/9becdb1d8dd5d854142f45a9d71949ed7e87a8e312b0bede2de849388cb9/jiter-0.10.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d0cb9a125d5a3ec971a094a845eadde2db0de85b33c9f13eb94a0c63d463879e", size = 513495 }, + { url = "https://files.pythonhosted.org/packages/9c/36/3468e5a18238bdedae7c4d19461265b5e9b8e288d3f86cd89d00cbb48686/jiter-0.10.0-cp313-cp313-win32.whl", hash = "sha256:48a403277ad1ee208fb930bdf91745e4d2d6e47253eedc96e2559d1e6527006d", size = 211289 }, + { url = "https://files.pythonhosted.org/packages/7e/07/1c96b623128bcb913706e294adb5f768fb7baf8db5e1338ce7b4ee8c78ef/jiter-0.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:75f9eb72ecb640619c29bf714e78c9c46c9c4eaafd644bf78577ede459f330d4", size = 205074 }, + { url = "https://files.pythonhosted.org/packages/54/46/caa2c1342655f57d8f0f2519774c6d67132205909c65e9aa8255e1d7b4f4/jiter-0.10.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:28ed2a4c05a1f32ef0e1d24c2611330219fed727dae01789f4a335617634b1ca", size = 318225 }, + { url = "https://files.pythonhosted.org/packages/43/84/c7d44c75767e18946219ba2d703a5a32ab37b0bc21886a97bc6062e4da42/jiter-0.10.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14a4c418b1ec86a195f1ca69da8b23e8926c752b685af665ce30777233dfe070", size = 350235 }, + { url = "https://files.pythonhosted.org/packages/01/16/f5a0135ccd968b480daad0e6ab34b0c7c5ba3bc447e5088152696140dcb3/jiter-0.10.0-cp313-cp313t-win_amd64.whl", hash = "sha256:d7bfed2fe1fe0e4dda6ef682cee888ba444b21e7a6553e03252e4feb6cf0adca", size = 207278 }, + { url = "https://files.pythonhosted.org/packages/1c/9b/1d646da42c3de6c2188fdaa15bce8ecb22b635904fc68be025e21249ba44/jiter-0.10.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:5e9251a5e83fab8d87799d3e1a46cb4b7f2919b895c6f4483629ed2446f66522", size = 310866 }, + { url = "https://files.pythonhosted.org/packages/ad/0e/26538b158e8a7c7987e94e7aeb2999e2e82b1f9d2e1f6e9874ddf71ebda0/jiter-0.10.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:023aa0204126fe5b87ccbcd75c8a0d0261b9abdbbf46d55e7ae9f8e22424eeb8", size = 318772 }, + { url = "https://files.pythonhosted.org/packages/7b/fb/d302893151caa1c2636d6574d213e4b34e31fd077af6050a9c5cbb42f6fb/jiter-0.10.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c189c4f1779c05f75fc17c0c1267594ed918996a231593a21a5ca5438445216", size = 344534 }, + { url = "https://files.pythonhosted.org/packages/01/d8/5780b64a149d74e347c5128d82176eb1e3241b1391ac07935693466d6219/jiter-0.10.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:15720084d90d1098ca0229352607cd68256c76991f6b374af96f36920eae13c4", size = 369087 }, + { url = "https://files.pythonhosted.org/packages/e8/5b/f235a1437445160e777544f3ade57544daf96ba7e96c1a5b24a6f7ac7004/jiter-0.10.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4f2fb68e5f1cfee30e2b2a09549a00683e0fde4c6a2ab88c94072fc33cb7426", size = 490694 }, + { url = "https://files.pythonhosted.org/packages/85/a9/9c3d4617caa2ff89cf61b41e83820c27ebb3f7b5fae8a72901e8cd6ff9be/jiter-0.10.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce541693355fc6da424c08b7edf39a2895f58d6ea17d92cc2b168d20907dee12", size = 388992 }, + { url = "https://files.pythonhosted.org/packages/68/b1/344fd14049ba5c94526540af7eb661871f9c54d5f5601ff41a959b9a0bbd/jiter-0.10.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31c50c40272e189d50006ad5c73883caabb73d4e9748a688b216e85a9a9ca3b9", size = 351723 }, + { url = "https://files.pythonhosted.org/packages/41/89/4c0e345041186f82a31aee7b9d4219a910df672b9fef26f129f0cda07a29/jiter-0.10.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fa3402a2ff9815960e0372a47b75c76979d74402448509ccd49a275fa983ef8a", size = 392215 }, + { url = "https://files.pythonhosted.org/packages/55/58/ee607863e18d3f895feb802154a2177d7e823a7103f000df182e0f718b38/jiter-0.10.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:1956f934dca32d7bb647ea21d06d93ca40868b505c228556d3373cbd255ce853", size = 522762 }, + { url = "https://files.pythonhosted.org/packages/15/d0/9123fb41825490d16929e73c212de9a42913d68324a8ce3c8476cae7ac9d/jiter-0.10.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:fcedb049bdfc555e261d6f65a6abe1d5ad68825b7202ccb9692636c70fcced86", size = 513427 }, + { url = "https://files.pythonhosted.org/packages/d8/b3/2bd02071c5a2430d0b70403a34411fc519c2f227da7b03da9ba6a956f931/jiter-0.10.0-cp314-cp314-win32.whl", hash = "sha256:ac509f7eccca54b2a29daeb516fb95b6f0bd0d0d8084efaf8ed5dfc7b9f0b357", size = 210127 }, + { url = "https://files.pythonhosted.org/packages/03/0c/5fe86614ea050c3ecd728ab4035534387cd41e7c1855ef6c031f1ca93e3f/jiter-0.10.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5ed975b83a2b8639356151cef5c0d597c68376fc4922b45d0eb384ac058cfa00", size = 318527 }, + { url = "https://files.pythonhosted.org/packages/b3/4a/4175a563579e884192ba6e81725fc0448b042024419be8d83aa8a80a3f44/jiter-0.10.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3aa96f2abba33dc77f79b4cf791840230375f9534e5fac927ccceb58c5e604a5", size = 354213 }, +] + +[[package]] +name = "json5" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/be/c6c745ec4c4539b25a278b70e29793f10382947df0d9efba2fa09120895d/json5-0.12.0.tar.gz", hash = "sha256:0b4b6ff56801a1c7dc817b0241bca4ce474a0e6a163bfef3fc594d3fd263ff3a", size = 51907 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/9f/3500910d5a98549e3098807493851eeef2b89cdd3032227558a104dfe926/json5-0.12.0-py3-none-any.whl", hash = "sha256:6d37aa6c08b0609f16e1ec5ff94697e2cbbfbad5ac112afa05794da9ab7810db", size = 36079 }, +] + +[[package]] +name = "jsonpatch" +version = "1.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpointer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898 }, +] + +[[package]] +name = "jsonpointer" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/0a/eebeb1fa92507ea94016a2a790b93c2ae41a7e18778f85471dc54475ed25/jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef", size = 9114 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595 }, +] + +[[package]] +name = "jsonschema" +version = "4.24.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bf/d3/1cf5326b923a53515d8f3a2cd442e6d7e94fcc444716e879ea70a0ce3177/jsonschema-4.24.0.tar.gz", hash = "sha256:0b4e8069eb12aedfa881333004bccaec24ecef5a8a6a4b6df142b2cc9599d196", size = 353480 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/3d/023389198f69c722d039351050738d6755376c8fd343e91dc493ea485905/jsonschema-4.24.0-py3-none-any.whl", hash = "sha256:a462455f19f5faf404a7902952b6f0e3ce868f3ee09a359b05eca6673bd8412d", size = 88709 }, +] + +[package.optional-dependencies] +format-nongpl = [ + { name = "fqdn" }, + { name = "idna" }, + { name = "isoduration" }, + { name = "jsonpointer" }, + { name = "rfc3339-validator" }, + { name = "rfc3986-validator" }, + { name = "uri-template" }, + { name = "webcolors" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bf/ce/46fbd9c8119cfc3581ee5643ea49464d168028cfb5caff5fc0596d0cf914/jsonschema_specifications-2025.4.1.tar.gz", hash = "sha256:630159c9f4dbea161a6a2205c3011cc4f18ff381b189fff48bb39b9bf26ae608", size = 15513 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/0e/b27cdbaccf30b890c40ed1da9fd4a3593a5cf94dae54fb34f8a4b74fcd3f/jsonschema_specifications-2025.4.1-py3-none-any.whl", hash = "sha256:4653bffbd6584f7de83a67e0d620ef16900b390ddc7939d56684d6c81e33f1af", size = 18437 }, +] + +[[package]] +name = "jupyter-client" +version = "8.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-core" }, + { name = "python-dateutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/22/bf9f12fdaeae18019a468b68952a60fe6dbab5d67cd2a103cac7659b41ca/jupyter_client-8.6.3.tar.gz", hash = "sha256:35b3a0947c4a6e9d589eb97d7d4cd5e90f910ee73101611f01283732bd6d9419", size = 342019 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/85/b0394e0b6fcccd2c1eeefc230978a6f8cb0c5df1e4cd3e7625735a0d7d1e/jupyter_client-8.6.3-py3-none-any.whl", hash = "sha256:e8a19cc986cc45905ac3362915f410f3af85424b4c0905e94fa5f2cb08e8f23f", size = 106105 }, +] + +[[package]] +name = "jupyter-core" +version = "5.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "pywin32", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'win32'" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/1b/72906d554acfeb588332eaaa6f61577705e9ec752ddb486f302dafa292d9/jupyter_core-5.8.1.tar.gz", hash = "sha256:0a5f9706f70e64786b75acba995988915ebd4601c8a52e534a40b51c95f59941", size = 88923 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/57/6bffd4b20b88da3800c5d691e0337761576ee688eb01299eae865689d2df/jupyter_core-5.8.1-py3-none-any.whl", hash = "sha256:c28d268fc90fb53f1338ded2eb410704c5449a358406e8a948b75706e24863d0", size = 28880 }, +] + +[[package]] +name = "jupyter-events" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonschema", extra = ["format-nongpl"] }, + { name = "packaging" }, + { name = "python-json-logger" }, + { name = "pyyaml" }, + { name = "referencing" }, + { name = "rfc3339-validator" }, + { name = "rfc3986-validator" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/c3/306d090461e4cf3cd91eceaff84bede12a8e52cd821c2d20c9a4fd728385/jupyter_events-0.12.0.tar.gz", hash = "sha256:fc3fce98865f6784c9cd0a56a20644fc6098f21c8c33834a8d9fe383c17e554b", size = 62196 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/48/577993f1f99c552f18a0428731a755e06171f9902fa118c379eb7c04ea22/jupyter_events-0.12.0-py3-none-any.whl", hash = "sha256:6464b2fa5ad10451c3d35fabc75eab39556ae1e2853ad0c0cc31b656731a97fb", size = 19430 }, +] + +[[package]] +name = "jupyter-lsp" +version = "2.2.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-server" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/85/b4/3200b0b09c12bc3b72d943d923323c398eff382d1dcc7c0dbc8b74630e40/jupyter-lsp-2.2.5.tar.gz", hash = "sha256:793147a05ad446f809fd53ef1cd19a9f5256fd0a2d6b7ce943a982cb4f545001", size = 48741 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/e0/7bd7cff65594fd9936e2f9385701e44574fc7d721331ff676ce440b14100/jupyter_lsp-2.2.5-py3-none-any.whl", hash = "sha256:45fbddbd505f3fbfb0b6cb2f1bc5e15e83ab7c79cd6e89416b248cb3c00c11da", size = 69146 }, +] + +[[package]] +name = "jupyter-server" +version = "2.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "argon2-cffi" }, + { name = "jinja2" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "jupyter-events" }, + { name = "jupyter-server-terminals" }, + { name = "nbconvert" }, + { name = "nbformat" }, + { name = "overrides" }, + { name = "packaging" }, + { name = "prometheus-client" }, + { name = "pywinpty", marker = "os_name == 'nt'" }, + { name = "pyzmq" }, + { name = "send2trash" }, + { name = "terminado" }, + { name = "tornado" }, + { name = "traitlets" }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/c8/ba2bbcd758c47f1124c4ca14061e8ce60d9c6fd537faee9534a95f83521a/jupyter_server-2.16.0.tar.gz", hash = "sha256:65d4b44fdf2dcbbdfe0aa1ace4a842d4aaf746a2b7b168134d5aaed35621b7f6", size = 728177 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/1f/5ebbced977171d09a7b0c08a285ff9a20aafb9c51bde07e52349ff1ddd71/jupyter_server-2.16.0-py3-none-any.whl", hash = "sha256:3d8db5be3bc64403b1c65b400a1d7f4647a5ce743f3b20dbdefe8ddb7b55af9e", size = 386904 }, +] + +[[package]] +name = "jupyter-server-terminals" +version = "0.5.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywinpty", marker = "os_name == 'nt'" }, + { name = "terminado" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/d5/562469734f476159e99a55426d697cbf8e7eb5efe89fb0e0b4f83a3d3459/jupyter_server_terminals-0.5.3.tar.gz", hash = "sha256:5ae0295167220e9ace0edcfdb212afd2b01ee8d179fe6f23c899590e9b8a5269", size = 31430 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/2d/2b32cdbe8d2a602f697a649798554e4f072115438e92249624e532e8aca6/jupyter_server_terminals-0.5.3-py3-none-any.whl", hash = "sha256:41ee0d7dc0ebf2809c668e0fc726dfaf258fcd3e769568996ca731b6194ae9aa", size = 13656 }, +] + +[[package]] +name = "jupyterlab" +version = "4.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-lru" }, + { name = "httpx" }, + { name = "ipykernel" }, + { name = "jinja2" }, + { name = "jupyter-core" }, + { name = "jupyter-lsp" }, + { name = "jupyter-server" }, + { name = "jupyterlab-server" }, + { name = "notebook-shim" }, + { name = "packaging" }, + { name = "setuptools" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/2d/d1678dcf2db66cb4a38a80d9e5fcf48c349f3ac12f2d38882993353ae768/jupyterlab-4.4.3.tar.gz", hash = "sha256:a94c32fd7f8b93e82a49dc70a6ec45a5c18281ca2a7228d12765e4e210e5bca2", size = 23032376 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/4d/7dd5c2ffbb960930452a031dc8410746183c924580f2ab4e68ceb5b3043f/jupyterlab-4.4.3-py3-none-any.whl", hash = "sha256:164302f6d4b6c44773dfc38d585665a4db401a16e5296c37df5cba63904fbdea", size = 12295480 }, +] + +[[package]] +name = "jupyterlab-pygments" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/51/9187be60d989df97f5f0aba133fa54e7300f17616e065d1ada7d7646b6d6/jupyterlab_pygments-0.3.0.tar.gz", hash = "sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d", size = 512900 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/dd/ead9d8ea85bf202d90cc513b533f9c363121c7792674f78e0d8a854b63b4/jupyterlab_pygments-0.3.0-py3-none-any.whl", hash = "sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780", size = 15884 }, +] + +[[package]] +name = "jupyterlab-server" +version = "2.27.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "jinja2" }, + { name = "json5" }, + { name = "jsonschema" }, + { name = "jupyter-server" }, + { name = "packaging" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0a/c9/a883ce65eb27905ce77ace410d83587c82ea64dc85a48d1f7ed52bcfa68d/jupyterlab_server-2.27.3.tar.gz", hash = "sha256:eb36caca59e74471988f0ae25c77945610b887f777255aa21f8065def9e51ed4", size = 76173 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/09/2032e7d15c544a0e3cd831c51d77a8ca57f7555b2e1b2922142eddb02a84/jupyterlab_server-2.27.3-py3-none-any.whl", hash = "sha256:e697488f66c3db49df675158a77b3b017520d772c6e1548c7d9bcc5df7944ee4", size = 59700 }, +] + +[[package]] +name = "jupyterlab-widgets" +version = "3.0.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/7d/160595ca88ee87ac6ba95d82177d29ec60aaa63821d3077babb22ce031a5/jupyterlab_widgets-3.0.15.tar.gz", hash = "sha256:2920888a0c2922351a9202817957a68c07d99673504d6cd37345299e971bb08b", size = 213149 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/6a/ca128561b22b60bd5a0c4ea26649e68c8556b82bc70a0c396eebc977fe86/jupyterlab_widgets-3.0.15-py3-none-any.whl", hash = "sha256:d59023d7d7ef71400d51e6fee9a88867f6e65e10a4201605d2d7f3e8f012a31c", size = 216571 }, +] + +[[package]] +name = "kiwisolver" +version = "1.4.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/59/7c91426a8ac292e1cdd53a63b6d9439abd573c875c3f92c146767dd33faf/kiwisolver-1.4.8.tar.gz", hash = "sha256:23d5f023bdc8c7e54eb65f03ca5d5bb25b601eac4d7f1a042888a1f45237987e", size = 97538 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/aa/cea685c4ab647f349c3bc92d2daf7ae34c8e8cf405a6dcd3a497f58a2ac3/kiwisolver-1.4.8-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d6af5e8815fd02997cb6ad9bbed0ee1e60014438ee1a5c2444c96f87b8843502", size = 124152 }, + { url = "https://files.pythonhosted.org/packages/c5/0b/8db6d2e2452d60d5ebc4ce4b204feeb16176a851fd42462f66ade6808084/kiwisolver-1.4.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bade438f86e21d91e0cf5dd7c0ed00cda0f77c8c1616bd83f9fc157fa6760d31", size = 66555 }, + { url = "https://files.pythonhosted.org/packages/60/26/d6a0db6785dd35d3ba5bf2b2df0aedc5af089962c6eb2cbf67a15b81369e/kiwisolver-1.4.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b83dc6769ddbc57613280118fb4ce3cd08899cc3369f7d0e0fab518a7cf37fdb", size = 65067 }, + { url = "https://files.pythonhosted.org/packages/c9/ed/1d97f7e3561e09757a196231edccc1bcf59d55ddccefa2afc9c615abd8e0/kiwisolver-1.4.8-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:111793b232842991be367ed828076b03d96202c19221b5ebab421ce8bcad016f", size = 1378443 }, + { url = "https://files.pythonhosted.org/packages/29/61/39d30b99954e6b46f760e6289c12fede2ab96a254c443639052d1b573fbc/kiwisolver-1.4.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:257af1622860e51b1a9d0ce387bf5c2c4f36a90594cb9514f55b074bcc787cfc", size = 1472728 }, + { url = "https://files.pythonhosted.org/packages/0c/3e/804163b932f7603ef256e4a715e5843a9600802bb23a68b4e08c8c0ff61d/kiwisolver-1.4.8-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:69b5637c3f316cab1ec1c9a12b8c5f4750a4c4b71af9157645bf32830e39c03a", size = 1478388 }, + { url = "https://files.pythonhosted.org/packages/8a/9e/60eaa75169a154700be74f875a4d9961b11ba048bef315fbe89cb6999056/kiwisolver-1.4.8-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:782bb86f245ec18009890e7cb8d13a5ef54dcf2ebe18ed65f795e635a96a1c6a", size = 1413849 }, + { url = "https://files.pythonhosted.org/packages/bc/b3/9458adb9472e61a998c8c4d95cfdfec91c73c53a375b30b1428310f923e4/kiwisolver-1.4.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc978a80a0db3a66d25767b03688f1147a69e6237175c0f4ffffaaedf744055a", size = 1475533 }, + { url = "https://files.pythonhosted.org/packages/e4/7a/0a42d9571e35798de80aef4bb43a9b672aa7f8e58643d7bd1950398ffb0a/kiwisolver-1.4.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:36dbbfd34838500a31f52c9786990d00150860e46cd5041386f217101350f0d3", size = 2268898 }, + { url = "https://files.pythonhosted.org/packages/d9/07/1255dc8d80271400126ed8db35a1795b1a2c098ac3a72645075d06fe5c5d/kiwisolver-1.4.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:eaa973f1e05131de5ff3569bbba7f5fd07ea0595d3870ed4a526d486fe57fa1b", size = 2425605 }, + { url = "https://files.pythonhosted.org/packages/84/df/5a3b4cf13780ef6f6942df67b138b03b7e79e9f1f08f57c49957d5867f6e/kiwisolver-1.4.8-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a66f60f8d0c87ab7f59b6fb80e642ebb29fec354a4dfad687ca4092ae69d04f4", size = 2375801 }, + { url = "https://files.pythonhosted.org/packages/8f/10/2348d068e8b0f635c8c86892788dac7a6b5c0cb12356620ab575775aad89/kiwisolver-1.4.8-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:858416b7fb777a53f0c59ca08190ce24e9abbd3cffa18886a5781b8e3e26f65d", size = 2520077 }, + { url = "https://files.pythonhosted.org/packages/32/d8/014b89fee5d4dce157d814303b0fce4d31385a2af4c41fed194b173b81ac/kiwisolver-1.4.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:085940635c62697391baafaaeabdf3dd7a6c3643577dde337f4d66eba021b2b8", size = 2338410 }, + { url = "https://files.pythonhosted.org/packages/bd/72/dfff0cc97f2a0776e1c9eb5bef1ddfd45f46246c6533b0191887a427bca5/kiwisolver-1.4.8-cp312-cp312-win_amd64.whl", hash = "sha256:01c3d31902c7db5fb6182832713d3b4122ad9317c2c5877d0539227d96bb2e50", size = 71853 }, + { url = "https://files.pythonhosted.org/packages/dc/85/220d13d914485c0948a00f0b9eb419efaf6da81b7d72e88ce2391f7aed8d/kiwisolver-1.4.8-cp312-cp312-win_arm64.whl", hash = "sha256:a3c44cb68861de93f0c4a8175fbaa691f0aa22550c331fefef02b618a9dcb476", size = 65424 }, + { url = "https://files.pythonhosted.org/packages/79/b3/e62464a652f4f8cd9006e13d07abad844a47df1e6537f73ddfbf1bc997ec/kiwisolver-1.4.8-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1c8ceb754339793c24aee1c9fb2485b5b1f5bb1c2c214ff13368431e51fc9a09", size = 124156 }, + { url = "https://files.pythonhosted.org/packages/8d/2d/f13d06998b546a2ad4f48607a146e045bbe48030774de29f90bdc573df15/kiwisolver-1.4.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a62808ac74b5e55a04a408cda6156f986cefbcf0ada13572696b507cc92fa1", size = 66555 }, + { url = "https://files.pythonhosted.org/packages/59/e3/b8bd14b0a54998a9fd1e8da591c60998dc003618cb19a3f94cb233ec1511/kiwisolver-1.4.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:68269e60ee4929893aad82666821aaacbd455284124817af45c11e50a4b42e3c", size = 65071 }, + { url = "https://files.pythonhosted.org/packages/f0/1c/6c86f6d85ffe4d0ce04228d976f00674f1df5dc893bf2dd4f1928748f187/kiwisolver-1.4.8-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:34d142fba9c464bc3bbfeff15c96eab0e7310343d6aefb62a79d51421fcc5f1b", size = 1378053 }, + { url = "https://files.pythonhosted.org/packages/4e/b9/1c6e9f6dcb103ac5cf87cb695845f5fa71379021500153566d8a8a9fc291/kiwisolver-1.4.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ddc373e0eef45b59197de815b1b28ef89ae3955e7722cc9710fb91cd77b7f47", size = 1472278 }, + { url = "https://files.pythonhosted.org/packages/ee/81/aca1eb176de671f8bda479b11acdc42c132b61a2ac861c883907dde6debb/kiwisolver-1.4.8-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:77e6f57a20b9bd4e1e2cedda4d0b986ebd0216236f0106e55c28aea3d3d69b16", size = 1478139 }, + { url = "https://files.pythonhosted.org/packages/49/f4/e081522473671c97b2687d380e9e4c26f748a86363ce5af48b4a28e48d06/kiwisolver-1.4.8-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08e77738ed7538f036cd1170cbed942ef749137b1311fa2bbe2a7fda2f6bf3cc", size = 1413517 }, + { url = "https://files.pythonhosted.org/packages/8f/e9/6a7d025d8da8c4931522922cd706105aa32b3291d1add8c5427cdcd66e63/kiwisolver-1.4.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a5ce1e481a74b44dd5e92ff03ea0cb371ae7a0268318e202be06c8f04f4f1246", size = 1474952 }, + { url = "https://files.pythonhosted.org/packages/82/13/13fa685ae167bee5d94b415991c4fc7bb0a1b6ebea6e753a87044b209678/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fc2ace710ba7c1dfd1a3b42530b62b9ceed115f19a1656adefce7b1782a37794", size = 2269132 }, + { url = "https://files.pythonhosted.org/packages/ef/92/bb7c9395489b99a6cb41d502d3686bac692586db2045adc19e45ee64ed23/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3452046c37c7692bd52b0e752b87954ef86ee2224e624ef7ce6cb21e8c41cc1b", size = 2425997 }, + { url = "https://files.pythonhosted.org/packages/ed/12/87f0e9271e2b63d35d0d8524954145837dd1a6c15b62a2d8c1ebe0f182b4/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7e9a60b50fe8b2ec6f448fe8d81b07e40141bfced7f896309df271a0b92f80f3", size = 2376060 }, + { url = "https://files.pythonhosted.org/packages/02/6e/c8af39288edbce8bf0fa35dee427b082758a4b71e9c91ef18fa667782138/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:918139571133f366e8362fa4a297aeba86c7816b7ecf0bc79168080e2bd79957", size = 2520471 }, + { url = "https://files.pythonhosted.org/packages/13/78/df381bc7b26e535c91469f77f16adcd073beb3e2dd25042efd064af82323/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e063ef9f89885a1d68dd8b2e18f5ead48653176d10a0e324e3b0030e3a69adeb", size = 2338793 }, + { url = "https://files.pythonhosted.org/packages/d0/dc/c1abe38c37c071d0fc71c9a474fd0b9ede05d42f5a458d584619cfd2371a/kiwisolver-1.4.8-cp313-cp313-win_amd64.whl", hash = "sha256:a17b7c4f5b2c51bb68ed379defd608a03954a1845dfed7cc0117f1cc8a9b7fd2", size = 71855 }, + { url = "https://files.pythonhosted.org/packages/a0/b6/21529d595b126ac298fdd90b705d87d4c5693de60023e0efcb4f387ed99e/kiwisolver-1.4.8-cp313-cp313-win_arm64.whl", hash = "sha256:3cd3bc628b25f74aedc6d374d5babf0166a92ff1317f46267f12d2ed54bc1d30", size = 65430 }, + { url = "https://files.pythonhosted.org/packages/34/bd/b89380b7298e3af9b39f49334e3e2a4af0e04819789f04b43d560516c0c8/kiwisolver-1.4.8-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:370fd2df41660ed4e26b8c9d6bbcad668fbe2560462cba151a721d49e5b6628c", size = 126294 }, + { url = "https://files.pythonhosted.org/packages/83/41/5857dc72e5e4148eaac5aa76e0703e594e4465f8ab7ec0fc60e3a9bb8fea/kiwisolver-1.4.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:84a2f830d42707de1d191b9490ac186bf7997a9495d4e9072210a1296345f7dc", size = 67736 }, + { url = "https://files.pythonhosted.org/packages/e1/d1/be059b8db56ac270489fb0b3297fd1e53d195ba76e9bbb30e5401fa6b759/kiwisolver-1.4.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7a3ad337add5148cf51ce0b55642dc551c0b9d6248458a757f98796ca7348712", size = 66194 }, + { url = "https://files.pythonhosted.org/packages/e1/83/4b73975f149819eb7dcf9299ed467eba068ecb16439a98990dcb12e63fdd/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7506488470f41169b86d8c9aeff587293f530a23a23a49d6bc64dab66bedc71e", size = 1465942 }, + { url = "https://files.pythonhosted.org/packages/c7/2c/30a5cdde5102958e602c07466bce058b9d7cb48734aa7a4327261ac8e002/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f0121b07b356a22fb0414cec4666bbe36fd6d0d759db3d37228f496ed67c880", size = 1595341 }, + { url = "https://files.pythonhosted.org/packages/ff/9b/1e71db1c000385aa069704f5990574b8244cce854ecd83119c19e83c9586/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d6d6bd87df62c27d4185de7c511c6248040afae67028a8a22012b010bc7ad062", size = 1598455 }, + { url = "https://files.pythonhosted.org/packages/85/92/c8fec52ddf06231b31cbb779af77e99b8253cd96bd135250b9498144c78b/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:291331973c64bb9cce50bbe871fb2e675c4331dab4f31abe89f175ad7679a4d7", size = 1522138 }, + { url = "https://files.pythonhosted.org/packages/0b/51/9eb7e2cd07a15d8bdd976f6190c0164f92ce1904e5c0c79198c4972926b7/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:893f5525bb92d3d735878ec00f781b2de998333659507d29ea4466208df37bed", size = 1582857 }, + { url = "https://files.pythonhosted.org/packages/0f/95/c5a00387a5405e68ba32cc64af65ce881a39b98d73cc394b24143bebc5b8/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b47a465040146981dc9db8647981b8cb96366fbc8d452b031e4f8fdffec3f26d", size = 2293129 }, + { url = "https://files.pythonhosted.org/packages/44/83/eeb7af7d706b8347548313fa3a3a15931f404533cc54fe01f39e830dd231/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:99cea8b9dd34ff80c521aef46a1dddb0dcc0283cf18bde6d756f1e6f31772165", size = 2421538 }, + { url = "https://files.pythonhosted.org/packages/05/f9/27e94c1b3eb29e6933b6986ffc5fa1177d2cd1f0c8efc5f02c91c9ac61de/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:151dffc4865e5fe6dafce5480fab84f950d14566c480c08a53c663a0020504b6", size = 2390661 }, + { url = "https://files.pythonhosted.org/packages/d9/d4/3c9735faa36ac591a4afcc2980d2691000506050b7a7e80bcfe44048daa7/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:577facaa411c10421314598b50413aa1ebcf5126f704f1e5d72d7e4e9f020d90", size = 2546710 }, + { url = "https://files.pythonhosted.org/packages/4c/fa/be89a49c640930180657482a74970cdcf6f7072c8d2471e1babe17a222dc/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:be4816dc51c8a471749d664161b434912eee82f2ea66bd7628bd14583a833e85", size = 2349213 }, +] + +[[package]] +name = "langchain" +version = "0.3.26" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "langchain-text-splitters" }, + { name = "langsmith" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/13/a9931800ee42bbe0f8850dd540de14e80dda4945e7ee36e20b5d5964286e/langchain-0.3.26.tar.gz", hash = "sha256:8ff034ee0556d3e45eff1f1e96d0d745ced57858414dba7171c8ebdbeb5580c9", size = 10226808 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/f2/c09a2e383283e3af1db669ab037ac05a45814f4b9c472c48dc24c0cef039/langchain-0.3.26-py3-none-any.whl", hash = "sha256:361bb2e61371024a8c473da9f9c55f4ee50f269c5ab43afdb2b1309cb7ac36cf", size = 1012336 }, +] + +[[package]] +name = "langchain-community" +version = "0.3.26" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "dataclasses-json" }, + { name = "httpx-sse" }, + { name = "langchain" }, + { name = "langchain-core" }, + { name = "langsmith" }, + { name = "numpy" }, + { name = "pydantic-settings" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "sqlalchemy" }, + { name = "tenacity" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/15/69940212569e7d7ac7b486fba244701448e8685f79069b73206c44e96fde/langchain_community-0.3.26.tar.gz", hash = "sha256:49f9d71dc20bc42ccecd6875d02fafef1be0e211a0b22cecbd678f5fd3719487", size = 33235791 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/8e/d3d201f648e8d09dc1072a734c4dc1f59455b91d7d162427256533bf5a87/langchain_community-0.3.26-py3-none-any.whl", hash = "sha256:b25a553ee9d44a6c02092a440da6c561a9312c7013ffc25365ac3f8694edb53a", size = 2529186 }, +] + +[[package]] +name = "langchain-core" +version = "0.3.66" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpatch" }, + { name = "langsmith" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tenacity" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/63/470aa84393bad5d51749417af58522a691174f8b2d05843f5633d473faa0/langchain_core-0.3.66.tar.gz", hash = "sha256:350c92e792ec1401f4b740d759b95f297710a50de29e1be9fbfff8676ef62117", size = 560102 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/c3/8080431fd7567a340d3a42e36c0bb3970a8d00d5e27bf3ca2103b3b55996/langchain_core-0.3.66-py3-none-any.whl", hash = "sha256:65cd6c3659afa4f91de7aa681397a0c53ff9282425c281e53646dd7faf16099e", size = 438874 }, +] + +[[package]] +name = "langchain-google-genai" +version = "2.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filetype" }, + { name = "google-ai-generativelanguage" }, + { name = "langchain-core" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/a5/d9b8d5afdf4a33f13e7d973f2705891cd13cc1dfb578719c9861a8d8385b/langchain_google_genai-2.1.5.tar.gz", hash = "sha256:6e71375a7707667bdecc5a7d1c86438ec10f2c7bb6dc6e3f095f5b22523c4fc9", size = 40813 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/70/0747358eca996f713f715e2bfc2d0805804f8f705af57381fbee91bb475a/langchain_google_genai-2.1.5-py3-none-any.whl", hash = "sha256:6c8ccaf33a41f83b1d08a2398edbf47a1eebea27a7ec6930f34a0c019f309253", size = 44788 }, +] + +[[package]] +name = "langchain-ollama" +version = "0.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "ollama" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/9f/6683f69f14b0cde3556c6b7752fb290bfce743981dc1312efa924619365f/langchain_ollama-0.3.3.tar.gz", hash = "sha256:7d6ed75bfb706751b83173fe886b72ae25bb0b1bd7f3eb2622821c4149f7807b", size = 21913 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/6f/ab7a470522e27b95ed008eb9ef81b1ab55321f3f3aff21ca0109aae53cdf/langchain_ollama-0.3.3-py3-none-any.whl", hash = "sha256:f1c745a4b59d36bb51995c23c6b0fbc20f71956715659425ab88639a14b213cd", size = 21156 }, +] + +[[package]] +name = "langchain-openai" +version = "0.3.25" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "openai" }, + { name = "tiktoken" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/31/52c385ff5a6cc2576605c44f7b34c2f476b918db54a7ec7006f314628b10/langchain_openai-0.3.25.tar.gz", hash = "sha256:6dd33e4a2513cf915af6c2508e782d2c90956a88650739fd8d31e14bdb7f7e44", size = 688157 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/bd/87b77f001f8aa90a54d9390c29ad462cd9f379d0ae57e125e0d079e8a57a/langchain_openai-0.3.25-py3-none-any.whl", hash = "sha256:a7d5c9d4f4ff2b6156f313e92e652833fdfd42084ecfd0980e719dc8472ea51c", size = 69171 }, +] + +[[package]] +name = "langchain-text-splitters" +version = "0.3.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/ac/b4a25c5716bb0103b1515f1f52cc69ffb1035a5a225ee5afe3aed28bf57b/langchain_text_splitters-0.3.8.tar.gz", hash = "sha256:116d4b9f2a22dda357d0b79e30acf005c5518177971c66a9f1ab0edfdb0f912e", size = 42128 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/a3/3696ff2444658053c01b6b7443e761f28bb71217d82bb89137a978c5f66f/langchain_text_splitters-0.3.8-py3-none-any.whl", hash = "sha256:e75cc0f4ae58dcf07d9f18776400cf8ade27fadd4ff6d264df6278bb302f6f02", size = 32440 }, +] + +[[package]] +name = "langsmith" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "zstandard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/ef/fa15298f31833118429c03c552fde3f600788f480cc73700177599656334/langsmith-0.4.1.tar.gz", hash = "sha256:ae8ec403fb2b9cabcfc3b0c54556d65555598c85879dac83b009576927f7eb1d", size = 349576 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/9d/a2fa9f15f8834de882a92b51e9f33a25da4b38c683a16c2a94a92d86f640/langsmith-0.4.1-py3-none-any.whl", hash = "sha256:19c4c40bbb6735cb1136c453b2edcde265ca5ba1b108b7e0e3583ec4bda28625", size = 364599 }, +] + +[[package]] +name = "markdown2" +version = "2.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/52/d7dcc6284d59edb8301b8400435fbb4926a9b0f13a12b5cbaf3a4a54bb7b/markdown2-2.5.3.tar.gz", hash = "sha256:4d502953a4633408b0ab3ec503c5d6984d1b14307e32b325ec7d16ea57524895", size = 141676 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/37/0a13c83ccf5365b8e08ea572dfbc04b8cb87cadd359b2451a567f5248878/markdown2-2.5.3-py3-none-any.whl", hash = "sha256:a8ebb7e84b8519c37bf7382b3db600f1798a22c245bfd754a1f87ca8d7ea63b3", size = 48550 }, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274 }, + { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348 }, + { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149 }, + { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118 }, + { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993 }, + { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178 }, + { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319 }, + { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352 }, + { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097 }, + { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601 }, + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274 }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352 }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122 }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085 }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978 }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208 }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357 }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344 }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101 }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603 }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510 }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486 }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480 }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914 }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796 }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473 }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114 }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 }, +] + +[[package]] +name = "marshmallow" +version = "3.26.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/5e/5e53d26b42ab75491cda89b871dab9e97c840bf12c63ec58a1919710cd06/marshmallow-3.26.1.tar.gz", hash = "sha256:e6d8affb6cb61d39d26402096dc0aee12d5a26d490a121f118d2e81dc0719dc6", size = 221825 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/75/51952c7b2d3873b44a0028b1bd26a25078c18f92f256608e8d1dc61b39fd/marshmallow-3.26.1-py3-none-any.whl", hash = "sha256:3350409f20a70a7e4e11a27661187b77cdcaeb20abca41c1454fe33636bea09c", size = 50878 }, +] + +[[package]] +name = "matplotlib" +version = "3.10.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/91/d49359a21893183ed2a5b6c76bec40e0b1dcbf8ca148f864d134897cfc75/matplotlib-3.10.3.tar.gz", hash = "sha256:2f82d2c5bb7ae93aaaa4cd42aca65d76ce6376f83304fa3a630b569aca274df0", size = 34799811 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/43/6b80eb47d1071f234ef0c96ca370c2ca621f91c12045f1401b5c9b28a639/matplotlib-3.10.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0ab1affc11d1f495ab9e6362b8174a25afc19c081ba5b0775ef00533a4236eea", size = 8179689 }, + { url = "https://files.pythonhosted.org/packages/0f/70/d61a591958325c357204870b5e7b164f93f2a8cca1dc6ce940f563909a13/matplotlib-3.10.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2a818d8bdcafa7ed2eed74487fdb071c09c1ae24152d403952adad11fa3c65b4", size = 8050466 }, + { url = "https://files.pythonhosted.org/packages/e7/75/70c9d2306203148cc7902a961240c5927dd8728afedf35e6a77e105a2985/matplotlib-3.10.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:748ebc3470c253e770b17d8b0557f0aa85cf8c63fd52f1a61af5b27ec0b7ffee", size = 8456252 }, + { url = "https://files.pythonhosted.org/packages/c4/91/ba0ae1ff4b3f30972ad01cd4a8029e70a0ec3b8ea5be04764b128b66f763/matplotlib-3.10.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed70453fd99733293ace1aec568255bc51c6361cb0da94fa5ebf0649fdb2150a", size = 8601321 }, + { url = "https://files.pythonhosted.org/packages/d2/88/d636041eb54a84b889e11872d91f7cbf036b3b0e194a70fa064eb8b04f7a/matplotlib-3.10.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dbed9917b44070e55640bd13419de83b4c918e52d97561544814ba463811cbc7", size = 9406972 }, + { url = "https://files.pythonhosted.org/packages/b1/79/0d1c165eac44405a86478082e225fce87874f7198300bbebc55faaf6d28d/matplotlib-3.10.3-cp312-cp312-win_amd64.whl", hash = "sha256:cf37d8c6ef1a48829443e8ba5227b44236d7fcaf7647caa3178a4ff9f7a5be05", size = 8067954 }, + { url = "https://files.pythonhosted.org/packages/3b/c1/23cfb566a74c696a3b338d8955c549900d18fe2b898b6e94d682ca21e7c2/matplotlib-3.10.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9f2efccc8dcf2b86fc4ee849eea5dcaecedd0773b30f47980dc0cbeabf26ec84", size = 8180318 }, + { url = "https://files.pythonhosted.org/packages/6c/0c/02f1c3b66b30da9ee343c343acbb6251bef5b01d34fad732446eaadcd108/matplotlib-3.10.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ddbba06a6c126e3301c3d272a99dcbe7f6c24c14024e80307ff03791a5f294e", size = 8051132 }, + { url = "https://files.pythonhosted.org/packages/b4/ab/8db1a5ac9b3a7352fb914133001dae889f9fcecb3146541be46bed41339c/matplotlib-3.10.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:748302b33ae9326995b238f606e9ed840bf5886ebafcb233775d946aa8107a15", size = 8457633 }, + { url = "https://files.pythonhosted.org/packages/f5/64/41c4367bcaecbc03ef0d2a3ecee58a7065d0a36ae1aa817fe573a2da66d4/matplotlib-3.10.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a80fcccbef63302c0efd78042ea3c2436104c5b1a4d3ae20f864593696364ac7", size = 8601031 }, + { url = "https://files.pythonhosted.org/packages/12/6f/6cc79e9e5ab89d13ed64da28898e40fe5b105a9ab9c98f83abd24e46d7d7/matplotlib-3.10.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:55e46cbfe1f8586adb34f7587c3e4f7dedc59d5226719faf6cb54fc24f2fd52d", size = 9406988 }, + { url = "https://files.pythonhosted.org/packages/b1/0f/eed564407bd4d935ffabf561ed31099ed609e19287409a27b6d336848653/matplotlib-3.10.3-cp313-cp313-win_amd64.whl", hash = "sha256:151d89cb8d33cb23345cd12490c76fd5d18a56581a16d950b48c6ff19bb2ab93", size = 8068034 }, + { url = "https://files.pythonhosted.org/packages/3e/e5/2f14791ff69b12b09e9975e1d116d9578ac684460860ce542c2588cb7a1c/matplotlib-3.10.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c26dd9834e74d164d06433dc7be5d75a1e9890b926b3e57e74fa446e1a62c3e2", size = 8218223 }, + { url = "https://files.pythonhosted.org/packages/5c/08/30a94afd828b6e02d0a52cae4a29d6e9ccfcf4c8b56cc28b021d3588873e/matplotlib-3.10.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:24853dad5b8c84c8c2390fc31ce4858b6df504156893292ce8092d190ef8151d", size = 8094985 }, + { url = "https://files.pythonhosted.org/packages/89/44/f3bc6b53066c889d7a1a3ea8094c13af6a667c5ca6220ec60ecceec2dabe/matplotlib-3.10.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68f7878214d369d7d4215e2a9075fef743be38fa401d32e6020bab2dfabaa566", size = 8483109 }, + { url = "https://files.pythonhosted.org/packages/ba/c7/473bc559beec08ebee9f86ca77a844b65747e1a6c2691e8c92e40b9f42a8/matplotlib-3.10.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6929fc618cb6db9cb75086f73b3219bbb25920cb24cee2ea7a12b04971a4158", size = 8618082 }, + { url = "https://files.pythonhosted.org/packages/d8/e9/6ce8edd264c8819e37bbed8172e0ccdc7107fe86999b76ab5752276357a4/matplotlib-3.10.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6c7818292a5cc372a2dc4c795e5c356942eb8350b98ef913f7fda51fe175ac5d", size = 9413699 }, + { url = "https://files.pythonhosted.org/packages/1b/92/9a45c91089c3cf690b5badd4be81e392ff086ccca8a1d4e3a08463d8a966/matplotlib-3.10.3-cp313-cp313t-win_amd64.whl", hash = "sha256:4f23ffe95c5667ef8a2b56eea9b53db7f43910fa4a2d5472ae0f72b64deab4d5", size = 8139044 }, +] + +[[package]] +name = "matplotlib-inline" +version = "0.1.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/5b/a36a337438a14116b16480db471ad061c36c3694df7c2084a0da7ba538b7/matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90", size = 8159 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/8e/9ad090d3553c280a8060fbf6e24dc1c0c29704ee7d1c372f0c174aa59285/matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca", size = 9899 }, +] + +[[package]] +name = "mistune" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/79/bda47f7dd7c3c55770478d6d02c9960c430b0cf1773b72366ff89126ea31/mistune-3.1.3.tar.gz", hash = "sha256:a7035c21782b2becb6be62f8f25d3df81ccb4d6fa477a6525b15af06539f02a0", size = 94347 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/4d/23c4e4f09da849e127e9f123241946c23c1e30f45a88366879e064211815/mistune-3.1.3-py3-none-any.whl", hash = "sha256:1a32314113cff28aa6432e99e522677c8587fd83e3d51c29b82a52409c842bd9", size = 53410 }, +] + +[[package]] +name = "multidict" +version = "6.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/b5/59f27b4ce9951a4bce56b88ba5ff5159486797ab18863f2b4c1c5e8465bd/multidict-6.5.0.tar.gz", hash = "sha256:942bd8002492ba819426a8d7aefde3189c1b87099cdf18aaaefefcf7f3f7b6d2", size = 98512 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/fa/18f4950e00924f7e84c8195f4fc303295e14df23f713d64e778b8fa8b903/multidict-6.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1bb986c8ea9d49947bc325c51eced1ada6d8d9b4c5b15fd3fcdc3c93edef5a74", size = 73474 }, + { url = "https://files.pythonhosted.org/packages/6c/66/0392a2a8948bccff57e4793c9dde3e5c088f01e8b7f8867ee58a2f187fc5/multidict-6.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:03c0923da300120830fc467e23805d63bbb4e98b94032bd863bc7797ea5fa653", size = 43741 }, + { url = "https://files.pythonhosted.org/packages/98/3e/f48487c91b2a070566cfbab876d7e1ebe7deb0a8002e4e896a97998ae066/multidict-6.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4c78d5ec00fdd35c91680ab5cf58368faad4bd1a8721f87127326270248de9bc", size = 42143 }, + { url = "https://files.pythonhosted.org/packages/3f/49/439c6cc1cd00365cf561bdd3579cc3fa1a0d38effb3a59b8d9562839197f/multidict-6.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aadc3cb78be90a887f8f6b73945b840da44b4a483d1c9750459ae69687940c97", size = 239303 }, + { url = "https://files.pythonhosted.org/packages/c4/24/491786269e90081cb536e4d7429508725bc92ece176d1204a4449de7c41c/multidict-6.5.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b02e1ca495d71e07e652e4cef91adae3bf7ae4493507a263f56e617de65dafc", size = 236913 }, + { url = "https://files.pythonhosted.org/packages/e8/76/bbe2558b820ebeca8a317ab034541790e8160ca4b1e450415383ac69b339/multidict-6.5.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7fe92a62326eef351668eec4e2dfc494927764a0840a1895cff16707fceffcd3", size = 250752 }, + { url = "https://files.pythonhosted.org/packages/3e/e3/3977f2c1123f553ceff9f53cd4de04be2c1912333c6fabbcd51531655476/multidict-6.5.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7673ee4f63879ecd526488deb1989041abcb101b2d30a9165e1e90c489f3f7fb", size = 243937 }, + { url = "https://files.pythonhosted.org/packages/b6/b8/7a6e9c13c79709cdd2f22ee849f058e6da76892d141a67acc0e6c30d845c/multidict-6.5.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa097ae2a29f573de7e2d86620cbdda5676d27772d4ed2669cfa9961a0d73955", size = 237419 }, + { url = "https://files.pythonhosted.org/packages/84/9d/8557f5e88da71bc7e7a8ace1ada4c28197f3bfdc2dd6e51d3b88f2e16e8e/multidict-6.5.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:300da0fa4f8457d9c4bd579695496116563409e676ac79b5e4dca18e49d1c308", size = 237222 }, + { url = "https://files.pythonhosted.org/packages/a3/3b/8f023ad60e7969cb6bc0683738d0e1618f5ff5723d6d2d7818dc6df6ad3d/multidict-6.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9a19bd108c35877b57393243d392d024cfbfdefe759fd137abb98f6fc910b64c", size = 247861 }, + { url = "https://files.pythonhosted.org/packages/af/1c/9cf5a099ce7e3189906cf5daa72c44ee962dcb4c1983659f3a6f8a7446ab/multidict-6.5.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f32a1777465a35c35ddbbd7fc1293077938a69402fcc59e40b2846d04a120dd", size = 243917 }, + { url = "https://files.pythonhosted.org/packages/6c/bb/88ee66ebeef56868044bac58feb1cc25658bff27b20e3cfc464edc181287/multidict-6.5.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9cc1e10c14ce8112d1e6d8971fe3cdbe13e314f68bea0e727429249d4a6ce164", size = 249214 }, + { url = "https://files.pythonhosted.org/packages/3e/ec/a90e88cc4a1309f33088ab1cdd5c0487718f49dfb82c5ffc845bb17c1973/multidict-6.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e95c5e07a06594bdc288117ca90e89156aee8cb2d7c330b920d9c3dd19c05414", size = 258682 }, + { url = "https://files.pythonhosted.org/packages/d2/d8/16dd69a6811920a31f4e06114ebe67b1cd922c8b05c9c82b050706d0b6fe/multidict-6.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:40ff26f58323795f5cd2855e2718a1720a1123fb90df4553426f0efd76135462", size = 254254 }, + { url = "https://files.pythonhosted.org/packages/ac/a8/90193a5f5ca1bdbf92633d69a25a2ef9bcac7b412b8d48c84d01a2732518/multidict-6.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:76803a29fd71869a8b59c2118c9dcfb3b8f9c8723e2cce6baeb20705459505cf", size = 247741 }, + { url = "https://files.pythonhosted.org/packages/cd/43/29c7a747153c05b41d1f67455426af39ed88d6de3f21c232b8f2724bde13/multidict-6.5.0-cp312-cp312-win32.whl", hash = "sha256:df7ecbc65a53a2ce1b3a0c82e6ad1a43dcfe7c6137733f9176a92516b9f5b851", size = 41049 }, + { url = "https://files.pythonhosted.org/packages/1e/e8/8f3fc32b7e901f3a2719764d64aeaf6ae77b4ba961f1c3a3cf3867766636/multidict-6.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:0ec1c3fbbb0b655a6540bce408f48b9a7474fd94ed657dcd2e890671fefa7743", size = 44700 }, + { url = "https://files.pythonhosted.org/packages/24/e4/e250806adc98d524d41e69c8d4a42bc3513464adb88cb96224df12928617/multidict-6.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:2d24a00d34808b22c1f15902899b9d82d0faeca9f56281641c791d8605eacd35", size = 41703 }, + { url = "https://files.pythonhosted.org/packages/1a/c9/092c4e9402b6d16de761cff88cb842a5c8cc50ccecaf9c4481ba53264b9e/multidict-6.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:53d92df1752df67a928fa7f884aa51edae6f1cf00eeb38cbcf318cf841c17456", size = 73486 }, + { url = "https://files.pythonhosted.org/packages/08/f9/6f7ddb8213f5fdf4db48d1d640b78e8aef89b63a5de8a2313286db709250/multidict-6.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:680210de2c38eef17ce46b8df8bf2c1ece489261a14a6e43c997d49843a27c99", size = 43745 }, + { url = "https://files.pythonhosted.org/packages/f3/a7/b9be0163bfeee3bb08a77a1705e24eb7e651d594ea554107fac8a1ca6a4d/multidict-6.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e279259bcb936732bfa1a8eec82b5d2352b3df69d2fa90d25808cfc403cee90a", size = 42135 }, + { url = "https://files.pythonhosted.org/packages/8e/30/93c8203f943a417bda3c573a34d5db0cf733afdfffb0ca78545c7716dbd8/multidict-6.5.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1c185fc1069781e3fc8b622c4331fb3b433979850392daa5efbb97f7f9959bb", size = 238585 }, + { url = "https://files.pythonhosted.org/packages/9d/fe/2582b56a1807604774f566eeef183b0d6b148f4b89d1612cd077567b2e1e/multidict-6.5.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6bb5f65ff91daf19ce97f48f63585e51595539a8a523258b34f7cef2ec7e0617", size = 236174 }, + { url = "https://files.pythonhosted.org/packages/9b/c4/d8b66d42d385bd4f974cbd1eaa8b265e6b8d297249009f312081d5ded5c7/multidict-6.5.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8646b4259450c59b9286db280dd57745897897284f6308edbdf437166d93855", size = 250145 }, + { url = "https://files.pythonhosted.org/packages/bc/64/62feda5093ee852426aae3df86fab079f8bf1cdbe403e1078c94672ad3ec/multidict-6.5.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d245973d4ecc04eea0a8e5ebec7882cf515480036e1b48e65dffcfbdf86d00be", size = 243470 }, + { url = "https://files.pythonhosted.org/packages/67/dc/9f6fa6e854625cf289c0e9f4464b40212a01f76b2f3edfe89b6779b4fb93/multidict-6.5.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a133e7ddc9bc7fb053733d0ff697ce78c7bf39b5aec4ac12857b6116324c8d75", size = 236968 }, + { url = "https://files.pythonhosted.org/packages/46/ae/4b81c6e3745faee81a156f3f87402315bdccf04236f75c03e37be19c94ff/multidict-6.5.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80d696fa38d738fcebfd53eec4d2e3aeb86a67679fd5e53c325756682f152826", size = 236575 }, + { url = "https://files.pythonhosted.org/packages/8a/fa/4089d7642ea344226e1bfab60dd588761d4791754f8072e911836a39bedf/multidict-6.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:20d30c9410ac3908abbaa52ee5967a754c62142043cf2ba091e39681bd51d21a", size = 247632 }, + { url = "https://files.pythonhosted.org/packages/16/ee/a353dac797de0f28fb7f078cc181c5f2eefe8dd16aa11a7100cbdc234037/multidict-6.5.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6c65068cc026f217e815fa519d8e959a7188e94ec163ffa029c94ca3ef9d4a73", size = 243520 }, + { url = "https://files.pythonhosted.org/packages/50/ec/560deb3d2d95822d6eb1bcb1f1cb728f8f0197ec25be7c936d5d6a5d133c/multidict-6.5.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e355ac668a8c3e49c2ca8daa4c92f0ad5b705d26da3d5af6f7d971e46c096da7", size = 248551 }, + { url = "https://files.pythonhosted.org/packages/10/85/ddf277e67c78205f6695f2a7639be459bca9cc353b962fd8085a492a262f/multidict-6.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:08db204213d0375a91a381cae0677ab95dd8c67a465eb370549daf6dbbf8ba10", size = 258362 }, + { url = "https://files.pythonhosted.org/packages/02/fc/d64ee1df9b87c5210f2d4c419cab07f28589c81b4e5711eda05a122d0614/multidict-6.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ffa58e3e215af8f6536dc837a990e456129857bb6fd546b3991be470abd9597a", size = 253862 }, + { url = "https://files.pythonhosted.org/packages/c9/7c/a2743c00d9e25f4826d3a77cc13d4746398872cf21c843eef96bb9945665/multidict-6.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3e86eb90015c6f21658dbd257bb8e6aa18bdb365b92dd1fba27ec04e58cdc31b", size = 247391 }, + { url = "https://files.pythonhosted.org/packages/9b/03/7773518db74c442904dbd349074f1e7f2a854cee4d9529fc59e623d3949e/multidict-6.5.0-cp313-cp313-win32.whl", hash = "sha256:f34a90fbd9959d0f857323bd3c52b3e6011ed48f78d7d7b9e04980b8a41da3af", size = 41115 }, + { url = "https://files.pythonhosted.org/packages/eb/9a/6fc51b1dc11a7baa944bc101a92167d8b0f5929d376a8c65168fc0d35917/multidict-6.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:fcb2aa79ac6aef8d5b709bbfc2fdb1d75210ba43038d70fbb595b35af470ce06", size = 44768 }, + { url = "https://files.pythonhosted.org/packages/82/2d/0d010be24b663b3c16e3d3307bbba2de5ae8eec496f6027d5c0515b371a8/multidict-6.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:6dcee5e7e92060b4bb9bb6f01efcbb78c13d0e17d9bc6eec71660dd71dc7b0c2", size = 41770 }, + { url = "https://files.pythonhosted.org/packages/aa/d1/a71711a5f32f84b7b036e82182e3250b949a0ce70d51a2c6a4079e665449/multidict-6.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:cbbc88abea2388fde41dd574159dec2cda005cb61aa84950828610cb5010f21a", size = 80450 }, + { url = "https://files.pythonhosted.org/packages/0f/a2/953a9eede63a98fcec2c1a2c1a0d88de120056219931013b871884f51b43/multidict-6.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:70b599f70ae6536e5976364d3c3cf36f40334708bd6cebdd1e2438395d5e7676", size = 46971 }, + { url = "https://files.pythonhosted.org/packages/44/61/60250212953459edda2c729e1d85130912f23c67bd4f585546fe4bdb1578/multidict-6.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:828bab777aa8d29d59700018178061854e3a47727e0611cb9bec579d3882de3b", size = 45548 }, + { url = "https://files.pythonhosted.org/packages/11/b6/e78ee82e96c495bc2582b303f68bed176b481c8d81a441fec07404fce2ca/multidict-6.5.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9695fc1462f17b131c111cf0856a22ff154b0480f86f539d24b2778571ff94d", size = 238545 }, + { url = "https://files.pythonhosted.org/packages/5a/0f/6132ca06670c8d7b374c3a4fd1ba896fc37fbb66b0de903f61db7d1020ec/multidict-6.5.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b5ac6ebaf5d9814b15f399337ebc6d3a7f4ce9331edd404e76c49a01620b68d", size = 229931 }, + { url = "https://files.pythonhosted.org/packages/c0/63/d9957c506e6df6b3e7a194f0eea62955c12875e454b978f18262a65d017b/multidict-6.5.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84a51e3baa77ded07be4766a9e41d977987b97e49884d4c94f6d30ab6acaee14", size = 248181 }, + { url = "https://files.pythonhosted.org/packages/43/3f/7d5490579640db5999a948e2c41d4a0efd91a75989bda3e0a03a79c92be2/multidict-6.5.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8de67f79314d24179e9b1869ed15e88d6ba5452a73fc9891ac142e0ee018b5d6", size = 241846 }, + { url = "https://files.pythonhosted.org/packages/e1/f7/252b1ce949ece52bba4c0de7aa2e3a3d5964e800bce71fb778c2e6c66f7c/multidict-6.5.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17f78a52c214481d30550ec18208e287dfc4736f0c0148208334b105fd9e0887", size = 232893 }, + { url = "https://files.pythonhosted.org/packages/45/7e/0070bfd48c16afc26e056f2acce49e853c0d604a69c7124bc0bbdb1bcc0a/multidict-6.5.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2966d0099cb2e2039f9b0e73e7fd5eb9c85805681aa2a7f867f9d95b35356921", size = 228567 }, + { url = "https://files.pythonhosted.org/packages/2a/31/90551c75322113ebf5fd9c5422e8641d6952f6edaf6b6c07fdc49b1bebdd/multidict-6.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:86fb42ed5ed1971c642cc52acc82491af97567534a8e381a8d50c02169c4e684", size = 246188 }, + { url = "https://files.pythonhosted.org/packages/cc/e2/aa4b02a55e7767ff292871023817fe4db83668d514dab7ccbce25eaf7659/multidict-6.5.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:4e990cbcb6382f9eae4ec720bcac6a1351509e6fc4a5bb70e4984b27973934e6", size = 235178 }, + { url = "https://files.pythonhosted.org/packages/7d/5c/f67e726717c4b138b166be1700e2b56e06fbbcb84643d15f9a9d7335ff41/multidict-6.5.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d99a59d64bb1f7f2117bec837d9e534c5aeb5dcedf4c2b16b9753ed28fdc20a3", size = 243422 }, + { url = "https://files.pythonhosted.org/packages/e5/1c/15fa318285e26a50aa3fa979bbcffb90f9b4d5ec58882d0590eda067d0da/multidict-6.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:e8ef15cc97c9890212e1caf90f0d63f6560e1e101cf83aeaf63a57556689fb34", size = 254898 }, + { url = "https://files.pythonhosted.org/packages/ad/3d/d6c6d1c2e9b61ca80313912d30bb90d4179335405e421ef0a164eac2c0f9/multidict-6.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:b8a09aec921b34bd8b9f842f0bcfd76c6a8c033dc5773511e15f2d517e7e1068", size = 247129 }, + { url = "https://files.pythonhosted.org/packages/29/15/1568258cf0090bfa78d44be66247cfdb16e27dfd935c8136a1e8632d3057/multidict-6.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ff07b504c23b67f2044533244c230808a1258b3493aaf3ea2a0785f70b7be461", size = 243841 }, + { url = "https://files.pythonhosted.org/packages/65/57/64af5dbcfd61427056e840c8e520b502879d480f9632fbe210929fd87393/multidict-6.5.0-cp313-cp313t-win32.whl", hash = "sha256:9232a117341e7e979d210e41c04e18f1dc3a1d251268df6c818f5334301274e1", size = 46761 }, + { url = "https://files.pythonhosted.org/packages/26/a8/cac7f7d61e188ff44f28e46cb98f9cc21762e671c96e031f06c84a60556e/multidict-6.5.0-cp313-cp313t-win_amd64.whl", hash = "sha256:44cb5c53fb2d4cbcee70a768d796052b75d89b827643788a75ea68189f0980a1", size = 52112 }, + { url = "https://files.pythonhosted.org/packages/51/9f/076533feb1b5488d22936da98b9c217205cfbf9f56f7174e8c5c86d86fe6/multidict-6.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:51d33fafa82640c0217391d4ce895d32b7e84a832b8aee0dcc1b04d8981ec7f4", size = 44358 }, + { url = "https://files.pythonhosted.org/packages/44/d8/45e8fc9892a7386d074941429e033adb4640e59ff0780d96a8cf46fe788e/multidict-6.5.0-py3-none-any.whl", hash = "sha256:5634b35f225977605385f56153bd95a7133faffc0ffe12ad26e10517537e8dfc", size = 12181 }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 }, +] + +[[package]] +name = "narwhals" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/e5/0b875d29e2a4d112c58fef6aac2ed3a73bbdd4d8d0dce722fd154357248a/narwhals-1.44.0.tar.gz", hash = "sha256:8cf0616d4f6f21225b3b56fcde96ccab6d05023561a0f162402aa9b8c33ad31d", size = 499250 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/fb/12f4a971467aac3cb7cbccbbfca5d0f05e23722068112c1ac4a393613ebe/narwhals-1.44.0-py3-none-any.whl", hash = "sha256:a170ea0bab4cf1f323d9f8bf17f2d7042c3d73802bea321996b39bf075d57de5", size = 365240 }, +] + +[[package]] +name = "nbclient" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "nbformat" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/66/7ffd18d58eae90d5721f9f39212327695b749e23ad44b3881744eaf4d9e8/nbclient-0.10.2.tar.gz", hash = "sha256:90b7fc6b810630db87a6d0c2250b1f0ab4cf4d3c27a299b0cde78a4ed3fd9193", size = 62424 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/6d/e7fa07f03a4a7b221d94b4d586edb754a9b0dc3c9e2c93353e9fa4e0d117/nbclient-0.10.2-py3-none-any.whl", hash = "sha256:4ffee11e788b4a27fabeb7955547e4318a5298f34342a4bfd01f2e1faaeadc3d", size = 25434 }, +] + +[[package]] +name = "nbconvert" +version = "7.16.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "bleach", extra = ["css"] }, + { name = "defusedxml" }, + { name = "jinja2" }, + { name = "jupyter-core" }, + { name = "jupyterlab-pygments" }, + { name = "markupsafe" }, + { name = "mistune" }, + { name = "nbclient" }, + { name = "nbformat" }, + { name = "packaging" }, + { name = "pandocfilters" }, + { name = "pygments" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/59/f28e15fc47ffb73af68a8d9b47367a8630d76e97ae85ad18271b9db96fdf/nbconvert-7.16.6.tar.gz", hash = "sha256:576a7e37c6480da7b8465eefa66c17844243816ce1ccc372633c6b71c3c0f582", size = 857715 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/9a/cd673b2f773a12c992f41309ef81b99da1690426bd2f96957a7ade0d3ed7/nbconvert-7.16.6-py3-none-any.whl", hash = "sha256:1375a7b67e0c2883678c48e506dc320febb57685e5ee67faa51b18a90f3a712b", size = 258525 }, +] + +[[package]] +name = "nbformat" +version = "5.10.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastjsonschema" }, + { name = "jsonschema" }, + { name = "jupyter-core" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/fd/91545e604bc3dad7dca9ed03284086039b294c6b3d75c0d2fa45f9e9caf3/nbformat-5.10.4.tar.gz", hash = "sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a", size = 142749 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454 }, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195 }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, +] + +[[package]] +name = "notebook-shim" +version = "0.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-server" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/54/d2/92fa3243712b9a3e8bafaf60aac366da1cada3639ca767ff4b5b3654ec28/notebook_shim-0.2.4.tar.gz", hash = "sha256:b4b2cfa1b65d98307ca24361f5b30fe785b53c3fd07b7a47e89acb5e6ac638cb", size = 13167 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/33/bd5b9137445ea4b680023eb0469b2bb969d61303dedb2aac6560ff3d14a1/notebook_shim-0.2.4-py3-none-any.whl", hash = "sha256:411a5be4e9dc882a074ccbcae671eda64cceb068767e9a3419096986560e1cef", size = 13307 }, +] + +[[package]] +name = "numpy" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/19/d7c972dfe90a353dbd3efbbe1d14a5951de80c99c9dc1b93cd998d51dc0f/numpy-2.3.1.tar.gz", hash = "sha256:1ec9ae20a4226da374362cca3c62cd753faf2f951440b0e3b98e93c235441d2b", size = 20390372 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/56/71ad5022e2f63cfe0ca93559403d0edef14aea70a841d640bd13cdba578e/numpy-2.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2959d8f268f3d8ee402b04a9ec4bb7604555aeacf78b360dc4ec27f1d508177d", size = 20896664 }, + { url = "https://files.pythonhosted.org/packages/25/65/2db52ba049813670f7f987cc5db6dac9be7cd95e923cc6832b3d32d87cef/numpy-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:762e0c0c6b56bdedfef9a8e1d4538556438288c4276901ea008ae44091954e29", size = 14131078 }, + { url = "https://files.pythonhosted.org/packages/57/dd/28fa3c17b0e751047ac928c1e1b6990238faad76e9b147e585b573d9d1bd/numpy-2.3.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:867ef172a0976aaa1f1d1b63cf2090de8b636a7674607d514505fb7276ab08fc", size = 5112554 }, + { url = "https://files.pythonhosted.org/packages/c9/fc/84ea0cba8e760c4644b708b6819d91784c290288c27aca916115e3311d17/numpy-2.3.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:4e602e1b8682c2b833af89ba641ad4176053aaa50f5cacda1a27004352dde943", size = 6646560 }, + { url = "https://files.pythonhosted.org/packages/61/b2/512b0c2ddec985ad1e496b0bd853eeb572315c0f07cd6997473ced8f15e2/numpy-2.3.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:8e333040d069eba1652fb08962ec5b76af7f2c7bce1df7e1418c8055cf776f25", size = 14260638 }, + { url = "https://files.pythonhosted.org/packages/6e/45/c51cb248e679a6c6ab14b7a8e3ead3f4a3fe7425fc7a6f98b3f147bec532/numpy-2.3.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:e7cbf5a5eafd8d230a3ce356d892512185230e4781a361229bd902ff403bc660", size = 16632729 }, + { url = "https://files.pythonhosted.org/packages/e4/ff/feb4be2e5c09a3da161b412019caf47183099cbea1132fd98061808c2df2/numpy-2.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5f1b8f26d1086835f442286c1d9b64bb3974b0b1e41bb105358fd07d20872952", size = 15565330 }, + { url = "https://files.pythonhosted.org/packages/bc/6d/ceafe87587101e9ab0d370e4f6e5f3f3a85b9a697f2318738e5e7e176ce3/numpy-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ee8340cb48c9b7a5899d1149eece41ca535513a9698098edbade2a8e7a84da77", size = 18361734 }, + { url = "https://files.pythonhosted.org/packages/2b/19/0fb49a3ea088be691f040c9bf1817e4669a339d6e98579f91859b902c636/numpy-2.3.1-cp312-cp312-win32.whl", hash = "sha256:e772dda20a6002ef7061713dc1e2585bc1b534e7909b2030b5a46dae8ff077ab", size = 6320411 }, + { url = "https://files.pythonhosted.org/packages/b1/3e/e28f4c1dd9e042eb57a3eb652f200225e311b608632bc727ae378623d4f8/numpy-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:cfecc7822543abdea6de08758091da655ea2210b8ffa1faf116b940693d3df76", size = 12734973 }, + { url = "https://files.pythonhosted.org/packages/04/a8/8a5e9079dc722acf53522b8f8842e79541ea81835e9b5483388701421073/numpy-2.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:7be91b2239af2658653c5bb6f1b8bccafaf08226a258caf78ce44710a0160d30", size = 10191491 }, + { url = "https://files.pythonhosted.org/packages/d4/bd/35ad97006d8abff8631293f8ea6adf07b0108ce6fec68da3c3fcca1197f2/numpy-2.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25a1992b0a3fdcdaec9f552ef10d8103186f5397ab45e2d25f8ac51b1a6b97e8", size = 20889381 }, + { url = "https://files.pythonhosted.org/packages/f1/4f/df5923874d8095b6062495b39729178eef4a922119cee32a12ee1bd4664c/numpy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7dea630156d39b02a63c18f508f85010230409db5b2927ba59c8ba4ab3e8272e", size = 14152726 }, + { url = "https://files.pythonhosted.org/packages/8c/0f/a1f269b125806212a876f7efb049b06c6f8772cf0121139f97774cd95626/numpy-2.3.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:bada6058dd886061f10ea15f230ccf7dfff40572e99fef440a4a857c8728c9c0", size = 5105145 }, + { url = "https://files.pythonhosted.org/packages/6d/63/a7f7fd5f375b0361682f6ffbf686787e82b7bbd561268e4f30afad2bb3c0/numpy-2.3.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:a894f3816eb17b29e4783e5873f92faf55b710c2519e5c351767c51f79d8526d", size = 6639409 }, + { url = "https://files.pythonhosted.org/packages/bf/0d/1854a4121af895aab383f4aa233748f1df4671ef331d898e32426756a8a6/numpy-2.3.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:18703df6c4a4fee55fd3d6e5a253d01c5d33a295409b03fda0c86b3ca2ff41a1", size = 14257630 }, + { url = "https://files.pythonhosted.org/packages/50/30/af1b277b443f2fb08acf1c55ce9d68ee540043f158630d62cef012750f9f/numpy-2.3.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5902660491bd7a48b2ec16c23ccb9124b8abfd9583c5fdfa123fe6b421e03de1", size = 16627546 }, + { url = "https://files.pythonhosted.org/packages/6e/ec/3b68220c277e463095342d254c61be8144c31208db18d3fd8ef02712bcd6/numpy-2.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:36890eb9e9d2081137bd78d29050ba63b8dab95dff7912eadf1185e80074b2a0", size = 15562538 }, + { url = "https://files.pythonhosted.org/packages/77/2b/4014f2bcc4404484021c74d4c5ee8eb3de7e3f7ac75f06672f8dcf85140a/numpy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a780033466159c2270531e2b8ac063704592a0bc62ec4a1b991c7c40705eb0e8", size = 18360327 }, + { url = "https://files.pythonhosted.org/packages/40/8d/2ddd6c9b30fcf920837b8672f6c65590c7d92e43084c25fc65edc22e93ca/numpy-2.3.1-cp313-cp313-win32.whl", hash = "sha256:39bff12c076812595c3a306f22bfe49919c5513aa1e0e70fac756a0be7c2a2b8", size = 6312330 }, + { url = "https://files.pythonhosted.org/packages/dd/c8/beaba449925988d415efccb45bf977ff8327a02f655090627318f6398c7b/numpy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d5ee6eec45f08ce507a6570e06f2f879b374a552087a4179ea7838edbcbfa42", size = 12731565 }, + { url = "https://files.pythonhosted.org/packages/0b/c3/5c0c575d7ec78c1126998071f58facfc124006635da75b090805e642c62e/numpy-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:0c4d9e0a8368db90f93bd192bfa771ace63137c3488d198ee21dfb8e7771916e", size = 10190262 }, + { url = "https://files.pythonhosted.org/packages/ea/19/a029cd335cf72f79d2644dcfc22d90f09caa86265cbbde3b5702ccef6890/numpy-2.3.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:b0b5397374f32ec0649dd98c652a1798192042e715df918c20672c62fb52d4b8", size = 20987593 }, + { url = "https://files.pythonhosted.org/packages/25/91/8ea8894406209107d9ce19b66314194675d31761fe2cb3c84fe2eeae2f37/numpy-2.3.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c5bdf2015ccfcee8253fb8be695516ac4457c743473a43290fd36eba6a1777eb", size = 14300523 }, + { url = "https://files.pythonhosted.org/packages/a6/7f/06187b0066eefc9e7ce77d5f2ddb4e314a55220ad62dd0bfc9f2c44bac14/numpy-2.3.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d70f20df7f08b90a2062c1f07737dd340adccf2068d0f1b9b3d56e2038979fee", size = 5227993 }, + { url = "https://files.pythonhosted.org/packages/e8/ec/a926c293c605fa75e9cfb09f1e4840098ed46d2edaa6e2152ee35dc01ed3/numpy-2.3.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:2fb86b7e58f9ac50e1e9dd1290154107e47d1eef23a0ae9145ded06ea606f992", size = 6736652 }, + { url = "https://files.pythonhosted.org/packages/e3/62/d68e52fb6fde5586650d4c0ce0b05ff3a48ad4df4ffd1b8866479d1d671d/numpy-2.3.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:23ab05b2d241f76cb883ce8b9a93a680752fbfcbd51c50eff0b88b979e471d8c", size = 14331561 }, + { url = "https://files.pythonhosted.org/packages/fc/ec/b74d3f2430960044bdad6900d9f5edc2dc0fb8bf5a0be0f65287bf2cbe27/numpy-2.3.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ce2ce9e5de4703a673e705183f64fd5da5bf36e7beddcb63a25ee2286e71ca48", size = 16693349 }, + { url = "https://files.pythonhosted.org/packages/0d/15/def96774b9d7eb198ddadfcbd20281b20ebb510580419197e225f5c55c3e/numpy-2.3.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c4913079974eeb5c16ccfd2b1f09354b8fed7e0d6f2cab933104a09a6419b1ee", size = 15642053 }, + { url = "https://files.pythonhosted.org/packages/2b/57/c3203974762a759540c6ae71d0ea2341c1fa41d84e4971a8e76d7141678a/numpy-2.3.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:010ce9b4f00d5c036053ca684c77441f2f2c934fd23bee058b4d6f196efd8280", size = 18434184 }, + { url = "https://files.pythonhosted.org/packages/22/8a/ccdf201457ed8ac6245187850aff4ca56a79edbea4829f4e9f14d46fa9a5/numpy-2.3.1-cp313-cp313t-win32.whl", hash = "sha256:6269b9edfe32912584ec496d91b00b6d34282ca1d07eb10e82dfc780907d6c2e", size = 6440678 }, + { url = "https://files.pythonhosted.org/packages/f1/7e/7f431d8bd8eb7e03d79294aed238b1b0b174b3148570d03a8a8a8f6a0da9/numpy-2.3.1-cp313-cp313t-win_amd64.whl", hash = "sha256:2a809637460e88a113e186e87f228d74ae2852a2e0c44de275263376f17b5bdc", size = 12870697 }, + { url = "https://files.pythonhosted.org/packages/d4/ca/af82bf0fad4c3e573c6930ed743b5308492ff19917c7caaf2f9b6f9e2e98/numpy-2.3.1-cp313-cp313t-win_arm64.whl", hash = "sha256:eccb9a159db9aed60800187bc47a6d3451553f0e1b08b068d8b277ddfbb9b244", size = 10260376 }, +] + +[[package]] +name = "ollama" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8d/96/c7fe0d2d1b3053be614822a7b722c7465161b3672ce90df71515137580a0/ollama-0.5.1.tar.gz", hash = "sha256:5a799e4dc4e7af638b11e3ae588ab17623ee019e496caaf4323efbaa8feeff93", size = 41112 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/76/3f96c8cdbf3955d7a73ee94ce3e0db0755d6de1e0098a70275940d1aff2f/ollama-0.5.1-py3-none-any.whl", hash = "sha256:4c8839f35bc173c7057b1eb2cbe7f498c1a7e134eafc9192824c8aecb3617506", size = 13369 }, +] + +[[package]] +name = "omegaconf" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "antlr4-python3-runtime" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/48/6388f1bb9da707110532cb70ec4d2822858ddfb44f1cdf1233c20a80ea4b/omegaconf-2.3.0.tar.gz", hash = "sha256:d5d4b6d29955cc50ad50c46dc269bcd92c6e00f5f90d23ab5fee7bfca4ba4cc7", size = 3298120 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/94/1843518e420fa3ed6919835845df698c7e27e183cb997394e4a670973a65/omegaconf-2.3.0-py3-none-any.whl", hash = "sha256:7b4df175cdb08ba400f45cae3bdcae7ba8365db4d165fc65fd04b050ab63b46b", size = 79500 }, +] + +[[package]] +name = "openai" +version = "1.91.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/e2/a22f2973b729eff3f1f429017bdf717930c5de0fbf9e14017bae330e4e7a/openai-1.91.0.tar.gz", hash = "sha256:d6b07730d2f7c6745d0991997c16f85cddfc90ddcde8d569c862c30716b9fc90", size = 472529 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/d2/f99bdd6fc737d6b3cf0df895508d621fc9a386b375a1230ee81d46c5436e/openai-1.91.0-py3-none-any.whl", hash = "sha256:207f87aa3bc49365e014fac2f7e291b99929f4fe126c4654143440e0ad446a5f", size = 735837 }, +] + +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910 }, +] + +[[package]] +name = "orjson" +version = "3.10.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/0b/fea456a3ffe74e70ba30e01ec183a9b26bec4d497f61dcfce1b601059c60/orjson-3.10.18.tar.gz", hash = "sha256:e8da3947d92123eda795b68228cafe2724815621fe35e8e320a9e9593a4bcd53", size = 5422810 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/1a/67236da0916c1a192d5f4ccbe10ec495367a726996ceb7614eaa687112f2/orjson-3.10.18-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:50c15557afb7f6d63bc6d6348e0337a880a04eaa9cd7c9d569bcb4e760a24753", size = 249184 }, + { url = "https://files.pythonhosted.org/packages/b3/bc/c7f1db3b1d094dc0c6c83ed16b161a16c214aaa77f311118a93f647b32dc/orjson-3.10.18-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:356b076f1662c9813d5fa56db7d63ccceef4c271b1fb3dd522aca291375fcf17", size = 133279 }, + { url = "https://files.pythonhosted.org/packages/af/84/664657cd14cc11f0d81e80e64766c7ba5c9b7fc1ec304117878cc1b4659c/orjson-3.10.18-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:559eb40a70a7494cd5beab2d73657262a74a2c59aff2068fdba8f0424ec5b39d", size = 136799 }, + { url = "https://files.pythonhosted.org/packages/9a/bb/f50039c5bb05a7ab024ed43ba25d0319e8722a0ac3babb0807e543349978/orjson-3.10.18-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f3c29eb9a81e2fbc6fd7ddcfba3e101ba92eaff455b8d602bf7511088bbc0eae", size = 132791 }, + { url = "https://files.pythonhosted.org/packages/93/8c/ee74709fc072c3ee219784173ddfe46f699598a1723d9d49cbc78d66df65/orjson-3.10.18-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6612787e5b0756a171c7d81ba245ef63a3533a637c335aa7fcb8e665f4a0966f", size = 137059 }, + { url = "https://files.pythonhosted.org/packages/6a/37/e6d3109ee004296c80426b5a62b47bcadd96a3deab7443e56507823588c5/orjson-3.10.18-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ac6bd7be0dcab5b702c9d43d25e70eb456dfd2e119d512447468f6405b4a69c", size = 138359 }, + { url = "https://files.pythonhosted.org/packages/4f/5d/387dafae0e4691857c62bd02839a3bf3fa648eebd26185adfac58d09f207/orjson-3.10.18-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9f72f100cee8dde70100406d5c1abba515a7df926d4ed81e20a9730c062fe9ad", size = 142853 }, + { url = "https://files.pythonhosted.org/packages/27/6f/875e8e282105350b9a5341c0222a13419758545ae32ad6e0fcf5f64d76aa/orjson-3.10.18-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9dca85398d6d093dd41dc0983cbf54ab8e6afd1c547b6b8a311643917fbf4e0c", size = 133131 }, + { url = "https://files.pythonhosted.org/packages/48/b2/73a1f0b4790dcb1e5a45f058f4f5dcadc8a85d90137b50d6bbc6afd0ae50/orjson-3.10.18-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:22748de2a07fcc8781a70edb887abf801bb6142e6236123ff93d12d92db3d406", size = 134834 }, + { url = "https://files.pythonhosted.org/packages/56/f5/7ed133a5525add9c14dbdf17d011dd82206ca6840811d32ac52a35935d19/orjson-3.10.18-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:3a83c9954a4107b9acd10291b7f12a6b29e35e8d43a414799906ea10e75438e6", size = 413368 }, + { url = "https://files.pythonhosted.org/packages/11/7c/439654221ed9c3324bbac7bdf94cf06a971206b7b62327f11a52544e4982/orjson-3.10.18-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:303565c67a6c7b1f194c94632a4a39918e067bd6176a48bec697393865ce4f06", size = 153359 }, + { url = "https://files.pythonhosted.org/packages/48/e7/d58074fa0cc9dd29a8fa2a6c8d5deebdfd82c6cfef72b0e4277c4017563a/orjson-3.10.18-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:86314fdb5053a2f5a5d881f03fca0219bfdf832912aa88d18676a5175c6916b5", size = 137466 }, + { url = "https://files.pythonhosted.org/packages/57/4d/fe17581cf81fb70dfcef44e966aa4003360e4194d15a3f38cbffe873333a/orjson-3.10.18-cp312-cp312-win32.whl", hash = "sha256:187ec33bbec58c76dbd4066340067d9ece6e10067bb0cc074a21ae3300caa84e", size = 142683 }, + { url = "https://files.pythonhosted.org/packages/e6/22/469f62d25ab5f0f3aee256ea732e72dc3aab6d73bac777bd6277955bceef/orjson-3.10.18-cp312-cp312-win_amd64.whl", hash = "sha256:f9f94cf6d3f9cd720d641f8399e390e7411487e493962213390d1ae45c7814fc", size = 134754 }, + { url = "https://files.pythonhosted.org/packages/10/b0/1040c447fac5b91bc1e9c004b69ee50abb0c1ffd0d24406e1350c58a7fcb/orjson-3.10.18-cp312-cp312-win_arm64.whl", hash = "sha256:3d600be83fe4514944500fa8c2a0a77099025ec6482e8087d7659e891f23058a", size = 131218 }, + { url = "https://files.pythonhosted.org/packages/04/f0/8aedb6574b68096f3be8f74c0b56d36fd94bcf47e6c7ed47a7bd1474aaa8/orjson-3.10.18-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:69c34b9441b863175cc6a01f2935de994025e773f814412030f269da4f7be147", size = 249087 }, + { url = "https://files.pythonhosted.org/packages/bc/f7/7118f965541aeac6844fcb18d6988e111ac0d349c9b80cda53583e758908/orjson-3.10.18-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:1ebeda919725f9dbdb269f59bc94f861afbe2a27dce5608cdba2d92772364d1c", size = 133273 }, + { url = "https://files.pythonhosted.org/packages/fb/d9/839637cc06eaf528dd8127b36004247bf56e064501f68df9ee6fd56a88ee/orjson-3.10.18-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5adf5f4eed520a4959d29ea80192fa626ab9a20b2ea13f8f6dc58644f6927103", size = 136779 }, + { url = "https://files.pythonhosted.org/packages/2b/6d/f226ecfef31a1f0e7d6bf9a31a0bbaf384c7cbe3fce49cc9c2acc51f902a/orjson-3.10.18-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7592bb48a214e18cd670974f289520f12b7aed1fa0b2e2616b8ed9e069e08595", size = 132811 }, + { url = "https://files.pythonhosted.org/packages/73/2d/371513d04143c85b681cf8f3bce743656eb5b640cb1f461dad750ac4b4d4/orjson-3.10.18-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f872bef9f042734110642b7a11937440797ace8c87527de25e0c53558b579ccc", size = 137018 }, + { url = "https://files.pythonhosted.org/packages/69/cb/a4d37a30507b7a59bdc484e4a3253c8141bf756d4e13fcc1da760a0b00cb/orjson-3.10.18-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0315317601149c244cb3ecef246ef5861a64824ccbcb8018d32c66a60a84ffbc", size = 138368 }, + { url = "https://files.pythonhosted.org/packages/1e/ae/cd10883c48d912d216d541eb3db8b2433415fde67f620afe6f311f5cd2ca/orjson-3.10.18-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e0da26957e77e9e55a6c2ce2e7182a36a6f6b180ab7189315cb0995ec362e049", size = 142840 }, + { url = "https://files.pythonhosted.org/packages/6d/4c/2bda09855c6b5f2c055034c9eda1529967b042ff8d81a05005115c4e6772/orjson-3.10.18-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb70d489bc79b7519e5803e2cc4c72343c9dc1154258adf2f8925d0b60da7c58", size = 133135 }, + { url = "https://files.pythonhosted.org/packages/13/4a/35971fd809a8896731930a80dfff0b8ff48eeb5d8b57bb4d0d525160017f/orjson-3.10.18-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e9e86a6af31b92299b00736c89caf63816f70a4001e750bda179e15564d7a034", size = 134810 }, + { url = "https://files.pythonhosted.org/packages/99/70/0fa9e6310cda98365629182486ff37a1c6578e34c33992df271a476ea1cd/orjson-3.10.18-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:c382a5c0b5931a5fc5405053d36c1ce3fd561694738626c77ae0b1dfc0242ca1", size = 413491 }, + { url = "https://files.pythonhosted.org/packages/32/cb/990a0e88498babddb74fb97855ae4fbd22a82960e9b06eab5775cac435da/orjson-3.10.18-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8e4b2ae732431127171b875cb2668f883e1234711d3c147ffd69fe5be51a8012", size = 153277 }, + { url = "https://files.pythonhosted.org/packages/92/44/473248c3305bf782a384ed50dd8bc2d3cde1543d107138fd99b707480ca1/orjson-3.10.18-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d808e34ddb24fc29a4d4041dcfafbae13e129c93509b847b14432717d94b44f", size = 137367 }, + { url = "https://files.pythonhosted.org/packages/ad/fd/7f1d3edd4ffcd944a6a40e9f88af2197b619c931ac4d3cfba4798d4d3815/orjson-3.10.18-cp313-cp313-win32.whl", hash = "sha256:ad8eacbb5d904d5591f27dee4031e2c1db43d559edb8f91778efd642d70e6bea", size = 142687 }, + { url = "https://files.pythonhosted.org/packages/4b/03/c75c6ad46be41c16f4cfe0352a2d1450546f3c09ad2c9d341110cd87b025/orjson-3.10.18-cp313-cp313-win_amd64.whl", hash = "sha256:aed411bcb68bf62e85588f2a7e03a6082cc42e5a2796e06e72a962d7c6310b52", size = 134794 }, + { url = "https://files.pythonhosted.org/packages/c2/28/f53038a5a72cc4fd0b56c1eafb4ef64aec9685460d5ac34de98ca78b6e29/orjson-3.10.18-cp313-cp313-win_arm64.whl", hash = "sha256:f54c1385a0e6aba2f15a40d703b858bedad36ded0491e55d35d905b2c34a4cc3", size = 131186 }, +] + +[[package]] +name = "overrides" +version = "7.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/86/b585f53236dec60aba864e050778b25045f857e17f6e5ea0ae95fe80edd2/overrides-7.7.0.tar.gz", hash = "sha256:55158fa3d93b98cc75299b1e67078ad9003ca27945c76162c1c0766d6f91820a", size = 22812 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/ab/fc8290c6a4c722e5514d80f62b2dc4c4df1a68a41d1364e625c35990fcf3/overrides-7.7.0-py3-none-any.whl", hash = "sha256:c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49", size = 17832 }, +] + +[[package]] +name = "packaging" +version = "24.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, +] + +[[package]] +name = "pandas" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/51/48f713c4c728d7c55ef7444ba5ea027c26998d96d1a40953b346438602fc/pandas-2.3.0.tar.gz", hash = "sha256:34600ab34ebf1131a7613a260a61dbe8b62c188ec0ea4c296da7c9a06b004133", size = 4484490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/46/24192607058dd607dbfacdd060a2370f6afb19c2ccb617406469b9aeb8e7/pandas-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2eb4728a18dcd2908c7fccf74a982e241b467d178724545a48d0caf534b38ebf", size = 11573865 }, + { url = "https://files.pythonhosted.org/packages/9f/cc/ae8ea3b800757a70c9fdccc68b67dc0280a6e814efcf74e4211fd5dea1ca/pandas-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9d8c3187be7479ea5c3d30c32a5d73d62a621166675063b2edd21bc47614027", size = 10702154 }, + { url = "https://files.pythonhosted.org/packages/d8/ba/a7883d7aab3d24c6540a2768f679e7414582cc389876d469b40ec749d78b/pandas-2.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9ff730713d4c4f2f1c860e36c005c7cefc1c7c80c21c0688fd605aa43c9fcf09", size = 11262180 }, + { url = "https://files.pythonhosted.org/packages/01/a5/931fc3ad333d9d87b10107d948d757d67ebcfc33b1988d5faccc39c6845c/pandas-2.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba24af48643b12ffe49b27065d3babd52702d95ab70f50e1b34f71ca703e2c0d", size = 11991493 }, + { url = "https://files.pythonhosted.org/packages/d7/bf/0213986830a92d44d55153c1d69b509431a972eb73f204242988c4e66e86/pandas-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:404d681c698e3c8a40a61d0cd9412cc7364ab9a9cc6e144ae2992e11a2e77a20", size = 12470733 }, + { url = "https://files.pythonhosted.org/packages/a4/0e/21eb48a3a34a7d4bac982afc2c4eb5ab09f2d988bdf29d92ba9ae8e90a79/pandas-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6021910b086b3ca756755e86ddc64e0ddafd5e58e076c72cb1585162e5ad259b", size = 13212406 }, + { url = "https://files.pythonhosted.org/packages/1f/d9/74017c4eec7a28892d8d6e31ae9de3baef71f5a5286e74e6b7aad7f8c837/pandas-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:094e271a15b579650ebf4c5155c05dcd2a14fd4fdd72cf4854b2f7ad31ea30be", size = 10976199 }, + { url = "https://files.pythonhosted.org/packages/d3/57/5cb75a56a4842bbd0511c3d1c79186d8315b82dac802118322b2de1194fe/pandas-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c7e2fc25f89a49a11599ec1e76821322439d90820108309bf42130d2f36c983", size = 11518913 }, + { url = "https://files.pythonhosted.org/packages/05/01/0c8785610e465e4948a01a059562176e4c8088aa257e2e074db868f86d4e/pandas-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c6da97aeb6a6d233fb6b17986234cc723b396b50a3c6804776351994f2a658fd", size = 10655249 }, + { url = "https://files.pythonhosted.org/packages/e8/6a/47fd7517cd8abe72a58706aab2b99e9438360d36dcdb052cf917b7bf3bdc/pandas-2.3.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb32dc743b52467d488e7a7c8039b821da2826a9ba4f85b89ea95274f863280f", size = 11328359 }, + { url = "https://files.pythonhosted.org/packages/2a/b3/463bfe819ed60fb7e7ddffb4ae2ee04b887b3444feee6c19437b8f834837/pandas-2.3.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:213cd63c43263dbb522c1f8a7c9d072e25900f6975596f883f4bebd77295d4f3", size = 12024789 }, + { url = "https://files.pythonhosted.org/packages/04/0c/e0704ccdb0ac40aeb3434d1c641c43d05f75c92e67525df39575ace35468/pandas-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1d2b33e68d0ce64e26a4acc2e72d747292084f4e8db4c847c6f5f6cbe56ed6d8", size = 12480734 }, + { url = "https://files.pythonhosted.org/packages/e9/df/815d6583967001153bb27f5cf075653d69d51ad887ebbf4cfe1173a1ac58/pandas-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:430a63bae10b5086995db1b02694996336e5a8ac9a96b4200572b413dfdfccb9", size = 13223381 }, + { url = "https://files.pythonhosted.org/packages/79/88/ca5973ed07b7f484c493e941dbff990861ca55291ff7ac67c815ce347395/pandas-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:4930255e28ff5545e2ca404637bcc56f031893142773b3468dc021c6c32a1390", size = 10970135 }, + { url = "https://files.pythonhosted.org/packages/24/fb/0994c14d1f7909ce83f0b1fb27958135513c4f3f2528bde216180aa73bfc/pandas-2.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f925f1ef673b4bd0271b1809b72b3270384f2b7d9d14a189b12b7fc02574d575", size = 12141356 }, + { url = "https://files.pythonhosted.org/packages/9d/a2/9b903e5962134497ac4f8a96f862ee3081cb2506f69f8e4778ce3d9c9d82/pandas-2.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78ad363ddb873a631e92a3c063ade1ecfb34cae71e9a2be6ad100f875ac1042", size = 11474674 }, + { url = "https://files.pythonhosted.org/packages/81/3a/3806d041bce032f8de44380f866059437fb79e36d6b22c82c187e65f765b/pandas-2.3.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:951805d146922aed8357e4cc5671b8b0b9be1027f0619cea132a9f3f65f2f09c", size = 11439876 }, + { url = "https://files.pythonhosted.org/packages/15/aa/3fc3181d12b95da71f5c2537c3e3b3af6ab3a8c392ab41ebb766e0929bc6/pandas-2.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a881bc1309f3fce34696d07b00f13335c41f5f5a8770a33b09ebe23261cfc67", size = 11966182 }, + { url = "https://files.pythonhosted.org/packages/37/e7/e12f2d9b0a2c4a2cc86e2aabff7ccfd24f03e597d770abfa2acd313ee46b/pandas-2.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e1991bbb96f4050b09b5f811253c4f3cf05ee89a589379aa36cd623f21a31d6f", size = 12547686 }, + { url = "https://files.pythonhosted.org/packages/39/c2/646d2e93e0af70f4e5359d870a63584dacbc324b54d73e6b3267920ff117/pandas-2.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:bb3be958022198531eb7ec2008cfc78c5b1eed51af8600c6c5d9160d89d8d249", size = 13231847 }, +] + +[[package]] +name = "pandocfilters" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/6f/3dd4940bbe001c06a65f88e36bad298bc7a0de5036115639926b0c5c0458/pandocfilters-1.5.1.tar.gz", hash = "sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e", size = 8454 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/af/4fbc8cab944db5d21b7e2a5b8e9211a03a79852b1157e2c102fcc61ac440/pandocfilters-1.5.1-py2.py3-none-any.whl", hash = "sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc", size = 8663 }, +] + +[[package]] +name = "parso" +version = "0.8.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/94/68e2e17afaa9169cf6412ab0f28623903be73d1b32e208d9e8e541bb086d/parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d", size = 400609 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18", size = 103650 }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772 }, +] + +[[package]] +name = "pillow" +version = "11.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/cb/bb5c01fcd2a69335b86c22142b2bccfc3464087efb7fd382eee5ffc7fdf7/pillow-11.2.1.tar.gz", hash = "sha256:a64dd61998416367b7ef979b73d3a85853ba9bec4c2925f74e588879a58716b6", size = 47026707 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/40/052610b15a1b8961f52537cc8326ca6a881408bc2bdad0d852edeb6ed33b/pillow-11.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:78afba22027b4accef10dbd5eed84425930ba41b3ea0a86fa8d20baaf19d807f", size = 3190185 }, + { url = "https://files.pythonhosted.org/packages/e5/7e/b86dbd35a5f938632093dc40d1682874c33dcfe832558fc80ca56bfcb774/pillow-11.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78092232a4ab376a35d68c4e6d5e00dfd73454bd12b230420025fbe178ee3b0b", size = 3030306 }, + { url = "https://files.pythonhosted.org/packages/a4/5c/467a161f9ed53e5eab51a42923c33051bf8d1a2af4626ac04f5166e58e0c/pillow-11.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25a5f306095c6780c52e6bbb6109624b95c5b18e40aab1c3041da3e9e0cd3e2d", size = 4416121 }, + { url = "https://files.pythonhosted.org/packages/62/73/972b7742e38ae0e2ac76ab137ca6005dcf877480da0d9d61d93b613065b4/pillow-11.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c7b29dbd4281923a2bfe562acb734cee96bbb129e96e6972d315ed9f232bef4", size = 4501707 }, + { url = "https://files.pythonhosted.org/packages/e4/3a/427e4cb0b9e177efbc1a84798ed20498c4f233abde003c06d2650a6d60cb/pillow-11.2.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3e645b020f3209a0181a418bffe7b4a93171eef6c4ef6cc20980b30bebf17b7d", size = 4522921 }, + { url = "https://files.pythonhosted.org/packages/fe/7c/d8b1330458e4d2f3f45d9508796d7caf0c0d3764c00c823d10f6f1a3b76d/pillow-11.2.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b2dbea1012ccb784a65349f57bbc93730b96e85b42e9bf7b01ef40443db720b4", size = 4612523 }, + { url = "https://files.pythonhosted.org/packages/b3/2f/65738384e0b1acf451de5a573d8153fe84103772d139e1e0bdf1596be2ea/pillow-11.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:da3104c57bbd72948d75f6a9389e6727d2ab6333c3617f0a89d72d4940aa0443", size = 4587836 }, + { url = "https://files.pythonhosted.org/packages/6a/c5/e795c9f2ddf3debb2dedd0df889f2fe4b053308bb59a3cc02a0cd144d641/pillow-11.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:598174aef4589af795f66f9caab87ba4ff860ce08cd5bb447c6fc553ffee603c", size = 4669390 }, + { url = "https://files.pythonhosted.org/packages/96/ae/ca0099a3995976a9fce2f423166f7bff9b12244afdc7520f6ed38911539a/pillow-11.2.1-cp312-cp312-win32.whl", hash = "sha256:1d535df14716e7f8776b9e7fee118576d65572b4aad3ed639be9e4fa88a1cad3", size = 2332309 }, + { url = "https://files.pythonhosted.org/packages/7c/18/24bff2ad716257fc03da964c5e8f05d9790a779a8895d6566e493ccf0189/pillow-11.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:14e33b28bf17c7a38eede290f77db7c664e4eb01f7869e37fa98a5aa95978941", size = 2676768 }, + { url = "https://files.pythonhosted.org/packages/da/bb/e8d656c9543276517ee40184aaa39dcb41e683bca121022f9323ae11b39d/pillow-11.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:21e1470ac9e5739ff880c211fc3af01e3ae505859392bf65458c224d0bf283eb", size = 2415087 }, + { url = "https://files.pythonhosted.org/packages/36/9c/447528ee3776e7ab8897fe33697a7ff3f0475bb490c5ac1456a03dc57956/pillow-11.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fdec757fea0b793056419bca3e9932eb2b0ceec90ef4813ea4c1e072c389eb28", size = 3190098 }, + { url = "https://files.pythonhosted.org/packages/b5/09/29d5cd052f7566a63e5b506fac9c60526e9ecc553825551333e1e18a4858/pillow-11.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0e130705d568e2f43a17bcbe74d90958e8a16263868a12c3e0d9c8162690830", size = 3030166 }, + { url = "https://files.pythonhosted.org/packages/71/5d/446ee132ad35e7600652133f9c2840b4799bbd8e4adba881284860da0a36/pillow-11.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bdb5e09068332578214cadd9c05e3d64d99e0e87591be22a324bdbc18925be0", size = 4408674 }, + { url = "https://files.pythonhosted.org/packages/69/5f/cbe509c0ddf91cc3a03bbacf40e5c2339c4912d16458fcb797bb47bcb269/pillow-11.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d189ba1bebfbc0c0e529159631ec72bb9e9bc041f01ec6d3233d6d82eb823bc1", size = 4496005 }, + { url = "https://files.pythonhosted.org/packages/f9/b3/dd4338d8fb8a5f312021f2977fb8198a1184893f9b00b02b75d565c33b51/pillow-11.2.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:191955c55d8a712fab8934a42bfefbf99dd0b5875078240943f913bb66d46d9f", size = 4518707 }, + { url = "https://files.pythonhosted.org/packages/13/eb/2552ecebc0b887f539111c2cd241f538b8ff5891b8903dfe672e997529be/pillow-11.2.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:ad275964d52e2243430472fc5d2c2334b4fc3ff9c16cb0a19254e25efa03a155", size = 4610008 }, + { url = "https://files.pythonhosted.org/packages/72/d1/924ce51bea494cb6e7959522d69d7b1c7e74f6821d84c63c3dc430cbbf3b/pillow-11.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:750f96efe0597382660d8b53e90dd1dd44568a8edb51cb7f9d5d918b80d4de14", size = 4585420 }, + { url = "https://files.pythonhosted.org/packages/43/ab/8f81312d255d713b99ca37479a4cb4b0f48195e530cdc1611990eb8fd04b/pillow-11.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fe15238d3798788d00716637b3d4e7bb6bde18b26e5d08335a96e88564a36b6b", size = 4667655 }, + { url = "https://files.pythonhosted.org/packages/94/86/8f2e9d2dc3d308dfd137a07fe1cc478df0a23d42a6c4093b087e738e4827/pillow-11.2.1-cp313-cp313-win32.whl", hash = "sha256:3fe735ced9a607fee4f481423a9c36701a39719252a9bb251679635f99d0f7d2", size = 2332329 }, + { url = "https://files.pythonhosted.org/packages/6d/ec/1179083b8d6067a613e4d595359b5fdea65d0a3b7ad623fee906e1b3c4d2/pillow-11.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:74ee3d7ecb3f3c05459ba95eed5efa28d6092d751ce9bf20e3e253a4e497e691", size = 2676388 }, + { url = "https://files.pythonhosted.org/packages/23/f1/2fc1e1e294de897df39fa8622d829b8828ddad938b0eaea256d65b84dd72/pillow-11.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:5119225c622403afb4b44bad4c1ca6c1f98eed79db8d3bc6e4e160fc6339d66c", size = 2414950 }, + { url = "https://files.pythonhosted.org/packages/c4/3e/c328c48b3f0ead7bab765a84b4977acb29f101d10e4ef57a5e3400447c03/pillow-11.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8ce2e8411c7aaef53e6bb29fe98f28cd4fbd9a1d9be2eeea434331aac0536b22", size = 3192759 }, + { url = "https://files.pythonhosted.org/packages/18/0e/1c68532d833fc8b9f404d3a642991441d9058eccd5606eab31617f29b6d4/pillow-11.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ee66787e095127116d91dea2143db65c7bb1e232f617aa5957c0d9d2a3f23a7", size = 3033284 }, + { url = "https://files.pythonhosted.org/packages/b7/cb/6faf3fb1e7705fd2db74e070f3bf6f88693601b0ed8e81049a8266de4754/pillow-11.2.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9622e3b6c1d8b551b6e6f21873bdcc55762b4b2126633014cea1803368a9aa16", size = 4445826 }, + { url = "https://files.pythonhosted.org/packages/07/94/8be03d50b70ca47fb434a358919d6a8d6580f282bbb7af7e4aa40103461d/pillow-11.2.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63b5dff3a68f371ea06025a1a6966c9a1e1ee452fc8020c2cd0ea41b83e9037b", size = 4527329 }, + { url = "https://files.pythonhosted.org/packages/fd/a4/bfe78777076dc405e3bd2080bc32da5ab3945b5a25dc5d8acaa9de64a162/pillow-11.2.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:31df6e2d3d8fc99f993fd253e97fae451a8db2e7207acf97859732273e108406", size = 4549049 }, + { url = "https://files.pythonhosted.org/packages/65/4d/eaf9068dc687c24979e977ce5677e253624bd8b616b286f543f0c1b91662/pillow-11.2.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:062b7a42d672c45a70fa1f8b43d1d38ff76b63421cbbe7f88146b39e8a558d91", size = 4635408 }, + { url = "https://files.pythonhosted.org/packages/1d/26/0fd443365d9c63bc79feb219f97d935cd4b93af28353cba78d8e77b61719/pillow-11.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4eb92eca2711ef8be42fd3f67533765d9fd043b8c80db204f16c8ea62ee1a751", size = 4614863 }, + { url = "https://files.pythonhosted.org/packages/49/65/dca4d2506be482c2c6641cacdba5c602bc76d8ceb618fd37de855653a419/pillow-11.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f91ebf30830a48c825590aede79376cb40f110b387c17ee9bd59932c961044f9", size = 4692938 }, + { url = "https://files.pythonhosted.org/packages/b3/92/1ca0c3f09233bd7decf8f7105a1c4e3162fb9142128c74adad0fb361b7eb/pillow-11.2.1-cp313-cp313t-win32.whl", hash = "sha256:e0b55f27f584ed623221cfe995c912c61606be8513bfa0e07d2c674b4516d9dd", size = 2335774 }, + { url = "https://files.pythonhosted.org/packages/a5/ac/77525347cb43b83ae905ffe257bbe2cc6fd23acb9796639a1f56aa59d191/pillow-11.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:36d6b82164c39ce5482f649b437382c0fb2395eabc1e2b1702a6deb8ad647d6e", size = 2681895 }, + { url = "https://files.pythonhosted.org/packages/67/32/32dc030cfa91ca0fc52baebbba2e009bb001122a1daa8b6a79ad830b38d3/pillow-11.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:225c832a13326e34f212d2072982bb1adb210e0cc0b153e688743018c94a2681", size = 2417234 }, +] + +[[package]] +name = "platformdirs" +version = "4.3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567 }, +] + +[[package]] +name = "plotly" +version = "6.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "narwhals" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/77/431447616eda6a432dc3ce541b3f808ecb8803ea3d4ab2573b67f8eb4208/plotly-6.1.2.tar.gz", hash = "sha256:4fdaa228926ba3e3a213f4d1713287e69dcad1a7e66cf2025bd7d7026d5014b4", size = 7662971 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/6f/759d5da0517547a5d38aabf05d04d9f8adf83391d2c7fc33f904417d3ba2/plotly-6.1.2-py3-none-any.whl", hash = "sha256:f1548a8ed9158d59e03d7fed548c7db5549f3130d9ae19293c8638c202648f6d", size = 16265530 }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, +] + +[[package]] +name = "pre-commit" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/39/679ca9b26c7bb2999ff122d50faa301e49af82ca9c066ec061cfbc0c6784/pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146", size = 193424 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707 }, +] + +[[package]] +name = "prometheus-client" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/cf/40dde0a2be27cc1eb41e333d1a674a74ce8b8b0457269cc640fd42b07cf7/prometheus_client-0.22.1.tar.gz", hash = "sha256:190f1331e783cf21eb60bca559354e0a4d4378facecf78f5428c39b675d20d28", size = 69746 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/ae/ec06af4fe3ee72d16973474f122541746196aaa16cea6f66d18b963c6177/prometheus_client-0.22.1-py3-none-any.whl", hash = "sha256:cca895342e308174341b2cbf99a56bef291fbc0ef7b9e5412a0f26d653ba7094", size = 58694 }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/6e/9d084c929dfe9e3bfe0c6a47e31f78a25c54627d64a66e884a8bf5474f1c/prompt_toolkit-3.0.51.tar.gz", hash = "sha256:931a162e3b27fc90c86f1b48bb1fb2c528c2761475e57c9c06de13311c7b54ed", size = 428940 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/4f/5249960887b1fbe561d9ff265496d170b55a735b76724f10ef19f9e40716/prompt_toolkit-3.0.51-py3-none-any.whl", hash = "sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07", size = 387810 }, +] + +[[package]] +name = "propcache" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/16/43264e4a779dd8588c21a70f0709665ee8f611211bdd2c87d952cfa7c776/propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168", size = 44139 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/42/9ca01b0a6f48e81615dca4765a8f1dd2c057e0540f6116a27dc5ee01dfb6/propcache-0.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8de106b6c84506b31c27168582cd3cb3000a6412c16df14a8628e5871ff83c10", size = 73674 }, + { url = "https://files.pythonhosted.org/packages/af/6e/21293133beb550f9c901bbece755d582bfaf2176bee4774000bd4dd41884/propcache-0.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:28710b0d3975117239c76600ea351934ac7b5ff56e60953474342608dbbb6154", size = 43570 }, + { url = "https://files.pythonhosted.org/packages/0c/c8/0393a0a3a2b8760eb3bde3c147f62b20044f0ddac81e9d6ed7318ec0d852/propcache-0.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce26862344bdf836650ed2487c3d724b00fbfec4233a1013f597b78c1cb73615", size = 43094 }, + { url = "https://files.pythonhosted.org/packages/37/2c/489afe311a690399d04a3e03b069225670c1d489eb7b044a566511c1c498/propcache-0.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bca54bd347a253af2cf4544bbec232ab982f4868de0dd684246b67a51bc6b1db", size = 226958 }, + { url = "https://files.pythonhosted.org/packages/9d/ca/63b520d2f3d418c968bf596839ae26cf7f87bead026b6192d4da6a08c467/propcache-0.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55780d5e9a2ddc59711d727226bb1ba83a22dd32f64ee15594b9392b1f544eb1", size = 234894 }, + { url = "https://files.pythonhosted.org/packages/11/60/1d0ed6fff455a028d678df30cc28dcee7af77fa2b0e6962ce1df95c9a2a9/propcache-0.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:035e631be25d6975ed87ab23153db6a73426a48db688070d925aa27e996fe93c", size = 233672 }, + { url = "https://files.pythonhosted.org/packages/37/7c/54fd5301ef38505ab235d98827207176a5c9b2aa61939b10a460ca53e123/propcache-0.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee6f22b6eaa39297c751d0e80c0d3a454f112f5c6481214fcf4c092074cecd67", size = 224395 }, + { url = "https://files.pythonhosted.org/packages/ee/1a/89a40e0846f5de05fdc6779883bf46ba980e6df4d2ff8fb02643de126592/propcache-0.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ca3aee1aa955438c4dba34fc20a9f390e4c79967257d830f137bd5a8a32ed3b", size = 212510 }, + { url = "https://files.pythonhosted.org/packages/5e/33/ca98368586c9566a6b8d5ef66e30484f8da84c0aac3f2d9aec6d31a11bd5/propcache-0.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a4f30862869fa2b68380d677cc1c5fcf1e0f2b9ea0cf665812895c75d0ca3b8", size = 222949 }, + { url = "https://files.pythonhosted.org/packages/ba/11/ace870d0aafe443b33b2f0b7efdb872b7c3abd505bfb4890716ad7865e9d/propcache-0.3.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b77ec3c257d7816d9f3700013639db7491a434644c906a2578a11daf13176251", size = 217258 }, + { url = "https://files.pythonhosted.org/packages/5b/d2/86fd6f7adffcfc74b42c10a6b7db721d1d9ca1055c45d39a1a8f2a740a21/propcache-0.3.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cab90ac9d3f14b2d5050928483d3d3b8fb6b4018893fc75710e6aa361ecb2474", size = 213036 }, + { url = "https://files.pythonhosted.org/packages/07/94/2d7d1e328f45ff34a0a284cf5a2847013701e24c2a53117e7c280a4316b3/propcache-0.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0b504d29f3c47cf6b9e936c1852246c83d450e8e063d50562115a6be6d3a2535", size = 227684 }, + { url = "https://files.pythonhosted.org/packages/b7/05/37ae63a0087677e90b1d14710e532ff104d44bc1efa3b3970fff99b891dc/propcache-0.3.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ce2ac2675a6aa41ddb2a0c9cbff53780a617ac3d43e620f8fd77ba1c84dcfc06", size = 234562 }, + { url = "https://files.pythonhosted.org/packages/a4/7c/3f539fcae630408d0bd8bf3208b9a647ccad10976eda62402a80adf8fc34/propcache-0.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b4239611205294cc433845b914131b2a1f03500ff3c1ed093ed216b82621e1", size = 222142 }, + { url = "https://files.pythonhosted.org/packages/7c/d2/34b9eac8c35f79f8a962546b3e97e9d4b990c420ee66ac8255d5d9611648/propcache-0.3.2-cp312-cp312-win32.whl", hash = "sha256:df4a81b9b53449ebc90cc4deefb052c1dd934ba85012aa912c7ea7b7e38b60c1", size = 37711 }, + { url = "https://files.pythonhosted.org/packages/19/61/d582be5d226cf79071681d1b46b848d6cb03d7b70af7063e33a2787eaa03/propcache-0.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7046e79b989d7fe457bb755844019e10f693752d169076138abf17f31380800c", size = 41479 }, + { url = "https://files.pythonhosted.org/packages/dc/d1/8c747fafa558c603c4ca19d8e20b288aa0c7cda74e9402f50f31eb65267e/propcache-0.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ca592ed634a73ca002967458187109265e980422116c0a107cf93d81f95af945", size = 71286 }, + { url = "https://files.pythonhosted.org/packages/61/99/d606cb7986b60d89c36de8a85d58764323b3a5ff07770a99d8e993b3fa73/propcache-0.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ecb0aad4020e275652ba3975740f241bd12a61f1a784df044cf7477a02bc252", size = 42425 }, + { url = "https://files.pythonhosted.org/packages/8c/96/ef98f91bbb42b79e9bb82bdd348b255eb9d65f14dbbe3b1594644c4073f7/propcache-0.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f08f1cc28bd2eade7a8a3d2954ccc673bb02062e3e7da09bc75d843386b342f", size = 41846 }, + { url = "https://files.pythonhosted.org/packages/5b/ad/3f0f9a705fb630d175146cd7b1d2bf5555c9beaed54e94132b21aac098a6/propcache-0.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1a342c834734edb4be5ecb1e9fb48cb64b1e2320fccbd8c54bf8da8f2a84c33", size = 208871 }, + { url = "https://files.pythonhosted.org/packages/3a/38/2085cda93d2c8b6ec3e92af2c89489a36a5886b712a34ab25de9fbca7992/propcache-0.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a544caaae1ac73f1fecfae70ded3e93728831affebd017d53449e3ac052ac1e", size = 215720 }, + { url = "https://files.pythonhosted.org/packages/61/c1/d72ea2dc83ac7f2c8e182786ab0fc2c7bd123a1ff9b7975bee671866fe5f/propcache-0.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:310d11aa44635298397db47a3ebce7db99a4cc4b9bbdfcf6c98a60c8d5261cf1", size = 215203 }, + { url = "https://files.pythonhosted.org/packages/af/81/b324c44ae60c56ef12007105f1460d5c304b0626ab0cc6b07c8f2a9aa0b8/propcache-0.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1396592321ac83157ac03a2023aa6cc4a3cc3cfdecb71090054c09e5a7cce3", size = 206365 }, + { url = "https://files.pythonhosted.org/packages/09/73/88549128bb89e66d2aff242488f62869014ae092db63ccea53c1cc75a81d/propcache-0.3.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cabf5b5902272565e78197edb682017d21cf3b550ba0460ee473753f28d23c1", size = 196016 }, + { url = "https://files.pythonhosted.org/packages/b9/3f/3bdd14e737d145114a5eb83cb172903afba7242f67c5877f9909a20d948d/propcache-0.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0a2f2235ac46a7aa25bdeb03a9e7060f6ecbd213b1f9101c43b3090ffb971ef6", size = 205596 }, + { url = "https://files.pythonhosted.org/packages/0f/ca/2f4aa819c357d3107c3763d7ef42c03980f9ed5c48c82e01e25945d437c1/propcache-0.3.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:92b69e12e34869a6970fd2f3da91669899994b47c98f5d430b781c26f1d9f387", size = 200977 }, + { url = "https://files.pythonhosted.org/packages/cd/4a/e65276c7477533c59085251ae88505caf6831c0e85ff8b2e31ebcbb949b1/propcache-0.3.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:54e02207c79968ebbdffc169591009f4474dde3b4679e16634d34c9363ff56b4", size = 197220 }, + { url = "https://files.pythonhosted.org/packages/7c/54/fc7152e517cf5578278b242396ce4d4b36795423988ef39bb8cd5bf274c8/propcache-0.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4adfb44cb588001f68c5466579d3f1157ca07f7504fc91ec87862e2b8e556b88", size = 210642 }, + { url = "https://files.pythonhosted.org/packages/b9/80/abeb4a896d2767bf5f1ea7b92eb7be6a5330645bd7fb844049c0e4045d9d/propcache-0.3.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fd3e6019dc1261cd0291ee8919dd91fbab7b169bb76aeef6c716833a3f65d206", size = 212789 }, + { url = "https://files.pythonhosted.org/packages/b3/db/ea12a49aa7b2b6d68a5da8293dcf50068d48d088100ac016ad92a6a780e6/propcache-0.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4c181cad81158d71c41a2bce88edce078458e2dd5ffee7eddd6b05da85079f43", size = 205880 }, + { url = "https://files.pythonhosted.org/packages/d1/e5/9076a0bbbfb65d1198007059c65639dfd56266cf8e477a9707e4b1999ff4/propcache-0.3.2-cp313-cp313-win32.whl", hash = "sha256:8a08154613f2249519e549de2330cf8e2071c2887309a7b07fb56098f5170a02", size = 37220 }, + { url = "https://files.pythonhosted.org/packages/d3/f5/b369e026b09a26cd77aa88d8fffd69141d2ae00a2abaaf5380d2603f4b7f/propcache-0.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e41671f1594fc4ab0a6dec1351864713cb3a279910ae8b58f884a88a0a632c05", size = 40678 }, + { url = "https://files.pythonhosted.org/packages/a4/3a/6ece377b55544941a08d03581c7bc400a3c8cd3c2865900a68d5de79e21f/propcache-0.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9a3cf035bbaf035f109987d9d55dc90e4b0e36e04bbbb95af3055ef17194057b", size = 76560 }, + { url = "https://files.pythonhosted.org/packages/0c/da/64a2bb16418740fa634b0e9c3d29edff1db07f56d3546ca2d86ddf0305e1/propcache-0.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:156c03d07dc1323d8dacaa221fbe028c5c70d16709cdd63502778e6c3ccca1b0", size = 44676 }, + { url = "https://files.pythonhosted.org/packages/36/7b/f025e06ea51cb72c52fb87e9b395cced02786610b60a3ed51da8af017170/propcache-0.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74413c0ba02ba86f55cf60d18daab219f7e531620c15f1e23d95563f505efe7e", size = 44701 }, + { url = "https://files.pythonhosted.org/packages/a4/00/faa1b1b7c3b74fc277f8642f32a4c72ba1d7b2de36d7cdfb676db7f4303e/propcache-0.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f066b437bb3fa39c58ff97ab2ca351db465157d68ed0440abecb21715eb24b28", size = 276934 }, + { url = "https://files.pythonhosted.org/packages/74/ab/935beb6f1756e0476a4d5938ff44bf0d13a055fed880caf93859b4f1baf4/propcache-0.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1304b085c83067914721e7e9d9917d41ad87696bf70f0bc7dee450e9c71ad0a", size = 278316 }, + { url = "https://files.pythonhosted.org/packages/f8/9d/994a5c1ce4389610838d1caec74bdf0e98b306c70314d46dbe4fcf21a3e2/propcache-0.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab50cef01b372763a13333b4e54021bdcb291fc9a8e2ccb9c2df98be51bcde6c", size = 282619 }, + { url = "https://files.pythonhosted.org/packages/2b/00/a10afce3d1ed0287cef2e09506d3be9822513f2c1e96457ee369adb9a6cd/propcache-0.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fad3b2a085ec259ad2c2842666b2a0a49dea8463579c606426128925af1ed725", size = 265896 }, + { url = "https://files.pythonhosted.org/packages/2e/a8/2aa6716ffa566ca57c749edb909ad27884680887d68517e4be41b02299f3/propcache-0.3.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:261fa020c1c14deafd54c76b014956e2f86991af198c51139faf41c4d5e83892", size = 252111 }, + { url = "https://files.pythonhosted.org/packages/36/4f/345ca9183b85ac29c8694b0941f7484bf419c7f0fea2d1e386b4f7893eed/propcache-0.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:46d7f8aa79c927e5f987ee3a80205c987717d3659f035c85cf0c3680526bdb44", size = 268334 }, + { url = "https://files.pythonhosted.org/packages/3e/ca/fcd54f78b59e3f97b3b9715501e3147f5340167733d27db423aa321e7148/propcache-0.3.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:6d8f3f0eebf73e3c0ff0e7853f68be638b4043c65a70517bb575eff54edd8dbe", size = 255026 }, + { url = "https://files.pythonhosted.org/packages/8b/95/8e6a6bbbd78ac89c30c225210a5c687790e532ba4088afb8c0445b77ef37/propcache-0.3.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:03c89c1b14a5452cf15403e291c0ccd7751d5b9736ecb2c5bab977ad6c5bcd81", size = 250724 }, + { url = "https://files.pythonhosted.org/packages/ee/b0/0dd03616142baba28e8b2d14ce5df6631b4673850a3d4f9c0f9dd714a404/propcache-0.3.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cc17efde71e12bbaad086d679ce575268d70bc123a5a71ea7ad76f70ba30bba", size = 268868 }, + { url = "https://files.pythonhosted.org/packages/c5/98/2c12407a7e4fbacd94ddd32f3b1e3d5231e77c30ef7162b12a60e2dd5ce3/propcache-0.3.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:acdf05d00696bc0447e278bb53cb04ca72354e562cf88ea6f9107df8e7fd9770", size = 271322 }, + { url = "https://files.pythonhosted.org/packages/35/91/9cb56efbb428b006bb85db28591e40b7736847b8331d43fe335acf95f6c8/propcache-0.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4445542398bd0b5d32df908031cb1b30d43ac848e20470a878b770ec2dcc6330", size = 265778 }, + { url = "https://files.pythonhosted.org/packages/9a/4c/b0fe775a2bdd01e176b14b574be679d84fc83958335790f7c9a686c1f468/propcache-0.3.2-cp313-cp313t-win32.whl", hash = "sha256:f86e5d7cd03afb3a1db8e9f9f6eff15794e79e791350ac48a8c924e6f439f394", size = 41175 }, + { url = "https://files.pythonhosted.org/packages/a4/ff/47f08595e3d9b5e149c150f88d9714574f1a7cbd89fe2817158a952674bf/propcache-0.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9704bedf6e7cbe3c65eca4379a9b53ee6a83749f047808cbb5044d40d7d72198", size = 44857 }, + { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663 }, +] + +[[package]] +name = "proto-plus" +version = "1.26.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/ac/87285f15f7cce6d4a008f33f1757fb5a13611ea8914eb58c3d0d26243468/proto_plus-1.26.1.tar.gz", hash = "sha256:21a515a4c4c0088a773899e23c7bbade3d18f9c66c73edd4c7ee3816bc96a012", size = 56142 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/6d/280c4c2ce28b1593a19ad5239c8b826871fc6ec275c21afc8e1820108039/proto_plus-1.26.1-py3-none-any.whl", hash = "sha256:13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66", size = 50163 }, +] + +[[package]] +name = "protobuf" +version = "6.31.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/f3/b9655a711b32c19720253f6f06326faf90580834e2e83f840472d752bc8b/protobuf-6.31.1.tar.gz", hash = "sha256:d8cac4c982f0b957a4dc73a80e2ea24fab08e679c0de9deb835f4a12d69aca9a", size = 441797 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/6f/6ab8e4bf962fd5570d3deaa2d5c38f0a363f57b4501047b5ebeb83ab1125/protobuf-6.31.1-cp310-abi3-win32.whl", hash = "sha256:7fa17d5a29c2e04b7d90e5e32388b8bfd0e7107cd8e616feef7ed3fa6bdab5c9", size = 423603 }, + { url = "https://files.pythonhosted.org/packages/44/3a/b15c4347dd4bf3a1b0ee882f384623e2063bb5cf9fa9d57990a4f7df2fb6/protobuf-6.31.1-cp310-abi3-win_amd64.whl", hash = "sha256:426f59d2964864a1a366254fa703b8632dcec0790d8862d30034d8245e1cd447", size = 435283 }, + { url = "https://files.pythonhosted.org/packages/6a/c9/b9689a2a250264a84e66c46d8862ba788ee7a641cdca39bccf64f59284b7/protobuf-6.31.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:6f1227473dc43d44ed644425268eb7c2e488ae245d51c6866d19fe158e207402", size = 425604 }, + { url = "https://files.pythonhosted.org/packages/76/a1/7a5a94032c83375e4fe7e7f56e3976ea6ac90c5e85fac8576409e25c39c3/protobuf-6.31.1-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:a40fc12b84c154884d7d4c4ebd675d5b3b5283e155f324049ae396b95ddebc39", size = 322115 }, + { url = "https://files.pythonhosted.org/packages/fa/b1/b59d405d64d31999244643d88c45c8241c58f17cc887e73bcb90602327f8/protobuf-6.31.1-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:4ee898bf66f7a8b0bd21bce523814e6fbd8c6add948045ce958b73af7e8878c6", size = 321070 }, + { url = "https://files.pythonhosted.org/packages/f7/af/ab3c51ab7507a7325e98ffe691d9495ee3d3aa5f589afad65ec920d39821/protobuf-6.31.1-py3-none-any.whl", hash = "sha256:720a6c7e6b77288b85063569baae8536671b39f15cc22037ec7045658d80489e", size = 168724 }, +] + +[[package]] +name = "psutil" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/80/336820c1ad9286a4ded7e845b2eccfcb27851ab8ac6abece774a6ff4d3de/psutil-7.0.0.tar.gz", hash = "sha256:7be9c3eba38beccb6495ea33afd982a44074b78f28c434a1f51cc07fd315c456", size = 497003 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/e6/2d26234410f8b8abdbf891c9da62bee396583f713fb9f3325a4760875d22/psutil-7.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:101d71dc322e3cffd7cea0650b09b3d08b8e7c4109dd6809fe452dfd00e58b25", size = 238051 }, + { url = "https://files.pythonhosted.org/packages/04/8b/30f930733afe425e3cbfc0e1468a30a18942350c1a8816acfade80c005c4/psutil-7.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:39db632f6bb862eeccf56660871433e111b6ea58f2caea825571951d4b6aa3da", size = 239535 }, + { url = "https://files.pythonhosted.org/packages/2a/ed/d362e84620dd22876b55389248e522338ed1bf134a5edd3b8231d7207f6d/psutil-7.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fcee592b4c6f146991ca55919ea3d1f8926497a713ed7faaf8225e174581e91", size = 275004 }, + { url = "https://files.pythonhosted.org/packages/bf/b9/b0eb3f3cbcb734d930fdf839431606844a825b23eaf9a6ab371edac8162c/psutil-7.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b1388a4f6875d7e2aff5c4ca1cc16c545ed41dd8bb596cefea80111db353a34", size = 277986 }, + { url = "https://files.pythonhosted.org/packages/eb/a2/709e0fe2f093556c17fbafda93ac032257242cabcc7ff3369e2cb76a97aa/psutil-7.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5f098451abc2828f7dc6b58d44b532b22f2088f4999a937557b603ce72b1993", size = 279544 }, + { url = "https://files.pythonhosted.org/packages/50/e6/eecf58810b9d12e6427369784efe814a1eec0f492084ce8eb8f4d89d6d61/psutil-7.0.0-cp37-abi3-win32.whl", hash = "sha256:ba3fcef7523064a6c9da440fc4d6bd07da93ac726b5733c29027d7dc95b39d99", size = 241053 }, + { url = "https://files.pythonhosted.org/packages/50/1b/6921afe68c74868b4c9fa424dad3be35b095e16687989ebbb50ce4fceb7c/psutil-7.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:4cf3d4eb1aa9b348dec30105c55cd9b7d4629285735a102beb4441e38db90553", size = 244885 }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993 }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842 }, +] + +[[package]] +name = "pyarrow" +version = "20.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/ee/a7810cb9f3d6e9238e61d312076a9859bf3668fd21c69744de9532383912/pyarrow-20.0.0.tar.gz", hash = "sha256:febc4a913592573c8d5805091a6c2b5064c8bd6e002131f01061797d91c783c1", size = 1125187 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/d6/0c10e0d54f6c13eb464ee9b67a68b8c71bcf2f67760ef5b6fbcddd2ab05f/pyarrow-20.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:75a51a5b0eef32727a247707d4755322cb970be7e935172b6a3a9f9ae98404ba", size = 30815067 }, + { url = "https://files.pythonhosted.org/packages/7e/e2/04e9874abe4094a06fd8b0cbb0f1312d8dd7d707f144c2ec1e5e8f452ffa/pyarrow-20.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:211d5e84cecc640c7a3ab900f930aaff5cd2702177e0d562d426fb7c4f737781", size = 32297128 }, + { url = "https://files.pythonhosted.org/packages/31/fd/c565e5dcc906a3b471a83273039cb75cb79aad4a2d4a12f76cc5ae90a4b8/pyarrow-20.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ba3cf4182828be7a896cbd232aa8dd6a31bd1f9e32776cc3796c012855e1199", size = 41334890 }, + { url = "https://files.pythonhosted.org/packages/af/a9/3bdd799e2c9b20c1ea6dc6fa8e83f29480a97711cf806e823f808c2316ac/pyarrow-20.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c3a01f313ffe27ac4126f4c2e5ea0f36a5fc6ab51f8726cf41fee4b256680bd", size = 42421775 }, + { url = "https://files.pythonhosted.org/packages/10/f7/da98ccd86354c332f593218101ae56568d5dcedb460e342000bd89c49cc1/pyarrow-20.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:a2791f69ad72addd33510fec7bb14ee06c2a448e06b649e264c094c5b5f7ce28", size = 40687231 }, + { url = "https://files.pythonhosted.org/packages/bb/1b/2168d6050e52ff1e6cefc61d600723870bf569cbf41d13db939c8cf97a16/pyarrow-20.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:4250e28a22302ce8692d3a0e8ec9d9dde54ec00d237cff4dfa9c1fbf79e472a8", size = 42295639 }, + { url = "https://files.pythonhosted.org/packages/b2/66/2d976c0c7158fd25591c8ca55aee026e6d5745a021915a1835578707feb3/pyarrow-20.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:89e030dc58fc760e4010148e6ff164d2f44441490280ef1e97a542375e41058e", size = 42908549 }, + { url = "https://files.pythonhosted.org/packages/31/a9/dfb999c2fc6911201dcbf348247f9cc382a8990f9ab45c12eabfd7243a38/pyarrow-20.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6102b4864d77102dbbb72965618e204e550135a940c2534711d5ffa787df2a5a", size = 44557216 }, + { url = "https://files.pythonhosted.org/packages/a0/8e/9adee63dfa3911be2382fb4d92e4b2e7d82610f9d9f668493bebaa2af50f/pyarrow-20.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:96d6a0a37d9c98be08f5ed6a10831d88d52cac7b13f5287f1e0f625a0de8062b", size = 25660496 }, + { url = "https://files.pythonhosted.org/packages/9b/aa/daa413b81446d20d4dad2944110dcf4cf4f4179ef7f685dd5a6d7570dc8e/pyarrow-20.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:a15532e77b94c61efadde86d10957950392999503b3616b2ffcef7621a002893", size = 30798501 }, + { url = "https://files.pythonhosted.org/packages/ff/75/2303d1caa410925de902d32ac215dc80a7ce7dd8dfe95358c165f2adf107/pyarrow-20.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:dd43f58037443af715f34f1322c782ec463a3c8a94a85fdb2d987ceb5658e061", size = 32277895 }, + { url = "https://files.pythonhosted.org/packages/92/41/fe18c7c0b38b20811b73d1bdd54b1fccba0dab0e51d2048878042d84afa8/pyarrow-20.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa0d288143a8585806e3cc7c39566407aab646fb9ece164609dac1cfff45f6ae", size = 41327322 }, + { url = "https://files.pythonhosted.org/packages/da/ab/7dbf3d11db67c72dbf36ae63dcbc9f30b866c153b3a22ef728523943eee6/pyarrow-20.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6953f0114f8d6f3d905d98e987d0924dabce59c3cda380bdfaa25a6201563b4", size = 42411441 }, + { url = "https://files.pythonhosted.org/packages/90/c3/0c7da7b6dac863af75b64e2f827e4742161128c350bfe7955b426484e226/pyarrow-20.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:991f85b48a8a5e839b2128590ce07611fae48a904cae6cab1f089c5955b57eb5", size = 40677027 }, + { url = "https://files.pythonhosted.org/packages/be/27/43a47fa0ff9053ab5203bb3faeec435d43c0d8bfa40179bfd076cdbd4e1c/pyarrow-20.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:97c8dc984ed09cb07d618d57d8d4b67a5100a30c3818c2fb0b04599f0da2de7b", size = 42281473 }, + { url = "https://files.pythonhosted.org/packages/bc/0b/d56c63b078876da81bbb9ba695a596eabee9b085555ed12bf6eb3b7cab0e/pyarrow-20.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9b71daf534f4745818f96c214dbc1e6124d7daf059167330b610fc69b6f3d3e3", size = 42893897 }, + { url = "https://files.pythonhosted.org/packages/92/ac/7d4bd020ba9145f354012838692d48300c1b8fe5634bfda886abcada67ed/pyarrow-20.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8b88758f9303fa5a83d6c90e176714b2fd3852e776fc2d7e42a22dd6c2fb368", size = 44543847 }, + { url = "https://files.pythonhosted.org/packages/9d/07/290f4abf9ca702c5df7b47739c1b2c83588641ddfa2cc75e34a301d42e55/pyarrow-20.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:30b3051b7975801c1e1d387e17c588d8ab05ced9b1e14eec57915f79869b5031", size = 25653219 }, + { url = "https://files.pythonhosted.org/packages/95/df/720bb17704b10bd69dde086e1400b8eefb8f58df3f8ac9cff6c425bf57f1/pyarrow-20.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:ca151afa4f9b7bc45bcc791eb9a89e90a9eb2772767d0b1e5389609c7d03db63", size = 30853957 }, + { url = "https://files.pythonhosted.org/packages/d9/72/0d5f875efc31baef742ba55a00a25213a19ea64d7176e0fe001c5d8b6e9a/pyarrow-20.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:4680f01ecd86e0dd63e39eb5cd59ef9ff24a9d166db328679e36c108dc993d4c", size = 32247972 }, + { url = "https://files.pythonhosted.org/packages/d5/bc/e48b4fa544d2eea72f7844180eb77f83f2030b84c8dad860f199f94307ed/pyarrow-20.0.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f4c8534e2ff059765647aa69b75d6543f9fef59e2cd4c6d18015192565d2b70", size = 41256434 }, + { url = "https://files.pythonhosted.org/packages/c3/01/974043a29874aa2cf4f87fb07fd108828fc7362300265a2a64a94965e35b/pyarrow-20.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e1f8a47f4b4ae4c69c4d702cfbdfe4d41e18e5c7ef6f1bb1c50918c1e81c57b", size = 42353648 }, + { url = "https://files.pythonhosted.org/packages/68/95/cc0d3634cde9ca69b0e51cbe830d8915ea32dda2157560dda27ff3b3337b/pyarrow-20.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:a1f60dc14658efaa927f8214734f6a01a806d7690be4b3232ba526836d216122", size = 40619853 }, + { url = "https://files.pythonhosted.org/packages/29/c2/3ad40e07e96a3e74e7ed7cc8285aadfa84eb848a798c98ec0ad009eb6bcc/pyarrow-20.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:204a846dca751428991346976b914d6d2a82ae5b8316a6ed99789ebf976551e6", size = 42241743 }, + { url = "https://files.pythonhosted.org/packages/eb/cb/65fa110b483339add6a9bc7b6373614166b14e20375d4daa73483755f830/pyarrow-20.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f3b117b922af5e4c6b9a9115825726cac7d8b1421c37c2b5e24fbacc8930612c", size = 42839441 }, + { url = "https://files.pythonhosted.org/packages/98/7b/f30b1954589243207d7a0fbc9997401044bf9a033eec78f6cb50da3f304a/pyarrow-20.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e724a3fd23ae5b9c010e7be857f4405ed5e679db5c93e66204db1a69f733936a", size = 44503279 }, + { url = "https://files.pythonhosted.org/packages/37/40/ad395740cd641869a13bcf60851296c89624662575621968dcfafabaa7f6/pyarrow-20.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:82f1ee5133bd8f49d31be1299dc07f585136679666b502540db854968576faf9", size = 25944982 }, +] + +[[package]] +name = "pyasn1" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135 }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259 }, +] + +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, +] + +[[package]] +name = "pydantic" +version = "2.11.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782 }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000 }, + { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996 }, + { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957 }, + { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199 }, + { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296 }, + { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109 }, + { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028 }, + { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044 }, + { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881 }, + { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034 }, + { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187 }, + { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628 }, + { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866 }, + { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894 }, + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688 }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808 }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580 }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859 }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810 }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498 }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611 }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924 }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196 }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389 }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223 }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473 }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269 }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921 }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162 }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560 }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777 }, +] + +[[package]] +name = "pydantic-settings" +version = "2.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/ef/3d61472b7801c896f9efd9bb8750977d9577098b05224c5c41820690155e/pydantic_settings-2.10.0.tar.gz", hash = "sha256:7a12e0767ba283954f3fd3fefdd0df3af21b28aa849c40c35811d52d682fa876", size = 172625 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/9e/fce9331fecf1d2761ff0516c5dceab8a5fd415e82943e727dc4c5fa84a90/pydantic_settings-2.10.0-py3-none-any.whl", hash = "sha256:33781dfa1c7405d5ed2b6f150830a93bb58462a847357bd8f162f8bacb77c027", size = 45232 }, +] + +[[package]] +name = "pydeck" +version = "0.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/ca/40e14e196864a0f61a92abb14d09b3d3da98f94ccb03b49cf51688140dab/pydeck-0.9.1.tar.gz", hash = "sha256:f74475ae637951d63f2ee58326757f8d4f9cd9f2a457cf42950715003e2cb605", size = 3832240 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/4c/b888e6cf58bd9db9c93f40d1c6be8283ff49d88919231afe93a6bcf61626/pydeck-0.9.1-py2.py3-none-any.whl", hash = "sha256:b3f75ba0d273fc917094fa61224f3f6076ca8752b93d46faf3bcfd9f9d59b038", size = 6900403 }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 }, +] + +[[package]] +name = "pyparsing" +version = "3.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/22/f1129e69d94ffff626bdb5c835506b3a5b4f3d070f17ea295e12c2c6f60f/pyparsing-3.2.3.tar.gz", hash = "sha256:b9c13f1ab8b3b542f72e28f634bad4de758ab3ce4546e4301970ad6fa77c38be", size = 1088608 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/e7/df2285f3d08fee213f2d041540fa4fc9ca6c2d44cf36d3a035bf2a8d2bcc/pyparsing-3.2.3-py3-none-any.whl", hash = "sha256:a749938e02d6fd0b59b356ca504a24982314bb090c383e3cf201c95ef7e2bfcf", size = 111120 }, +] + +[[package]] +name = "pyright" +version = "1.1.405" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/6c/ba4bbee22e76af700ea593a1d8701e3225080956753bee9750dcc25e2649/pyright-1.1.405.tar.gz", hash = "sha256:5c2a30e1037af27eb463a1cc0b9f6d65fec48478ccf092c1ac28385a15c55763", size = 4068319 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/1a/524f832e1ff1962a22a1accc775ca7b143ba2e9f5924bb6749dce566784a/pyright-1.1.405-py3-none-any.whl", hash = "sha256:a2cb13700b5508ce8e5d4546034cb7ea4aedb60215c6c33f56cec7f53996035a", size = 5905038 }, +] + +[[package]] +name = "pytest" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474 }, +] + +[[package]] +name = "pytest-cov" +version = "6.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/99/668cade231f434aaa59bbfbf49469068d2ddd945000621d3d165d2e7dd7b/pytest_cov-6.2.1.tar.gz", hash = "sha256:25cc6cc0a5358204b8108ecedc51a9b57b34cc6b8c967cc2c01a4e00d8a67da2", size = 69432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/16/4ea354101abb1287856baa4af2732be351c7bee728065aed451b678153fd/pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5", size = 24644 }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095 }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, +] + +[[package]] +name = "python-dotenv" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556 }, +] + +[[package]] +name = "python-json-logger" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/de/d3144a0bceede957f961e975f3752760fbe390d57fbe194baf709d8f1f7b/python_json_logger-3.3.0.tar.gz", hash = "sha256:12b7e74b17775e7d565129296105bbe3910842d9d0eb083fc83a6a617aa8df84", size = 16642 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/20/0f2523b9e50a8052bc6a8b732dfc8568abbdc42010aef03a2d750bdab3b2/python_json_logger-3.3.0-py3-none-any.whl", hash = "sha256:dd980fae8cffb24c13caf6e158d3d61c0d6d22342f932cb6e9deedab3d35eec7", size = 15163 }, +] + +[[package]] +name = "pytz" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225 }, +] + +[[package]] +name = "pywin32" +version = "310" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/ec/4fdbe47932f671d6e348474ea35ed94227fb5df56a7c30cbbb42cd396ed0/pywin32-310-cp312-cp312-win32.whl", hash = "sha256:8a75a5cc3893e83a108c05d82198880704c44bbaee4d06e442e471d3c9ea4f3d", size = 8796239 }, + { url = "https://files.pythonhosted.org/packages/e3/e5/b0627f8bb84e06991bea89ad8153a9e50ace40b2e1195d68e9dff6b03d0f/pywin32-310-cp312-cp312-win_amd64.whl", hash = "sha256:bf5c397c9a9a19a6f62f3fb821fbf36cac08f03770056711f765ec1503972060", size = 9503839 }, + { url = "https://files.pythonhosted.org/packages/1f/32/9ccf53748df72301a89713936645a664ec001abd35ecc8578beda593d37d/pywin32-310-cp312-cp312-win_arm64.whl", hash = "sha256:2349cc906eae872d0663d4d6290d13b90621eaf78964bb1578632ff20e152966", size = 8459470 }, + { url = "https://files.pythonhosted.org/packages/1c/09/9c1b978ffc4ae53999e89c19c77ba882d9fce476729f23ef55211ea1c034/pywin32-310-cp313-cp313-win32.whl", hash = "sha256:5d241a659c496ada3253cd01cfaa779b048e90ce4b2b38cd44168ad555ce74ab", size = 8794384 }, + { url = "https://files.pythonhosted.org/packages/45/3c/b4640f740ffebadd5d34df35fecba0e1cfef8fde9f3e594df91c28ad9b50/pywin32-310-cp313-cp313-win_amd64.whl", hash = "sha256:667827eb3a90208ddbdcc9e860c81bde63a135710e21e4cb3348968e4bd5249e", size = 9503039 }, + { url = "https://files.pythonhosted.org/packages/b4/f4/f785020090fb050e7fb6d34b780f2231f302609dc964672f72bfaeb59a28/pywin32-310-cp313-cp313-win_arm64.whl", hash = "sha256:e308f831de771482b7cf692a1f308f8fca701b2d8f9dde6cc440c7da17e47b33", size = 8458152 }, +] + +[[package]] +name = "pywinpty" +version = "2.0.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/7c/917f9c4681bb8d34bfbe0b79d36bbcd902651aeab48790df3d30ba0202fb/pywinpty-2.0.15.tar.gz", hash = "sha256:312cf39153a8736c617d45ce8b6ad6cd2107de121df91c455b10ce6bba7a39b2", size = 29017 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/e5/9714def18c3a411809771a3fbcec70bffa764b9675afb00048a620fca604/pywinpty-2.0.15-cp312-cp312-win_amd64.whl", hash = "sha256:83a8f20b430bbc5d8957249f875341a60219a4e971580f2ba694fbfb54a45ebc", size = 1405243 }, + { url = "https://files.pythonhosted.org/packages/fb/16/2ab7b3b7f55f3c6929e5f629e1a68362981e4e5fed592a2ed1cb4b4914a5/pywinpty-2.0.15-cp313-cp313-win_amd64.whl", hash = "sha256:ab5920877dd632c124b4ed17bc6dd6ef3b9f86cd492b963ffdb1a67b85b0f408", size = 1405020 }, + { url = "https://files.pythonhosted.org/packages/7c/16/edef3515dd2030db2795dbfbe392232c7a0f3dc41b98e92b38b42ba497c7/pywinpty-2.0.15-cp313-cp313t-win_amd64.whl", hash = "sha256:a4560ad8c01e537708d2790dbe7da7d986791de805d89dd0d3697ca59e9e4901", size = 1404151 }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, +] + +[[package]] +name = "pyzmq" +version = "27.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "implementation_name == 'pypy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/06/50a4e9648b3e8b992bef8eb632e457307553a89d294103213cfd47b3da69/pyzmq-27.0.0.tar.gz", hash = "sha256:b1f08eeb9ce1510e6939b6e5dcd46a17765e2333daae78ecf4606808442e52cf", size = 280478 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/a7/9ad68f55b8834ede477842214feba6a4c786d936c022a67625497aacf61d/pyzmq-27.0.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:cbabc59dcfaac66655c040dfcb8118f133fb5dde185e5fc152628354c1598e52", size = 1305438 }, + { url = "https://files.pythonhosted.org/packages/ba/ee/26aa0f98665a22bc90ebe12dced1de5f3eaca05363b717f6fb229b3421b3/pyzmq-27.0.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:cb0ac5179cba4b2f94f1aa208fbb77b62c4c9bf24dd446278b8b602cf85fcda3", size = 895095 }, + { url = "https://files.pythonhosted.org/packages/cf/85/c57e7ab216ecd8aa4cc7e3b83b06cc4e9cf45c87b0afc095f10cd5ce87c1/pyzmq-27.0.0-cp312-abi3-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53a48f0228eab6cbf69fde3aa3c03cbe04e50e623ef92ae395fce47ef8a76152", size = 651826 }, + { url = "https://files.pythonhosted.org/packages/69/9a/9ea7e230feda9400fb0ae0d61d7d6ddda635e718d941c44eeab22a179d34/pyzmq-27.0.0-cp312-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:111db5f395e09f7e775f759d598f43cb815fc58e0147623c4816486e1a39dc22", size = 839750 }, + { url = "https://files.pythonhosted.org/packages/08/66/4cebfbe71f3dfbd417011daca267539f62ed0fbc68105357b68bbb1a25b7/pyzmq-27.0.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c8878011653dcdc27cc2c57e04ff96f0471e797f5c19ac3d7813a245bcb24371", size = 1641357 }, + { url = "https://files.pythonhosted.org/packages/ac/f6/b0f62578c08d2471c791287149cb8c2aaea414ae98c6e995c7dbe008adfb/pyzmq-27.0.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:c0ed2c1f335ba55b5fdc964622254917d6b782311c50e138863eda409fbb3b6d", size = 2020281 }, + { url = "https://files.pythonhosted.org/packages/37/b9/4f670b15c7498495da9159edc374ec09c88a86d9cd5a47d892f69df23450/pyzmq-27.0.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e918d70862d4cfd4b1c187310015646a14e1f5917922ab45b29f28f345eeb6be", size = 1877110 }, + { url = "https://files.pythonhosted.org/packages/66/31/9dee25c226295b740609f0d46db2fe972b23b6f5cf786360980524a3ba92/pyzmq-27.0.0-cp312-abi3-win32.whl", hash = "sha256:88b4e43cab04c3c0f0d55df3b1eef62df2b629a1a369b5289a58f6fa8b07c4f4", size = 559297 }, + { url = "https://files.pythonhosted.org/packages/9b/12/52da5509800f7ff2d287b2f2b4e636e7ea0f001181cba6964ff6c1537778/pyzmq-27.0.0-cp312-abi3-win_amd64.whl", hash = "sha256:dce4199bf5f648a902ce37e7b3afa286f305cd2ef7a8b6ec907470ccb6c8b371", size = 619203 }, + { url = "https://files.pythonhosted.org/packages/93/6d/7f2e53b19d1edb1eb4f09ec7c3a1f945ca0aac272099eab757d15699202b/pyzmq-27.0.0-cp312-abi3-win_arm64.whl", hash = "sha256:56e46bbb85d52c1072b3f809cc1ce77251d560bc036d3a312b96db1afe76db2e", size = 551927 }, + { url = "https://files.pythonhosted.org/packages/19/62/876b27c4ff777db4ceba1c69ea90d3c825bb4f8d5e7cd987ce5802e33c55/pyzmq-27.0.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:c36ad534c0c29b4afa088dc53543c525b23c0797e01b69fef59b1a9c0e38b688", size = 1340826 }, + { url = "https://files.pythonhosted.org/packages/43/69/58ef8f4f59d3bcd505260c73bee87b008850f45edca40ddaba54273c35f4/pyzmq-27.0.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:67855c14173aec36395d7777aaba3cc527b393821f30143fd20b98e1ff31fd38", size = 897283 }, + { url = "https://files.pythonhosted.org/packages/43/15/93a0d0396700a60475ad3c5d42c5f1c308d3570bc94626b86c71ef9953e0/pyzmq-27.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8617c7d43cd8ccdb62aebe984bfed77ca8f036e6c3e46dd3dddda64b10f0ab7a", size = 660567 }, + { url = "https://files.pythonhosted.org/packages/0e/b3/fe055513e498ca32f64509abae19b9c9eb4d7c829e02bd8997dd51b029eb/pyzmq-27.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:67bfbcbd0a04c575e8103a6061d03e393d9f80ffdb9beb3189261e9e9bc5d5e9", size = 847681 }, + { url = "https://files.pythonhosted.org/packages/b6/4f/ff15300b00b5b602191f3df06bbc8dd4164e805fdd65bb77ffbb9c5facdc/pyzmq-27.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5cd11d46d7b7e5958121b3eaf4cd8638eff3a720ec527692132f05a57f14341d", size = 1650148 }, + { url = "https://files.pythonhosted.org/packages/c4/6f/84bdfff2a224a6f26a24249a342e5906993c50b0761e311e81b39aef52a7/pyzmq-27.0.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:b801c2e40c5aa6072c2f4876de8dccd100af6d9918d4d0d7aa54a1d982fd4f44", size = 2023768 }, + { url = "https://files.pythonhosted.org/packages/64/39/dc2db178c26a42228c5ac94a9cc595030458aa64c8d796a7727947afbf55/pyzmq-27.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:20d5cb29e8c5f76a127c75b6e7a77e846bc4b655c373baa098c26a61b7ecd0ef", size = 1885199 }, + { url = "https://files.pythonhosted.org/packages/c7/21/dae7b06a1f8cdee5d8e7a63d99c5d129c401acc40410bef2cbf42025e26f/pyzmq-27.0.0-cp313-cp313t-win32.whl", hash = "sha256:a20528da85c7ac7a19b7384e8c3f8fa707841fd85afc4ed56eda59d93e3d98ad", size = 575439 }, + { url = "https://files.pythonhosted.org/packages/eb/bc/1709dc55f0970cf4cb8259e435e6773f9946f41a045c2cb90e870b7072da/pyzmq-27.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:d8229f2efece6a660ee211d74d91dbc2a76b95544d46c74c615e491900dc107f", size = 639933 }, +] + +[[package]] +name = "referencing" +version = "0.36.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775 }, +] + +[[package]] +name = "regex" +version = "2024.11.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/5f/bd69653fbfb76cf8604468d3b4ec4c403197144c7bfe0e6a5fc9e02a07cb/regex-2024.11.6.tar.gz", hash = "sha256:7ab159b063c52a0333c884e4679f8d7a85112ee3078fe3d9004b2dd875585519", size = 399494 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/30/9a87ce8336b172cc232a0db89a3af97929d06c11ceaa19d97d84fa90a8f8/regex-2024.11.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:52fb28f528778f184f870b7cf8f225f5eef0a8f6e3778529bdd40c7b3920796a", size = 483781 }, + { url = "https://files.pythonhosted.org/packages/01/e8/00008ad4ff4be8b1844786ba6636035f7ef926db5686e4c0f98093612add/regex-2024.11.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdd6028445d2460f33136c55eeb1f601ab06d74cb3347132e1c24250187500d9", size = 288455 }, + { url = "https://files.pythonhosted.org/packages/60/85/cebcc0aff603ea0a201667b203f13ba75d9fc8668fab917ac5b2de3967bc/regex-2024.11.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:805e6b60c54bf766b251e94526ebad60b7de0c70f70a4e6210ee2891acb70bf2", size = 284759 }, + { url = "https://files.pythonhosted.org/packages/94/2b/701a4b0585cb05472a4da28ee28fdfe155f3638f5e1ec92306d924e5faf0/regex-2024.11.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b85c2530be953a890eaffde05485238f07029600e8f098cdf1848d414a8b45e4", size = 794976 }, + { url = "https://files.pythonhosted.org/packages/4b/bf/fa87e563bf5fee75db8915f7352e1887b1249126a1be4813837f5dbec965/regex-2024.11.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb26437975da7dc36b7efad18aa9dd4ea569d2357ae6b783bf1118dabd9ea577", size = 833077 }, + { url = "https://files.pythonhosted.org/packages/a1/56/7295e6bad94b047f4d0834e4779491b81216583c00c288252ef625c01d23/regex-2024.11.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abfa5080c374a76a251ba60683242bc17eeb2c9818d0d30117b4486be10c59d3", size = 823160 }, + { url = "https://files.pythonhosted.org/packages/fb/13/e3b075031a738c9598c51cfbc4c7879e26729c53aa9cca59211c44235314/regex-2024.11.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b7fa6606c2881c1db9479b0eaa11ed5dfa11c8d60a474ff0e095099f39d98e", size = 796896 }, + { url = "https://files.pythonhosted.org/packages/24/56/0b3f1b66d592be6efec23a795b37732682520b47c53da5a32c33ed7d84e3/regex-2024.11.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c32f75920cf99fe6b6c539c399a4a128452eaf1af27f39bce8909c9a3fd8cbe", size = 783997 }, + { url = "https://files.pythonhosted.org/packages/f9/a1/eb378dada8b91c0e4c5f08ffb56f25fcae47bf52ad18f9b2f33b83e6d498/regex-2024.11.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:982e6d21414e78e1f51cf595d7f321dcd14de1f2881c5dc6a6e23bbbbd68435e", size = 781725 }, + { url = "https://files.pythonhosted.org/packages/83/f2/033e7dec0cfd6dda93390089864732a3409246ffe8b042e9554afa9bff4e/regex-2024.11.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a7c2155f790e2fb448faed6dd241386719802296ec588a8b9051c1f5c481bc29", size = 789481 }, + { url = "https://files.pythonhosted.org/packages/83/23/15d4552ea28990a74e7696780c438aadd73a20318c47e527b47a4a5a596d/regex-2024.11.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:149f5008d286636e48cd0b1dd65018548944e495b0265b45e1bffecce1ef7f39", size = 852896 }, + { url = "https://files.pythonhosted.org/packages/e3/39/ed4416bc90deedbfdada2568b2cb0bc1fdb98efe11f5378d9892b2a88f8f/regex-2024.11.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e5364a4502efca094731680e80009632ad6624084aff9a23ce8c8c6820de3e51", size = 860138 }, + { url = "https://files.pythonhosted.org/packages/93/2d/dd56bb76bd8e95bbce684326302f287455b56242a4f9c61f1bc76e28360e/regex-2024.11.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0a86e7eeca091c09e021db8eb72d54751e527fa47b8d5787caf96d9831bd02ad", size = 787692 }, + { url = "https://files.pythonhosted.org/packages/0b/55/31877a249ab7a5156758246b9c59539abbeba22461b7d8adc9e8475ff73e/regex-2024.11.6-cp312-cp312-win32.whl", hash = "sha256:32f9a4c643baad4efa81d549c2aadefaeba12249b2adc5af541759237eee1c54", size = 262135 }, + { url = "https://files.pythonhosted.org/packages/38/ec/ad2d7de49a600cdb8dd78434a1aeffe28b9d6fc42eb36afab4a27ad23384/regex-2024.11.6-cp312-cp312-win_amd64.whl", hash = "sha256:a93c194e2df18f7d264092dc8539b8ffb86b45b899ab976aa15d48214138e81b", size = 273567 }, + { url = "https://files.pythonhosted.org/packages/90/73/bcb0e36614601016552fa9344544a3a2ae1809dc1401b100eab02e772e1f/regex-2024.11.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a6ba92c0bcdf96cbf43a12c717eae4bc98325ca3730f6b130ffa2e3c3c723d84", size = 483525 }, + { url = "https://files.pythonhosted.org/packages/0f/3f/f1a082a46b31e25291d830b369b6b0c5576a6f7fb89d3053a354c24b8a83/regex-2024.11.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:525eab0b789891ac3be914d36893bdf972d483fe66551f79d3e27146191a37d4", size = 288324 }, + { url = "https://files.pythonhosted.org/packages/09/c9/4e68181a4a652fb3ef5099e077faf4fd2a694ea6e0f806a7737aff9e758a/regex-2024.11.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:086a27a0b4ca227941700e0b31425e7a28ef1ae8e5e05a33826e17e47fbfdba0", size = 284617 }, + { url = "https://files.pythonhosted.org/packages/fc/fd/37868b75eaf63843165f1d2122ca6cb94bfc0271e4428cf58c0616786dce/regex-2024.11.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bde01f35767c4a7899b7eb6e823b125a64de314a8ee9791367c9a34d56af18d0", size = 795023 }, + { url = "https://files.pythonhosted.org/packages/c4/7c/d4cd9c528502a3dedb5c13c146e7a7a539a3853dc20209c8e75d9ba9d1b2/regex-2024.11.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b583904576650166b3d920d2bcce13971f6f9e9a396c673187f49811b2769dc7", size = 833072 }, + { url = "https://files.pythonhosted.org/packages/4f/db/46f563a08f969159c5a0f0e722260568425363bea43bb7ae370becb66a67/regex-2024.11.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c4de13f06a0d54fa0d5ab1b7138bfa0d883220965a29616e3ea61b35d5f5fc7", size = 823130 }, + { url = "https://files.pythonhosted.org/packages/db/60/1eeca2074f5b87df394fccaa432ae3fc06c9c9bfa97c5051aed70e6e00c2/regex-2024.11.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3cde6e9f2580eb1665965ce9bf17ff4952f34f5b126beb509fee8f4e994f143c", size = 796857 }, + { url = "https://files.pythonhosted.org/packages/10/db/ac718a08fcee981554d2f7bb8402f1faa7e868c1345c16ab1ebec54b0d7b/regex-2024.11.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d7f453dca13f40a02b79636a339c5b62b670141e63efd511d3f8f73fba162b3", size = 784006 }, + { url = "https://files.pythonhosted.org/packages/c2/41/7da3fe70216cea93144bf12da2b87367590bcf07db97604edeea55dac9ad/regex-2024.11.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59dfe1ed21aea057a65c6b586afd2a945de04fc7db3de0a6e3ed5397ad491b07", size = 781650 }, + { url = "https://files.pythonhosted.org/packages/a7/d5/880921ee4eec393a4752e6ab9f0fe28009435417c3102fc413f3fe81c4e5/regex-2024.11.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b97c1e0bd37c5cd7902e65f410779d39eeda155800b65fc4d04cc432efa9bc6e", size = 789545 }, + { url = "https://files.pythonhosted.org/packages/dc/96/53770115e507081122beca8899ab7f5ae28ae790bfcc82b5e38976df6a77/regex-2024.11.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f9d1e379028e0fc2ae3654bac3cbbef81bf3fd571272a42d56c24007979bafb6", size = 853045 }, + { url = "https://files.pythonhosted.org/packages/31/d3/1372add5251cc2d44b451bd94f43b2ec78e15a6e82bff6a290ef9fd8f00a/regex-2024.11.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:13291b39131e2d002a7940fb176e120bec5145f3aeb7621be6534e46251912c4", size = 860182 }, + { url = "https://files.pythonhosted.org/packages/ed/e3/c446a64984ea9f69982ba1a69d4658d5014bc7a0ea468a07e1a1265db6e2/regex-2024.11.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f51f88c126370dcec4908576c5a627220da6c09d0bff31cfa89f2523843316d", size = 787733 }, + { url = "https://files.pythonhosted.org/packages/2b/f1/e40c8373e3480e4f29f2692bd21b3e05f296d3afebc7e5dcf21b9756ca1c/regex-2024.11.6-cp313-cp313-win32.whl", hash = "sha256:63b13cfd72e9601125027202cad74995ab26921d8cd935c25f09c630436348ff", size = 262122 }, + { url = "https://files.pythonhosted.org/packages/45/94/bc295babb3062a731f52621cdc992d123111282e291abaf23faa413443ea/regex-2024.11.6-cp313-cp313-win_amd64.whl", hash = "sha256:2b3361af3198667e99927da8b84c1b010752fa4b1115ee30beaa332cabc3ef1a", size = 273545 }, +] + +[[package]] +name = "reportlab" +version = "4.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "charset-normalizer" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/9b/3483c7e4ad33d15f22d528872439e5bc92485814d7e7d10dbc3130368a83/reportlab-4.4.2.tar.gz", hash = "sha256:fc6283048ddd0781a9db1d671715990e6aa059c8d40ec9baf34294c4bd583a36", size = 3509063 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/74/ed990bc9586605d4e46f6b0e0b978a5b8e757aa599e39664bee26d6dc666/reportlab-4.4.2-py3-none-any.whl", hash = "sha256:58e11be387457928707c12153b7e41e52533a5da3f587b15ba8f8fd0805c6ee2", size = 1953624 }, +] + +[[package]] +name = "requests" +version = "2.32.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847 }, +] + +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481 }, +] + +[[package]] +name = "rfc3339-validator" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490 }, +] + +[[package]] +name = "rfc3986-validator" +version = "0.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/88/f270de456dd7d11dcc808abfa291ecdd3f45ff44e3b549ffa01b126464d0/rfc3986_validator-0.1.1.tar.gz", hash = "sha256:3d44bde7921b3b9ec3ae4e3adca370438eccebc676456449b145d533b240d055", size = 6760 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/51/17023c0f8f1869d8806b979a2bffa3f861f26a3f1a66b094288323fba52f/rfc3986_validator-0.1.1-py2.py3-none-any.whl", hash = "sha256:2f235c432ef459970b4306369336b9d5dbdda31b510ca1e327636e01f528bfa9", size = 4242 }, +] + +[[package]] +name = "rpds-py" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/a6/60184b7fc00dd3ca80ac635dd5b8577d444c57e8e8742cecabfacb829921/rpds_py-0.25.1.tar.gz", hash = "sha256:8960b6dac09b62dac26e75d7e2c4a22efb835d827a7278c34f72b2b84fa160e3", size = 27304 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/81/28ab0408391b1dc57393653b6a0cf2014cc282cc2909e4615e63e58262be/rpds_py-0.25.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b5ffe453cde61f73fea9430223c81d29e2fbf412a6073951102146c84e19e34c", size = 364647 }, + { url = "https://files.pythonhosted.org/packages/2c/9a/7797f04cad0d5e56310e1238434f71fc6939d0bc517192a18bb99a72a95f/rpds_py-0.25.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:115874ae5e2fdcfc16b2aedc95b5eef4aebe91b28e7e21951eda8a5dc0d3461b", size = 350454 }, + { url = "https://files.pythonhosted.org/packages/69/3c/93d2ef941b04898011e5d6eaa56a1acf46a3b4c9f4b3ad1bbcbafa0bee1f/rpds_py-0.25.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a714bf6e5e81b0e570d01f56e0c89c6375101b8463999ead3a93a5d2a4af91fa", size = 389665 }, + { url = "https://files.pythonhosted.org/packages/c1/57/ad0e31e928751dde8903a11102559628d24173428a0f85e25e187defb2c1/rpds_py-0.25.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:35634369325906bcd01577da4c19e3b9541a15e99f31e91a02d010816b49bfda", size = 403873 }, + { url = "https://files.pythonhosted.org/packages/16/ad/c0c652fa9bba778b4f54980a02962748479dc09632e1fd34e5282cf2556c/rpds_py-0.25.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4cb2b3ddc16710548801c6fcc0cfcdeeff9dafbc983f77265877793f2660309", size = 525866 }, + { url = "https://files.pythonhosted.org/packages/2a/39/3e1839bc527e6fcf48d5fec4770070f872cdee6c6fbc9b259932f4e88a38/rpds_py-0.25.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9ceca1cf097ed77e1a51f1dbc8d174d10cb5931c188a4505ff9f3e119dfe519b", size = 416886 }, + { url = "https://files.pythonhosted.org/packages/7a/95/dd6b91cd4560da41df9d7030a038298a67d24f8ca38e150562644c829c48/rpds_py-0.25.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c2cd1a4b0c2b8c5e31ffff50d09f39906fe351389ba143c195566056c13a7ea", size = 390666 }, + { url = "https://files.pythonhosted.org/packages/64/48/1be88a820e7494ce0a15c2d390ccb7c52212370badabf128e6a7bb4cb802/rpds_py-0.25.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1de336a4b164c9188cb23f3703adb74a7623ab32d20090d0e9bf499a2203ad65", size = 425109 }, + { url = "https://files.pythonhosted.org/packages/cf/07/3e2a17927ef6d7720b9949ec1b37d1e963b829ad0387f7af18d923d5cfa5/rpds_py-0.25.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9fca84a15333e925dd59ce01da0ffe2ffe0d6e5d29a9eeba2148916d1824948c", size = 567244 }, + { url = "https://files.pythonhosted.org/packages/d2/e5/76cf010998deccc4f95305d827847e2eae9c568099c06b405cf96384762b/rpds_py-0.25.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:88ec04afe0c59fa64e2f6ea0dd9657e04fc83e38de90f6de201954b4d4eb59bd", size = 596023 }, + { url = "https://files.pythonhosted.org/packages/52/9a/df55efd84403736ba37a5a6377b70aad0fd1cb469a9109ee8a1e21299a1c/rpds_py-0.25.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a8bd2f19e312ce3e1d2c635618e8a8d8132892bb746a7cf74780a489f0f6cdcb", size = 561634 }, + { url = "https://files.pythonhosted.org/packages/ab/aa/dc3620dd8db84454aaf9374bd318f1aa02578bba5e567f5bf6b79492aca4/rpds_py-0.25.1-cp312-cp312-win32.whl", hash = "sha256:e5e2f7280d8d0d3ef06f3ec1b4fd598d386cc6f0721e54f09109a8132182fbfe", size = 222713 }, + { url = "https://files.pythonhosted.org/packages/a3/7f/7cef485269a50ed5b4e9bae145f512d2a111ca638ae70cc101f661b4defd/rpds_py-0.25.1-cp312-cp312-win_amd64.whl", hash = "sha256:db58483f71c5db67d643857404da360dce3573031586034b7d59f245144cc192", size = 235280 }, + { url = "https://files.pythonhosted.org/packages/99/f2/c2d64f6564f32af913bf5f3f7ae41c7c263c5ae4c4e8f1a17af8af66cd46/rpds_py-0.25.1-cp312-cp312-win_arm64.whl", hash = "sha256:6d50841c425d16faf3206ddbba44c21aa3310a0cebc3c1cdfc3e3f4f9f6f5728", size = 225399 }, + { url = "https://files.pythonhosted.org/packages/2b/da/323848a2b62abe6a0fec16ebe199dc6889c5d0a332458da8985b2980dffe/rpds_py-0.25.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:659d87430a8c8c704d52d094f5ba6fa72ef13b4d385b7e542a08fc240cb4a559", size = 364498 }, + { url = "https://files.pythonhosted.org/packages/1f/b4/4d3820f731c80fd0cd823b3e95b9963fec681ae45ba35b5281a42382c67d/rpds_py-0.25.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:68f6f060f0bbdfb0245267da014d3a6da9be127fe3e8cc4a68c6f833f8a23bb1", size = 350083 }, + { url = "https://files.pythonhosted.org/packages/d5/b1/3a8ee1c9d480e8493619a437dec685d005f706b69253286f50f498cbdbcf/rpds_py-0.25.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:083a9513a33e0b92cf6e7a6366036c6bb43ea595332c1ab5c8ae329e4bcc0a9c", size = 389023 }, + { url = "https://files.pythonhosted.org/packages/3b/31/17293edcfc934dc62c3bf74a0cb449ecd549531f956b72287203e6880b87/rpds_py-0.25.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:816568614ecb22b18a010c7a12559c19f6fe993526af88e95a76d5a60b8b75fb", size = 403283 }, + { url = "https://files.pythonhosted.org/packages/d1/ca/e0f0bc1a75a8925024f343258c8ecbd8828f8997ea2ac71e02f67b6f5299/rpds_py-0.25.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c6564c0947a7f52e4792983f8e6cf9bac140438ebf81f527a21d944f2fd0a40", size = 524634 }, + { url = "https://files.pythonhosted.org/packages/3e/03/5d0be919037178fff33a6672ffc0afa04ea1cfcb61afd4119d1b5280ff0f/rpds_py-0.25.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c4a128527fe415d73cf1f70a9a688d06130d5810be69f3b553bf7b45e8acf79", size = 416233 }, + { url = "https://files.pythonhosted.org/packages/05/7c/8abb70f9017a231c6c961a8941403ed6557664c0913e1bf413cbdc039e75/rpds_py-0.25.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a49e1d7a4978ed554f095430b89ecc23f42014a50ac385eb0c4d163ce213c325", size = 390375 }, + { url = "https://files.pythonhosted.org/packages/7a/ac/a87f339f0e066b9535074a9f403b9313fd3892d4a164d5d5f5875ac9f29f/rpds_py-0.25.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d74ec9bc0e2feb81d3f16946b005748119c0f52a153f6db6a29e8cd68636f295", size = 424537 }, + { url = "https://files.pythonhosted.org/packages/1f/8f/8d5c1567eaf8c8afe98a838dd24de5013ce6e8f53a01bd47fe8bb06b5533/rpds_py-0.25.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3af5b4cc10fa41e5bc64e5c198a1b2d2864337f8fcbb9a67e747e34002ce812b", size = 566425 }, + { url = "https://files.pythonhosted.org/packages/95/33/03016a6be5663b389c8ab0bbbcca68d9e96af14faeff0a04affcb587e776/rpds_py-0.25.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:79dc317a5f1c51fd9c6a0c4f48209c6b8526d0524a6904fc1076476e79b00f98", size = 595197 }, + { url = "https://files.pythonhosted.org/packages/33/8d/da9f4d3e208c82fda311bff0cf0a19579afceb77cf456e46c559a1c075ba/rpds_py-0.25.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1521031351865e0181bc585147624d66b3b00a84109b57fcb7a779c3ec3772cd", size = 561244 }, + { url = "https://files.pythonhosted.org/packages/e2/b3/39d5dcf7c5f742ecd6dbc88f6f84ae54184b92f5f387a4053be2107b17f1/rpds_py-0.25.1-cp313-cp313-win32.whl", hash = "sha256:5d473be2b13600b93a5675d78f59e63b51b1ba2d0476893415dfbb5477e65b31", size = 222254 }, + { url = "https://files.pythonhosted.org/packages/5f/19/2d6772c8eeb8302c5f834e6d0dfd83935a884e7c5ce16340c7eaf89ce925/rpds_py-0.25.1-cp313-cp313-win_amd64.whl", hash = "sha256:a7b74e92a3b212390bdce1d93da9f6488c3878c1d434c5e751cbc202c5e09500", size = 234741 }, + { url = "https://files.pythonhosted.org/packages/5b/5a/145ada26cfaf86018d0eb304fe55eafdd4f0b6b84530246bb4a7c4fb5c4b/rpds_py-0.25.1-cp313-cp313-win_arm64.whl", hash = "sha256:dd326a81afe332ede08eb39ab75b301d5676802cdffd3a8f287a5f0b694dc3f5", size = 224830 }, + { url = "https://files.pythonhosted.org/packages/4b/ca/d435844829c384fd2c22754ff65889c5c556a675d2ed9eb0e148435c6690/rpds_py-0.25.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:a58d1ed49a94d4183483a3ce0af22f20318d4a1434acee255d683ad90bf78129", size = 359668 }, + { url = "https://files.pythonhosted.org/packages/1f/01/b056f21db3a09f89410d493d2f6614d87bb162499f98b649d1dbd2a81988/rpds_py-0.25.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f251bf23deb8332823aef1da169d5d89fa84c89f67bdfb566c49dea1fccfd50d", size = 345649 }, + { url = "https://files.pythonhosted.org/packages/e0/0f/e0d00dc991e3d40e03ca36383b44995126c36b3eafa0ccbbd19664709c88/rpds_py-0.25.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8dbd586bfa270c1103ece2109314dd423df1fa3d9719928b5d09e4840cec0d72", size = 384776 }, + { url = "https://files.pythonhosted.org/packages/9f/a2/59374837f105f2ca79bde3c3cd1065b2f8c01678900924949f6392eab66d/rpds_py-0.25.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6d273f136e912aa101a9274c3145dcbddbe4bac560e77e6d5b3c9f6e0ed06d34", size = 395131 }, + { url = "https://files.pythonhosted.org/packages/9c/dc/48e8d84887627a0fe0bac53f0b4631e90976fd5d35fff8be66b8e4f3916b/rpds_py-0.25.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:666fa7b1bd0a3810a7f18f6d3a25ccd8866291fbbc3c9b912b917a6715874bb9", size = 520942 }, + { url = "https://files.pythonhosted.org/packages/7c/f5/ee056966aeae401913d37befeeab57a4a43a4f00099e0a20297f17b8f00c/rpds_py-0.25.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:921954d7fbf3fccc7de8f717799304b14b6d9a45bbeec5a8d7408ccbf531faf5", size = 411330 }, + { url = "https://files.pythonhosted.org/packages/ab/74/b2cffb46a097cefe5d17f94ede7a174184b9d158a0aeb195f39f2c0361e8/rpds_py-0.25.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3d86373ff19ca0441ebeb696ef64cb58b8b5cbacffcda5a0ec2f3911732a194", size = 387339 }, + { url = "https://files.pythonhosted.org/packages/7f/9a/0ff0b375dcb5161c2b7054e7d0b7575f1680127505945f5cabaac890bc07/rpds_py-0.25.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c8980cde3bb8575e7c956a530f2c217c1d6aac453474bf3ea0f9c89868b531b6", size = 418077 }, + { url = "https://files.pythonhosted.org/packages/0d/a1/fda629bf20d6b698ae84c7c840cfb0e9e4200f664fc96e1f456f00e4ad6e/rpds_py-0.25.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8eb8c84ecea987a2523e057c0d950bcb3f789696c0499290b8d7b3107a719d78", size = 562441 }, + { url = "https://files.pythonhosted.org/packages/20/15/ce4b5257f654132f326f4acd87268e1006cc071e2c59794c5bdf4bebbb51/rpds_py-0.25.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:e43a005671a9ed5a650f3bc39e4dbccd6d4326b24fb5ea8be5f3a43a6f576c72", size = 590750 }, + { url = "https://files.pythonhosted.org/packages/fb/ab/e04bf58a8d375aeedb5268edcc835c6a660ebf79d4384d8e0889439448b0/rpds_py-0.25.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:58f77c60956501a4a627749a6dcb78dac522f249dd96b5c9f1c6af29bfacfb66", size = 558891 }, + { url = "https://files.pythonhosted.org/packages/90/82/cb8c6028a6ef6cd2b7991e2e4ced01c854b6236ecf51e81b64b569c43d73/rpds_py-0.25.1-cp313-cp313t-win32.whl", hash = "sha256:2cb9e5b5e26fc02c8a4345048cd9998c2aca7c2712bd1b36da0c72ee969a3523", size = 218718 }, + { url = "https://files.pythonhosted.org/packages/b6/97/5a4b59697111c89477d20ba8a44df9ca16b41e737fa569d5ae8bff99e650/rpds_py-0.25.1-cp313-cp313t-win_amd64.whl", hash = "sha256:401ca1c4a20cc0510d3435d89c069fe0a9ae2ee6495135ac46bdd49ec0495763", size = 232218 }, +] + +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696 }, +] + +[[package]] +name = "seaborn" +version = "0.13.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "matplotlib" }, + { name = "numpy" }, + { name = "pandas" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/59/a451d7420a77ab0b98f7affa3a1d78a313d2f7281a57afb1a34bae8ab412/seaborn-0.13.2.tar.gz", hash = "sha256:93e60a40988f4d65e9f4885df477e2fdaff6b73a9ded434c1ab356dd57eefff7", size = 1457696 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/11/00d3c3dfc25ad54e731d91449895a79e4bf2384dc3ac01809010ba88f6d5/seaborn-0.13.2-py3-none-any.whl", hash = "sha256:636f8336facf092165e27924f223d3c62ca560b1f2bb5dff7ab7fad265361987", size = 294914 }, +] + +[[package]] +name = "send2trash" +version = "1.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/3a/aec9b02217bb79b87bbc1a21bc6abc51e3d5dcf65c30487ac96c0908c722/Send2Trash-1.8.3.tar.gz", hash = "sha256:b18e7a3966d99871aefeb00cfbcfdced55ce4871194810fc71f4aa484b953abf", size = 17394 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/b0/4562db6223154aa4e22f939003cb92514c79f3d4dccca3444253fd17f902/Send2Trash-1.8.3-py3-none-any.whl", hash = "sha256:0c31227e0bd08961c7665474a3d1ef7193929fedda4233843689baa056be46c9", size = 18072 }, +] + +[[package]] +name = "sentinel" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "fastapi" }, + { name = "fpdf" }, + { name = "hydra-core" }, + { name = "langchain" }, + { name = "langchain-community" }, + { name = "langchain-google-genai" }, + { name = "langchain-ollama" }, + { name = "langchain-openai" }, + { name = "markdown2" }, + { name = "matplotlib" }, + { name = "openpyxl" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "reportlab" }, + { name = "streamlit" }, +] + +[package.optional-dependencies] +dev = [ + { name = "ipywidgets" }, + { name = "jupyterlab" }, + { name = "plotly" }, + { name = "pre-commit" }, + { name = "pyright" }, + { name = "seaborn" }, + { name = "uvicorn" }, +] +test = [ + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, +] + +[package.dev-dependencies] +dev = [ + { name = "sentinel", extra = ["dev"] }, +] +test = [ + { name = "sentinel", extra = ["test"] }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi" }, + { name = "fpdf", specifier = ">=1.7.2" }, + { name = "hydra-core", specifier = ">=1.3.2" }, + { name = "ipywidgets", marker = "extra == 'dev'" }, + { name = "jupyterlab", marker = "extra == 'dev'" }, + { name = "langchain" }, + { name = "langchain-community" }, + { name = "langchain-google-genai" }, + { name = "langchain-ollama" }, + { name = "langchain-openai" }, + { name = "markdown2", specifier = ">=2.5.3" }, + { name = "matplotlib", specifier = ">=3.10.3" }, + { name = "openpyxl", specifier = ">=3.1.0" }, + { name = "plotly", marker = "extra == 'dev'" }, + { name = "pre-commit", marker = "extra == 'dev'" }, + { name = "pyright", marker = "extra == 'dev'" }, + { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.405" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=7.0.0" }, + { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=4.0.0" }, + { name = "pytest-mock", marker = "extra == 'test'", specifier = ">=3.15.1" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "reportlab", specifier = ">=4.0.0" }, + { name = "seaborn", marker = "extra == 'dev'" }, + { name = "streamlit", specifier = ">=1.46.0" }, + { name = "uvicorn", marker = "extra == 'dev'" }, +] +provides-extras = ["dev", "test"] + +[package.metadata.requires-dev] +dev = [{ name = "sentinel", extras = ["dev"] }] +test = [{ name = "sentinel", extras = ["test"] }] + +[[package]] +name = "setuptools" +version = "80.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486 }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + +[[package]] +name = "smmap" +version = "5.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/cd/a040c4b3119bbe532e5b0732286f805445375489fceaec1f48306068ee3b/smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5", size = 22329 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303 }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, +] + +[[package]] +name = "soupsieve" +version = "2.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/f4/4a80cd6ef364b2e8b65b15816a843c0980f7a5a2b4dc701fc574952aa19f/soupsieve-2.7.tar.gz", hash = "sha256:ad282f9b6926286d2ead4750552c8a6142bc4c783fd66b0293547c8fe6ae126a", size = 103418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/9c/0e6afc12c269578be5c0c1c9f4b49a8d32770a080260c333ac04cc1c832d/soupsieve-2.7-py3-none-any.whl", hash = "sha256:6e60cc5c1ffaf1cebcc12e8188320b72071e922c2e897f737cadce79ad5d30c4", size = 36677 }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.41" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64') or (python_full_version < '3.14' and platform_machine == 'WIN32') or (python_full_version < '3.14' and platform_machine == 'aarch64') or (python_full_version < '3.14' and platform_machine == 'amd64') or (python_full_version < '3.14' and platform_machine == 'ppc64le') or (python_full_version < '3.14' and platform_machine == 'win32') or (python_full_version < '3.14' and platform_machine == 'x86_64')" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/66/45b165c595ec89aa7dcc2c1cd222ab269bc753f1fc7a1e68f8481bd957bf/sqlalchemy-2.0.41.tar.gz", hash = "sha256:edba70118c4be3c2b1f90754d308d0b79c6fe2c0fdc52d8ddf603916f83f4db9", size = 9689424 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/2a/f1f4e068b371154740dd10fb81afb5240d5af4aa0087b88d8b308b5429c2/sqlalchemy-2.0.41-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:81f413674d85cfd0dfcd6512e10e0f33c19c21860342a4890c3a2b59479929f9", size = 2119645 }, + { url = "https://files.pythonhosted.org/packages/9b/e8/c664a7e73d36fbfc4730f8cf2bf930444ea87270f2825efbe17bf808b998/sqlalchemy-2.0.41-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:598d9ebc1e796431bbd068e41e4de4dc34312b7aa3292571bb3674a0cb415dd1", size = 2107399 }, + { url = "https://files.pythonhosted.org/packages/5c/78/8a9cf6c5e7135540cb682128d091d6afa1b9e48bd049b0d691bf54114f70/sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a104c5694dfd2d864a6f91b0956eb5d5883234119cb40010115fd45a16da5e70", size = 3293269 }, + { url = "https://files.pythonhosted.org/packages/3c/35/f74add3978c20de6323fb11cb5162702670cc7a9420033befb43d8d5b7a4/sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6145afea51ff0af7f2564a05fa95eb46f542919e6523729663a5d285ecb3cf5e", size = 3303364 }, + { url = "https://files.pythonhosted.org/packages/6a/d4/c990f37f52c3f7748ebe98883e2a0f7d038108c2c5a82468d1ff3eec50b7/sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b46fa6eae1cd1c20e6e6f44e19984d438b6b2d8616d21d783d150df714f44078", size = 3229072 }, + { url = "https://files.pythonhosted.org/packages/15/69/cab11fecc7eb64bc561011be2bd03d065b762d87add52a4ca0aca2e12904/sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41836fe661cc98abfae476e14ba1906220f92c4e528771a8a3ae6a151242d2ae", size = 3268074 }, + { url = "https://files.pythonhosted.org/packages/5c/ca/0c19ec16858585d37767b167fc9602593f98998a68a798450558239fb04a/sqlalchemy-2.0.41-cp312-cp312-win32.whl", hash = "sha256:a8808d5cf866c781150d36a3c8eb3adccfa41a8105d031bf27e92c251e3969d6", size = 2084514 }, + { url = "https://files.pythonhosted.org/packages/7f/23/4c2833d78ff3010a4e17f984c734f52b531a8c9060a50429c9d4b0211be6/sqlalchemy-2.0.41-cp312-cp312-win_amd64.whl", hash = "sha256:5b14e97886199c1f52c14629c11d90c11fbb09e9334fa7bb5f6d068d9ced0ce0", size = 2111557 }, + { url = "https://files.pythonhosted.org/packages/d3/ad/2e1c6d4f235a97eeef52d0200d8ddda16f6c4dd70ae5ad88c46963440480/sqlalchemy-2.0.41-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4eeb195cdedaf17aab6b247894ff2734dcead6c08f748e617bfe05bd5a218443", size = 2115491 }, + { url = "https://files.pythonhosted.org/packages/cf/8d/be490e5db8400dacc89056f78a52d44b04fbf75e8439569d5b879623a53b/sqlalchemy-2.0.41-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d4ae769b9c1c7757e4ccce94b0641bc203bbdf43ba7a2413ab2523d8d047d8dc", size = 2102827 }, + { url = "https://files.pythonhosted.org/packages/a0/72/c97ad430f0b0e78efaf2791342e13ffeafcbb3c06242f01a3bb8fe44f65d/sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a62448526dd9ed3e3beedc93df9bb6b55a436ed1474db31a2af13b313a70a7e1", size = 3225224 }, + { url = "https://files.pythonhosted.org/packages/5e/51/5ba9ea3246ea068630acf35a6ba0d181e99f1af1afd17e159eac7e8bc2b8/sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc56c9788617b8964ad02e8fcfeed4001c1f8ba91a9e1f31483c0dffb207002a", size = 3230045 }, + { url = "https://files.pythonhosted.org/packages/78/2f/8c14443b2acea700c62f9b4a8bad9e49fc1b65cfb260edead71fd38e9f19/sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c153265408d18de4cc5ded1941dcd8315894572cddd3c58df5d5b5705b3fa28d", size = 3159357 }, + { url = "https://files.pythonhosted.org/packages/fc/b2/43eacbf6ccc5276d76cea18cb7c3d73e294d6fb21f9ff8b4eef9b42bbfd5/sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f67766965996e63bb46cfbf2ce5355fc32d9dd3b8ad7e536a920ff9ee422e23", size = 3197511 }, + { url = "https://files.pythonhosted.org/packages/fa/2e/677c17c5d6a004c3c45334ab1dbe7b7deb834430b282b8a0f75ae220c8eb/sqlalchemy-2.0.41-cp313-cp313-win32.whl", hash = "sha256:bfc9064f6658a3d1cadeaa0ba07570b83ce6801a1314985bf98ec9b95d74e15f", size = 2082420 }, + { url = "https://files.pythonhosted.org/packages/e9/61/e8c1b9b6307c57157d328dd8b8348ddc4c47ffdf1279365a13b2b98b8049/sqlalchemy-2.0.41-cp313-cp313-win_amd64.whl", hash = "sha256:82ca366a844eb551daff9d2e6e7a9e5e76d2612c8564f58db6c19a726869c1df", size = 2108329 }, + { url = "https://files.pythonhosted.org/packages/1c/fc/9ba22f01b5cdacc8f5ed0d22304718d2c758fce3fd49a5372b886a86f37c/sqlalchemy-2.0.41-py3-none-any.whl", hash = "sha256:57df5dc6fdb5ed1a88a1ed2195fd31927e705cad62dedd86b46972752a80f576", size = 1911224 }, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521 }, +] + +[[package]] +name = "starlette" +version = "0.46.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037 }, +] + +[[package]] +name = "streamlit" +version = "1.46.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altair" }, + { name = "blinker" }, + { name = "cachetools" }, + { name = "click" }, + { name = "gitpython" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "pillow" }, + { name = "protobuf" }, + { name = "pyarrow" }, + { name = "pydeck" }, + { name = "requests" }, + { name = "tenacity" }, + { name = "toml" }, + { name = "tornado" }, + { name = "typing-extensions" }, + { name = "watchdog", marker = "sys_platform != 'darwin'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/83/f2aac961479594d1d7ee42cf485e3674992769d506732005cea91e11504a/streamlit-1.46.0.tar.gz", hash = "sha256:0b2734b48f11f1e5c8046011b6b1a2274982dc657eef2ade8db70f0e1dc53dda", size = 9651454 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/26/79bbb77bec3d605f7de7a4b45c806b44d112e8c9bce77fb620e03d9f2b88/streamlit-1.46.0-py3-none-any.whl", hash = "sha256:f8624acabafcf18611a0fac2635cf181a7ba922b45bd131ae15fc8f80e1a5482", size = 10050930 }, +] + +[[package]] +name = "tenacity" +version = "9.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248 }, +] + +[[package]] +name = "terminado" +version = "0.18.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess", marker = "os_name != 'nt'" }, + { name = "pywinpty", marker = "os_name == 'nt'" }, + { name = "tornado" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/11/965c6fd8e5cc254f1fe142d547387da17a8ebfd75a3455f637c663fb38a0/terminado-0.18.1.tar.gz", hash = "sha256:de09f2c4b85de4765f7714688fff57d3e75bad1f909b589fde880460c753fd2e", size = 32701 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl", hash = "sha256:a4468e1b37bb318f8a86514f65814e1afc977cf29b3992a4500d9dd305dcceb0", size = 14154 }, +] + +[[package]] +name = "tiktoken" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "regex" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/cf/756fedf6981e82897f2d570dd25fa597eb3f4459068ae0572d7e888cfd6f/tiktoken-0.9.0.tar.gz", hash = "sha256:d02a5ca6a938e0490e1ff957bc48c8b078c88cb83977be1625b1fd8aac792c5d", size = 35991 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/e5/21ff33ecfa2101c1bb0f9b6df750553bd873b7fb532ce2cb276ff40b197f/tiktoken-0.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e88f121c1c22b726649ce67c089b90ddda8b9662545a8aeb03cfef15967ddd03", size = 1065073 }, + { url = "https://files.pythonhosted.org/packages/8e/03/a95e7b4863ee9ceec1c55983e4cc9558bcfd8f4f80e19c4f8a99642f697d/tiktoken-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a6600660f2f72369acb13a57fb3e212434ed38b045fd8cc6cdd74947b4b5d210", size = 1008075 }, + { url = "https://files.pythonhosted.org/packages/40/10/1305bb02a561595088235a513ec73e50b32e74364fef4de519da69bc8010/tiktoken-0.9.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95e811743b5dfa74f4b227927ed86cbc57cad4df859cb3b643be797914e41794", size = 1140754 }, + { url = "https://files.pythonhosted.org/packages/1b/40/da42522018ca496432ffd02793c3a72a739ac04c3794a4914570c9bb2925/tiktoken-0.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99376e1370d59bcf6935c933cb9ba64adc29033b7e73f5f7569f3aad86552b22", size = 1196678 }, + { url = "https://files.pythonhosted.org/packages/5c/41/1e59dddaae270ba20187ceb8aa52c75b24ffc09f547233991d5fd822838b/tiktoken-0.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:badb947c32739fb6ddde173e14885fb3de4d32ab9d8c591cbd013c22b4c31dd2", size = 1259283 }, + { url = "https://files.pythonhosted.org/packages/5b/64/b16003419a1d7728d0d8c0d56a4c24325e7b10a21a9dd1fc0f7115c02f0a/tiktoken-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:5a62d7a25225bafed786a524c1b9f0910a1128f4232615bf3f8257a73aaa3b16", size = 894897 }, + { url = "https://files.pythonhosted.org/packages/7a/11/09d936d37f49f4f494ffe660af44acd2d99eb2429d60a57c71318af214e0/tiktoken-0.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b0e8e05a26eda1249e824156d537015480af7ae222ccb798e5234ae0285dbdb", size = 1064919 }, + { url = "https://files.pythonhosted.org/packages/80/0e/f38ba35713edb8d4197ae602e80837d574244ced7fb1b6070b31c29816e0/tiktoken-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:27d457f096f87685195eea0165a1807fae87b97b2161fe8c9b1df5bd74ca6f63", size = 1007877 }, + { url = "https://files.pythonhosted.org/packages/fe/82/9197f77421e2a01373e27a79dd36efdd99e6b4115746ecc553318ecafbf0/tiktoken-0.9.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cf8ded49cddf825390e36dd1ad35cd49589e8161fdcb52aa25f0583e90a3e01", size = 1140095 }, + { url = "https://files.pythonhosted.org/packages/f2/bb/4513da71cac187383541facd0291c4572b03ec23c561de5811781bbd988f/tiktoken-0.9.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc156cb314119a8bb9748257a2eaebd5cc0753b6cb491d26694ed42fc7cb3139", size = 1195649 }, + { url = "https://files.pythonhosted.org/packages/fa/5c/74e4c137530dd8504e97e3a41729b1103a4ac29036cbfd3250b11fd29451/tiktoken-0.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cd69372e8c9dd761f0ab873112aba55a0e3e506332dd9f7522ca466e817b1b7a", size = 1258465 }, + { url = "https://files.pythonhosted.org/packages/de/a8/8f499c179ec900783ffe133e9aab10044481679bb9aad78436d239eee716/tiktoken-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:5ea0edb6f83dc56d794723286215918c1cde03712cbbafa0348b33448faf5b95", size = 894669 }, +] + +[[package]] +name = "tinycss2" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/fd/7a5ee21fd08ff70d3d33a5781c255cbe779659bd03278feb98b19ee550f4/tinycss2-1.4.0.tar.gz", hash = "sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7", size = 87085 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/34/ebdc18bae6aa14fbee1a08b63c015c72b64868ff7dae68808ab500c492e2/tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289", size = 26610 }, +] + +[[package]] +name = "toml" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588 }, +] + +[[package]] +name = "tornado" +version = "6.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/89/c72771c81d25d53fe33e3dca61c233b665b2780f21820ba6fd2c6793c12b/tornado-6.5.1.tar.gz", hash = "sha256:84ceece391e8eb9b2b95578db65e920d2a61070260594819589609ba9bc6308c", size = 509934 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/89/f4532dee6843c9e0ebc4e28d4be04c67f54f60813e4bf73d595fe7567452/tornado-6.5.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d50065ba7fd11d3bd41bcad0825227cc9a95154bad83239357094c36708001f7", size = 441948 }, + { url = "https://files.pythonhosted.org/packages/15/9a/557406b62cffa395d18772e0cdcf03bed2fff03b374677348eef9f6a3792/tornado-6.5.1-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9e9ca370f717997cb85606d074b0e5b247282cf5e2e1611568b8821afe0342d6", size = 440112 }, + { url = "https://files.pythonhosted.org/packages/55/82/7721b7319013a3cf881f4dffa4f60ceff07b31b394e459984e7a36dc99ec/tornado-6.5.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b77e9dfa7ed69754a54c89d82ef746398be82f749df69c4d3abe75c4d1ff4888", size = 443672 }, + { url = "https://files.pythonhosted.org/packages/7d/42/d11c4376e7d101171b94e03cef0cbce43e823ed6567ceda571f54cf6e3ce/tornado-6.5.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:253b76040ee3bab8bcf7ba9feb136436a3787208717a1fb9f2c16b744fba7331", size = 443019 }, + { url = "https://files.pythonhosted.org/packages/7d/f7/0c48ba992d875521ac761e6e04b0a1750f8150ae42ea26df1852d6a98942/tornado-6.5.1-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:308473f4cc5a76227157cdf904de33ac268af770b2c5f05ca6c1161d82fdd95e", size = 443252 }, + { url = "https://files.pythonhosted.org/packages/89/46/d8d7413d11987e316df4ad42e16023cd62666a3c0dfa1518ffa30b8df06c/tornado-6.5.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:caec6314ce8a81cf69bd89909f4b633b9f523834dc1a352021775d45e51d9401", size = 443930 }, + { url = "https://files.pythonhosted.org/packages/78/b2/f8049221c96a06df89bed68260e8ca94beca5ea532ffc63b1175ad31f9cc/tornado-6.5.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:13ce6e3396c24e2808774741331638ee6c2f50b114b97a55c5b442df65fd9692", size = 443351 }, + { url = "https://files.pythonhosted.org/packages/76/ff/6a0079e65b326cc222a54720a748e04a4db246870c4da54ece4577bfa702/tornado-6.5.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5cae6145f4cdf5ab24744526cc0f55a17d76f02c98f4cff9daa08ae9a217448a", size = 443328 }, + { url = "https://files.pythonhosted.org/packages/49/18/e3f902a1d21f14035b5bc6246a8c0f51e0eef562ace3a2cea403c1fb7021/tornado-6.5.1-cp39-abi3-win32.whl", hash = "sha256:e0a36e1bc684dca10b1aa75a31df8bdfed656831489bc1e6a6ebed05dc1ec365", size = 444396 }, + { url = "https://files.pythonhosted.org/packages/7b/09/6526e32bf1049ee7de3bebba81572673b19a2a8541f795d887e92af1a8bc/tornado-6.5.1-cp39-abi3-win_amd64.whl", hash = "sha256:908e7d64567cecd4c2b458075589a775063453aeb1d2a1853eedb806922f568b", size = 444840 }, + { url = "https://files.pythonhosted.org/packages/55/a7/535c44c7bea4578e48281d83c615219f3ab19e6abc67625ef637c73987be/tornado-6.5.1-cp39-abi3-win_arm64.whl", hash = "sha256:02420a0eb7bf617257b9935e2b754d1b63897525d8a289c9d65690d580b4dcf7", size = 443596 }, +] + +[[package]] +name = "tqdm" +version = "4.67.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540 }, +] + +[[package]] +name = "traitlets" +version = "5.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359 }, +] + +[[package]] +name = "types-python-dateutil" +version = "2.9.0.20250516" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/88/d65ed807393285204ab6e2801e5d11fbbea811adcaa979a2ed3b67a5ef41/types_python_dateutil-2.9.0.20250516.tar.gz", hash = "sha256:13e80d6c9c47df23ad773d54b2826bd52dbbb41be87c3f339381c1700ad21ee5", size = 13943 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/3f/b0e8db149896005adc938a1e7f371d6d7e9eca4053a29b108978ed15e0c2/types_python_dateutil-2.9.0.20250516-py3-none-any.whl", hash = "sha256:2b2b3f57f9c6a61fba26a9c0ffb9ea5681c9b83e69cd897c6b5f668d9c0cab93", size = 14356 }, +] + +[[package]] +name = "typing-extensions" +version = "4.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d05cb083ccc402f93e441606ff1f01739a62c8ad09ba5/typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", size = 107423 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839 }, +] + +[[package]] +name = "typing-inspect" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/74/1789779d91f1961fa9438e9a8710cdae6bd138c80d7303996933d117264a/typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78", size = 13825 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f", size = 8827 }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552 }, +] + +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839 }, +] + +[[package]] +name = "uri-template" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/c7/0336f2bd0bcbada6ccef7aaa25e443c118a704f828a0620c6fa0207c1b64/uri-template-1.3.0.tar.gz", hash = "sha256:0e00f8eb65e18c7de20d595a14336e9f337ead580c70934141624b6d1ffdacc7", size = 21678 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/00/3fca040d7cf8a32776d3d81a00c8ee7457e00f80c649f1e4a863c8321ae9/uri_template-1.3.0-py3-none-any.whl", hash = "sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363", size = 11140 }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795 }, +] + +[[package]] +name = "uvicorn" +version = "0.34.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/ad/713be230bcda622eaa35c28f0d328c3675c371238470abdea52417f17a8e/uvicorn-0.34.3.tar.gz", hash = "sha256:35919a9a979d7a59334b6b10e05d77c1d0d574c50e0fc98b8b1a0f165708b55a", size = 76631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/0d/8adfeaa62945f90d19ddc461c55f4a50c258af7662d34b6a3d5d1f8646f6/uvicorn-0.34.3-py3-none-any.whl", hash = "sha256:16246631db62bdfbf069b0645177d6e8a77ba950cfedbfd093acef9444e4d885", size = 62431 }, +] + +[[package]] +name = "virtualenv" +version = "20.31.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/2c/444f465fb2c65f40c3a104fd0c495184c4f2336d65baf398e3c75d72ea94/virtualenv-20.31.2.tar.gz", hash = "sha256:e10c0a9d02835e592521be48b332b6caee6887f332c111aa79a09b9e79efc2af", size = 6076316 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/40/b1c265d4b2b62b58576588510fc4d1fe60a86319c8de99fd8e9fec617d2c/virtualenv-20.31.2-py3-none-any.whl", hash = "sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11", size = 6057982 }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079 }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078 }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076 }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077 }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078 }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077 }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078 }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065 }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070 }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067 }, +] + +[[package]] +name = "wcwidth" +version = "0.2.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166 }, +] + +[[package]] +name = "webcolors" +version = "24.11.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/29/061ec845fb58521848f3739e466efd8250b4b7b98c1b6c5bf4d40b419b7e/webcolors-24.11.1.tar.gz", hash = "sha256:ecb3d768f32202af770477b8b65f318fa4f566c22948673a977b00d589dd80f6", size = 45064 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/e8/c0e05e4684d13459f93d312077a9a2efbe04d59c393bc2b8802248c908d4/webcolors-24.11.1-py3-none-any.whl", hash = "sha256:515291393b4cdf0eb19c155749a096f779f7d909f7cceea072791cb9095b92e9", size = 14934 }, +] + +[[package]] +name = "webencodings" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774 }, +] + +[[package]] +name = "websocket-client" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/30/fba0d96b4b5fbf5948ed3f4681f7da2f9f64512e1d303f94b4cc174c24a5/websocket_client-1.8.0.tar.gz", hash = "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da", size = 54648 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/84/44687a29792a70e111c5c477230a72c4b957d88d16141199bf9acb7537a3/websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526", size = 58826 }, +] + +[[package]] +name = "widgetsnbextension" +version = "4.0.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/53/2e0253c5efd69c9656b1843892052a31c36d37ad42812b5da45c62191f7e/widgetsnbextension-4.0.14.tar.gz", hash = "sha256:a3629b04e3edb893212df862038c7232f62973373869db5084aed739b437b5af", size = 1097428 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/51/5447876806d1088a0f8f71e16542bf350918128d0a69437df26047c8e46f/widgetsnbextension-4.0.14-py3-none-any.whl", hash = "sha256:4875a9eaf72fbf5079dc372a51a9f268fc38d46f767cbf85c43a36da5cb9b575", size = 2196503 }, +] + +[[package]] +name = "yarl" +version = "1.20.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/fb/efaa23fa4e45537b827620f04cf8f3cd658b76642205162e072703a5b963/yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac", size = 186428 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/9a/cb7fad7d73c69f296eda6815e4a2c7ed53fc70c2f136479a91c8e5fbdb6d/yarl-1.20.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdcc4cd244e58593a4379fe60fdee5ac0331f8eb70320a24d591a3be197b94a9", size = 133667 }, + { url = "https://files.pythonhosted.org/packages/67/38/688577a1cb1e656e3971fb66a3492501c5a5df56d99722e57c98249e5b8a/yarl-1.20.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b29a2c385a5f5b9c7d9347e5812b6f7ab267193c62d282a540b4fc528c8a9d2a", size = 91025 }, + { url = "https://files.pythonhosted.org/packages/50/ec/72991ae51febeb11a42813fc259f0d4c8e0507f2b74b5514618d8b640365/yarl-1.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1112ae8154186dfe2de4732197f59c05a83dc814849a5ced892b708033f40dc2", size = 89709 }, + { url = "https://files.pythonhosted.org/packages/99/da/4d798025490e89426e9f976702e5f9482005c548c579bdae792a4c37769e/yarl-1.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90bbd29c4fe234233f7fa2b9b121fb63c321830e5d05b45153a2ca68f7d310ee", size = 352287 }, + { url = "https://files.pythonhosted.org/packages/1a/26/54a15c6a567aac1c61b18aa0f4b8aa2e285a52d547d1be8bf48abe2b3991/yarl-1.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:680e19c7ce3710ac4cd964e90dad99bf9b5029372ba0c7cbfcd55e54d90ea819", size = 345429 }, + { url = "https://files.pythonhosted.org/packages/d6/95/9dcf2386cb875b234353b93ec43e40219e14900e046bf6ac118f94b1e353/yarl-1.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a979218c1fdb4246a05efc2cc23859d47c89af463a90b99b7c56094daf25a16", size = 365429 }, + { url = "https://files.pythonhosted.org/packages/91/b2/33a8750f6a4bc224242a635f5f2cff6d6ad5ba651f6edcccf721992c21a0/yarl-1.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255b468adf57b4a7b65d8aad5b5138dce6a0752c139965711bdcb81bc370e1b6", size = 363862 }, + { url = "https://files.pythonhosted.org/packages/98/28/3ab7acc5b51f4434b181b0cee8f1f4b77a65919700a355fb3617f9488874/yarl-1.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a97d67108e79cfe22e2b430d80d7571ae57d19f17cda8bb967057ca8a7bf5bfd", size = 355616 }, + { url = "https://files.pythonhosted.org/packages/36/a3/f666894aa947a371724ec7cd2e5daa78ee8a777b21509b4252dd7bd15e29/yarl-1.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8570d998db4ddbfb9a590b185a0a33dbf8aafb831d07a5257b4ec9948df9cb0a", size = 339954 }, + { url = "https://files.pythonhosted.org/packages/f1/81/5f466427e09773c04219d3450d7a1256138a010b6c9f0af2d48565e9ad13/yarl-1.20.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:97c75596019baae7c71ccf1d8cc4738bc08134060d0adfcbe5642f778d1dca38", size = 365575 }, + { url = "https://files.pythonhosted.org/packages/2e/e3/e4b0ad8403e97e6c9972dd587388940a032f030ebec196ab81a3b8e94d31/yarl-1.20.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1c48912653e63aef91ff988c5432832692ac5a1d8f0fb8a33091520b5bbe19ef", size = 365061 }, + { url = "https://files.pythonhosted.org/packages/ac/99/b8a142e79eb86c926f9f06452eb13ecb1bb5713bd01dc0038faf5452e544/yarl-1.20.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4c3ae28f3ae1563c50f3d37f064ddb1511ecc1d5584e88c6b7c63cf7702a6d5f", size = 364142 }, + { url = "https://files.pythonhosted.org/packages/34/f2/08ed34a4a506d82a1a3e5bab99ccd930a040f9b6449e9fd050320e45845c/yarl-1.20.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e9642f27036283550f5f57dc6156c51084b458570b9d0d96100c8bebb186a8", size = 381894 }, + { url = "https://files.pythonhosted.org/packages/92/f8/9a3fbf0968eac704f681726eff595dce9b49c8a25cd92bf83df209668285/yarl-1.20.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2c26b0c49220d5799f7b22c6838409ee9bc58ee5c95361a4d7831f03cc225b5a", size = 383378 }, + { url = "https://files.pythonhosted.org/packages/af/85/9363f77bdfa1e4d690957cd39d192c4cacd1c58965df0470a4905253b54f/yarl-1.20.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564ab3d517e3d01c408c67f2e5247aad4019dcf1969982aba3974b4093279004", size = 374069 }, + { url = "https://files.pythonhosted.org/packages/35/99/9918c8739ba271dcd935400cff8b32e3cd319eaf02fcd023d5dcd487a7c8/yarl-1.20.1-cp312-cp312-win32.whl", hash = "sha256:daea0d313868da1cf2fac6b2d3a25c6e3a9e879483244be38c8e6a41f1d876a5", size = 81249 }, + { url = "https://files.pythonhosted.org/packages/eb/83/5d9092950565481b413b31a23e75dd3418ff0a277d6e0abf3729d4d1ce25/yarl-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:48ea7d7f9be0487339828a4de0360d7ce0efc06524a48e1810f945c45b813698", size = 86710 }, + { url = "https://files.pythonhosted.org/packages/8a/e1/2411b6d7f769a07687acee88a062af5833cf1966b7266f3d8dfb3d3dc7d3/yarl-1.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0b5ff0fbb7c9f1b1b5ab53330acbfc5247893069e7716840c8e7d5bb7355038a", size = 131811 }, + { url = "https://files.pythonhosted.org/packages/b2/27/584394e1cb76fb771371770eccad35de400e7b434ce3142c2dd27392c968/yarl-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:14f326acd845c2b2e2eb38fb1346c94f7f3b01a4f5c788f8144f9b630bfff9a3", size = 90078 }, + { url = "https://files.pythonhosted.org/packages/bf/9a/3246ae92d4049099f52d9b0fe3486e3b500e29b7ea872d0f152966fc209d/yarl-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f60e4ad5db23f0b96e49c018596707c3ae89f5d0bd97f0ad3684bcbad899f1e7", size = 88748 }, + { url = "https://files.pythonhosted.org/packages/a3/25/35afe384e31115a1a801fbcf84012d7a066d89035befae7c5d4284df1e03/yarl-1.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49bdd1b8e00ce57e68ba51916e4bb04461746e794e7c4d4bbc42ba2f18297691", size = 349595 }, + { url = "https://files.pythonhosted.org/packages/28/2d/8aca6cb2cabc8f12efcb82749b9cefecbccfc7b0384e56cd71058ccee433/yarl-1.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:66252d780b45189975abfed839616e8fd2dbacbdc262105ad7742c6ae58f3e31", size = 342616 }, + { url = "https://files.pythonhosted.org/packages/0b/e9/1312633d16b31acf0098d30440ca855e3492d66623dafb8e25b03d00c3da/yarl-1.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59174e7332f5d153d8f7452a102b103e2e74035ad085f404df2e40e663a22b28", size = 361324 }, + { url = "https://files.pythonhosted.org/packages/bc/a0/688cc99463f12f7669eec7c8acc71ef56a1521b99eab7cd3abb75af887b0/yarl-1.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3968ec7d92a0c0f9ac34d5ecfd03869ec0cab0697c91a45db3fbbd95fe1b653", size = 359676 }, + { url = "https://files.pythonhosted.org/packages/af/44/46407d7f7a56e9a85a4c207724c9f2c545c060380718eea9088f222ba697/yarl-1.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1a4fbb50e14396ba3d375f68bfe02215d8e7bc3ec49da8341fe3157f59d2ff5", size = 352614 }, + { url = "https://files.pythonhosted.org/packages/b1/91/31163295e82b8d5485d31d9cf7754d973d41915cadce070491778d9c9825/yarl-1.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11a62c839c3a8eac2410e951301309426f368388ff2f33799052787035793b02", size = 336766 }, + { url = "https://files.pythonhosted.org/packages/b4/8e/c41a5bc482121f51c083c4c2bcd16b9e01e1cf8729e380273a952513a21f/yarl-1.20.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:041eaa14f73ff5a8986b4388ac6bb43a77f2ea09bf1913df7a35d4646db69e53", size = 364615 }, + { url = "https://files.pythonhosted.org/packages/e3/5b/61a3b054238d33d70ea06ebba7e58597891b71c699e247df35cc984ab393/yarl-1.20.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:377fae2fef158e8fd9d60b4c8751387b8d1fb121d3d0b8e9b0be07d1b41e83dc", size = 360982 }, + { url = "https://files.pythonhosted.org/packages/df/a3/6a72fb83f8d478cb201d14927bc8040af901811a88e0ff2da7842dd0ed19/yarl-1.20.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1c92f4390e407513f619d49319023664643d3339bd5e5a56a3bebe01bc67ec04", size = 369792 }, + { url = "https://files.pythonhosted.org/packages/7c/af/4cc3c36dfc7c077f8dedb561eb21f69e1e9f2456b91b593882b0b18c19dc/yarl-1.20.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d25ddcf954df1754ab0f86bb696af765c5bfaba39b74095f27eececa049ef9a4", size = 382049 }, + { url = "https://files.pythonhosted.org/packages/19/3a/e54e2c4752160115183a66dc9ee75a153f81f3ab2ba4bf79c3c53b33de34/yarl-1.20.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:909313577e9619dcff8c31a0ea2aa0a2a828341d92673015456b3ae492e7317b", size = 384774 }, + { url = "https://files.pythonhosted.org/packages/9c/20/200ae86dabfca89060ec6447649f219b4cbd94531e425e50d57e5f5ac330/yarl-1.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:793fd0580cb9664548c6b83c63b43c477212c0260891ddf86809e1c06c8b08f1", size = 374252 }, + { url = "https://files.pythonhosted.org/packages/83/75/11ee332f2f516b3d094e89448da73d557687f7d137d5a0f48c40ff211487/yarl-1.20.1-cp313-cp313-win32.whl", hash = "sha256:468f6e40285de5a5b3c44981ca3a319a4b208ccc07d526b20b12aeedcfa654b7", size = 81198 }, + { url = "https://files.pythonhosted.org/packages/ba/ba/39b1ecbf51620b40ab402b0fc817f0ff750f6d92712b44689c2c215be89d/yarl-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:495b4ef2fea40596bfc0affe3837411d6aa3371abcf31aac0ccc4bdd64d4ef5c", size = 86346 }, + { url = "https://files.pythonhosted.org/packages/43/c7/669c52519dca4c95153c8ad96dd123c79f354a376346b198f438e56ffeb4/yarl-1.20.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f60233b98423aab21d249a30eb27c389c14929f47be8430efa7dbd91493a729d", size = 138826 }, + { url = "https://files.pythonhosted.org/packages/6a/42/fc0053719b44f6ad04a75d7f05e0e9674d45ef62f2d9ad2c1163e5c05827/yarl-1.20.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6f3eff4cc3f03d650d8755c6eefc844edde99d641d0dcf4da3ab27141a5f8ddf", size = 93217 }, + { url = "https://files.pythonhosted.org/packages/4f/7f/fa59c4c27e2a076bba0d959386e26eba77eb52ea4a0aac48e3515c186b4c/yarl-1.20.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:69ff8439d8ba832d6bed88af2c2b3445977eba9a4588b787b32945871c2444e3", size = 92700 }, + { url = "https://files.pythonhosted.org/packages/2f/d4/062b2f48e7c93481e88eff97a6312dca15ea200e959f23e96d8ab898c5b8/yarl-1.20.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cf34efa60eb81dd2645a2e13e00bb98b76c35ab5061a3989c7a70f78c85006d", size = 347644 }, + { url = "https://files.pythonhosted.org/packages/89/47/78b7f40d13c8f62b499cc702fdf69e090455518ae544c00a3bf4afc9fc77/yarl-1.20.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8e0fe9364ad0fddab2688ce72cb7a8e61ea42eff3c7caeeb83874a5d479c896c", size = 323452 }, + { url = "https://files.pythonhosted.org/packages/eb/2b/490d3b2dc66f52987d4ee0d3090a147ea67732ce6b4d61e362c1846d0d32/yarl-1.20.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f64fbf81878ba914562c672024089e3401974a39767747691c65080a67b18c1", size = 346378 }, + { url = "https://files.pythonhosted.org/packages/66/ad/775da9c8a94ce925d1537f939a4f17d782efef1f973039d821cbe4bcc211/yarl-1.20.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6342d643bf9a1de97e512e45e4b9560a043347e779a173250824f8b254bd5ce", size = 353261 }, + { url = "https://files.pythonhosted.org/packages/4b/23/0ed0922b47a4f5c6eb9065d5ff1e459747226ddce5c6a4c111e728c9f701/yarl-1.20.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56dac5f452ed25eef0f6e3c6a066c6ab68971d96a9fb441791cad0efba6140d3", size = 335987 }, + { url = "https://files.pythonhosted.org/packages/3e/49/bc728a7fe7d0e9336e2b78f0958a2d6b288ba89f25a1762407a222bf53c3/yarl-1.20.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7d7f497126d65e2cad8dc5f97d34c27b19199b6414a40cb36b52f41b79014be", size = 329361 }, + { url = "https://files.pythonhosted.org/packages/93/8f/b811b9d1f617c83c907e7082a76e2b92b655400e61730cd61a1f67178393/yarl-1.20.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:67e708dfb8e78d8a19169818eeb5c7a80717562de9051bf2413aca8e3696bf16", size = 346460 }, + { url = "https://files.pythonhosted.org/packages/70/fd/af94f04f275f95da2c3b8b5e1d49e3e79f1ed8b6ceb0f1664cbd902773ff/yarl-1.20.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:595c07bc79af2494365cc96ddeb772f76272364ef7c80fb892ef9d0649586513", size = 334486 }, + { url = "https://files.pythonhosted.org/packages/84/65/04c62e82704e7dd0a9b3f61dbaa8447f8507655fd16c51da0637b39b2910/yarl-1.20.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7bdd2f80f4a7df852ab9ab49484a4dee8030023aa536df41f2d922fd57bf023f", size = 342219 }, + { url = "https://files.pythonhosted.org/packages/91/95/459ca62eb958381b342d94ab9a4b6aec1ddec1f7057c487e926f03c06d30/yarl-1.20.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c03bfebc4ae8d862f853a9757199677ab74ec25424d0ebd68a0027e9c639a390", size = 350693 }, + { url = "https://files.pythonhosted.org/packages/a6/00/d393e82dd955ad20617abc546a8f1aee40534d599ff555ea053d0ec9bf03/yarl-1.20.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:344d1103e9c1523f32a5ed704d576172d2cabed3122ea90b1d4e11fe17c66458", size = 355803 }, + { url = "https://files.pythonhosted.org/packages/9e/ed/c5fb04869b99b717985e244fd93029c7a8e8febdfcffa06093e32d7d44e7/yarl-1.20.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:88cab98aa4e13e1ade8c141daeedd300a4603b7132819c484841bb7af3edce9e", size = 341709 }, + { url = "https://files.pythonhosted.org/packages/24/fd/725b8e73ac2a50e78a4534ac43c6addf5c1c2d65380dd48a9169cc6739a9/yarl-1.20.1-cp313-cp313t-win32.whl", hash = "sha256:b121ff6a7cbd4abc28985b6028235491941b9fe8fe226e6fdc539c977ea1739d", size = 86591 }, + { url = "https://files.pythonhosted.org/packages/94/c3/b2e9f38bc3e11191981d57ea08cab2166e74ea770024a646617c9cddd9f6/yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f", size = 93003 }, + { url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542 }, +] + +[[package]] +name = "zstandard" +version = "0.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation == 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/f6/2ac0287b442160a89d726b17a9184a4c615bb5237db763791a7fd16d9df1/zstandard-0.23.0.tar.gz", hash = "sha256:b2d8c62d08e7255f68f7a740bae85b3c9b8e5466baa9cbf7f57f1cde0ac6bc09", size = 681701 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/83/f23338c963bd9de687d47bf32efe9fd30164e722ba27fb59df33e6b1719b/zstandard-0.23.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b4567955a6bc1b20e9c31612e615af6b53733491aeaa19a6b3b37f3b65477094", size = 788713 }, + { url = "https://files.pythonhosted.org/packages/5b/b3/1a028f6750fd9227ee0b937a278a434ab7f7fdc3066c3173f64366fe2466/zstandard-0.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e172f57cd78c20f13a3415cc8dfe24bf388614324d25539146594c16d78fcc8", size = 633459 }, + { url = "https://files.pythonhosted.org/packages/26/af/36d89aae0c1f95a0a98e50711bc5d92c144939efc1f81a2fcd3e78d7f4c1/zstandard-0.23.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0e166f698c5a3e914947388c162be2583e0c638a4703fc6a543e23a88dea3c1", size = 4945707 }, + { url = "https://files.pythonhosted.org/packages/cd/2e/2051f5c772f4dfc0aae3741d5fc72c3dcfe3aaeb461cc231668a4db1ce14/zstandard-0.23.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12a289832e520c6bd4dcaad68e944b86da3bad0d339ef7989fb7e88f92e96072", size = 5306545 }, + { url = "https://files.pythonhosted.org/packages/0a/9e/a11c97b087f89cab030fa71206963090d2fecd8eb83e67bb8f3ffb84c024/zstandard-0.23.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d50d31bfedd53a928fed6707b15a8dbeef011bb6366297cc435accc888b27c20", size = 5337533 }, + { url = "https://files.pythonhosted.org/packages/fc/79/edeb217c57fe1bf16d890aa91a1c2c96b28c07b46afed54a5dcf310c3f6f/zstandard-0.23.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72c68dda124a1a138340fb62fa21b9bf4848437d9ca60bd35db36f2d3345f373", size = 5436510 }, + { url = "https://files.pythonhosted.org/packages/81/4f/c21383d97cb7a422ddf1ae824b53ce4b51063d0eeb2afa757eb40804a8ef/zstandard-0.23.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53dd9d5e3d29f95acd5de6802e909ada8d8d8cfa37a3ac64836f3bc4bc5512db", size = 4859973 }, + { url = "https://files.pythonhosted.org/packages/ab/15/08d22e87753304405ccac8be2493a495f529edd81d39a0870621462276ef/zstandard-0.23.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:6a41c120c3dbc0d81a8e8adc73312d668cd34acd7725f036992b1b72d22c1772", size = 4936968 }, + { url = "https://files.pythonhosted.org/packages/eb/fa/f3670a597949fe7dcf38119a39f7da49a8a84a6f0b1a2e46b2f71a0ab83f/zstandard-0.23.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:40b33d93c6eddf02d2c19f5773196068d875c41ca25730e8288e9b672897c105", size = 5467179 }, + { url = "https://files.pythonhosted.org/packages/4e/a9/dad2ab22020211e380adc477a1dbf9f109b1f8d94c614944843e20dc2a99/zstandard-0.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9206649ec587e6b02bd124fb7799b86cddec350f6f6c14bc82a2b70183e708ba", size = 4848577 }, + { url = "https://files.pythonhosted.org/packages/08/03/dd28b4484b0770f1e23478413e01bee476ae8227bbc81561f9c329e12564/zstandard-0.23.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:76e79bc28a65f467e0409098fa2c4376931fd3207fbeb6b956c7c476d53746dd", size = 4693899 }, + { url = "https://files.pythonhosted.org/packages/2b/64/3da7497eb635d025841e958bcd66a86117ae320c3b14b0ae86e9e8627518/zstandard-0.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:66b689c107857eceabf2cf3d3fc699c3c0fe8ccd18df2219d978c0283e4c508a", size = 5199964 }, + { url = "https://files.pythonhosted.org/packages/43/a4/d82decbab158a0e8a6ebb7fc98bc4d903266bce85b6e9aaedea1d288338c/zstandard-0.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9c236e635582742fee16603042553d276cca506e824fa2e6489db04039521e90", size = 5655398 }, + { url = "https://files.pythonhosted.org/packages/f2/61/ac78a1263bc83a5cf29e7458b77a568eda5a8f81980691bbc6eb6a0d45cc/zstandard-0.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a8fffdbd9d1408006baaf02f1068d7dd1f016c6bcb7538682622c556e7b68e35", size = 5191313 }, + { url = "https://files.pythonhosted.org/packages/e7/54/967c478314e16af5baf849b6ee9d6ea724ae5b100eb506011f045d3d4e16/zstandard-0.23.0-cp312-cp312-win32.whl", hash = "sha256:dc1d33abb8a0d754ea4763bad944fd965d3d95b5baef6b121c0c9013eaf1907d", size = 430877 }, + { url = "https://files.pythonhosted.org/packages/75/37/872d74bd7739639c4553bf94c84af7d54d8211b626b352bc57f0fd8d1e3f/zstandard-0.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:64585e1dba664dc67c7cdabd56c1e5685233fbb1fc1966cfba2a340ec0dfff7b", size = 495595 }, + { url = "https://files.pythonhosted.org/packages/80/f1/8386f3f7c10261fe85fbc2c012fdb3d4db793b921c9abcc995d8da1b7a80/zstandard-0.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:576856e8594e6649aee06ddbfc738fec6a834f7c85bf7cadd1c53d4a58186ef9", size = 788975 }, + { url = "https://files.pythonhosted.org/packages/16/e8/cbf01077550b3e5dc86089035ff8f6fbbb312bc0983757c2d1117ebba242/zstandard-0.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:38302b78a850ff82656beaddeb0bb989a0322a8bbb1bf1ab10c17506681d772a", size = 633448 }, + { url = "https://files.pythonhosted.org/packages/06/27/4a1b4c267c29a464a161aeb2589aff212b4db653a1d96bffe3598f3f0d22/zstandard-0.23.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2240ddc86b74966c34554c49d00eaafa8200a18d3a5b6ffbf7da63b11d74ee2", size = 4945269 }, + { url = "https://files.pythonhosted.org/packages/7c/64/d99261cc57afd9ae65b707e38045ed8269fbdae73544fd2e4a4d50d0ed83/zstandard-0.23.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2ef230a8fd217a2015bc91b74f6b3b7d6522ba48be29ad4ea0ca3a3775bf7dd5", size = 5306228 }, + { url = "https://files.pythonhosted.org/packages/7a/cf/27b74c6f22541f0263016a0fd6369b1b7818941de639215c84e4e94b2a1c/zstandard-0.23.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:774d45b1fac1461f48698a9d4b5fa19a69d47ece02fa469825b442263f04021f", size = 5336891 }, + { url = "https://files.pythonhosted.org/packages/fa/18/89ac62eac46b69948bf35fcd90d37103f38722968e2981f752d69081ec4d/zstandard-0.23.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f77fa49079891a4aab203d0b1744acc85577ed16d767b52fc089d83faf8d8ed", size = 5436310 }, + { url = "https://files.pythonhosted.org/packages/a8/a8/5ca5328ee568a873f5118d5b5f70d1f36c6387716efe2e369010289a5738/zstandard-0.23.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ac184f87ff521f4840e6ea0b10c0ec90c6b1dcd0bad2f1e4a9a1b4fa177982ea", size = 4859912 }, + { url = "https://files.pythonhosted.org/packages/ea/ca/3781059c95fd0868658b1cf0440edd832b942f84ae60685d0cfdb808bca1/zstandard-0.23.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c363b53e257246a954ebc7c488304b5592b9c53fbe74d03bc1c64dda153fb847", size = 4936946 }, + { url = "https://files.pythonhosted.org/packages/ce/11/41a58986f809532742c2b832c53b74ba0e0a5dae7e8ab4642bf5876f35de/zstandard-0.23.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e7792606d606c8df5277c32ccb58f29b9b8603bf83b48639b7aedf6df4fe8171", size = 5466994 }, + { url = "https://files.pythonhosted.org/packages/83/e3/97d84fe95edd38d7053af05159465d298c8b20cebe9ccb3d26783faa9094/zstandard-0.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a0817825b900fcd43ac5d05b8b3079937073d2b1ff9cf89427590718b70dd840", size = 4848681 }, + { url = "https://files.pythonhosted.org/packages/6e/99/cb1e63e931de15c88af26085e3f2d9af9ce53ccafac73b6e48418fd5a6e6/zstandard-0.23.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9da6bc32faac9a293ddfdcb9108d4b20416219461e4ec64dfea8383cac186690", size = 4694239 }, + { url = "https://files.pythonhosted.org/packages/ab/50/b1e703016eebbc6501fc92f34db7b1c68e54e567ef39e6e59cf5fb6f2ec0/zstandard-0.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fd7699e8fd9969f455ef2926221e0233f81a2542921471382e77a9e2f2b57f4b", size = 5200149 }, + { url = "https://files.pythonhosted.org/packages/aa/e0/932388630aaba70197c78bdb10cce2c91fae01a7e553b76ce85471aec690/zstandard-0.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d477ed829077cd945b01fc3115edd132c47e6540ddcd96ca169facff28173057", size = 5655392 }, + { url = "https://files.pythonhosted.org/packages/02/90/2633473864f67a15526324b007a9f96c96f56d5f32ef2a56cc12f9548723/zstandard-0.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa6ce8b52c5987b3e34d5674b0ab529a4602b632ebab0a93b07bfb4dfc8f8a33", size = 5191299 }, + { url = "https://files.pythonhosted.org/packages/b0/4c/315ca5c32da7e2dc3455f3b2caee5c8c2246074a61aac6ec3378a97b7136/zstandard-0.23.0-cp313-cp313-win32.whl", hash = "sha256:a9b07268d0c3ca5c170a385a0ab9fb7fdd9f5fd866be004c4ea39e44edce47dd", size = 430862 }, + { url = "https://files.pythonhosted.org/packages/a2/bf/c6aaba098e2d04781e8f4f7c0ba3c7aa73d00e4c436bcc0cf059a66691d1/zstandard-0.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:f3513916e8c645d0610815c257cbfd3242adfd5c4cfa78be514e5a3ebb42a41b", size = 495578 }, +]