diff --git a/.github/workflows/ci-python-workflow-builder-evals.yml b/.github/workflows/ci-python-workflow-builder-evals.yml new file mode 100644 index 00000000000..f4c992613b1 --- /dev/null +++ b/.github/workflows/ci-python-workflow-builder-evals.yml @@ -0,0 +1,56 @@ +name: Python CI + +on: + pull_request: + paths: + - packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/** + - .github/workflows/ci-python-workflow-builder-evals.yml + push: + paths: + - packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/** + +jobs: + workflow-comparison: + name: Workflow Comparison Python + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python + steps: + - name: Check out project + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Install uv + uses: astral-sh/setup-uv@d9e0f98d3fc6adb07d1e3d37f3043649ddad06a1 # 6.5.0 + with: + enable-cache: true + + - name: Install just + uses: extractions/setup-just@e33e0265a09d6d736e2ee1e0eb685ef1de4669ff # v3.0.0 + + - name: Install Python + run: uv python install 3.11 + + - name: Install project dependencies + run: just sync-all + + - name: Format check + run: just format-check + + - name: Typecheck + run: just typecheck + + - name: Lint + run: just lint + + - name: Python unit tests + run: uv run pytest --cov=src --cov-report=xml --cov-report=term-missing + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/coverage.xml + flags: tests + name: workflow-comparison-python + fail_ci_if_error: false diff --git a/packages/@n8n/ai-workflow-builder.ee/eslint.config.mjs b/packages/@n8n/ai-workflow-builder.ee/eslint.config.mjs index af822b79639..b1b4b5ff4c7 100644 --- a/packages/@n8n/ai-workflow-builder.ee/eslint.config.mjs +++ b/packages/@n8n/ai-workflow-builder.ee/eslint.config.mjs @@ -2,7 +2,7 @@ import { defineConfig, globalIgnores } from 'eslint/config'; import { nodeConfig } from '@n8n/eslint-config/node'; export default defineConfig( - globalIgnores(['jest.config*.js']), + globalIgnores(['jest.config*.js', 'evaluations/programmatic/python/.venv/**']), nodeConfig, { rules: { diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/CONFIGURATION.md b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/CONFIGURATION.md new file mode 100644 index 00000000000..4f367075328 --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/CONFIGURATION.md @@ -0,0 +1,1017 @@ +# Configuration Format Documentation + +This document provides comprehensive documentation for the workflow comparison configuration format. + +## Table of Contents + +- [Overview](#overview) +- [Configuration File Format](#configuration-file-format) +- [Top-Level Fields](#top-level-fields) +- [Cost Configuration](#cost-configuration) +- [Similarity Groups](#similarity-groups) +- [Ignore Rules](#ignore-rules) +- [Parameter Comparison Rules](#parameter-comparison-rules) +- [Exemptions](#exemptions) +- [Connection Rules](#connection-rules) +- [Output Configuration](#output-configuration) +- [Examples](#examples) + +## Overview + +Configuration files can be written in either YAML or JSON format. YAML is recommended for readability and easier maintenance. The configuration controls: + +- **Cost weights** for different types of graph edits +- **Similarity groups** to treat similar node types as equivalent +- **Ignore rules** to exclude certain nodes or parameters +- **Parameter comparison** rules for flexible matching +- **Exemptions** for optional nodes +- **Output formatting** preferences + +## Configuration File Format + +### YAML Example + +```yaml +version: "1.0" +name: "my-config" +description: "My custom configuration" + +costs: + nodes: + insertion: 10.0 + deletion: 10.0 + # ... more costs + +similarity_groups: + triggers: + - "n8n-nodes-base.webhook" + - "n8n-nodes-base.manualTrigger" + +ignore: + node_types: + - "n8n-nodes-base.stickyNote" + +parameter_comparison: + numeric_tolerance: + - parameter: "options.temperature" + tolerance: 0.1 + +output: + max_edits: 15 +``` + +### JSON Example + +```json +{ + "version": "1.0", + "name": "my-config", + "description": "My custom configuration", + "costs": { + "nodes": { + "insertion": 10.0, + "deletion": 10.0 + } + }, + "similarity_groups": { + "triggers": [ + "n8n-nodes-base.webhook", + "n8n-nodes-base.manualTrigger" + ] + } +} +``` + +## Top-Level Fields + +### `version` (string, required) + +Configuration format version. Currently `"1.0"`. + +```yaml +version: "1.0" +``` + +### `name` (string, optional) + +A unique identifier for this configuration. + +```yaml +name: "my-custom-config" +``` + +### `description` (string, optional) + +Human-readable description of what this configuration does. + +```yaml +description: "Strict comparison for production workflows" +``` + +## Cost Configuration + +The `costs` section defines penalties for different graph edit operations. These costs directly impact the similarity score. + +### Structure + +```yaml +costs: + nodes: + insertion: + deletion: + substitution: + same_type: + similar_type: + different_type: + trigger_mismatch: + + edges: + insertion: + deletion: + substitution: + + parameters: + mismatch_weight: + nested_weight: +``` + +### Node Costs + +#### `costs.nodes.insertion` (float, default: 10.0) + +Cost penalty when a node exists in the ground truth but is missing from the generated workflow. + +**Use case**: Set higher for stricter matching (e.g., 15.0), lower for lenient matching (e.g., 5.0). + +```yaml +costs: + nodes: + insertion: 10.0 +``` + +#### `costs.nodes.deletion` (float, default: 10.0) + +Cost penalty when a node exists in the generated workflow but not in the ground truth. + +**Use case**: Set higher to penalize extra nodes more severely. + +```yaml +costs: + nodes: + deletion: 15.0 +``` + +#### `costs.nodes.substitution.same_type` (float, default: 1.0) + +Cost when two nodes have the same type but different parameters. + +**Use case**: +- Low values (0.5-1.0): Allow parameter variations +- High values (2.0-5.0): Require exact parameter matches + +```yaml +costs: + nodes: + substitution: + same_type: 1.0 +``` + +#### `costs.nodes.substitution.similar_type` (float, default: 5.0) + +Cost when two nodes are in the same similarity group (see [Similarity Groups](#similarity-groups)). + +**Example**: Replacing `lmChatOpenAi` with `lmChatAnthropic` (both are LLMs). + +```yaml +costs: + nodes: + substitution: + similar_type: 5.0 +``` + +#### `costs.nodes.substitution.different_type` (float, default: 15.0) + +Cost when replacing a node with a completely different type. + +**Example**: Replacing `httpRequest` with `webhook`. + +```yaml +costs: + nodes: + substitution: + different_type: 15.0 +``` + +#### `costs.nodes.substitution.trigger_mismatch` (float, default: 50.0) + +Special high-cost penalty for trigger node mismatches. Triggers are critical to workflow functionality. + +**Use case**: Keep this high (50.0-100.0) to ensure trigger correctness. + +```yaml +costs: + nodes: + substitution: + trigger_mismatch: 50.0 +``` + +### Edge Costs + +#### `costs.edges.insertion` (float, default: 5.0) + +Cost for a missing connection between nodes. + +```yaml +costs: + edges: + insertion: 5.0 +``` + +#### `costs.edges.deletion` (float, default: 5.0) + +Cost for an extra connection that shouldn't exist. + +```yaml +costs: + edges: + deletion: 5.0 +``` + +#### `costs.edges.substitution` (float, default: 3.0) + +Cost for changing the type or properties of a connection. + +```yaml +costs: + edges: + substitution: 3.0 +``` + +### Parameter Costs + +#### `costs.parameters.mismatch_weight` (float, default: 0.5) + +Weight multiplier for parameter mismatches within a node. + +**Formula**: `parameter_cost = base_cost * mismatch_weight * num_mismatches` + +```yaml +costs: + parameters: + mismatch_weight: 0.5 +``` + +#### `costs.parameters.nested_weight` (float, default: 0.3) + +Weight multiplier for nested/deep parameter differences. + +**Use case**: Set lower to be more forgiving about deep configuration differences. + +```yaml +costs: + parameters: + nested_weight: 0.3 +``` + +## Similarity Groups + +Similarity groups define sets of node types that should be considered "similar" rather than "different" when substituted. Nodes within the same group incur the `similar_type` cost instead of `different_type`. + +### Structure + +```yaml +similarity_groups: + : + - "" + - "" + - "" +``` + +### Example + +```yaml +similarity_groups: + triggers: + - "n8n-nodes-base.webhook" + - "n8n-nodes-base.manualTrigger" + - "n8n-nodes-base.scheduleTrigger" + + ai_llms: + - "@n8n/n8n-nodes-langchain.lmChatOpenAi" + - "@n8n/n8n-nodes-langchain.lmChatAnthropic" + - "@n8n/n8n-nodes-langchain.lmChatOllama" + - "@n8n/n8n-nodes-langchain.lmChatMistralCloud" + + http_requests: + - "n8n-nodes-base.httpRequest" + - "@n8n/n8n-nodes-langchain.toolHttpRequest" +``` + +### Common Similarity Groups + +#### AI Agents +```yaml +ai_agents: + - "n8n-nodes-langchain.agent" + - "@n8n/n8n-nodes-langchain.agent" + - "n8n-nodes-langchain.basicAgent" +``` + +#### AI Tools +```yaml +ai_tools: + - "@n8n/n8n-nodes-langchain.toolHttpRequest" + - "@n8n/n8n-nodes-langchain.toolCalculator" + - "@n8n/n8n-nodes-langchain.toolCode" + - "@n8n/n8n-nodes-langchain.toolWorkflow" +``` + +## Ignore Rules + +Ignore rules allow you to exclude certain nodes or parameters from comparison. This is useful for: +- UI-only elements that don't affect workflow execution +- Metadata fields like IDs and positions +- Parameters that vary legitimately across implementations + +### Structure + +```yaml +ignore: + node_types: [...] + nodes: [...] + global_parameters: [...] + node_type_parameters: {...} + parameter_paths: [...] +``` + +### `ignore.node_types` (list of strings) + +Completely ignore nodes of specific types. + +**Use case**: Ignore decorative nodes like sticky notes. + +```yaml +ignore: + node_types: + - "n8n-nodes-base.stickyNote" + - "n8n-nodes-base.comment" +``` + +### `ignore.nodes` (list of objects) + +Flexible rules for ignoring nodes based on name patterns or other criteria. + +**Structure**: +```yaml +ignore: + nodes: + - pattern: "" + reason: "Why this is ignored" + - name: "" + reason: "Why this is ignored" + - node_type: "" + reason: "Why this is ignored" +``` + +**Example**: +```yaml +ignore: + nodes: + - pattern: "^Temp.*" + reason: "Temporary debugging nodes" + - name: "Development Only" + reason: "Used only in development" +``` + +### `ignore.global_parameters` (list of strings) + +Parameter names to ignore across all node types. + +**Common use case**: Ignore UI-specific metadata. + +```yaml +ignore: + global_parameters: + - "position" + - "id" + - "notes" + - "notesInFlow" + - "color" + - "disabled" +``` + +### `ignore.node_type_parameters` (object) + +Parameters to ignore for specific node types. + +**Structure**: +```yaml +ignore: + node_type_parameters: + "": + - "" + - "" +``` + +**Example**: +```yaml +ignore: + node_type_parameters: + "@n8n/n8n-nodes-langchain.agent": + - "options.systemMessage" # Allow different prompts + - "options.maxIterations" # Allow iteration variance + + "n8n-nodes-base.httpRequest": + - "options.timeout" # Timeout can vary by environment +``` + +### `ignore.parameter_paths` (list of strings) + +Ignore parameters using path patterns. Supports wildcards: +- `*` - matches any single path segment +- `**` - matches any number of path segments + +**Example**: +```yaml +ignore: + parameter_paths: + - "options.*.timeout" # Ignore timeout in any option + - "**.temperature" # Ignore temperature at any nesting level + - "options.advanced.**" # Ignore all advanced options +``` + +## Parameter Comparison Rules + +Parameter comparison rules allow for flexible matching of specific parameters, such as numeric tolerance or semantic similarity. + +### Structure + +```yaml +parameter_comparison: + fuzzy_match: [...] + numeric_tolerance: [...] +``` + +### Fuzzy Match Rules + +For semantic or approximate text matching. + +**Structure**: +```yaml +parameter_comparison: + fuzzy_match: + - parameter: "" + type: "semantic" + threshold: + cost_if_below: + options: + : +``` + +**Example**: +```yaml +parameter_comparison: + fuzzy_match: + - parameter: "options.systemMessage" + type: "semantic" + threshold: 0.8 + cost_if_below: 3.0 + options: + model: "sentence-transformers" +``` + +### Numeric Tolerance Rules + +For numeric parameters that should be "close enough" rather than exact. + +**Structure**: +```yaml +parameter_comparison: + numeric_tolerance: + - parameter: "" + tolerance: + cost_if_exceeded: +``` + +**Example**: +```yaml +parameter_comparison: + numeric_tolerance: + - parameter: "options.temperature" + tolerance: 0.1 + cost_if_exceeded: 2.0 + + - parameter: "options.maxTokens" + tolerance: 100 + cost_if_exceeded: 1.0 + + - parameter: "options.topP" + tolerance: 0.05 + cost_if_exceeded: 1.5 +``` + +**How it works**: +- If `|value1 - value2| <= tolerance`, parameters are considered equal (no cost) +- If `|value1 - value2| > tolerance`, `cost_if_exceeded` is added to the edit cost + +### Wildcard Support + +Parameter paths support wildcards: + +```yaml +parameter_comparison: + numeric_tolerance: + - parameter: "options.*.temperature" + tolerance: 0.1 + cost_if_exceeded: 2.0 +``` + +This applies to `options.llm.temperature`, `options.model.temperature`, etc. + +## Exemptions + +Exemptions reduce penalties for certain nodes that are optional or conditionally required. + +### Structure + +```yaml +exemptions: + optional_in_generated: [...] + optional_in_ground_truth: [...] +``` + +### `exemptions.optional_in_generated` (list of objects) + +Nodes that can be missing from the generated workflow without full penalty. + +**Use case**: Ground truth has optional nodes that aren't critical. + +**Structure**: +```yaml +exemptions: + optional_in_generated: + - name_pattern: "" + penalty: + reason: "Why this is optional" + - node_type: "" + penalty: + when: + : +``` + +**Example**: +```yaml +exemptions: + optional_in_generated: + - node_type: "@n8n/n8n-nodes-langchain.memoryBufferWindow" + penalty: 2.0 + reason: "Memory is optional for simple workflows" + + - name_pattern: ".*Debug.*" + penalty: 1.0 + reason: "Debug nodes are optional in production" +``` + +### `exemptions.optional_in_ground_truth` (list of objects) + +Nodes that can exist in the generated workflow as extras without full penalty. + +**Use case**: Generated workflow includes helpful but non-essential nodes. + +**Example**: +```yaml +exemptions: + optional_in_ground_truth: + - node_type: "n8n-nodes-base.set" + penalty: 3.0 + reason: "Set nodes for data transformation are okay to add" + + - node_type: "@n8n/n8n-nodes-langchain.toolCalculator" + penalty: 2.0 + reason: "Extra tools are acceptable" +``` + +### Conditional Exemptions + +Use the `when` clause to apply exemptions conditionally: + +```yaml +exemptions: + optional_in_generated: + - node_type: "n8n-nodes-base.errorTrigger" + penalty: 1.0 + when: + disabled: true + reason: "Disabled error handlers are optional" +``` + +## Connection Rules + +Rules for handling workflow connections (edges). + +### Structure + +```yaml +connections: + ignore_connection_types: [...] + equivalent_types: [...] +``` + +### `connections.ignore_connection_types` (list of strings) + +Connection types to completely ignore during comparison. + +```yaml +connections: + ignore_connection_types: + - "main" # Ignore main data flow connections +``` + +### `connections.equivalent_types` (list of lists) + +Define groups of connection types that should be treated as equivalent. + +**Example**: +```yaml +connections: + equivalent_types: + - ["main", "ai"] + - ["error", "fallback"] +``` + +This means: +- `main` and `ai` connections are interchangeable +- `error` and `fallback` connections are interchangeable + +## Output Configuration + +Controls how results are formatted and presented. + +### Structure + +```yaml +output: + max_edits: + group_by: "" + include_explanations: + include_suggestions: +``` + +### `output.max_edits` (integer, default: 15) + +Maximum number of edit operations to return in the results. + +**Use case**: +- Set higher (e.g., 20-50) for detailed debugging +- Set lower (e.g., 5-10) for quick summaries + +```yaml +output: + max_edits: 15 +``` + +### `output.group_by` (string, default: "priority") + +How to group edit operations in the output. + +**Options**: +- `"priority"`: Group by priority (critical, major, minor) +- `"type"`: Group by edit type (node, edge, parameter) +- `"cost"`: Order by cost (highest first) + +```yaml +output: + group_by: "priority" +``` + +### `output.include_explanations` (boolean, default: true) + +Include detailed explanations for each edit operation. + +```yaml +output: + include_explanations: true +``` + +### `output.include_suggestions` (boolean, default: true) + +Include suggestions for how to fix issues. + +```yaml +output: + include_suggestions: true +``` + +## Examples + +### Example 1: Strict Production Configuration + +For production workflows where exact matching is critical: + +```yaml +version: "1.0" +name: "production-strict" +description: "Strict matching for production workflows" + +costs: + nodes: + insertion: 20.0 + deletion: 20.0 + substitution: + same_type: 0.5 + similar_type: 10.0 + different_type: 30.0 + trigger_mismatch: 100.0 + + edges: + insertion: 10.0 + deletion: 10.0 + substitution: 5.0 + + parameters: + mismatch_weight: 1.0 + nested_weight: 0.8 + +similarity_groups: + triggers: + - "n8n-nodes-base.webhook" + - "n8n-nodes-base.scheduleTrigger" + +ignore: + node_types: + - "n8n-nodes-base.stickyNote" + + global_parameters: + - "position" + - "id" + +parameter_comparison: + numeric_tolerance: + - parameter: "options.temperature" + tolerance: 0.05 + cost_if_exceeded: 5.0 + +output: + max_edits: 20 + group_by: "priority" + include_explanations: true + include_suggestions: true +``` + +### Example 2: Lenient Development Configuration + +For development workflows where flexibility is needed: + +```yaml +version: "1.0" +name: "development-lenient" +description: "Lenient matching for development and testing" + +costs: + nodes: + insertion: 5.0 + deletion: 5.0 + substitution: + same_type: 1.0 + similar_type: 3.0 + different_type: 8.0 + trigger_mismatch: 20.0 + + edges: + insertion: 2.0 + deletion: 2.0 + substitution: 1.0 + + parameters: + mismatch_weight: 0.3 + nested_weight: 0.1 + +similarity_groups: + ai_llms: + - "@n8n/n8n-nodes-langchain.lmChatOpenAi" + - "@n8n/n8n-nodes-langchain.lmChatAnthropic" + - "@n8n/n8n-nodes-langchain.lmChatOllama" + + ai_tools: + - "@n8n/n8n-nodes-langchain.toolHttpRequest" + - "@n8n/n8n-nodes-langchain.toolCalculator" + - "@n8n/n8n-nodes-langchain.toolCode" + +ignore: + node_types: + - "n8n-nodes-base.stickyNote" + + global_parameters: + - "position" + - "id" + - "notes" + - "notesInFlow" + - "color" + - "disabled" + + node_type_parameters: + "@n8n/n8n-nodes-langchain.agent": + - "options.systemMessage" + - "options.maxIterations" + +parameter_comparison: + numeric_tolerance: + - parameter: "options.temperature" + tolerance: 0.2 + cost_if_exceeded: 1.0 + + - parameter: "options.maxTokens" + tolerance: 500 + cost_if_exceeded: 0.5 + +exemptions: + optional_in_generated: + - node_type: "@n8n/n8n-nodes-langchain.memoryBufferWindow" + penalty: 1.0 + reason: "Memory is optional" + + optional_in_ground_truth: + - node_type: "n8n-nodes-base.set" + penalty: 2.0 + reason: "Data transformation nodes are okay to add" + +output: + max_edits: 10 + group_by: "priority" + include_explanations: true + include_suggestions: false +``` + +### Example 3: AI-Specific Configuration + +Optimized for AI workflow comparisons: + +```yaml +version: "1.0" +name: "ai-workflows" +description: "Specialized configuration for AI agent workflows" + +costs: + nodes: + insertion: 10.0 + deletion: 10.0 + substitution: + same_type: 1.0 + similar_type: 4.0 + different_type: 15.0 + trigger_mismatch: 50.0 + + edges: + insertion: 5.0 + deletion: 5.0 + substitution: 3.0 + + parameters: + mismatch_weight: 0.4 + nested_weight: 0.2 + +similarity_groups: + ai_agents: + - "n8n-nodes-langchain.agent" + - "@n8n/n8n-nodes-langchain.agent" + - "n8n-nodes-langchain.basicAgent" + + ai_llms: + - "@n8n/n8n-nodes-langchain.lmChatOpenAi" + - "@n8n/n8n-nodes-langchain.lmChatAnthropic" + - "@n8n/n8n-nodes-langchain.lmChatOllama" + - "@n8n/n8n-nodes-langchain.lmChatMistralCloud" + - "@n8n/n8n-nodes-langchain.lmChatAws" + + ai_tools: + - "@n8n/n8n-nodes-langchain.toolHttpRequest" + - "@n8n/n8n-nodes-langchain.toolCalculator" + - "@n8n/n8n-nodes-langchain.toolCode" + - "@n8n/n8n-nodes-langchain.toolWorkflow" + + memory_types: + - "@n8n/n8n-nodes-langchain.memoryBufferWindow" + - "@n8n/n8n-nodes-langchain.memoryConversation" + +ignore: + node_types: + - "n8n-nodes-base.stickyNote" + + global_parameters: + - "position" + - "id" + - "notes" + - "color" + + node_type_parameters: + "@n8n/n8n-nodes-langchain.agent": + - "options.systemMessage" # Prompts can legitimately vary + + "@n8n/n8n-nodes-langchain.lmChatOpenAi": + - "options.modelName" # Different models okay + + "@n8n/n8n-nodes-langchain.lmChatAnthropic": + - "options.modelName" + +parameter_comparison: + numeric_tolerance: + - parameter: "**.temperature" + tolerance: 0.15 + cost_if_exceeded: 2.0 + + - parameter: "**.maxTokens" + tolerance: 200 + cost_if_exceeded: 1.0 + + - parameter: "**.topP" + tolerance: 0.1 + cost_if_exceeded: 1.5 + +exemptions: + optional_in_generated: + - node_type: "@n8n/n8n-nodes-langchain.memoryBufferWindow" + penalty: 2.0 + reason: "Memory is optional for stateless workflows" + + - node_type: "@n8n/n8n-nodes-langchain.toolCalculator" + penalty: 3.0 + reason: "Calculator tool is optional" + + optional_in_ground_truth: + - node_type: "@n8n/n8n-nodes-langchain.toolCode" + penalty: 2.0 + reason: "Additional code tools are acceptable" + +output: + max_edits: 15 + group_by: "priority" + include_explanations: true + include_suggestions: true +``` + +## Loading Configuration + +### From Preset + +```bash +# Python CLI +uvx --from . python -m compare_workflows workflow1.json workflow2.json --preset standard + +# Python API +from config_loader import load_config +config = load_config("preset:standard") +``` + +### From File + +```bash +# Python CLI +uvx --from . python -m compare_workflows workflow1.json workflow2.json --config my-config.yaml + +# Python API +from config_loader import load_config +config = load_config("/path/to/my-config.yaml") +``` + +### Programmatically + +```python +from config_loader import WorkflowComparisonConfig + +# Create from dictionary +config_dict = { + "version": "1.0", + "name": "custom", + "costs": { + "nodes": { + "insertion": 12.0 + } + } +} + +config = WorkflowComparisonConfig._from_dict(config_dict) +``` + +## Best Practices + +1. **Start with a preset**: Begin with `standard`, `strict`, or `lenient` and customize from there. + +2. **Test iteratively**: Make small changes and test to understand the impact on similarity scores. + +3. **Use similarity groups**: Group related node types to avoid harsh penalties for equivalent substitutions. + +4. **Ignore UI elements**: Always ignore cosmetic parameters like `position`, `id`, `color`, etc. + +5. **Set appropriate tolerances**: Use numeric tolerances for parameters that shouldn't need exact matches (e.g., temperature, maxTokens). + +6. **Document your changes**: Use the `description` field and comments to explain why you made specific choices. + +7. **Version control**: Keep configuration files in version control alongside your workflows. + +8. **Environment-specific configs**: Create different configurations for development, testing, and production environments. + +## Further Reading + +- [README.md](README.md) - General usage and examples +- [src/config_loader.py](src/config_loader.py) - Implementation details +- [src/configs/presets/](src/configs/presets/) - Built-in preset configurations diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/README.md b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/README.md new file mode 100644 index 00000000000..6053cf84c25 --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/README.md @@ -0,0 +1,254 @@ +# n8n Workflow Comparison + +Graph-based workflow similarity comparison using NetworkX and graph edit distance. + +## Features + +- **Graph Edit Distance**: Uses NetworkX's graph edit distance algorithm for accurate structural comparison +- **Configurable Cost Functions**: Customize costs for different types of edits (node/edge insertion, deletion, substitution) +- **Special Case Handling**: Higher penalties for trigger mismatches, similar node types grouped together +- **Parameter Comparison**: Deep comparison of node parameters with configurable ignore rules +- **External Configuration**: YAML/JSON config files for easy customization without code changes ([see CONFIGURATION.md](CONFIGURATION.md)) +- **Built-in Presets**: Strict, standard, and lenient comparison modes +- **Detailed Output**: Returns similarity score and top edit operations needed + +## Installation + +This module uses `uv` for dependency management. No installation is needed - dependencies are automatically managed by `uvx`. + +### Prerequisites + +Install `uv`: + +```bash +# macOS/Linux +curl -LsSf https://astral.sh/uv/install.sh | sh + +# Windows +powershell -c "irm https://astral.sh/uv/install.ps1 | iex" +``` + +Install [just](https://github.com/casey/just) +```bash +# on macOS via homebrew +brew install just +# or gloabl install via NPM +npm install -g rust-just +# or cross platform via curl to DEST +curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to DEST + +``` + + +## Usage + +### CLI Usage + +```bash +# Using default (standard) configuration +uvx --from . python -m src.compare_workflows generated.json ground_truth.json + +# Using a preset +uvx --from . python -m src.compare_workflows generated.json ground_truth.json --preset strict + +# Using custom configuration +uvx --from . python -m src.compare_workflows generated.json ground_truth.json --config my-config.yaml + +# Output as human-readable summary +uvx --from . python -m src.compare_workflows generated.json ground_truth.json --output-format summary +``` + +### Python API Usage + +```python +from config_loader import load_config +from graph_builder import build_workflow_graph +from similarity import calculate_graph_edit_distance +import json + +# Load workflows +with open('generated.json') as f: + generated = json.load(f) +with open('ground_truth.json') as f: + ground_truth = json.load(f) + +# Load configuration +config = load_config('preset:standard') + +# Build graphs +g1 = build_workflow_graph(generated, config) +g2 = build_workflow_graph(ground_truth, config) + +# Calculate similarity +result = calculate_graph_edit_distance(g1, g2, config) + +print(f"Similarity: {result['similarity_score']:.2%}") +print(f"Edit cost: {result['edit_cost']:.1f}") +print(f"Top edits: {len(result['top_edits'])}") +``` + +## Configuration + +> **📖 For detailed configuration documentation, see [CONFIGURATION.md](CONFIGURATION.md)** + +### Built-in Presets + +- **strict**: High penalties, exact matching required +- **standard**: Balanced comparison (default) +- **lenient**: Low penalties, focus on structure over details + +### Quick Start + +Create a YAML or JSON file with your custom rules: + +```yaml +version: "1.0" +name: "my-custom-config" +description: "Custom configuration for my use case" + +costs: + nodes: + insertion: 10.0 + deletion: 10.0 + substitution: + same_type: 1.0 + similar_type: 5.0 + different_type: 15.0 + trigger_mismatch: 50.0 + + edges: + insertion: 5.0 + deletion: 5.0 + substitution: 3.0 + +similarity_groups: + triggers: + - "n8n-nodes-base.webhook" + - "n8n-nodes-base.manualTrigger" + +ignore: + node_types: + - "n8n-nodes-base.stickyNote" + global_parameters: + - "position" + - "id" + +parameter_comparison: + numeric_tolerance: + - parameter: "options.temperature" + tolerance: 0.1 + cost_if_exceeded: 2.0 +``` + +**For comprehensive documentation including:** +- Complete field reference +- Cost configuration strategies +- Advanced ignore rules and wildcards +- Parameter comparison rules +- Exemptions and conditional logic +- Real-world examples + +See **[CONFIGURATION.md](CONFIGURATION.md)** + +## Output Format + +### JSON Output + +```json +{ + "similarity_score": 0.78, + "similarity_percentage": "78.0%", + "edit_cost": 45.0, + "max_possible_cost": 205.0, + "top_edits": [ + { + "type": "node_substitute", + "description": "Replace 'Manual Trigger' with 'Webhook Trigger'", + "cost": 25.0, + "priority": "critical" + } + ], + "metadata": { + "generated_nodes": 5, + "ground_truth_nodes": 6 + } +} +``` + +### Summary Output + +``` +============================================================ +WORKFLOW COMPARISON SUMMARY +============================================================ + +Overall Similarity: 78.0% +Edit Cost: 45.0 / 205.0 + +Configuration: standard + Standard balanced comparison configuration + +Top 3 Required Edits: +------------------------------------------------------------ +1. 🔴 [CRITICAL] Cost: 25.0 + Replace 'Manual Trigger' with 'Webhook Trigger' + +2. 🟠 [MAJOR] Cost: 10.0 + Add missing 'HTTP Request' tool node + +3. 🟡 [MINOR] Cost: 5.0 + Remove connection from 'Agent' to 'Extra Node' + +============================================================ +✅ PASS - Workflows are sufficiently similar +============================================================ +``` + +## Testing + +Run the test suite: + +```bash +# Install dev dependencies +uv sync --dev + +# Run tests +uv run pytest + +# Run with coverage +uv run pytest --cov +``` + +## Algorithm Details + +### Graph Representation +- Each workflow node becomes a graph node with attributes (type, parameters, etc.) +- Node and edge get a generated ID based on their position in the workflow +- Each workflow connection becomes a directed edge with connection type +- Nodes and edges are filtered based on configuration rules + +### Graph Edit Distance +Uses NetworkX's `optimize_graph_edit_distance` with custom cost functions: +- Node operations: insertion, deletion, substitution +- Edge operations: insertion, deletion, substitution +- Cost functions consider node types, parameters, and configuration rules + +### Similarity Score +``` +similarity = 1 - (edit_cost / max_possible_cost) +``` + +Where `max_possible_cost` is the cost of deleting all nodes/edges from g1 and inserting all from g2. + +## Troubleshooting + +### Timeout errors +For very large or complex workflows, the comparison may timeout. Consider: +- Using a lenient preset to reduce computation +- Simplifying the workflow structure +- Increasing the timeout in the TypeScript wrapper + +### Configuration errors +- Ensure YAML/JSON syntax is valid +- Check that node types and parameter paths are correct +- Use `--verbose` flag to see detailed configuration info diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/example_workflows/generated.json b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/example_workflows/generated.json new file mode 100644 index 00000000000..d8051770a76 --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/example_workflows/generated.json @@ -0,0 +1,38 @@ +{ + "name": "Simple Test Workflow", + "nodes": [ + { + "id": "1", + "name": "Webhook Trigger", + "type": "n8n-nodes-base.webhook", + "typeVersion": 1, + "position": [250, 300], + "parameters": { + "path": "/test" + } + }, + { + "id": "2", + "name": "Code", + "type": "n8n-nodes-base.code", + "typeVersion": 1, + "position": [450, 300], + "parameters": { + "jsCode": "return items;" + } + } + ], + "connections": { + "Webhook Trigger": { + "main": [ + [ + { + "node": "Code", + "type": "main", + "index": 0 + } + ] + ] + } + } +} diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/example_workflows/generated_wrong.json b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/example_workflows/generated_wrong.json new file mode 100644 index 00000000000..dd287d454b5 --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/example_workflows/generated_wrong.json @@ -0,0 +1,38 @@ +{ + "name": "Simple Test Workflow", + "nodes": [ + { + "id": "1", + "name": "Webhook Trigger 2", + "type": "n8n-nodes-base.webhook", + "typeVersion": 1, + "position": [250, 300], + "parameters": { + "path": "/test2" + } + }, + { + "id": "2", + "name": "Code", + "type": "n8n-nodes-base.code", + "typeVersion": 1, + "position": [450, 300], + "parameters": { + "jsCode": "return items.map(item => item.name);" + } + } + ], + "connections": { + "Webhook Trigger 2": { + "main": [ + [ + { + "node": "Code", + "type": "main", + "index": 0 + } + ] + ] + } + } +} diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/example_workflows/multi_trigger.json b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/example_workflows/multi_trigger.json new file mode 100644 index 00000000000..b1bf127b098 --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/example_workflows/multi_trigger.json @@ -0,0 +1,85 @@ +{ + "name": "Multi-Trigger Workflow", + "nodes": [ + { + "id": "1", + "name": "Webhook Trigger", + "type": "n8n-nodes-base.webhook", + "typeVersion": 1, + "position": [250, 300], + "parameters": { + "path": "/webhook1" + } + }, + { + "id": "2", + "name": "Schedule Trigger", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1, + "position": [250, 450], + "parameters": { + "rule": { + "interval": [ + { + "field": "hours", + "hoursInterval": 1 + } + ] + } + } + }, + { + "id": "3", + "name": "Merge", + "type": "n8n-nodes-base.merge", + "typeVersion": 1, + "position": [450, 375], + "parameters": {} + }, + { + "id": "4", + "name": "Process Data", + "type": "n8n-nodes-base.code", + "typeVersion": 1, + "position": [650, 375], + "parameters": { + "jsCode": "return items;" + } + } + ], + "connections": { + "Webhook Trigger": { + "main": [ + [ + { + "node": "Merge", + "type": "main", + "index": 0 + } + ] + ] + }, + "Schedule Trigger": { + "main": [ + [ + { + "node": "Merge", + "type": "main", + "index": 1 + } + ] + ] + }, + "Merge": { + "main": [ + [ + { + "node": "Process Data", + "type": "main", + "index": 0 + } + ] + ] + } + } +} diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/example_workflows/multi_trigger_different_order.json b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/example_workflows/multi_trigger_different_order.json new file mode 100644 index 00000000000..fd235cfa64c --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/example_workflows/multi_trigger_different_order.json @@ -0,0 +1,85 @@ +{ + "name": "Multi-Trigger Workflow Different Order", + "nodes": [ + { + "id": "4", + "name": "Process Data Node", + "type": "n8n-nodes-base.code", + "typeVersion": 1, + "position": [650, 375], + "parameters": { + "jsCode": "return items;" + } + }, + { + "id": "2", + "name": "Schedule Trigger Different", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1, + "position": [250, 450], + "parameters": { + "rule": { + "interval": [ + { + "field": "hours", + "hoursInterval": 1 + } + ] + } + } + }, + { + "id": "3", + "name": "Merge Node", + "type": "n8n-nodes-base.merge", + "typeVersion": 1, + "position": [450, 375], + "parameters": {} + }, + { + "id": "1", + "name": "Webhook Trigger Different", + "type": "n8n-nodes-base.webhook", + "typeVersion": 1, + "position": [250, 300], + "parameters": { + "path": "/webhook1" + } + } + ], + "connections": { + "Webhook Trigger Different": { + "main": [ + [ + { + "node": "Merge Node", + "type": "main", + "index": 0 + } + ] + ] + }, + "Schedule Trigger Different": { + "main": [ + [ + { + "node": "Merge Node", + "type": "main", + "index": 1 + } + ] + ] + }, + "Merge Node": { + "main": [ + [ + { + "node": "Process Data Node", + "type": "main", + "index": 0 + } + ] + ] + } + } +} diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/example_workflows/multi_trigger_missing_node.json b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/example_workflows/multi_trigger_missing_node.json new file mode 100644 index 00000000000..ce134901380 --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/example_workflows/multi_trigger_missing_node.json @@ -0,0 +1,55 @@ +{ + "name": "Multi-Trigger Workflow Missing Merge", + "nodes": [ + { + "id": "1", + "name": "HTTP Trigger", + "type": "n8n-nodes-base.webhook", + "typeVersion": 1, + "position": [250, 300], + "parameters": { + "path": "/webhook1" + } + }, + { + "id": "2", + "name": "Timer Trigger", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1, + "position": [250, 450], + "parameters": { + "rule": { + "interval": [ + { + "field": "hours", + "hoursInterval": 2 + } + ] + } + } + }, + { + "id": "4", + "name": "Process Data", + "type": "n8n-nodes-base.code", + "typeVersion": 1, + "position": [650, 375], + "parameters": { + "jsCode": "return items;" + } + } + ], + "connections": { + "HTTP Trigger": { + "main": [ + [ + { + "node": "Process Data", + "type": "main", + "index": 0 + } + ] + ] + } + } +} diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/example_workflows/simple_workflow.json b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/example_workflows/simple_workflow.json new file mode 100644 index 00000000000..d8051770a76 --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/example_workflows/simple_workflow.json @@ -0,0 +1,38 @@ +{ + "name": "Simple Test Workflow", + "nodes": [ + { + "id": "1", + "name": "Webhook Trigger", + "type": "n8n-nodes-base.webhook", + "typeVersion": 1, + "position": [250, 300], + "parameters": { + "path": "/test" + } + }, + { + "id": "2", + "name": "Code", + "type": "n8n-nodes-base.code", + "typeVersion": 1, + "position": [450, 300], + "parameters": { + "jsCode": "return items;" + } + } + ], + "connections": { + "Webhook Trigger": { + "main": [ + [ + { + "node": "Code", + "type": "main", + "index": 0 + } + ] + ] + } + } +} diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/justfile b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/justfile new file mode 100644 index 00000000000..fd22bada1bf --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/justfile @@ -0,0 +1,37 @@ +check: + just lint + just format-check + just typecheck + +run: + uv run python -m src.main + +sync: + uv sync --group dev + +sync-all: + uv sync --all-extras --group dev + +lint: + uv run ruff check + +lintfix: + uv run ruff check --fix + +format: + uv run ruff format + +format-check: + uv run ruff format --check + +test: + uv run pytest + +test-cov: + uv run pytest --cov=src --cov-report=term-missing + +test-v: + uv run pytest -vv + +typecheck: + uv run ty check src/ diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/pyproject.toml b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/pyproject.toml new file mode 100644 index 00000000000..165a0df4053 --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "n8n-workflow-comparison" +version = "0.1.0" +description = "Graph-based workflow similarity comparison for n8n" +requires-python = ">=3.11" +dependencies = [ + "networkx>=3.2", + "numpy>=2.3.4", + "pyyaml>=6.0", + "scipy>=1.16.3", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["."] + +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"] +addopts = "-v" + +[tool.coverage.run] +source = ["."] +omit = ["tests/*", "**/__pycache__/*"] + + +[dependency-groups] +dev = [ + "pytest>=9.0.1", + "pytest-cov>=7.0.0", + "ruff>=0.14.5", + "ty>=0.0.1a26", +] diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/src/__init__.py b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/src/__init__.py new file mode 100644 index 00000000000..d8a09e5e648 --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/src/__init__.py @@ -0,0 +1,21 @@ +""" +n8n Workflow Comparison Module + +Graph-based workflow similarity comparison using NetworkX. +""" + +from __future__ import annotations + +from src.config_loader import WorkflowComparisonConfig, load_config +from src.graph_builder import build_workflow_graph, graph_stats +from src.similarity import calculate_graph_edit_distance + +__version__ = "0.1.0" + +__all__ = [ + "WorkflowComparisonConfig", + "load_config", + "build_workflow_graph", + "graph_stats", + "calculate_graph_edit_distance", +] diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/src/__main__.py b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/src/__main__.py new file mode 100644 index 00000000000..38f5f5ef97e --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/src/__main__.py @@ -0,0 +1,8 @@ +""" +Allow running as a module: python -m compare_workflows +""" + +from compare_workflows import main + +if __name__ == "__main__": + main() diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/src/compare_workflows.py b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/src/compare_workflows.py new file mode 100755 index 00000000000..d1eeca31da2 --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/src/compare_workflows.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +""" +Workflow comparison using graph edit distance. + +Options: + --config PATH Path to custom config file (.yaml or .json) + --preset NAME Use built-in preset (strict|standard|lenient) + --output-format FORMAT Output format (json|summary) [default: json] + --verbose Show detailed comparison info + --help Show this help message +""" + +import argparse +import json +import sys +from typing import Dict, Any + +from src.graph_builder import build_workflow_graph, graph_stats +from src.similarity import calculate_graph_edit_distance +from src.config_loader import load_config + + +def parse_args(): + """Parse command line arguments""" + parser = argparse.ArgumentParser( + description="Compare n8n workflows using graph edit distance", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Use default configuration + python compare_workflows.py generated.json ground_truth.json + + # Use a preset + python compare_workflows.py generated.json ground_truth.json --preset strict + + # Use custom configuration + python compare_workflows.py generated.json ground_truth.json --config my-config.yaml + + # Output summary instead of JSON + python compare_workflows.py generated.json ground_truth.json --output-format summary + """, + ) + + parser.add_argument("generated", help="Path to generated workflow JSON file") + parser.add_argument("ground_truth", help="Path to ground truth workflow JSON file") + parser.add_argument( + "--config", help="Path to custom configuration file (.yaml or .json)" + ) + parser.add_argument( + "--preset", + choices=["strict", "standard", "lenient"], + help="Use built-in configuration preset", + ) + parser.add_argument( + "--output-format", + choices=["json", "summary"], + default="json", + help="Output format (default: json)", + ) + parser.add_argument( + "--verbose", action="store_true", help="Show detailed comparison information" + ) + + return parser.parse_args() + + +def load_workflow(path: str) -> Dict[str, Any]: + """ + Load workflow JSON from file. + + Args: + path: Path to workflow JSON file + + Returns: + Workflow dictionary + + Raises: + SystemExit: If file cannot be loaded + """ + try: + with open(path) as f: + return json.load(f) + except FileNotFoundError: + print(f"Error: Workflow file not found: {path}", file=sys.stderr) + sys.exit(1) + except json.JSONDecodeError as e: + print(f"Error: Invalid JSON in {path}: {e}", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"Error loading {path}: {e}", file=sys.stderr) + sys.exit(1) + + +def format_output_json( + result: Dict[str, Any], metadata: Dict[str, Any], verbose: bool = False +) -> str: + """Format result as JSON""" + output: Dict[str, Any] = { + "similarity_score": result["similarity_score"], + "similarity_percentage": f"{result['similarity_score'] * 100:.1f}%", + "edit_cost": result["edit_cost"], + "max_possible_cost": result["max_possible_cost"], + "top_edits": result["top_edits"], + "metadata": metadata, + } + + if verbose: + metadata_dict = output["metadata"] + assert isinstance(metadata_dict, dict) + metadata_dict["verbose"] = True + else: + for edit in output["top_edits"]: + if "parameter_diff" in edit: + del edit["parameter_diff"] + + return json.dumps(output, indent=2) + + +def _format_parameter_diff( + diff: Dict[str, Any], + indent: str = "", + max_value_length: int = 50, +) -> list: + """ + Format parameter diff for human-readable display. + + Args: + diff: Parameter diff dictionary with 'added', 'removed', 'changed' keys + indent: Indentation prefix for each line + max_value_length: Maximum length for displayed values before truncation + + Returns: + List of formatted lines + """ + lines = [] + + def truncate_value(value: Any) -> str: + """Truncate long values for display""" + value_str = str(value) + if len(value_str) > max_value_length: + return value_str[:max_value_length] + "..." + return value_str + + # Added parameters + if "added" in diff and diff["added"]: + lines.append(f"{indent}Added:") + for key, value in diff["added"].items(): + lines.append(f"{indent} + {key}: {truncate_value(value)}") + + # Removed parameters + if "removed" in diff and diff["removed"]: + lines.append(f"{indent}Removed:") + for key, value in diff["removed"].items(): + lines.append(f"{indent} - {key}: {truncate_value(value)}") + + # Changed parameters + if "changed" in diff and diff["changed"]: + lines.append(f"{indent}Changed:") + for key, value in diff["changed"].items(): + if isinstance(value, dict) and "from" in value and "to" in value: + # Simple value change + lines.append(f"{indent} ~ {key}:") + lines.append(f"{indent} from: {truncate_value(value['from'])}") + lines.append(f"{indent} to: {truncate_value(value['to'])}") + elif isinstance(value, dict): + # Nested diff + lines.append(f"{indent} ~ {key}:") + nested_lines = _format_parameter_diff( + value, indent + " ", max_value_length + ) + lines.extend(nested_lines) + + return lines + + +def format_output_summary( + result: Dict[str, Any], metadata: Dict[str, Any], verbose: bool = False +) -> str: + """Format result as human-readable summary""" + lines = [] + + # Header + lines.append("=" * 60) + lines.append("WORKFLOW COMPARISON SUMMARY") + lines.append("=" * 60) + lines.append("") + + # Similarity score + similarity_pct = result["similarity_score"] * 100 + lines.append(f"Overall Similarity: {similarity_pct:.1f}%") + lines.append( + f"Edit Cost: {result['edit_cost']:.1f} / {result['max_possible_cost']:.1f}" + ) + lines.append("") + + # Configuration info + lines.append(f"Configuration: {metadata['config_name']}") + if metadata.get("config_description"): + lines.append(f" {metadata['config_description']}") + lines.append("") + + # Graph statistics + lines.append("Graph Statistics:") + lines.append( + f" Generated workflow: {metadata['generated_nodes']} nodes " + f"({metadata.get('generated_nodes_after_filter', metadata['generated_nodes'])} after filtering)" + ) + lines.append( + f" Ground truth workflow: {metadata['ground_truth_nodes']} nodes " + f"({metadata.get('ground_truth_nodes_after_filter', metadata['ground_truth_nodes'])} after filtering)" + ) + lines.append("") + + # Top edits + if result["top_edits"]: + lines.append(f"Top {len(result['top_edits'])} Required Edits:") + lines.append("-" * 60) + + for i, edit in enumerate(result["top_edits"], 1): + priority = edit["priority"].upper() + cost = edit["cost"] + desc = edit["description"] + + # Priority indicator + if priority == "CRITICAL": + indicator = "🔴" + elif priority == "MAJOR": + indicator = "🟠" + else: + indicator = "🟡" + + lines.append(f"{i}. {indicator} [{priority}] Cost: {cost:.1f}") + lines.append(f" {desc}") + + # Add parameter diff if present + if verbose and "parameter_diff" in edit: + lines.append("") + lines.extend( + _format_parameter_diff(edit["parameter_diff"], indent=" ") + ) + + lines.append("") + else: + lines.append("No edits required - workflows are identical!") + lines.append("") + + # Pass/Fail indicator + lines.append("=" * 60) + if similarity_pct >= 70: + lines.append("✅ PASS - Workflows are sufficiently similar") + else: + lines.append("❌ FAIL - Workflows differ significantly") + lines.append("=" * 60) + + return "\n".join(lines) + + +def main(): + """Main entry point""" + args = parse_args() + + # Load workflows + generated = load_workflow(args.generated) + ground_truth = load_workflow(args.ground_truth) + + # Load configuration + try: + if args.config: + config = load_config(args.config) + elif args.preset: + config = load_config(f"preset:{args.preset}") + else: + config = load_config() # Default (standard) + except Exception as e: + print(f"Error loading configuration: {e}", file=sys.stderr) + sys.exit(1) + + # Build graphs with config filtering + try: + g1 = build_workflow_graph(generated, config) + g2 = build_workflow_graph(ground_truth, config) + except Exception as e: + print(f"Error building workflow graphs: {e}", file=sys.stderr) + sys.exit(1) + + # Get graph statistics + stats1 = graph_stats(g1) + stats2 = graph_stats(g2) + + # Calculate similarity + try: + result = calculate_graph_edit_distance(g1, g2, config) + except Exception as e: + print(f"Error calculating similarity: {e}", file=sys.stderr) + sys.exit(1) + + # Prepare metadata + metadata = { + "generated_nodes": len(generated.get("nodes", [])), + "ground_truth_nodes": len(ground_truth.get("nodes", [])), + "generated_nodes_after_filter": stats1["node_count"], + "ground_truth_nodes_after_filter": stats2["node_count"], + "config_name": config.name, + "config_description": config.description, + } + + if args.verbose: + metadata["verbose_info"] = { + "generated_stats": stats1, + "ground_truth_stats": stats2, + "config_details": config.to_dict(), + } + + # Format and output result + if args.output_format == "json": + output = format_output_json(result, metadata, args.verbose) + print(output) + elif args.output_format == "summary": + output = format_output_summary(result, metadata, args.verbose) + print(output) + + # Always exit with 0 - let the caller interpret the similarity score + sys.exit(0) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\nInterrupted by user", file=sys.stderr) + sys.exit(130) + except Exception as e: + print(f"Unexpected error: {e}", file=sys.stderr) + sys.exit(1) diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/src/config_loader.py b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/src/config_loader.py new file mode 100644 index 00000000000..1372e3d9e1d --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/src/config_loader.py @@ -0,0 +1,389 @@ +""" +Configuration loader for workflow comparison. +Supports loading from YAML, JSON, and built-in presets. +""" + +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Any, Set +from pathlib import Path +import yaml +import json +import re + + +def _get_param_path_matching_pattern(pattern: str) -> str: + """ + Convert glob-like pattern to regex pattern. + Supports wildcards: ** (matches any chars including dots), * (matches any chars except dots) + """ + # Use placeholders to preserve wildcards during escaping + regex_pattern = pattern.replace("**", "\x00DOUBLE_STAR\x00").replace( + "*", "\x00STAR\x00" + ) + regex_pattern = regex_pattern.replace(".", r"\.") + regex_pattern = regex_pattern.replace("\x00DOUBLE_STAR\x00", ".*").replace( + "\x00STAR\x00", "[^.]*" + ) + return regex_pattern + + +@dataclass +class NodeIgnoreRule: + """Rule for ignoring nodes during comparison""" + + pattern: Optional[str] = None + name: Optional[str] = None + node_type: Optional[str] = None + reason: str = "" + + def matches(self, node: Dict) -> bool: + """Check if this rule matches a node""" + if self.name and node.get("name") == self.name: + return True + if self.pattern and re.match(self.pattern, node.get("name", "")): + return True + if self.node_type and node.get("type") == self.node_type: + return True + return False + + +@dataclass +class ParameterComparisonRule: + """Rule for comparing specific parameters""" + + parameter: str + type: str # 'semantic', 'normalized', 'exact', 'numeric' + threshold: Optional[float] = None + tolerance: Optional[float] = None + cost_if_below: float = 0.0 + cost_if_exceeded: float = 0.0 + options: Dict[str, Any] = field(default_factory=dict) + + def matches_parameter(self, param_path: str) -> bool: + """Check if this rule applies to a parameter path""" + regex_pattern = _get_param_path_matching_pattern(self.parameter) + return bool(re.match(f"^{regex_pattern}$", param_path)) + + +@dataclass +class ExemptionRule: + """Rule for exempting certain nodes from full cost""" + + name_pattern: Optional[str] = None + node_type: Optional[str] = None + penalty: float = 0.0 + reason: str = "" + when: Optional[Dict[str, Any]] = None + + def matches(self, node: Dict) -> bool: + """Check if node matches exemption""" + match = False + if self.name_pattern and re.match(self.name_pattern, node.get("name", "")): + match = True + if self.node_type and node.get("type") == self.node_type: + match = True + + # Check additional conditions + if match and self.when: + for key, value in self.when.items(): + if node.get(key) != value: + return False + + return match + + +@dataclass +class WorkflowComparisonConfig: + """Complete configuration for workflow comparison""" + + version: str = "1.0" + name: str = "default" + description: str = "" + + # Cost weights + node_insertion_cost: float = 10.0 + node_deletion_cost: float = 10.0 + node_substitution_same_type: float = 1.0 + node_substitution_similar_type: float = 5.0 + node_substitution_different_type: float = 15.0 + node_substitution_trigger: float = 50.0 + + edge_insertion_cost: float = 5.0 + edge_deletion_cost: float = 5.0 + edge_substitution_cost: float = 3.0 + + parameter_mismatch_weight: float = 0.5 + parameter_nested_weight: float = 0.3 + + # Similarity groups + similarity_groups: Dict[str, List[str]] = field(default_factory=dict) + + # Ignore rules + ignored_node_rules: List[NodeIgnoreRule] = field(default_factory=list) + ignored_node_types: Set[str] = field(default_factory=set) + ignored_global_parameters: Set[str] = field(default_factory=set) + ignored_node_type_parameters: Dict[str, Set[str]] = field(default_factory=dict) + ignored_parameter_paths: List[str] = field(default_factory=list) + + # Parameter comparison rules + parameter_rules: List[ParameterComparisonRule] = field(default_factory=list) + + # Exemptions + optional_in_generated: List[ExemptionRule] = field(default_factory=list) + optional_in_ground_truth: List[ExemptionRule] = field(default_factory=list) + + # Connection rules + ignored_connection_types: Set[str] = field(default_factory=set) + equivalent_connection_types: List[List[str]] = field(default_factory=list) + + # Output config + max_edits: int = 15 + group_by: str = "priority" + include_explanations: bool = True + include_suggestions: bool = True + + def should_ignore_node(self, node: Dict) -> bool: + """Check if node should be ignored""" + # Check node type + if node.get("type") in self.ignored_node_types: + return True + + # Check node rules + for rule in self.ignored_node_rules: + if rule.matches(node): + return True + + return False + + def should_ignore_parameter(self, node_type: str, param_path: str) -> bool: + """Check if parameter should be ignored""" + # Global parameters + param_name = param_path.split(".")[-1] + if param_name in self.ignored_global_parameters: + return True + + # Node type specific + if node_type in self.ignored_node_type_parameters: + if param_path in self.ignored_node_type_parameters[node_type]: + return True + # Check wildcards + for ignored_path in self.ignored_node_type_parameters[node_type]: + if self._matches_path_pattern(param_path, ignored_path): + return True + + # Parameter path patterns + for pattern in self.ignored_parameter_paths: + if self._matches_path_pattern(param_path, pattern): + return True + + return False + + def get_parameter_rule(self, param_path: str) -> Optional[ParameterComparisonRule]: + """Get comparison rule for parameter""" + for rule in self.parameter_rules: + if rule.matches_parameter(param_path): + return rule + return None + + def get_exemption_penalty( + self, + node: Dict, + context: str, # 'generated' or 'ground_truth' + ) -> Optional[float]: + """Get exemption penalty for a node, if applicable""" + exemptions = ( + self.optional_in_generated + if context == "generated" + else self.optional_in_ground_truth + ) + + for exemption in exemptions: + if exemption.matches(node): + return exemption.penalty + + return None + + @staticmethod + def _matches_path_pattern(path: str, pattern: str) -> bool: + """Check if path matches pattern (supports ** and *)""" + regex_pattern = _get_param_path_matching_pattern(pattern) + return bool(re.match(f"^{regex_pattern}$", path)) + + def are_node_types_similar(self, type1: str, type2: str) -> bool: + """Check if two node types are in the same similarity group""" + for group_name, types in self.similarity_groups.items(): + if type1 in types and type2 in types: + return True + return False + + def to_dict(self) -> Dict: + """Convert config to dictionary for serialization""" + return { + "version": self.version, + "name": self.name, + "description": self.description, + "costs": { + "nodes": { + "insertion": self.node_insertion_cost, + "deletion": self.node_deletion_cost, + "substitution": { + "same_type": self.node_substitution_same_type, + "similar_type": self.node_substitution_similar_type, + "different_type": self.node_substitution_different_type, + "trigger_mismatch": self.node_substitution_trigger, + }, + }, + "edges": { + "insertion": self.edge_insertion_cost, + "deletion": self.edge_deletion_cost, + "substitution": self.edge_substitution_cost, + }, + "parameters": { + "mismatch_weight": self.parameter_mismatch_weight, + "nested_weight": self.parameter_nested_weight, + }, + }, + "similarity_groups": self.similarity_groups, + "max_edits": self.max_edits, + } + + @classmethod + def from_yaml(cls, path: Path) -> "WorkflowComparisonConfig": + """Load configuration from YAML file""" + with open(path) as f: + data = yaml.safe_load(f) + return cls._from_dict(data) + + @classmethod + def from_json(cls, path: Path) -> "WorkflowComparisonConfig": + """Load configuration from JSON file""" + with open(path) as f: + data = json.load(f) + return cls._from_dict(data) + + @classmethod + def _from_dict(cls, data: Dict) -> "WorkflowComparisonConfig": + """Parse configuration dictionary""" + config = cls() + + # Basic info + config.version = data.get("version", "1.0") + config.name = data.get("name", "default") + config.description = data.get("description", "") + + # Costs + costs = data.get("costs", {}) + nodes = costs.get("nodes", {}) + config.node_insertion_cost = nodes.get("insertion", 10.0) + config.node_deletion_cost = nodes.get("deletion", 10.0) + + subst = nodes.get("substitution", {}) + config.node_substitution_same_type = subst.get("same_type", 1.0) + config.node_substitution_similar_type = subst.get("similar_type", 5.0) + config.node_substitution_different_type = subst.get("different_type", 15.0) + config.node_substitution_trigger = subst.get("trigger_mismatch", 50.0) + + edges = costs.get("edges", {}) + config.edge_insertion_cost = edges.get("insertion", 5.0) + config.edge_deletion_cost = edges.get("deletion", 5.0) + config.edge_substitution_cost = edges.get("substitution", 3.0) + + params = costs.get("parameters", {}) + config.parameter_mismatch_weight = params.get("mismatch_weight", 0.5) + config.parameter_nested_weight = params.get("nested_weight", 0.3) + + # Similarity groups + config.similarity_groups = data.get("similarity_groups", {}) + + # Ignore rules + ignore = data.get("ignore", {}) + + # Node ignore rules + for node_rule in ignore.get("nodes", []): + config.ignored_node_rules.append(NodeIgnoreRule(**node_rule)) + + config.ignored_node_types = set(ignore.get("node_types", [])) + config.ignored_global_parameters = set(ignore.get("global_parameters", [])) + + # Node type parameters + for node_type, params in ignore.get("node_type_parameters", {}).items(): + config.ignored_node_type_parameters[node_type] = set(params) + + config.ignored_parameter_paths = ignore.get("parameter_paths", []) + + # Parameter comparison rules + param_comp = data.get("parameter_comparison", {}) + for rule_data in param_comp.get("fuzzy_match", []): + config.parameter_rules.append(ParameterComparisonRule(**rule_data)) + + for rule_data in param_comp.get("numeric_tolerance", []): + rule_data["type"] = "numeric" + config.parameter_rules.append(ParameterComparisonRule(**rule_data)) + + # Exemptions + exemptions = data.get("exemptions", {}) + for exemption_data in exemptions.get("optional_in_generated", []): + config.optional_in_generated.append(ExemptionRule(**exemption_data)) + + for exemption_data in exemptions.get("optional_in_ground_truth", []): + config.optional_in_ground_truth.append(ExemptionRule(**exemption_data)) + + # Connection rules + connections = data.get("connections", {}) + config.ignored_connection_types = set( + connections.get("ignore_connection_types", []) + ) + config.equivalent_connection_types = connections.get("equivalent_types", []) + + # Output config + output = data.get("output", {}) + config.max_edits = output.get("max_edits", 15) + config.group_by = output.get("group_by", "priority") + config.include_explanations = output.get("include_explanations", True) + config.include_suggestions = output.get("include_suggestions", True) + + return config + + @classmethod + def load_preset(cls, preset_name: str) -> "WorkflowComparisonConfig": + """Load a built-in preset configuration""" + presets_dir = Path(__file__).parent / "configs" / "presets" + preset_path = presets_dir / f"{preset_name}.yaml" + + if not preset_path.exists(): + raise ValueError(f"Preset '{preset_name}' not found at {preset_path}") + + return cls.from_yaml(preset_path) + + +def load_config(config_source: Optional[str] = None) -> WorkflowComparisonConfig: + """ + Load configuration from various sources. + + Args: + config_source: Can be: + - None: use default config + - "preset:name": load built-in preset (e.g., "preset:strict") + - "/path/to/config.yaml": load custom YAML file + - "/path/to/config.json": load custom JSON file + + Returns: + WorkflowComparisonConfig instance + """ + if not config_source: + return WorkflowComparisonConfig() + + if config_source.startswith("preset:"): + preset_name = config_source.split(":", 1)[1] + return WorkflowComparisonConfig.load_preset(preset_name) + + path = Path(config_source) + if not path.exists(): + raise ValueError(f"Config file not found: {config_source}") + + if path.suffix in [".yaml", ".yml"]: + return WorkflowComparisonConfig.from_yaml(path) + elif path.suffix == ".json": + return WorkflowComparisonConfig.from_json(path) + else: + raise ValueError(f"Unsupported config format: {path.suffix}") diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/src/configs/presets/lenient.yaml b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/src/configs/presets/lenient.yaml new file mode 100644 index 00000000000..5fe615c83cc --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/src/configs/presets/lenient.yaml @@ -0,0 +1,83 @@ +version: "1.0" +name: "lenient" +description: "Lenient comparison - focus on structure over details" + +# Cost weights - lower penalties +costs: + nodes: + insertion: 7.0 + deletion: 7.0 + substitution: + same_type: 0.1 # Very low cost for parameter differences + similar_type: 2.0 + different_type: 8.0 + trigger_mismatch: 50.0 + + edges: + insertion: 3.0 + deletion: 3.0 + substitution: 2.0 + + parameters: + mismatch_weight: 0.2 # Low weight for parameter differences + nested_weight: 0.1 + +# Node type similarity groups (same as standard) +similarity_groups: + ai_llms: + - "@n8n/n8n-nodes-langchain.lmChatOpenAi" + - "@n8n/n8n-nodes-langchain.lmChatAnthropic" + - "@n8n/n8n-nodes-langchain.lmChatOllama" + - "@n8n/n8n-nodes-langchain.lmChatMistralCloud" + - "@n8n/n8n-nodes-langchain.lmChatAws" + - "@n8n/n8n-nodes-langchain.lmChatGroq" + + +ignore: + node_types: + - "n8n-nodes-base.stickyNote" + - "n8n-nodes-base.noOp" + + global_parameters: + - "position" + - "name" + - "id" + - "notes" + - "notesInFlow" + - "color" + - "alwaysOutputData" + - "executeOnce" + + # Ignore implementation details for specific node types + node_type_parameters: + "@n8n/n8n-nodes-langchain.agent": + - "options.*" # Ignore all options + + "@n8n/n8n-nodes-langchain.lmChatOpenAi": + - "model" # Model choice is flexible + + "@n8n/n8n-nodes-langchain.lmChatAnthropic": + - "model" + + # Ignore credential references (might differ between environments) + parameter_paths: + - "**.credentials" + +# Parameter comparison - very tolerant +parameter_comparison: + # High tolerance for numeric values + numeric_tolerance: + - parameter: "options.temperature" + tolerance: 0.3 + cost_if_exceeded: 0.5 + + - parameter: "options.maxTokens" + tolerance: 500 + cost_if_exceeded: 0.5 + +# Output configuration +output: + max_edits: 10 # Show fewer edits since many are ignored + group_by: "priority" + include_explanations: true + include_suggestions: true diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/src/configs/presets/standard.yaml b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/src/configs/presets/standard.yaml new file mode 100644 index 00000000000..68b7fab59c1 --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/src/configs/presets/standard.yaml @@ -0,0 +1,60 @@ +version: "1.0" +name: "standard" +description: "Standard balanced comparison configuration" + +# Cost weights - balanced approach +costs: + nodes: + insertion: 10.0 + deletion: 10.0 + substitution: + same_type: 1.0 + similar_type: 5.0 + different_type: 15.0 + trigger_mismatch: 50.0 + + edges: + insertion: 2.0 + deletion: 2.0 + substitution: 1.0 + + parameters: + mismatch_weight: 0.5 + nested_weight: 0.3 + +# Ignore rules +ignore: + # Ignore UI-only nodes + node_types: + - "n8n-nodes-base.stickyNote" + + # Ignore cosmetic parameters + global_parameters: + - "position" + - "id" + - "notes" + - "notesInFlow" + - "color" + + +# Ignore credential references (might differ between environments) +parameter_paths: + - "**.credentials" + +# Parameter comparison rules +parameter_comparison: + # Numeric tolerance for temperature and similar params + numeric_tolerance: + - parameter: "options.temperature" + tolerance: 0.1 + cost_if_exceeded: 2.0 + + - parameter: "options.maxTokens" + tolerance: 100 + cost_if_exceeded: 1.0 + +# Output configuration +output: + max_edits: 29 + group_by: "priority" + include_explanations: true diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/src/configs/presets/strict.yaml b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/src/configs/presets/strict.yaml new file mode 100644 index 00000000000..b92cd32af97 --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/src/configs/presets/strict.yaml @@ -0,0 +1,44 @@ +version: "1.0" +name: "strict" +description: "Strict comparison - exact match required for most aspects" + +# Cost weights - higher penalties +costs: + nodes: + insertion: 15.0 + deletion: 15.0 + substitution: + same_type: 0.5 # Very sensitive to parameter changes + similar_type: 10.0 + different_type: 25.0 + trigger_mismatch: 100.0 # Trigger mismatch is critical + + edges: + insertion: 8.0 + deletion: 8.0 + substitution: 5.0 + + parameters: + mismatch_weight: 1.0 # High weight for parameter differences + nested_weight: 0.8 + +# Minimal ignores - most things should match +ignore: + # Only ignore pure UI elements + node_types: + - "n8n-nodes-base.stickyNote" + + # Only ignore metadata that doesn't affect execution + global_parameters: + - "position" + - "id" + +# No parameter tolerance - exact match required +parameter_comparison: {} + +# Output configuration +output: + max_edits: 20 # Show more edits for strict mode + group_by: "priority" + include_explanations: true + include_suggestions: true diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/src/cost_functions.py b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/src/cost_functions.py new file mode 100644 index 00000000000..19b2de2b2f5 --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/src/cost_functions.py @@ -0,0 +1,497 @@ +""" +Cost functions for graph edit distance operations. +Configuration-aware costs for node and edge operations. +""" + +import re +from typing import Dict, Any +from src.config_loader import WorkflowComparisonConfig, ParameterComparisonRule + + +def normalize_expression(value: Any) -> Any: + """ + Normalize expression strings for comparison. + + This function: + 1. Removes all /* */ style comments + 2. Normalizes whitespace + 3. Normalizes $fromAI function calls with optional parameters + + Args: + value: The value to normalize (typically a string expression) + + Returns: + Normalized value for comparison + """ + if not isinstance(value, str): + return value + + # Remove all /* */ style comments + cleaned = re.sub(r"/\*.*?\*/", "", value) + + # Normalize whitespace - collapse multiple spaces/tabs to single space + cleaned = re.sub(r"\s+", " ", cleaned) + + # Normalize $fromAI calls with optional parameters + def normalize_fromAI(match): + # Extract the first parameter and clean it up + first_param = match.group(1).strip() + # If there's a comma in first_param, split and take first part + if "," in first_param: + first_param = first_param.split(",")[0].strip() + return f"$fromAI({first_param})" + + cleaned = re.sub( + r"\$fromAI\(([^)]*?)\)", + normalize_fromAI, + cleaned, + ) + + # Strip any remaining extra whitespace + cleaned = cleaned.strip() + + return cleaned + + +def node_substitution_cost( + node1_data: Dict[str, Any], + node2_data: Dict[str, Any], + config: WorkflowComparisonConfig, +) -> float: + """ + Calculate cost of substituting node1 with node2. + + Args: + node1_data: Attributes of first node + node2_data: Attributes of second node + config: Configuration with cost weights + + Returns: + Cost value (0 = identical, higher = more different) + """ + # Check for exemptions + original_data = node1_data.get("parameters", "{}") + exemption_penalty = config.get_exemption_penalty(original_data, "generated") + if exemption_penalty is not None: + return exemption_penalty + + # Get node types first for trigger detection + type1 = node1_data.get("type", "") + type2 = node2_data.get("type", "") + + # Critical: Trigger nodes + is_trigger1 = node1_data.get("is_trigger", False) + is_trigger2 = node2_data.get("is_trigger", False) + + # If both are triggers but different types, high cost + if is_trigger1 and is_trigger2 and type1 != type2: + return config.node_substitution_trigger + + # If one is trigger and other isn't, high cost + if is_trigger1 != is_trigger2: + return config.node_substitution_trigger + + # Same type: compare parameters + + if type1 == type2: + # Get parameters directly from node data + params1 = node1_data.get("parameters", {}) + params2 = node2_data.get("parameters", {}) + + param_diff = compare_parameters(params1, params2, type1, config) + + # Check if names match using the hash + # This prevents GED from swapping nodes with same type but different names + name_hash1 = node1_data.get("_name_hash", 0) + name_hash2 = node2_data.get("_name_hash", 0) + + # If hashes are present and different, add penalty + # This prevents swapping "Slack Assistant" with "Master Orchestrator Agent" + # Names are normalized (smart quotes -> regular quotes) before hashing + name_mismatch_penalty = 0.0 + if name_hash1 != 0 and name_hash2 != 0 and name_hash1 != name_hash2: + name_mismatch_penalty = config.node_substitution_different_type * 0.5 + + # If parameters are identical and names match, no cost + if param_diff == 0 and name_mismatch_penalty == 0: + return 0.0 + + # Otherwise, base cost plus parameter difference plus name penalty + return ( + config.node_substitution_same_type + + (param_diff * config.parameter_mismatch_weight) + + name_mismatch_penalty + ) + + # Similar types (from config) + if config.are_node_types_similar(type1, type2): + return config.node_substitution_similar_type + + # Completely different + return config.node_substitution_different_type + + +def node_deletion_cost( + node_data: Dict[str, Any], config: WorkflowComparisonConfig +) -> float: + """ + Calculate cost of deleting a node. + + Args: + node_data: Attributes of the node to delete + config: Configuration with cost weights + + Returns: + Cost value + """ + # Check for exemptions + original_data = node_data.get("parameters", "{}") + exemption_penalty = config.get_exemption_penalty(original_data, "generated") + if exemption_penalty is not None: + return exemption_penalty + + is_trigger = node_data.get("is_trigger", False) + if is_trigger: + return config.node_substitution_trigger / 2 + + return config.node_deletion_cost + + +def node_insertion_cost( + node_data: Dict[str, Any], config: WorkflowComparisonConfig +) -> float: + """ + Calculate cost of inserting a node. + + Args: + node_data: Attributes of the node to insert + config: Configuration with cost weights + + Returns: + Cost value + """ + # Check for exemptions + original_data = node_data.get("parameters", "{}") + exemption_penalty = config.get_exemption_penalty(original_data, "ground_truth") + if exemption_penalty is not None: + return exemption_penalty + is_trigger = node_data.get("is_trigger", False) + if is_trigger: + return config.node_substitution_trigger / 2 + + return config.node_insertion_cost + + +def edge_substitution_cost( + edge1_data: Dict[str, Any], + edge2_data: Dict[str, Any], + config: WorkflowComparisonConfig, +) -> float: + """ + Calculate cost of changing edge connection type. + + Args: + edge1_data: Attributes of first edge + edge2_data: Attributes of second edge + config: Configuration with cost weights + + Returns: + Cost value + """ + # Check if source and target node types match + # If they do, this edge is likely following a node match + source_type1 = edge1_data.get("source_node_type", "") + source_type2 = edge2_data.get("source_node_type", "") + target_type1 = edge1_data.get("target_node_type", "") + target_type2 = edge2_data.get("target_node_type", "") + + # If both source and target node types match, check connection compatibility + if source_type1 == source_type2 and target_type1 == target_type2: + conn_type1 = edge1_data.get("connection_type", "main") + conn_type2 = edge2_data.get("connection_type", "main") + + # No cost if connection types are identical + if conn_type1 == conn_type2: + return 0.0 + + # Check for equivalent types in config + for equiv_group in config.equivalent_connection_types: + if conn_type1 in equiv_group and conn_type2 in equiv_group: + return 0.0 # No cost for equivalent types + + # If node types match but connection types differ significantly, + # this is still an edge substitution with cost + return config.edge_substitution_cost + + # Node types don't match - this is a structural change + # Higher cost because the edge endpoints are different + return config.edge_substitution_cost + + +def edge_deletion_cost( + edge_data: Dict[str, Any], config: WorkflowComparisonConfig +) -> float: + """Calculate cost of deleting an edge""" + return config.edge_deletion_cost + + +def edge_insertion_cost( + edge_data: Dict[str, Any], config: WorkflowComparisonConfig +) -> float: + """Calculate cost of inserting an edge""" + return config.edge_insertion_cost + + +def compare_parameters( + params1: Dict[str, Any], + params2: Dict[str, Any], + node_type: str, + config: WorkflowComparisonConfig, + path_prefix: str = "", +) -> float: + """ + Deep comparison of parameters with config-aware filtering. + + Args: + params1: First parameter dictionary + params2: Second parameter dictionary + node_type: Type of the node (for node-specific rules) + config: Configuration with comparison rules + path_prefix: Current parameter path (for nested params) + + Returns: + Score representing parameter difference (higher = more different) + """ + all_keys = set(params1.keys()) | set(params2.keys()) + diff_score = 0.0 + + for key in all_keys: + param_path = f"{path_prefix}.{key}" if path_prefix else key + + # Check if this parameter should be ignored (should be filtered already, but double-check) + if config.should_ignore_parameter(node_type, param_path): + continue + + # Get comparison rule for this parameter + rule = config.get_parameter_rule(param_path) + + # Handle missing keys + if key not in params1 or key not in params2: + diff_score += 1.0 + continue + + val1, val2 = params1[key], params2[key] + + # Normalize expressions for comparison + val1_cleaned = normalize_expression(val1) + val2_cleaned = normalize_expression(val2) + + # Apply comparison rule if exists + if rule: + cost = apply_comparison_rule(val1_cleaned, val2_cleaned, rule) + diff_score += cost + continue + + # Default comparison logic + if isinstance(val1_cleaned, dict) and isinstance(val2_cleaned, dict): + # Recursive for nested objects + nested_diff = compare_parameters( + val1_cleaned, val2_cleaned, node_type, config, param_path + ) + diff_score += nested_diff * config.parameter_nested_weight + elif isinstance(val1_cleaned, list) and isinstance(val2_cleaned, list): + # Compare lists (order matters) + diff_score += compare_lists(val1_cleaned, val2_cleaned, config) + elif val1_cleaned != val2_cleaned: + diff_score += 1.0 + + return diff_score + + +def compare_lists(list1: list, list2: list, config: WorkflowComparisonConfig) -> float: + """ + Compare two lists with tolerance for order and content. + + Args: + list1: First list + list2: Second list + config: Configuration + + Returns: + Difference score + """ + # Simple approach: penalize length difference and content difference + if len(list1) != len(list2): + return abs(len(list1) - len(list2)) * 0.5 + + # Compare elements pairwise + diff = 0.0 + for v1, v2 in zip(list1, list2): + if isinstance(v1, dict) and isinstance(v2, dict): + # Nested dict comparison (simplified) + if v1 != v2: + diff += 0.5 + elif v1 != v2: + diff += 1.0 + + return diff + + +def apply_comparison_rule(val1: Any, val2: Any, rule: ParameterComparisonRule) -> float: + """ + Apply custom comparison rule to parameter values. + + Args: + val1: First value + val2: Second value + rule: Comparison rule to apply + + Returns: + Cost based on rule + """ + if rule.type == "semantic": + # Semantic similarity (simplified version) + similarity = calculate_semantic_similarity(str(val1), str(val2)) + if similarity >= (rule.threshold or 0.8): + return 0.0 + return rule.cost_if_below + + elif rule.type == "numeric": + # Numeric tolerance + try: + num1, num2 = float(val1), float(val2) + if abs(num1 - num2) <= (rule.tolerance or 0): + return 0.0 + return rule.cost_if_exceeded + except (ValueError, TypeError): + # Not numeric, count as different + return rule.cost_if_exceeded + + elif rule.type == "normalized": + # Normalized comparison (e.g., URLs) + norm1 = normalize_value(val1, rule.options) + norm2 = normalize_value(val2, rule.options) + if norm1 == norm2: + return 0.0 + return 1.0 + + # Default: exact match + return 0.0 if val1 == val2 else 1.0 + + +def calculate_semantic_similarity(text1: str, text2: str) -> float: + """ + Calculate semantic similarity between two text strings. + + This is a placeholder using simple word overlap (Jaccard similarity). + In a production system, this could use sentence-transformers or similar. + + Args: + text1: First text + text2: Second text + + Returns: + Similarity score 0-1 (1 = identical) + """ + # Convert to lowercase and split into words + words1 = set(text1.lower().split()) + words2 = set(text2.lower().split()) + + if not words1 and not words2: + return 1.0 + if not words1 or not words2: + return 0.0 + + # Jaccard similarity: intersection / union + intersection = words1 & words2 + union = words1 | words2 + + return len(intersection) / len(union) + + +def normalize_value(value: Any, options: Dict[str, Any]) -> Any: + """ + Normalize value based on options. + + Args: + value: Value to normalize + options: Normalization options [ignore_trailing_slash, case_insensitive] + + Returns: + Normalized value + """ + if isinstance(value, str): + # URL normalization + if value.startswith("http"): + normalized = value + if options.get("ignore_trailing_slash", False): + normalized = normalized.rstrip("/") + # Could add more URL normalization here + return normalized + + # String normalization + if options.get("case_insensitive", False): + return value.lower() + + return value + + +def get_parameter_diff( + params1: Dict[str, Any], + params2: Dict[str, Any], + node_type: str, + config: WorkflowComparisonConfig, + path_prefix: str = "", +) -> Dict[str, Any]: + """ + Get detailed diff of parameters for display purposes. + + Args: + params1: First parameter dictionary + params2: Second parameter dictionary + node_type: Type of the node (for node-specific rules) + config: Configuration with comparison rules + path_prefix: Current parameter path (for nested params) + + Returns: + Dictionary with added, removed, and changed parameters + """ + diff = {"added": {}, "removed": {}, "changed": {}} + + all_keys = set(params1.keys()) | set(params2.keys()) + + for key in all_keys: + param_path = f"{path_prefix}.{key}" if path_prefix else key + + # Skip ignored parameters + if config.should_ignore_parameter(node_type, param_path): + continue + + if key not in params1: + # Parameter added in params2 + diff["added"][key] = params2[key] + elif key not in params2: + # Parameter removed in params1 + diff["removed"][key] = params1[key] + else: + val1, val2 = params1[key], params2[key] + + # Normalize expressions before comparing + val1_cleaned = normalize_expression(val1) + val2_cleaned = normalize_expression(val2) + + # Handle nested dicts + if isinstance(val1_cleaned, dict) and isinstance(val2_cleaned, dict): + nested_diff = get_parameter_diff( + val1_cleaned, val2_cleaned, node_type, config, param_path + ) + if any(nested_diff.values()): + diff["changed"][key] = nested_diff + elif val1_cleaned != val2_cleaned: + # Store cleaned values for display + diff["changed"][key] = {"from": val1_cleaned, "to": val2_cleaned} + + # Clean up empty sections + diff = {k: v for k, v in diff.items() if v} + + return diff diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/src/graph_builder.py b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/src/graph_builder.py new file mode 100644 index 00000000000..e1ee871c3de --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/src/graph_builder.py @@ -0,0 +1,222 @@ +""" +Build NetworkX graphs from n8n workflow JSON structures. +""" + +import networkx as nx +from typing import Dict, Any, Optional +from src.config_loader import WorkflowComparisonConfig + + +def build_workflow_graph( + workflow: Dict[str, Any], config: Optional[WorkflowComparisonConfig] = None +) -> nx.DiGraph: + """ + Convert n8n workflow to NetworkX directed graph. + + Args: + workflow: n8n workflow JSON (with 'nodes' and 'connections') + config: Optional configuration for filtering + + Returns: + NetworkX DiGraph with nodes and edges + """ + G = nx.DiGraph() + + if config is None: + config = WorkflowComparisonConfig() + + # Add nodes with attributes + for node in workflow.get("nodes", []): + # Check if node should be ignored + if config.should_ignore_node(node): + continue + + node_name = node.get("name", node.get("id", "unknown")) + + # Filter parameters based on config + filtered_params = _filter_parameters( + node.get("parameters", {}), node.get("type", ""), config + ) + + # Add node with filtered parameters + G.add_node( + node_name, + type=node.get("type", ""), + type_version=node.get("typeVersion", 1), + parameters=filtered_params, + is_trigger=_is_trigger_node(node), + ) + + # Add edges from connections + connections = workflow.get("connections", {}) + for source_name, source_conns in connections.items(): + # Skip if source node was filtered out + if source_name not in G: + continue + + for conn_type, conn_arrays in source_conns.items(): + # Check if this connection type should be ignored + if conn_type in config.ignored_connection_types: + continue + + # conn_arrays is typically: [[connections], [connections], ...] + # where first index is output index + for output_index, conn_array in enumerate(conn_arrays): + if conn_array is None: + continue + + for conn in conn_array: + target_name = conn.get("node") + + # Skip if target node was filtered out + if target_name not in G: + continue + + # Add edge with connection metadata + # Include source and target node info for better matching + source_node_data = G.nodes[source_name] + target_node_data = G.nodes[target_name] + + G.add_edge( + source_name, + target_name, + connection_type=conn.get("type", "main"), + source_index=output_index, + target_index=conn.get("index", 0), + source_node_type=source_node_data.get("type", ""), + target_node_type=target_node_data.get("type", ""), + ) + + return G + + +def _filter_parameters( + params: Dict[str, Any], + node_type: str, + config: WorkflowComparisonConfig, + path_prefix: str = "", +) -> Dict[str, Any]: + """ + Filter parameters based on configuration ignore rules. + + Args: + params: Parameter dictionary to filter + node_type: Type of the node (for node-specific filtering) + config: Configuration with ignore rules + path_prefix: Current parameter path (for nested params) + + Returns: + Filtered parameter dictionary + """ + filtered = {} + + for key, value in params.items(): + param_path = f"{path_prefix}.{key}" if path_prefix else key + + # Check if this parameter should be ignored + if config.should_ignore_parameter(node_type, param_path): + continue + + # Recursively filter nested dictionaries + if isinstance(value, dict): + nested_filtered = _filter_parameters(value, node_type, config, param_path) + if nested_filtered: # Only include if not empty + filtered[key] = nested_filtered + else: + filtered[key] = value + + return filtered + + +def _is_trigger_node(node: Dict[str, Any]) -> bool: + """ + Detect if a node is a trigger node. + + Args: + node: Node dictionary + + Returns: + True if node is a trigger + """ + node_type = node.get("type", "").lower() + node_name = node.get("name", "").lower() + + # Check if 'trigger' is in type or name + if "trigger" in node_type or "trigger" in node_name: + return True + + # Check for known trigger node types that don't have 'trigger' in the name + trigger_types = [ + "webhook", # n8n-nodes-base.webhook + "webhooktrigger", + "manualtrigger", + "scheduletrigger", + "chattrigger", + "cron", # n8n-nodes-base.cron + ] + + for trigger_type in trigger_types: + if trigger_type in node_type: + return True + + return False + + +def get_node_data(graph: nx.DiGraph, node_name: str) -> Dict[str, Any]: + """ + Get all data for a node from the graph. + + Args: + graph: NetworkX graph + node_name: Name of the node + + Returns: + Dictionary with node attributes + """ + if node_name not in graph: + return {} + + return dict(graph.nodes[node_name]) + + +def get_edge_data(graph: nx.DiGraph, source: str, target: str) -> Dict[str, Any]: + """ + Get all data for an edge from the graph. + + Args: + graph: NetworkX graph + source: Source node name + target: Target node name + + Returns: + Dictionary with edge attributes + """ + if not graph.has_edge(source, target): + return {} + + return dict(graph.edges[source, target]) + + +def graph_stats(graph: nx.DiGraph) -> Dict[str, Any]: + """ + Get statistics about a workflow graph. + + Args: + graph: NetworkX graph + + Returns: + Dictionary with graph statistics + """ + return { + "node_count": graph.number_of_nodes(), + "edge_count": graph.number_of_edges(), + "trigger_count": sum( + 1 for _, data in graph.nodes(data=True) if data.get("is_trigger", False) + ), + "node_types": list( + set(data.get("type", "unknown") for _, data in graph.nodes(data=True)) + ), + "is_connected": nx.is_weakly_connected(graph) + if graph.number_of_nodes() > 0 + else False, + } diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/src/similarity.py b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/src/similarity.py new file mode 100644 index 00000000000..b9741a5ef0e --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/src/similarity.py @@ -0,0 +1,501 @@ +""" +Calculate workflow similarity using graph edit distance. +""" + +import networkx as nx +from typing import Dict, List, Any, Optional +from src.config_loader import WorkflowComparisonConfig +from src.cost_functions import ( + node_substitution_cost, + node_deletion_cost, + node_insertion_cost, + edge_substitution_cost, + edge_deletion_cost, + edge_insertion_cost, + get_parameter_diff, +) + + +def calculate_graph_edit_distance( + g1: nx.DiGraph, g2: nx.DiGraph, config: WorkflowComparisonConfig +) -> Dict[str, Any]: + """ + Calculate graph edit distance with custom cost functions. + + Args: + g1: First workflow graph (generated) + g2: Second workflow graph (ground truth) + config: Configuration with cost weights + + Returns: + Dictionary with: + - similarity_score: 0-1 (1 = identical) + - edit_cost: Total cost of edits + - max_possible_cost: Theoretical maximum cost + - top_edits: List of most important edit operations + """ + # Handle empty graphs + if g1.number_of_nodes() == 0 and g2.number_of_nodes() == 0: + return { + "similarity_score": 1.0, + "edit_cost": 0.0, + "max_possible_cost": 0.0, + "top_edits": [], + } + + # Relabel graphs to use structural IDs instead of node names + # This ensures nodes are matched by type/position, not by name + g1_relabeled, g1_mapping = _relabel_graph_by_structure(g1) + g2_relabeled, g2_mapping = _relabel_graph_by_structure(g2) + + # Create cost function closures with config + # NetworkX passes node ATTRIBUTE DICTS, not node names + def node_subst_cost(n1_attrs, n2_attrs): + return node_substitution_cost(n1_attrs, n2_attrs, config) + + def node_del_cost(n_attrs): + return node_deletion_cost(n_attrs, config) + + def node_ins_cost(n_attrs): + return node_insertion_cost(n_attrs, config) + + # Edge match function - returns True if edges are equivalent + # This is better than cost functions for preventing false positives + def edge_match(e1_attrs, e2_attrs): + """Check if two edges match (same connection type or equivalent)""" + conn_type1 = e1_attrs.get("connection_type", "main") + conn_type2 = e2_attrs.get("connection_type", "main") + + # Exact match + if conn_type1 == conn_type2: + return True + + # Check for equivalent types in config + for equiv_group in config.equivalent_connection_types: + if conn_type1 in equiv_group and conn_type2 in equiv_group: + return True + + return False + + # Calculate GED using NetworkX + # Note: This can be slow for large graphs, but workflow graphs are typically small + try: + # Use optimize_edit_paths with edge_match instead of edge cost functions + # This prevents false positive edge insertions/deletions + edit_path_generator = nx.optimize_edit_paths( + g1_relabeled, + g2_relabeled, + node_subst_cost=node_subst_cost, + node_del_cost=node_del_cost, + node_ins_cost=node_ins_cost, + edge_match=edge_match, + upper_bound=None, # Calculate exact + ) + + # Get the best (first) edit path + best_edit_path = None + for node_edit_path, edge_edit_path, cost in edit_path_generator: + best_edit_path = (node_edit_path, edge_edit_path, cost) + break # Take the first (best) path + + if not best_edit_path: + # Fallback to basic calculation + edit_cost = _calculate_basic_edit_cost(g1, g2, config) + node_edit_path = [] + edge_edit_path = [] + else: + node_edit_path, edge_edit_path, edit_cost = best_edit_path + + # Extract and rank edit operations + # Use the actual edit path from NetworkX if available + if best_edit_path: + edit_ops = _extract_operations_from_path( + node_edit_path, + edge_edit_path, + g1_relabeled, + g2_relabeled, + config, + g1_mapping, + g2_mapping, + ) + else: + edit_ops = [] + except Exception as e: + # Fallback if NetworkX GED fails + print(f"Warning: GED calculation failed, using fallback: {e}") + edit_cost = _calculate_basic_edit_cost(g1, g2, config) + edit_ops = [] + + # Calculate theoretical maximum cost + max_cost = _calculate_max_cost(g1, g2, config) + + # Avoid division by zero + if max_cost == 0: + similarity_score = 1.0 if edit_cost == 0 else 0.0 + else: + # Similarity score: 1 - (cost / max_cost) + similarity_score = max(0.0, min(1.0, 1.0 - (edit_cost / max_cost))) + + return { + "similarity_score": similarity_score, + "edit_cost": edit_cost, + "max_possible_cost": max_cost, + "top_edits": sorted(edit_ops, key=lambda x: x["cost"], reverse=True), + } + + +def _calculate_basic_edit_cost( + g1: nx.DiGraph, g2: nx.DiGraph, config: WorkflowComparisonConfig +) -> float: + """ + Calculate a basic edit cost when full GED fails. + Uses simple node and edge count differences. + + Args: + g1: First graph + g2: Second graph + config: Configuration + + Returns: + Estimated edit cost + """ + cost = 0.0 + + # Node differences + nodes1 = set(g1.nodes()) + nodes2 = set(g2.nodes()) + + # Nodes only in g1 (need to be deleted) + deleted_nodes = nodes1 - nodes2 + cost += len(deleted_nodes) * config.node_deletion_cost + + # Nodes only in g2 (need to be inserted) + inserted_nodes = nodes2 - nodes1 + cost += len(inserted_nodes) * config.node_insertion_cost + + # Nodes in both (might need substitution) + common_nodes = nodes1 & nodes2 + for node in common_nodes: + subst_cost = node_substitution_cost(g1.nodes[node], g2.nodes[node], config) + cost += subst_cost + + # Edge differences + edges1 = set(g1.edges()) + edges2 = set(g2.edges()) + + deleted_edges = edges1 - edges2 + cost += len(deleted_edges) * config.edge_deletion_cost + + inserted_edges = edges2 - edges1 + cost += len(inserted_edges) * config.edge_insertion_cost + + return cost + + +def _calculate_max_cost( + g1: nx.DiGraph, g2: nx.DiGraph, config: WorkflowComparisonConfig +) -> float: + """ + Calculate theoretical maximum edit cost. + This represents the cost of completely transforming g1 to g2. + + Args: + g1: First graph + g2: Second graph + config: Configuration + + Returns: + Maximum possible cost + """ + # Worst case: delete all of g1, insert all of g2 + delete_cost = ( + len(g1.nodes()) * config.node_deletion_cost + + len(g1.edges()) * config.edge_deletion_cost + ) + insert_cost = ( + len(g2.nodes()) * config.node_insertion_cost + + len(g2.edges()) * config.edge_insertion_cost + ) + + return delete_cost + insert_cost + + +def _extract_operations_from_path( + node_edit_path: List[tuple], + edge_edit_path: List[tuple], + g1: nx.DiGraph, + g2: nx.DiGraph, + config: WorkflowComparisonConfig, + g1_name_mapping: Dict[str, str], + g2_name_mapping: Dict[str, str], +) -> List[Dict[str, Any]]: + """ + Extract edit operations from NetworkX's edit path. + + Args: + node_edit_path: List of node edit tuples (u, v) where: + - (u, v): nodes u in g1 and v in g2 are matched/substituted + - (u, None): node u in g1 is deleted + - (None, v): node v in g2 is inserted + edge_edit_path: List of edge edit tuples ((u1, v1), (u2, v2)) + g1, g2: Relabeled graphs + config: Configuration + g1_name_mapping, g2_name_mapping: Mappings to original names + + Returns: + List of edit operations with descriptions and costs + """ + operations = [] + + # Helper to get display name + def get_display_name( + node_id: str, mapping: Dict[str, str], graph: nx.DiGraph + ) -> str: + if mapping and node_id in mapping: + return mapping[node_id] + return graph.nodes[node_id].get("_original_name", node_id) + + # Process node edits + for u, v in node_edit_path: + if u is None: + # Node insertion (v in g2 is inserted) + node_data = g2.nodes[v] + display_name = get_display_name(v, g2_name_mapping, g2) + cost = node_insertion_cost(node_data, config) + if cost > 0: + operations.append( + { + "type": "node_insert", + "description": f"Add missing node '{display_name}' (type: {node_data.get('type', 'unknown')})", + "cost": cost, + "priority": _determine_priority( + cost, config, node_data, "node_insert" + ), + "node_name": display_name, + } + ) + elif v is None: + # Node deletion (u in g1 is deleted) + node_data = g1.nodes[u] + display_name = get_display_name(u, g1_name_mapping, g1) + cost = node_deletion_cost(node_data, config) + if cost > 0: + operations.append( + { + "type": "node_delete", + "description": f"Remove node '{display_name}' (type: {node_data.get('type', 'unknown')})", + "cost": cost, + "priority": _determine_priority( + cost, config, node_data, "node_delete" + ), + "node_name": display_name, + } + ) + else: + # Node substitution (u in g1 matched to v in g2) + node1_data = g1.nodes[u] + node2_data = g2.nodes[v] + display_name = get_display_name(u, g1_name_mapping, g1) + cost = node_substitution_cost(node1_data, node2_data, config) + if cost > 0: + type1 = node1_data.get("type", "unknown") + type2 = node2_data.get("type", "unknown") + + operation_data = { + "type": "node_substitute", + "cost": cost, + "priority": _determine_priority( + cost, config, node1_data, "node_substitute" + ), + "node_name": display_name, + } + + if type1 != type2: + operation_data["description"] = ( + f"Change node '{display_name}' from type '{type1}' to '{type2}'" + ) + else: + operation_data["description"] = ( + f"Update parameters of node '{display_name}' (type: {type1})" + ) + # Extract parameter diff for same-type substitutions + params1 = node1_data.get("parameters", {}) + params2 = node2_data.get("parameters", {}) + if params1 or params2: + param_diff = get_parameter_diff(params1, params2, type1, config) + if param_diff: + operation_data["parameter_diff"] = param_diff + + operations.append(operation_data) + + # Process edge edits + # Note: With edge_match function, the GED algorithm should only report + # edges that truly differ, so we don't need complex filtering here + for e1, e2 in edge_edit_path: + if e1 is None: + # Edge insertion + u2, v2 = e2 + edge_data = g2.edges[e2] + source_display = get_display_name(u2, g2_name_mapping, g2) + target_display = get_display_name(v2, g2_name_mapping, g2) + cost = edge_insertion_cost(edge_data, config) + if cost > 0: + operations.append( + { + "type": "edge_insert", + "description": f"Add missing connection from '{source_display}' to '{target_display}'", + "cost": cost, + "priority": _determine_priority(cost, config), + } + ) + elif e2 is None: + # Edge deletion + u1, v1 = e1 + edge_data = g1.edges[e1] + source_display = get_display_name(u1, g1_name_mapping, g1) + target_display = get_display_name(v1, g1_name_mapping, g1) + cost = edge_deletion_cost(edge_data, config) + if cost > 0: + operations.append( + { + "type": "edge_delete", + "description": f"Remove connection from '{source_display}' to '{target_display}'", + "cost": cost, + "priority": _determine_priority(cost, config), + } + ) + else: + # Edge substitution + u1, v1 = e1 + u2, v2 = e2 + edge1_data = g1.edges[e1] + edge2_data = g2.edges[e2] + cost = edge_substitution_cost(edge1_data, edge2_data, config) + if cost > 0: + source_display = get_display_name(u1, g1_name_mapping, g1) + target_display = get_display_name(v1, g1_name_mapping, g1) + operations.append( + { + "type": "edge_substitute", + "description": f"Update connection from '{source_display}' to '{target_display}'", + "cost": cost, + "priority": _determine_priority(cost, config), + } + ) + + return operations + + +def _determine_priority( + cost: float, + config: WorkflowComparisonConfig, + node_data: Optional[Dict[str, Any]] = None, + operation_type: Optional[str] = None, +) -> str: + """ + Determine priority level based on cost, node type, and operation. + + Args: + cost: Edit operation cost + config: Configuration + node_data: Optional node data to check if it's a trigger + operation_type: Type of operation (node_insert, node_delete, node_substitute, etc.) + + Returns: + Priority level: 'critical', 'major', or 'minor' + """ + # Critical: trigger deletions/insertions (but not minor parameter updates) + if node_data and node_data.get("is_trigger", False): + if operation_type in ("node_insert", "node_delete"): + return "critical" + + # Critical: trigger mismatches and high-cost operations + if cost >= config.node_substitution_trigger * 0.8: + return "critical" + elif cost >= config.node_substitution_different_type * 0.8: + return "major" + else: + return "minor" + + +def _relabel_graph_by_structure(graph: nx.DiGraph) -> tuple[nx.DiGraph, Dict[str, str]]: + """ + Relabel graph nodes using structural IDs instead of names. + + This ensures nodes are matched by their type and position in the workflow, + not by their display names. The original name is preserved as a node attribute. + + Args: + graph: Original graph with name-based node IDs + + Returns: + Tuple of (relabeled_graph, mapping_dict) where: + - relabeled_graph: Graph with structural IDs + - mapping_dict: Maps new IDs back to original names + """ + # Sort nodes by structural properties for consistent matching + nodes_with_data = list(graph.nodes(data=True)) + + # Define sorting key based on structural properties + def node_sort_key(node_tuple): + name, data = node_tuple + return ( + data.get("type", ""), # Sort by type for consistency + -graph.out_degree(name), # Then by out-degree (descending) + -graph.in_degree(name), # Then by in-degree (descending) + name, # Finally by name for deterministic ordering + ) + + # Separate and sort triggers and non-triggers + triggers = sorted( + [ + (name, data) + for name, data in nodes_with_data + if data.get("is_trigger", False) + ], + key=node_sort_key, + ) + non_triggers = sorted( + [ + (name, data) + for name, data in nodes_with_data + if not data.get("is_trigger", False) + ], + key=node_sort_key, + ) + + # Create new labels: trigger_0, trigger_1, node_0, node_1, etc. + mapping = {} + reverse_mapping = {} + + for i, (original_name, _) in enumerate(triggers): + new_label = f"trigger_{i}" + mapping[original_name] = new_label + reverse_mapping[new_label] = original_name + + for i, (original_name, _) in enumerate(non_triggers): + new_label = f"node_{i}" + mapping[original_name] = new_label + reverse_mapping[new_label] = original_name + + # Create relabeled graph - this preserves all node attributes + relabeled = nx.relabel_nodes(graph, mapping, copy=True) + + # Store original names in node attributes for matching + # This helps the GED algorithm match nodes correctly + for new_label, original_name in reverse_mapping.items(): + if new_label in relabeled.nodes: + relabeled.nodes[new_label]["_original_name"] = original_name + # Add a normalized name hash to help with matching nodes of the same type + # Normalize by replacing smart quotes with regular quotes for comparison + # U+2018 (') -> U+0027 ('), U+2019 (') -> U+0027 (') + # U+201C (") -> U+0022 ("), U+201D (") -> U+0022 (") + normalized_name = original_name.replace("\u2018", "'").replace( + "\u2019", "'" + ) + normalized_name = normalized_name.replace("\u201c", '"').replace( + "\u201d", '"' + ) + relabeled.nodes[new_label]["_name_hash"] = hash(normalized_name) + + return relabeled, reverse_mapping diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/tests/__init__.py b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/tests/__init__.py new file mode 100644 index 00000000000..3d7a576a31d --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for n8n workflow comparison""" diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/tests/test_graph_builder.py b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/tests/test_graph_builder.py new file mode 100644 index 00000000000..7ce1729b9d7 --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/tests/test_graph_builder.py @@ -0,0 +1,161 @@ +""" +Tests for graph_builder module. +""" + +from src.graph_builder import build_workflow_graph, graph_stats, _is_trigger_node +from src.config_loader import WorkflowComparisonConfig + + +def test_build_simple_workflow_graph(): + """Test building graph from simple workflow""" + workflow = { + "name": "Test Workflow", + "nodes": [ + { + "id": "1", + "name": "Trigger", + "type": "n8n-nodes-base.webhook", + "parameters": {"path": "/test"}, + }, + { + "id": "2", + "name": "Process", + "type": "n8n-nodes-base.code", + "parameters": {}, + }, + ], + "connections": { + "Trigger": {"main": [[{"node": "Process", "type": "main", "index": 0}]]} + }, + } + + graph = build_workflow_graph(workflow) + + assert graph.number_of_nodes() == 2 + assert graph.number_of_edges() == 1 + assert "Trigger" in graph.nodes + assert "Process" in graph.nodes + assert graph.has_edge("Trigger", "Process") + + +def test_build_graph_with_filtering(): + """Test that ignored nodes are filtered out""" + workflow = { + "name": "Test Workflow", + "nodes": [ + { + "id": "1", + "name": "Note", + "type": "n8n-nodes-base.stickyNote", + "parameters": {"content": "This is a note"}, + }, + { + "id": "2", + "name": "Trigger", + "type": "n8n-nodes-base.webhook", + "parameters": {}, + }, + ], + "connections": {}, + } + + config = WorkflowComparisonConfig() + config.ignored_node_types.add("n8n-nodes-base.stickyNote") + + graph = build_workflow_graph(workflow, config) + + # Sticky note should be filtered out + assert graph.number_of_nodes() == 1 + assert "Trigger" in graph.nodes + assert "Note" not in graph.nodes + + +def test_parameter_filtering(): + """Test that ignored parameters are filtered""" + workflow = { + "name": "Test", + "nodes": [ + { + "id": "1", + "name": "Node1", + "type": "test.node", + "position": [100, 200], + "parameters": { + "important": "value", + "position": [100, 200], + "id": "123", + }, + } + ], + "connections": {}, + } + + config = WorkflowComparisonConfig() + config.ignored_global_parameters = {"position", "id"} + + graph = build_workflow_graph(workflow, config) + + node_data = graph.nodes["Node1"] + params = node_data["parameters"] + + assert "important" in params + assert "position" not in params + assert "id" not in params + + +def test_is_trigger_node(): + """Test trigger node detection""" + trigger_node = {"type": "n8n-nodes-base.webhook", "name": "Webhook Trigger"} + assert _is_trigger_node(trigger_node) is True + + regular_node = {"type": "n8n-nodes-base.httpRequest", "name": "HTTP Request"} + assert _is_trigger_node(regular_node) is False + + +def test_graph_stats(): + """Test graph statistics calculation""" + workflow = { + "name": "Test", + "nodes": [ + { + "id": "1", + "name": "Trigger", + "type": "n8n-nodes-base.manualTrigger", + "parameters": {}, + }, + { + "id": "2", + "name": "Node1", + "type": "n8n-nodes-base.code", + "parameters": {}, + }, + { + "id": "3", + "name": "Node2", + "type": "n8n-nodes-base.code", + "parameters": {}, + }, + ], + "connections": { + "Trigger": {"main": [[{"node": "Node1", "type": "main", "index": 0}]]}, + "Node1": {"main": [[{"node": "Node2", "type": "main", "index": 0}]]}, + }, + } + + graph = build_workflow_graph(workflow) + stats = graph_stats(graph) + + assert stats["node_count"] == 3 + assert stats["edge_count"] == 2 + assert stats["trigger_count"] == 1 + assert stats["is_connected"] is True + + +def test_empty_workflow(): + """Test building graph from empty workflow""" + workflow = {"name": "Empty", "nodes": [], "connections": {}} + + graph = build_workflow_graph(workflow) + + assert graph.number_of_nodes() == 0 + assert graph.number_of_edges() == 0 diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/tests/test_similarity.py b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/tests/test_similarity.py new file mode 100644 index 00000000000..24d9ad23bae --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/tests/test_similarity.py @@ -0,0 +1,328 @@ +""" +Tests for similarity module. +""" + +from src.graph_builder import build_workflow_graph +from src.similarity import calculate_graph_edit_distance +from src.config_loader import WorkflowComparisonConfig + + +def test_identical_workflows(): + """Test that identical workflows have 100% similarity""" + workflow = { + "name": "Test", + "nodes": [ + { + "id": "1", + "name": "Trigger", + "type": "n8n-nodes-base.webhook", + "parameters": {"path": "/test"}, + } + ], + "connections": {}, + } + + config = WorkflowComparisonConfig() + g1 = build_workflow_graph(workflow, config) + g2 = build_workflow_graph(workflow, config) + + result = calculate_graph_edit_distance(g1, g2, config) + + assert result["similarity_score"] == 1.0 + assert result["edit_cost"] == 0.0 + assert len(result["top_edits"]) == 0 + + +def test_empty_workflows(): + """Test that empty workflows are identical""" + workflow = {"name": "Empty", "nodes": [], "connections": {}} + + config = WorkflowComparisonConfig() + g1 = build_workflow_graph(workflow, config) + g2 = build_workflow_graph(workflow, config) + + result = calculate_graph_edit_distance(g1, g2, config) + + assert result["similarity_score"] == 1.0 + assert result["edit_cost"] == 0.0 + + +def test_missing_node(): + """Test similarity when one workflow is missing a node""" + workflow1 = { + "name": "Test1", + "nodes": [{"id": "1", "name": "Node1", "type": "test.node", "parameters": {}}], + "connections": {}, + } + + workflow2 = { + "name": "Test2", + "nodes": [ + {"id": "1", "name": "Node1", "type": "test.node", "parameters": {}}, + {"id": "2", "name": "Node2", "type": "test.node", "parameters": {}}, + ], + "connections": {}, + } + + config = WorkflowComparisonConfig() + g1 = build_workflow_graph(workflow1, config) + g2 = build_workflow_graph(workflow2, config) + + result = calculate_graph_edit_distance(g1, g2, config) + + # Should have less than 100% similarity + assert result["similarity_score"] < 1.0 + # Should have edit operations + assert len(result["top_edits"]) > 0 + # Should have a node insertion edit + assert any(edit["type"] == "node_insert" for edit in result["top_edits"]) + + +def test_trigger_mismatch(): + """Test that trigger mismatches have high cost""" + workflow1 = { + "name": "Test1", + "nodes": [ + { + "id": "1", + "name": "Trigger", + "type": "n8n-nodes-base.webhook", + "parameters": {}, + } + ], + "connections": {}, + } + + workflow2 = { + "name": "Test2", + "nodes": [ + { + "id": "1", + "name": "Trigger", + "type": "n8n-nodes-base.manualTrigger", + "parameters": {}, + } + ], + "connections": {}, + } + + config = WorkflowComparisonConfig() + g1 = build_workflow_graph(workflow1, config) + g2 = build_workflow_graph(workflow2, config) + + result = calculate_graph_edit_distance(g1, g2, config) + + # Trigger mismatch should result in low similarity + assert result["similarity_score"] < 0.8 + + # NetworkX may choose delete+insert (cost 20) over substitution (cost 50) + # but the important thing is that we detect it as a critical issue + assert result["edit_cost"] >= ( + config.node_deletion_cost + config.node_insertion_cost + ) + + # Should have critical priority edit in the top edits + assert len(result["top_edits"]) > 0 + assert any(edit["priority"] == "critical" for edit in result["top_edits"]) + + +def test_parameter_differences(): + """Test that parameter differences affect similarity""" + workflow1 = { + "name": "Test1", + "nodes": [ + { + "id": "1", + "name": "Node", + "type": "test.node", + "parameters": {"value": "a"}, + } + ], + "connections": {}, + } + + workflow2 = { + "name": "Test2", + "nodes": [ + { + "id": "1", + "name": "Node", + "type": "test.node", + "parameters": {"value": "b"}, + } + ], + "connections": {}, + } + + config = WorkflowComparisonConfig() + g1 = build_workflow_graph(workflow1, config) + g2 = build_workflow_graph(workflow2, config) + + result = calculate_graph_edit_distance(g1, g2, config) + + # Should not be identical due to parameter difference + assert result["similarity_score"] < 1.0 + # But should still be fairly similar (same structure) + assert result["similarity_score"] > 0.8 + + +def test_connection_difference(): + """Test that connection differences are detected""" + workflow1 = { + "name": "Test1", + "nodes": [ + {"id": "1", "name": "Node1", "type": "test.node", "parameters": {}}, + {"id": "2", "name": "Node2", "type": "test.node", "parameters": {}}, + ], + "connections": {}, # No connections + } + + workflow2 = { + "name": "Test2", + "nodes": [ + {"id": "1", "name": "Node1", "type": "test.node", "parameters": {}}, + {"id": "2", "name": "Node2", "type": "test.node", "parameters": {}}, + ], + "connections": { + "Node1": {"main": [[{"node": "Node2", "type": "main", "index": 0}]]} + }, + } + + config = WorkflowComparisonConfig() + g1 = build_workflow_graph(workflow1, config) + g2 = build_workflow_graph(workflow2, config) + + result = calculate_graph_edit_distance(g1, g2, config) + + # Should have lower similarity due to missing connection + assert result["similarity_score"] < 1.0 + # Should have edge insertion in edits + assert any(edit["type"] == "edge_insert" for edit in result["top_edits"]) + + +def test_trigger_parameter_update_priority(): + """Test that minor trigger parameter updates are not marked as critical.""" + from src.config_loader import load_config + + workflow1 = { + "name": "Test1", + "nodes": [ + { + "id": "1", + "name": "Webhook", + "type": "n8n-nodes-base.webhook", + "parameters": {"path": "test1"}, + } + ], + "connections": {}, + } + + workflow2 = { + "name": "Test2", + "nodes": [ + { + "id": "1", + "name": "Webhook", + "type": "n8n-nodes-base.webhook", + "parameters": {"path": "test2"}, + } + ], + "connections": {}, + } + + config = load_config("preset:lenient") + g1 = build_workflow_graph(workflow1, config) + g2 = build_workflow_graph(workflow2, config) + + result = calculate_graph_edit_distance(g1, g2, config) + + # Should have high similarity (just parameter change) + assert result["similarity_score"] > 0.9 + + # Should have one edit (parameter update) + assert len(result["top_edits"]) == 1 + edit = result["top_edits"][0] + + # Should NOT be critical priority (just a minor parameter change) + assert edit["priority"] != "critical" + assert edit["type"] == "node_substitute" + + +def test_trigger_deletion_is_critical(): + """Test that trigger deletions are marked as critical.""" + from src.config_loader import WorkflowComparisonConfig + + workflow1 = { + "name": "Test1", + "nodes": [ + { + "id": "1", + "name": "Webhook", + "type": "n8n-nodes-base.webhook", + "parameters": {}, + }, + {"id": "2", "name": "Node", "type": "test.node", "parameters": {}}, + ], + "connections": {}, + } + + workflow2 = { + "name": "Test2", + "nodes": [{"id": "2", "name": "Node", "type": "test.node", "parameters": {}}], + "connections": {}, + } + + config = WorkflowComparisonConfig() + g1 = build_workflow_graph(workflow1, config) + g2 = build_workflow_graph(workflow2, config) + + result = calculate_graph_edit_distance(g1, g2, config) + + # Should have lower similarity + assert result["similarity_score"] < 0.8 + + # Should have a critical priority edit (trigger deletion) + assert any( + edit["priority"] == "critical" and edit["type"] == "node_delete" + for edit in result["top_edits"] + ) + + +def test_trigger_insertion_is_critical(): + """Test that trigger insertions are marked as critical.""" + from src.config_loader import WorkflowComparisonConfig + + workflow1 = { + "name": "Test1", + "nodes": [{"id": "1", "name": "Node", "type": "test.node", "parameters": {}}], + "connections": {}, + } + + workflow2 = { + "name": "Test2", + "nodes": [ + { + "id": "1", + "name": "Webhook", + "type": "n8n-nodes-base.webhook", + "parameters": {}, + }, + {"id": "2", "name": "Node", "type": "test.node", "parameters": {}}, + ], + "connections": {}, + } + + config = WorkflowComparisonConfig() + g1 = build_workflow_graph(workflow1, config) + g2 = build_workflow_graph(workflow2, config) + + result = calculate_graph_edit_distance(g1, g2, config) + + # Should have lower similarity + assert result["similarity_score"] < 0.8 + + # Should have a critical priority edit (trigger insertion) + assert any( + edit["priority"] == "critical" and edit["type"] == "node_insert" + for edit in result["top_edits"] + ) diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/uv.lock b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/uv.lock new file mode 100644 index 00000000000..9b735293087 --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/uv.lock @@ -0,0 +1,521 @@ +version = 1 +revision = 2 +requires-python = ">=3.11" + +[[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, upload_time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload_time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.11.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d2/59/9698d57a3b11704c7b89b21d69e9d23ecf80d538cabb536c8b63f4a12322/coverage-7.11.3.tar.gz", hash = "sha256:0f59387f5e6edbbffec2281affb71cdc85e0776c1745150a3ab9b6c1d016106b", size = 815210, upload_time = "2025-11-10T00:13:17.18Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/92/43a961c0f57b666d01c92bcd960c7f93677de5e4ee7ca722564ad6dee0fa/coverage-7.11.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:200bb89fd2a8a07780eafcdff6463104dec459f3c838d980455cfa84f5e5e6e1", size = 216504, upload_time = "2025-11-10T00:10:49.524Z" }, + { url = "https://files.pythonhosted.org/packages/5d/5c/dbfc73329726aef26dbf7fefef81b8a2afd1789343a579ea6d99bf15d26e/coverage-7.11.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8d264402fc179776d43e557e1ca4a7d953020d3ee95f7ec19cc2c9d769277f06", size = 217006, upload_time = "2025-11-10T00:10:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e0/878c84fb6661964bc435beb1e28c050650aa30e4c1cdc12341e298700bda/coverage-7.11.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:385977d94fc155f8731c895accdfcc3dd0d9dd9ef90d102969df95d3c637ab80", size = 247415, upload_time = "2025-11-10T00:10:52.805Z" }, + { url = "https://files.pythonhosted.org/packages/56/9e/0677e78b1e6a13527f39c4b39c767b351e256b333050539861c63f98bd61/coverage-7.11.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0542ddf6107adbd2592f29da9f59f5d9cff7947b5bb4f734805085c327dcffaa", size = 249332, upload_time = "2025-11-10T00:10:54.35Z" }, + { url = "https://files.pythonhosted.org/packages/54/90/25fc343e4ce35514262451456de0953bcae5b37dda248aed50ee51234cee/coverage-7.11.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d60bf4d7f886989ddf80e121a7f4d140d9eac91f1d2385ce8eb6bda93d563297", size = 251443, upload_time = "2025-11-10T00:10:55.832Z" }, + { url = "https://files.pythonhosted.org/packages/13/56/bc02bbc890fd8b155a64285c93e2ab38647486701ac9c980d457cdae857a/coverage-7.11.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0a3b6e32457535df0d41d2d895da46434706dd85dbaf53fbc0d3bd7d914b362", size = 247554, upload_time = "2025-11-10T00:10:57.829Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ab/0318888d091d799a82d788c1e8d8bd280f1d5c41662bbb6e11187efe33e8/coverage-7.11.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:876a3ee7fd2613eb79602e4cdb39deb6b28c186e76124c3f29e580099ec21a87", size = 249139, upload_time = "2025-11-10T00:10:59.465Z" }, + { url = "https://files.pythonhosted.org/packages/79/d8/3ee50929c4cd36fcfcc0f45d753337001001116c8a5b8dd18d27ea645737/coverage-7.11.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a730cd0824e8083989f304e97b3f884189efb48e2151e07f57e9e138ab104200", size = 247209, upload_time = "2025-11-10T00:11:01.432Z" }, + { url = "https://files.pythonhosted.org/packages/94/7c/3cf06e327401c293e60c962b4b8a2ceb7167c1a428a02be3adbd1d7c7e4c/coverage-7.11.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b5cd111d3ab7390be0c07ad839235d5ad54d2ca497b5f5db86896098a77180a4", size = 246936, upload_time = "2025-11-10T00:11:02.964Z" }, + { url = "https://files.pythonhosted.org/packages/99/0b/ffc03dc8f4083817900fd367110015ef4dd227b37284104a5eb5edc9c106/coverage-7.11.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:074e6a5cd38e06671580b4d872c1a67955d4e69639e4b04e87fc03b494c1f060", size = 247835, upload_time = "2025-11-10T00:11:04.405Z" }, + { url = "https://files.pythonhosted.org/packages/17/4d/dbe54609ee066553d0bcdcdf108b177c78dab836292bee43f96d6a5674d1/coverage-7.11.3-cp311-cp311-win32.whl", hash = "sha256:86d27d2dd7c7c5a44710565933c7dc9cd70e65ef97142e260d16d555667deef7", size = 218994, upload_time = "2025-11-10T00:11:05.966Z" }, + { url = "https://files.pythonhosted.org/packages/94/11/8e7155df53f99553ad8114054806c01a2c0b08f303ea7e38b9831652d83d/coverage-7.11.3-cp311-cp311-win_amd64.whl", hash = "sha256:ca90ef33a152205fb6f2f0c1f3e55c50df4ef049bb0940ebba666edd4cdebc55", size = 219926, upload_time = "2025-11-10T00:11:07.936Z" }, + { url = "https://files.pythonhosted.org/packages/1f/93/bea91b6a9e35d89c89a1cd5824bc72e45151a9c2a9ca0b50d9e9a85e3ae3/coverage-7.11.3-cp311-cp311-win_arm64.whl", hash = "sha256:56f909a40d68947ef726ce6a34eb38f0ed241ffbe55c5007c64e616663bcbafc", size = 218599, upload_time = "2025-11-10T00:11:09.578Z" }, + { url = "https://files.pythonhosted.org/packages/c2/39/af056ec7a27c487e25c7f6b6e51d2ee9821dba1863173ddf4dc2eebef4f7/coverage-7.11.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5b771b59ac0dfb7f139f70c85b42717ef400a6790abb6475ebac1ecee8de782f", size = 216676, upload_time = "2025-11-10T00:11:11.566Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f8/21126d34b174d037b5d01bea39077725cbb9a0da94a95c5f96929c695433/coverage-7.11.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:603c4414125fc9ae9000f17912dcfd3d3eb677d4e360b85206539240c96ea76e", size = 217034, upload_time = "2025-11-10T00:11:13.12Z" }, + { url = "https://files.pythonhosted.org/packages/d5/3f/0fd35f35658cdd11f7686303214bd5908225838f374db47f9e457c8d6df8/coverage-7.11.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:77ffb3b7704eb7b9b3298a01fe4509cef70117a52d50bcba29cffc5f53dd326a", size = 248531, upload_time = "2025-11-10T00:11:15.023Z" }, + { url = "https://files.pythonhosted.org/packages/8f/59/0bfc5900fc15ce4fd186e092451de776bef244565c840c9c026fd50857e1/coverage-7.11.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4d4ca49f5ba432b0755ebb0fc3a56be944a19a16bb33802264bbc7311622c0d1", size = 251290, upload_time = "2025-11-10T00:11:16.628Z" }, + { url = "https://files.pythonhosted.org/packages/71/88/d5c184001fa2ac82edf1b8f2cd91894d2230d7c309e937c54c796176e35b/coverage-7.11.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05fd3fb6edff0c98874d752013588836f458261e5eba587afe4c547bba544afd", size = 252375, upload_time = "2025-11-10T00:11:18.249Z" }, + { url = "https://files.pythonhosted.org/packages/5c/29/f60af9f823bf62c7a00ce1ac88441b9a9a467e499493e5cc65028c8b8dd2/coverage-7.11.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0e920567f8c3a3ce68ae5a42cf7c2dc4bb6cc389f18bff2235dd8c03fa405de5", size = 248946, upload_time = "2025-11-10T00:11:20.202Z" }, + { url = "https://files.pythonhosted.org/packages/67/16/4662790f3b1e03fce5280cad93fd18711c35980beb3c6f28dca41b5230c6/coverage-7.11.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4bec8c7160688bd5a34e65c82984b25409563134d63285d8943d0599efbc448e", size = 250310, upload_time = "2025-11-10T00:11:21.689Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/dd6c2e28308a83e5fc1ee602f8204bd3aa5af685c104cb54499230cf56db/coverage-7.11.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:adb9b7b42c802bd8cb3927de8c1c26368ce50c8fdaa83a9d8551384d77537044", size = 248461, upload_time = "2025-11-10T00:11:23.384Z" }, + { url = "https://files.pythonhosted.org/packages/16/fe/b71af12be9f59dc9eb060688fa19a95bf3223f56c5af1e9861dfa2275d2c/coverage-7.11.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c8f563b245b4ddb591e99f28e3cd140b85f114b38b7f95b2e42542f0603eb7d7", size = 248039, upload_time = "2025-11-10T00:11:25.07Z" }, + { url = "https://files.pythonhosted.org/packages/11/b8/023b2003a2cd96bdf607afe03d9b96c763cab6d76e024abe4473707c4eb8/coverage-7.11.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e2a96fdc7643c9517a317553aca13b5cae9bad9a5f32f4654ce247ae4d321405", size = 249903, upload_time = "2025-11-10T00:11:26.992Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ee/5f1076311aa67b1fa4687a724cc044346380e90ce7d94fec09fd384aa5fd/coverage-7.11.3-cp312-cp312-win32.whl", hash = "sha256:e8feeb5e8705835f0622af0fe7ff8d5cb388948454647086494d6c41ec142c2e", size = 219201, upload_time = "2025-11-10T00:11:28.619Z" }, + { url = "https://files.pythonhosted.org/packages/4f/24/d21688f48fe9fcc778956680fd5aaf69f4e23b245b7c7a4755cbd421d25b/coverage-7.11.3-cp312-cp312-win_amd64.whl", hash = "sha256:abb903ffe46bd319d99979cdba350ae7016759bb69f47882242f7b93f3356055", size = 220012, upload_time = "2025-11-10T00:11:30.234Z" }, + { url = "https://files.pythonhosted.org/packages/4f/9e/d5eb508065f291456378aa9b16698b8417d87cb084c2b597f3beb00a8084/coverage-7.11.3-cp312-cp312-win_arm64.whl", hash = "sha256:1451464fd855d9bd000c19b71bb7dafea9ab815741fb0bd9e813d9b671462d6f", size = 218652, upload_time = "2025-11-10T00:11:32.165Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f6/d8572c058211c7d976f24dab71999a565501fb5b3cdcb59cf782f19c4acb/coverage-7.11.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84b892e968164b7a0498ddc5746cdf4e985700b902128421bb5cec1080a6ee36", size = 216694, upload_time = "2025-11-10T00:11:34.296Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f6/b6f9764d90c0ce1bce8d995649fa307fff21f4727b8d950fa2843b7b0de5/coverage-7.11.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f761dbcf45e9416ec4698e1a7649248005f0064ce3523a47402d1bff4af2779e", size = 217065, upload_time = "2025-11-10T00:11:36.281Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8d/a12cb424063019fd077b5be474258a0ed8369b92b6d0058e673f0a945982/coverage-7.11.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1410bac9e98afd9623f53876fae7d8a5db9f5a0ac1c9e7c5188463cb4b3212e2", size = 248062, upload_time = "2025-11-10T00:11:37.903Z" }, + { url = "https://files.pythonhosted.org/packages/7f/9c/dab1a4e8e75ce053d14259d3d7485d68528a662e286e184685ea49e71156/coverage-7.11.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:004cdcea3457c0ea3233622cd3464c1e32ebba9b41578421097402bee6461b63", size = 250657, upload_time = "2025-11-10T00:11:39.509Z" }, + { url = "https://files.pythonhosted.org/packages/3f/89/a14f256438324f33bae36f9a1a7137729bf26b0a43f5eda60b147ec7c8c7/coverage-7.11.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f067ada2c333609b52835ca4d4868645d3b63ac04fb2b9a658c55bba7f667d3", size = 251900, upload_time = "2025-11-10T00:11:41.372Z" }, + { url = "https://files.pythonhosted.org/packages/04/07/75b0d476eb349f1296486b1418b44f2d8780cc8db47493de3755e5340076/coverage-7.11.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07bc7745c945a6d95676953e86ba7cebb9f11de7773951c387f4c07dc76d03f5", size = 248254, upload_time = "2025-11-10T00:11:43.27Z" }, + { url = "https://files.pythonhosted.org/packages/5a/4b/0c486581fa72873489ca092c52792d008a17954aa352809a7cbe6cf0bf07/coverage-7.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bba7e4743e37484ae17d5c3b8eb1ce78b564cb91b7ace2e2182b25f0f764cb5", size = 250041, upload_time = "2025-11-10T00:11:45.274Z" }, + { url = "https://files.pythonhosted.org/packages/af/a3/0059dafb240ae3e3291f81b8de00e9c511d3dd41d687a227dd4b529be591/coverage-7.11.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:fbffc22d80d86fbe456af9abb17f7a7766e7b2101f7edaacc3535501691563f7", size = 248004, upload_time = "2025-11-10T00:11:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/83/93/967d9662b1eb8c7c46917dcc7e4c1875724ac3e73c3cb78e86d7a0ac719d/coverage-7.11.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:0dba4da36730e384669e05b765a2c49f39514dd3012fcc0398dd66fba8d746d5", size = 247828, upload_time = "2025-11-10T00:11:48.563Z" }, + { url = "https://files.pythonhosted.org/packages/4c/1c/5077493c03215701e212767e470b794548d817dfc6247a4718832cc71fac/coverage-7.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ae12fe90b00b71a71b69f513773310782ce01d5f58d2ceb2b7c595ab9d222094", size = 249588, upload_time = "2025-11-10T00:11:50.581Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a5/77f64de461016e7da3e05d7d07975c89756fe672753e4cf74417fc9b9052/coverage-7.11.3-cp313-cp313-win32.whl", hash = "sha256:12d821de7408292530b0d241468b698bce18dd12ecaf45316149f53877885f8c", size = 219223, upload_time = "2025-11-10T00:11:52.184Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1c/ec51a3c1a59d225b44bdd3a4d463135b3159a535c2686fac965b698524f4/coverage-7.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:6bb599052a974bb6cedfa114f9778fedfad66854107cf81397ec87cb9b8fbcf2", size = 220033, upload_time = "2025-11-10T00:11:53.871Z" }, + { url = "https://files.pythonhosted.org/packages/01/ec/e0ce39746ed558564c16f2cc25fa95ce6fc9fa8bfb3b9e62855d4386b886/coverage-7.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:bb9d7efdb063903b3fdf77caec7b77c3066885068bdc0d44bc1b0c171033f944", size = 218661, upload_time = "2025-11-10T00:11:55.597Z" }, + { url = "https://files.pythonhosted.org/packages/46/cb/483f130bc56cbbad2638248915d97b185374d58b19e3cc3107359715949f/coverage-7.11.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:fb58da65e3339b3dbe266b607bb936efb983d86b00b03eb04c4ad5b442c58428", size = 217389, upload_time = "2025-11-10T00:11:57.59Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ae/81f89bae3afef75553cf10e62feb57551535d16fd5859b9ee5a2a97ddd27/coverage-7.11.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8d16bbe566e16a71d123cd66382c1315fcd520c7573652a8074a8fe281b38c6a", size = 217742, upload_time = "2025-11-10T00:11:59.519Z" }, + { url = "https://files.pythonhosted.org/packages/db/6e/a0fb897041949888191a49c36afd5c6f5d9f5fd757e0b0cd99ec198a324b/coverage-7.11.3-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8258f10059b5ac837232c589a350a2df4a96406d6d5f2a09ec587cbdd539655", size = 259049, upload_time = "2025-11-10T00:12:01.592Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b6/d13acc67eb402d91eb94b9bd60593411799aed09ce176ee8d8c0e39c94ca/coverage-7.11.3-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4c5627429f7fbff4f4131cfdd6abd530734ef7761116811a707b88b7e205afd7", size = 261113, upload_time = "2025-11-10T00:12:03.639Z" }, + { url = "https://files.pythonhosted.org/packages/ea/07/a6868893c48191d60406df4356aa7f0f74e6de34ef1f03af0d49183e0fa1/coverage-7.11.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:465695268414e149bab754c54b0c45c8ceda73dd4a5c3ba255500da13984b16d", size = 263546, upload_time = "2025-11-10T00:12:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/24/e5/28598f70b2c1098332bac47925806353b3313511d984841111e6e760c016/coverage-7.11.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ebcddfcdfb4c614233cff6e9a3967a09484114a8b2e4f2c7a62dc83676ba13f", size = 258260, upload_time = "2025-11-10T00:12:07.137Z" }, + { url = "https://files.pythonhosted.org/packages/0e/58/58e2d9e6455a4ed746a480c4b9cf96dc3cb2a6b8f3efbee5efd33ae24b06/coverage-7.11.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:13b2066303a1c1833c654d2af0455bb009b6e1727b3883c9964bc5c2f643c1d0", size = 261121, upload_time = "2025-11-10T00:12:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/17/57/38803eefb9b0409934cbc5a14e3978f0c85cb251d2b6f6a369067a7105a0/coverage-7.11.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d8750dd20362a1b80e3cf84f58013d4672f89663aee457ea59336df50fab6739", size = 258736, upload_time = "2025-11-10T00:12:11.195Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/f94683167156e93677b3442be1d4ca70cb33718df32a2eea44a5898f04f6/coverage-7.11.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ab6212e62ea0e1006531a2234e209607f360d98d18d532c2fa8e403c1afbdd71", size = 257625, upload_time = "2025-11-10T00:12:12.843Z" }, + { url = "https://files.pythonhosted.org/packages/87/ed/42d0bf1bc6bfa7d65f52299a31daaa866b4c11000855d753857fe78260ac/coverage-7.11.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a6b17c2b5e0b9bb7702449200f93e2d04cb04b1414c41424c08aa1e5d352da76", size = 259827, upload_time = "2025-11-10T00:12:15.128Z" }, + { url = "https://files.pythonhosted.org/packages/d3/76/5682719f5d5fbedb0c624c9851ef847407cae23362deb941f185f489c54e/coverage-7.11.3-cp313-cp313t-win32.whl", hash = "sha256:426559f105f644b69290ea414e154a0d320c3ad8a2bb75e62884731f69cf8e2c", size = 219897, upload_time = "2025-11-10T00:12:17.274Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/1da511d0ac3d39e6676fa6cc5ec35320bbf1cebb9b24e9ee7548ee4e931a/coverage-7.11.3-cp313-cp313t-win_amd64.whl", hash = "sha256:90a96fcd824564eae6137ec2563bd061d49a32944858d4bdbae5c00fb10e76ac", size = 220959, upload_time = "2025-11-10T00:12:19.292Z" }, + { url = "https://files.pythonhosted.org/packages/e5/9d/e255da6a04e9ec5f7b633c54c0fdfa221a9e03550b67a9c83217de12e96c/coverage-7.11.3-cp313-cp313t-win_arm64.whl", hash = "sha256:1e33d0bebf895c7a0905fcfaff2b07ab900885fc78bba2a12291a2cfbab014cc", size = 219234, upload_time = "2025-11-10T00:12:21.251Z" }, + { url = "https://files.pythonhosted.org/packages/84/d6/634ec396e45aded1772dccf6c236e3e7c9604bc47b816e928f32ce7987d1/coverage-7.11.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fdc5255eb4815babcdf236fa1a806ccb546724c8a9b129fd1ea4a5448a0bf07c", size = 216746, upload_time = "2025-11-10T00:12:23.089Z" }, + { url = "https://files.pythonhosted.org/packages/28/76/1079547f9d46f9c7c7d0dad35b6873c98bc5aa721eeabceafabd722cd5e7/coverage-7.11.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fe3425dc6021f906c6325d3c415e048e7cdb955505a94f1eb774dafc779ba203", size = 217077, upload_time = "2025-11-10T00:12:24.863Z" }, + { url = "https://files.pythonhosted.org/packages/2d/71/6ad80d6ae0d7cb743b9a98df8bb88b1ff3dc54491508a4a97549c2b83400/coverage-7.11.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4ca5f876bf41b24378ee67c41d688155f0e54cdc720de8ef9ad6544005899240", size = 248122, upload_time = "2025-11-10T00:12:26.553Z" }, + { url = "https://files.pythonhosted.org/packages/20/1d/784b87270784b0b88e4beec9d028e8d58f73ae248032579c63ad2ac6f69a/coverage-7.11.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9061a3e3c92b27fd8036dafa26f25d95695b6aa2e4514ab16a254f297e664f83", size = 250638, upload_time = "2025-11-10T00:12:28.555Z" }, + { url = "https://files.pythonhosted.org/packages/f5/26/b6dd31e23e004e9de84d1a8672cd3d73e50f5dae65dbd0f03fa2cdde6100/coverage-7.11.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abcea3b5f0dc44e1d01c27090bc32ce6ffb7aa665f884f1890710454113ea902", size = 251972, upload_time = "2025-11-10T00:12:30.246Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ef/f9c64d76faac56b82daa036b34d4fe9ab55eb37f22062e68e9470583e688/coverage-7.11.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:68c4eb92997dbaaf839ea13527be463178ac0ddd37a7ac636b8bc11a51af2428", size = 248147, upload_time = "2025-11-10T00:12:32.195Z" }, + { url = "https://files.pythonhosted.org/packages/b6/eb/5b666f90a8f8053bd264a1ce693d2edef2368e518afe70680070fca13ecd/coverage-7.11.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:149eccc85d48c8f06547534068c41d69a1a35322deaa4d69ba1561e2e9127e75", size = 249995, upload_time = "2025-11-10T00:12:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/eb/7b/871e991ffb5d067f8e67ffb635dabba65b231d6e0eb724a4a558f4a702a5/coverage-7.11.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:08c0bcf932e47795c49f0406054824b9d45671362dfc4269e0bc6e4bff010704", size = 247948, upload_time = "2025-11-10T00:12:36.341Z" }, + { url = "https://files.pythonhosted.org/packages/0a/8b/ce454f0af9609431b06dbe5485fc9d1c35ddc387e32ae8e374f49005748b/coverage-7.11.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:39764c6167c82d68a2d8c97c33dba45ec0ad9172570860e12191416f4f8e6e1b", size = 247770, upload_time = "2025-11-10T00:12:38.167Z" }, + { url = "https://files.pythonhosted.org/packages/61/8f/79002cb58a61dfbd2085de7d0a46311ef2476823e7938db80284cedd2428/coverage-7.11.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3224c7baf34e923ffc78cb45e793925539d640d42c96646db62dbd61bbcfa131", size = 249431, upload_time = "2025-11-10T00:12:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/58/cc/d06685dae97468ed22999440f2f2f5060940ab0e7952a7295f236d98cce7/coverage-7.11.3-cp314-cp314-win32.whl", hash = "sha256:c713c1c528284d636cd37723b0b4c35c11190da6f932794e145fc40f8210a14a", size = 219508, upload_time = "2025-11-10T00:12:42.231Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ed/770cd07706a3598c545f62d75adf2e5bd3791bffccdcf708ec383ad42559/coverage-7.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:c381a252317f63ca0179d2c7918e83b99a4ff3101e1b24849b999a00f9cd4f86", size = 220325, upload_time = "2025-11-10T00:12:44.065Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ac/6a1c507899b6fb1b9a56069954365f655956bcc648e150ce64c2b0ecbed8/coverage-7.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:3e33a968672be1394eded257ec10d4acbb9af2ae263ba05a99ff901bb863557e", size = 218899, upload_time = "2025-11-10T00:12:46.18Z" }, + { url = "https://files.pythonhosted.org/packages/9a/58/142cd838d960cd740654d094f7b0300d7b81534bb7304437d2439fb685fb/coverage-7.11.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f9c96a29c6d65bd36a91f5634fef800212dff69dacdb44345c4c9783943ab0df", size = 217471, upload_time = "2025-11-10T00:12:48.392Z" }, + { url = "https://files.pythonhosted.org/packages/bc/2c/2f44d39eb33e41ab3aba80571daad32e0f67076afcf27cb443f9e5b5a3ee/coverage-7.11.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2ec27a7a991d229213c8070d31e3ecf44d005d96a9edc30c78eaeafaa421c001", size = 217742, upload_time = "2025-11-10T00:12:50.182Z" }, + { url = "https://files.pythonhosted.org/packages/32/76/8ebc66c3c699f4de3174a43424c34c086323cd93c4930ab0f835731c443a/coverage-7.11.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:72c8b494bd20ae1c58528b97c4a67d5cfeafcb3845c73542875ecd43924296de", size = 259120, upload_time = "2025-11-10T00:12:52.451Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/78a3302b9595f331b86e4f12dfbd9252c8e93d97b8631500888f9a3a2af7/coverage-7.11.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:60ca149a446da255d56c2a7a813b51a80d9497a62250532598d249b3cdb1a926", size = 261229, upload_time = "2025-11-10T00:12:54.667Z" }, + { url = "https://files.pythonhosted.org/packages/07/59/1a9c0844dadef2a6efac07316d9781e6c5a3f3ea7e5e701411e99d619bfd/coverage-7.11.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb5069074db19a534de3859c43eec78e962d6d119f637c41c8e028c5ab3f59dd", size = 263642, upload_time = "2025-11-10T00:12:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/37/86/66c15d190a8e82eee777793cabde730640f555db3c020a179625a2ad5320/coverage-7.11.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac5d5329c9c942bbe6295f4251b135d860ed9f86acd912d418dce186de7c19ac", size = 258193, upload_time = "2025-11-10T00:12:58.687Z" }, + { url = "https://files.pythonhosted.org/packages/c7/c7/4a4aeb25cb6f83c3ec4763e5f7cc78da1c6d4ef9e22128562204b7f39390/coverage-7.11.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e22539b676fafba17f0a90ac725f029a309eb6e483f364c86dcadee060429d46", size = 261107, upload_time = "2025-11-10T00:13:00.502Z" }, + { url = "https://files.pythonhosted.org/packages/ed/91/b986b5035f23cf0272446298967ecdd2c3c0105ee31f66f7e6b6948fd7f8/coverage-7.11.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2376e8a9c889016f25472c452389e98bc6e54a19570b107e27cde9d47f387b64", size = 258717, upload_time = "2025-11-10T00:13:02.747Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c7/6c084997f5a04d050c513545d3344bfa17bd3b67f143f388b5757d762b0b/coverage-7.11.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4234914b8c67238a3c4af2bba648dc716aa029ca44d01f3d51536d44ac16854f", size = 257541, upload_time = "2025-11-10T00:13:04.689Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c5/38e642917e406930cb67941210a366ccffa767365c8f8d9ec0f465a8b218/coverage-7.11.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f0b4101e2b3c6c352ff1f70b3a6fcc7c17c1ab1a91ccb7a33013cb0782af9820", size = 259872, upload_time = "2025-11-10T00:13:06.559Z" }, + { url = "https://files.pythonhosted.org/packages/b7/67/5e812979d20c167f81dbf9374048e0193ebe64c59a3d93d7d947b07865fa/coverage-7.11.3-cp314-cp314t-win32.whl", hash = "sha256:305716afb19133762e8cf62745c46c4853ad6f9eeba54a593e373289e24ea237", size = 220289, upload_time = "2025-11-10T00:13:08.635Z" }, + { url = "https://files.pythonhosted.org/packages/24/3a/b72573802672b680703e0df071faadfab7dcd4d659aaaffc4626bc8bbde8/coverage-7.11.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9245bd392572b9f799261c4c9e7216bafc9405537d0f4ce3ad93afe081a12dc9", size = 221398, upload_time = "2025-11-10T00:13:10.734Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4e/649628f28d38bad81e4e8eb3f78759d20ac173e3c456ac629123815feb40/coverage-7.11.3-cp314-cp314t-win_arm64.whl", hash = "sha256:9a1d577c20b4334e5e814c3d5fe07fa4a8c3ae42a601945e8d7940bab811d0bd", size = 219435, upload_time = "2025-11-10T00:13:12.712Z" }, + { url = "https://files.pythonhosted.org/packages/19/8f/92bdd27b067204b99f396a1414d6342122f3e2663459baf787108a6b8b84/coverage-7.11.3-py3-none-any.whl", hash = "sha256:351511ae28e2509c8d8cae5311577ea7dd511ab8e746ffc8814a0896c3d33fbe", size = 208478, upload_time = "2025-11-10T00:13:14.908Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload_time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload_time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "n8n-workflow-comparison" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "networkx" }, + { name = "numpy" }, + { name = "pyyaml" }, + { name = "scipy" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, + { name = "ty" }, +] + +[package.metadata] +requires-dist = [ + { name = "networkx", specifier = ">=3.2" }, + { name = "numpy", specifier = ">=2.3.4" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "scipy", specifier = ">=1.16.3" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=9.0.1" }, + { name = "pytest-cov", specifier = ">=7.0.0" }, + { name = "ruff", specifier = ">=0.14.5" }, + { name = "ty", specifier = ">=0.0.1a26" }, +] + +[[package]] +name = "networkx" +version = "3.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/4f/ccdb8ad3a38e583f214547fd2f7ff1fc160c43a75af88e6aec213404b96a/networkx-3.5.tar.gz", hash = "sha256:d4c6f9cf81f52d69230866796b82afbccdec3db7ae4fbd1b65ea750feed50037", size = 2471065, upload_time = "2025-05-29T11:35:07.804Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/8d/776adee7bbf76365fdd7f2552710282c79a4ead5d2a46408c9043a2b70ba/networkx-3.5-py3-none-any.whl", hash = "sha256:0030d386a9a06dee3565298b4a734b68589749a544acbb6c412dc9e2489ec6ec", size = 2034406, upload_time = "2025-05-29T11:35:04.961Z" }, +] + +[[package]] +name = "numpy" +version = "2.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/f4/098d2270d52b41f1bd7db9fc288aaa0400cb48c2a3e2af6fa365d9720947/numpy-2.3.4.tar.gz", hash = "sha256:a7d018bfedb375a8d979ac758b120ba846a7fe764911a64465fd87b8729f4a6a", size = 20582187, upload_time = "2025-10-15T16:18:11.77Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/e7/0e07379944aa8afb49a556a2b54587b828eb41dc9adc56fb7615b678ca53/numpy-2.3.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e78aecd2800b32e8347ce49316d3eaf04aed849cd5b38e0af39f829a4e59f5eb", size = 21259519, upload_time = "2025-10-15T16:15:19.012Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cb/5a69293561e8819b09e34ed9e873b9a82b5f2ade23dce4c51dc507f6cfe1/numpy-2.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7fd09cc5d65bda1e79432859c40978010622112e9194e581e3415a3eccc7f43f", size = 14452796, upload_time = "2025-10-15T16:15:23.094Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/ff11611200acd602a1e5129e36cfd25bf01ad8e5cf927baf2e90236eb02e/numpy-2.3.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:1b219560ae2c1de48ead517d085bc2d05b9433f8e49d0955c82e8cd37bd7bf36", size = 5381639, upload_time = "2025-10-15T16:15:25.572Z" }, + { url = "https://files.pythonhosted.org/packages/ea/77/e95c757a6fe7a48d28a009267408e8aa382630cc1ad1db7451b3bc21dbb4/numpy-2.3.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:bafa7d87d4c99752d07815ed7a2c0964f8ab311eb8168f41b910bd01d15b6032", size = 6914296, upload_time = "2025-10-15T16:15:27.079Z" }, + { url = "https://files.pythonhosted.org/packages/a3/d2/137c7b6841c942124eae921279e5c41b1c34bab0e6fc60c7348e69afd165/numpy-2.3.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36dc13af226aeab72b7abad501d370d606326a0029b9f435eacb3b8c94b8a8b7", size = 14591904, upload_time = "2025-10-15T16:15:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/bb/32/67e3b0f07b0aba57a078c4ab777a9e8e6bc62f24fb53a2337f75f9691699/numpy-2.3.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7b2f9a18b5ff9824a6af80de4f37f4ec3c2aab05ef08f51c77a093f5b89adda", size = 16939602, upload_time = "2025-10-15T16:15:31.106Z" }, + { url = "https://files.pythonhosted.org/packages/95/22/9639c30e32c93c4cee3ccdb4b09c2d0fbff4dcd06d36b357da06146530fb/numpy-2.3.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9984bd645a8db6ca15d850ff996856d8762c51a2239225288f08f9050ca240a0", size = 16372661, upload_time = "2025-10-15T16:15:33.546Z" }, + { url = "https://files.pythonhosted.org/packages/12/e9/a685079529be2b0156ae0c11b13d6be647743095bb51d46589e95be88086/numpy-2.3.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:64c5825affc76942973a70acf438a8ab618dbd692b84cd5ec40a0a0509edc09a", size = 18884682, upload_time = "2025-10-15T16:15:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/cf/85/f6f00d019b0cc741e64b4e00ce865a57b6bed945d1bbeb1ccadbc647959b/numpy-2.3.4-cp311-cp311-win32.whl", hash = "sha256:ed759bf7a70342f7817d88376eb7142fab9fef8320d6019ef87fae05a99874e1", size = 6570076, upload_time = "2025-10-15T16:15:38.225Z" }, + { url = "https://files.pythonhosted.org/packages/7d/10/f8850982021cb90e2ec31990291f9e830ce7d94eef432b15066e7cbe0bec/numpy-2.3.4-cp311-cp311-win_amd64.whl", hash = "sha256:faba246fb30ea2a526c2e9645f61612341de1a83fb1e0c5edf4ddda5a9c10996", size = 13089358, upload_time = "2025-10-15T16:15:40.404Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ad/afdd8351385edf0b3445f9e24210a9c3971ef4de8fd85155462fc4321d79/numpy-2.3.4-cp311-cp311-win_arm64.whl", hash = "sha256:4c01835e718bcebe80394fd0ac66c07cbb90147ebbdad3dcecd3f25de2ae7e2c", size = 10462292, upload_time = "2025-10-15T16:15:42.896Z" }, + { url = "https://files.pythonhosted.org/packages/96/7a/02420400b736f84317e759291b8edaeee9dc921f72b045475a9cbdb26b17/numpy-2.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef1b5a3e808bc40827b5fa2c8196151a4c5abe110e1726949d7abddfe5c7ae11", size = 20957727, upload_time = "2025-10-15T16:15:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/18/90/a014805d627aa5750f6f0e878172afb6454552da929144b3c07fcae1bb13/numpy-2.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c2f91f496a87235c6aaf6d3f3d89b17dba64996abadccb289f48456cff931ca9", size = 14187262, upload_time = "2025-10-15T16:15:47.761Z" }, + { url = "https://files.pythonhosted.org/packages/c7/e4/0a94b09abe89e500dc748e7515f21a13e30c5c3fe3396e6d4ac108c25fca/numpy-2.3.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:f77e5b3d3da652b474cc80a14084927a5e86a5eccf54ca8ca5cbd697bf7f2667", size = 5115992, upload_time = "2025-10-15T16:15:50.144Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/db77c75b055c6157cbd4f9c92c4458daef0dd9cbe6d8d2fe7f803cb64c37/numpy-2.3.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:8ab1c5f5ee40d6e01cbe96de5863e39b215a4d24e7d007cad56c7184fdf4aeef", size = 6648672, upload_time = "2025-10-15T16:15:52.442Z" }, + { url = "https://files.pythonhosted.org/packages/e1/e6/e31b0d713719610e406c0ea3ae0d90760465b086da8783e2fd835ad59027/numpy-2.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77b84453f3adcb994ddbd0d1c5d11db2d6bda1a2b7fd5ac5bd4649d6f5dc682e", size = 14284156, upload_time = "2025-10-15T16:15:54.351Z" }, + { url = "https://files.pythonhosted.org/packages/f9/58/30a85127bfee6f108282107caf8e06a1f0cc997cb6b52cdee699276fcce4/numpy-2.3.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4121c5beb58a7f9e6dfdee612cb24f4df5cd4db6e8261d7f4d7450a997a65d6a", size = 16641271, upload_time = "2025-10-15T16:15:56.67Z" }, + { url = "https://files.pythonhosted.org/packages/06/f2/2e06a0f2adf23e3ae29283ad96959267938d0efd20a2e25353b70065bfec/numpy-2.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:65611ecbb00ac9846efe04db15cbe6186f562f6bb7e5e05f077e53a599225d16", size = 16059531, upload_time = "2025-10-15T16:15:59.412Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e7/b106253c7c0d5dc352b9c8fab91afd76a93950998167fa3e5afe4ef3a18f/numpy-2.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dabc42f9c6577bcc13001b8810d300fe814b4cfbe8a92c873f269484594f9786", size = 18578983, upload_time = "2025-10-15T16:16:01.804Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/04ecc41e71462276ee867ccbef26a4448638eadecf1bc56772c9ed6d0255/numpy-2.3.4-cp312-cp312-win32.whl", hash = "sha256:a49d797192a8d950ca59ee2d0337a4d804f713bb5c3c50e8db26d49666e351dc", size = 6291380, upload_time = "2025-10-15T16:16:03.938Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a8/566578b10d8d0e9955b1b6cd5db4e9d4592dd0026a941ff7994cedda030a/numpy-2.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:985f1e46358f06c2a09921e8921e2c98168ed4ae12ccd6e5e87a4f1857923f32", size = 12787999, upload_time = "2025-10-15T16:16:05.801Z" }, + { url = "https://files.pythonhosted.org/packages/58/22/9c903a957d0a8071b607f5b1bff0761d6e608b9a965945411f867d515db1/numpy-2.3.4-cp312-cp312-win_arm64.whl", hash = "sha256:4635239814149e06e2cb9db3dd584b2fa64316c96f10656983b8026a82e6e4db", size = 10197412, upload_time = "2025-10-15T16:16:07.854Z" }, + { url = "https://files.pythonhosted.org/packages/57/7e/b72610cc91edf138bc588df5150957a4937221ca6058b825b4725c27be62/numpy-2.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c090d4860032b857d94144d1a9976b8e36709e40386db289aaf6672de2a81966", size = 20950335, upload_time = "2025-10-15T16:16:10.304Z" }, + { url = "https://files.pythonhosted.org/packages/3e/46/bdd3370dcea2f95ef14af79dbf81e6927102ddf1cc54adc0024d61252fd9/numpy-2.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a13fc473b6db0be619e45f11f9e81260f7302f8d180c49a22b6e6120022596b3", size = 14179878, upload_time = "2025-10-15T16:16:12.595Z" }, + { url = "https://files.pythonhosted.org/packages/ac/01/5a67cb785bda60f45415d09c2bc245433f1c68dd82eef9c9002c508b5a65/numpy-2.3.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:3634093d0b428e6c32c3a69b78e554f0cd20ee420dcad5a9f3b2a63762ce4197", size = 5108673, upload_time = "2025-10-15T16:16:14.877Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cd/8428e23a9fcebd33988f4cb61208fda832800ca03781f471f3727a820704/numpy-2.3.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:043885b4f7e6e232d7df4f51ffdef8c36320ee9d5f227b380ea636722c7ed12e", size = 6641438, upload_time = "2025-10-15T16:16:16.805Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d1/913fe563820f3c6b079f992458f7331278dcd7ba8427e8e745af37ddb44f/numpy-2.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ee6a571d1e4f0ea6d5f22d6e5fbd6ed1dc2b18542848e1e7301bd190500c9d7", size = 14281290, upload_time = "2025-10-15T16:16:18.764Z" }, + { url = "https://files.pythonhosted.org/packages/9e/7e/7d306ff7cb143e6d975cfa7eb98a93e73495c4deabb7d1b5ecf09ea0fd69/numpy-2.3.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc8a63918b04b8571789688b2780ab2b4a33ab44bfe8ccea36d3eba51228c953", size = 16636543, upload_time = "2025-10-15T16:16:21.072Z" }, + { url = "https://files.pythonhosted.org/packages/47/6a/8cfc486237e56ccfb0db234945552a557ca266f022d281a2f577b98e955c/numpy-2.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:40cc556d5abbc54aabe2b1ae287042d7bdb80c08edede19f0c0afb36ae586f37", size = 16056117, upload_time = "2025-10-15T16:16:23.369Z" }, + { url = "https://files.pythonhosted.org/packages/b1/0e/42cb5e69ea901e06ce24bfcc4b5664a56f950a70efdcf221f30d9615f3f3/numpy-2.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ecb63014bb7f4ce653f8be7f1df8cbc6093a5a2811211770f6606cc92b5a78fd", size = 18577788, upload_time = "2025-10-15T16:16:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/86/92/41c3d5157d3177559ef0a35da50f0cda7fa071f4ba2306dd36818591a5bc/numpy-2.3.4-cp313-cp313-win32.whl", hash = "sha256:e8370eb6925bb8c1c4264fec52b0384b44f675f191df91cbe0140ec9f0955646", size = 6282620, upload_time = "2025-10-15T16:16:29.811Z" }, + { url = "https://files.pythonhosted.org/packages/09/97/fd421e8bc50766665ad35536c2bb4ef916533ba1fdd053a62d96cc7c8b95/numpy-2.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:56209416e81a7893036eea03abcb91c130643eb14233b2515c90dcac963fe99d", size = 12784672, upload_time = "2025-10-15T16:16:31.589Z" }, + { url = "https://files.pythonhosted.org/packages/ad/df/5474fb2f74970ca8eb978093969b125a84cc3d30e47f82191f981f13a8a0/numpy-2.3.4-cp313-cp313-win_arm64.whl", hash = "sha256:a700a4031bc0fd6936e78a752eefb79092cecad2599ea9c8039c548bc097f9bc", size = 10196702, upload_time = "2025-10-15T16:16:33.902Z" }, + { url = "https://files.pythonhosted.org/packages/11/83/66ac031464ec1767ea3ed48ce40f615eb441072945e98693bec0bcd056cc/numpy-2.3.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:86966db35c4040fdca64f0816a1c1dd8dbd027d90fca5a57e00e1ca4cd41b879", size = 21049003, upload_time = "2025-10-15T16:16:36.101Z" }, + { url = "https://files.pythonhosted.org/packages/5f/99/5b14e0e686e61371659a1d5bebd04596b1d72227ce36eed121bb0aeab798/numpy-2.3.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:838f045478638b26c375ee96ea89464d38428c69170360b23a1a50fa4baa3562", size = 14302980, upload_time = "2025-10-15T16:16:39.124Z" }, + { url = "https://files.pythonhosted.org/packages/2c/44/e9486649cd087d9fc6920e3fc3ac2aba10838d10804b1e179fb7cbc4e634/numpy-2.3.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d7315ed1dab0286adca467377c8381cd748f3dc92235f22a7dfc42745644a96a", size = 5231472, upload_time = "2025-10-15T16:16:41.168Z" }, + { url = "https://files.pythonhosted.org/packages/3e/51/902b24fa8887e5fe2063fd61b1895a476d0bbf46811ab0c7fdf4bd127345/numpy-2.3.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:84f01a4d18b2cc4ade1814a08e5f3c907b079c847051d720fad15ce37aa930b6", size = 6739342, upload_time = "2025-10-15T16:16:43.777Z" }, + { url = "https://files.pythonhosted.org/packages/34/f1/4de9586d05b1962acdcdb1dc4af6646361a643f8c864cef7c852bf509740/numpy-2.3.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:817e719a868f0dacde4abdfc5c1910b301877970195db9ab6a5e2c4bd5b121f7", size = 14354338, upload_time = "2025-10-15T16:16:46.081Z" }, + { url = "https://files.pythonhosted.org/packages/1f/06/1c16103b425de7969d5a76bdf5ada0804b476fed05d5f9e17b777f1cbefd/numpy-2.3.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85e071da78d92a214212cacea81c6da557cab307f2c34b5f85b628e94803f9c0", size = 16702392, upload_time = "2025-10-15T16:16:48.455Z" }, + { url = "https://files.pythonhosted.org/packages/34/b2/65f4dc1b89b5322093572b6e55161bb42e3e0487067af73627f795cc9d47/numpy-2.3.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2ec646892819370cf3558f518797f16597b4e4669894a2ba712caccc9da53f1f", size = 16134998, upload_time = "2025-10-15T16:16:51.114Z" }, + { url = "https://files.pythonhosted.org/packages/d4/11/94ec578896cdb973aaf56425d6c7f2aff4186a5c00fac15ff2ec46998b46/numpy-2.3.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:035796aaaddfe2f9664b9a9372f089cfc88bd795a67bd1bfe15e6e770934cf64", size = 18651574, upload_time = "2025-10-15T16:16:53.429Z" }, + { url = "https://files.pythonhosted.org/packages/62/b7/7efa763ab33dbccf56dade36938a77345ce8e8192d6b39e470ca25ff3cd0/numpy-2.3.4-cp313-cp313t-win32.whl", hash = "sha256:fea80f4f4cf83b54c3a051f2f727870ee51e22f0248d3114b8e755d160b38cfb", size = 6413135, upload_time = "2025-10-15T16:16:55.992Z" }, + { url = "https://files.pythonhosted.org/packages/43/70/aba4c38e8400abcc2f345e13d972fb36c26409b3e644366db7649015f291/numpy-2.3.4-cp313-cp313t-win_amd64.whl", hash = "sha256:15eea9f306b98e0be91eb344a94c0e630689ef302e10c2ce5f7e11905c704f9c", size = 12928582, upload_time = "2025-10-15T16:16:57.943Z" }, + { url = "https://files.pythonhosted.org/packages/67/63/871fad5f0073fc00fbbdd7232962ea1ac40eeaae2bba66c76214f7954236/numpy-2.3.4-cp313-cp313t-win_arm64.whl", hash = "sha256:b6c231c9c2fadbae4011ca5e7e83e12dc4a5072f1a1d85a0a7b3ed754d145a40", size = 10266691, upload_time = "2025-10-15T16:17:00.048Z" }, + { url = "https://files.pythonhosted.org/packages/72/71/ae6170143c115732470ae3a2d01512870dd16e0953f8a6dc89525696069b/numpy-2.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:81c3e6d8c97295a7360d367f9f8553973651b76907988bb6066376bc2252f24e", size = 20955580, upload_time = "2025-10-15T16:17:02.509Z" }, + { url = "https://files.pythonhosted.org/packages/af/39/4be9222ffd6ca8a30eda033d5f753276a9c3426c397bb137d8e19dedd200/numpy-2.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7c26b0b2bf58009ed1f38a641f3db4be8d960a417ca96d14e5b06df1506d41ff", size = 14188056, upload_time = "2025-10-15T16:17:04.873Z" }, + { url = "https://files.pythonhosted.org/packages/6c/3d/d85f6700d0a4aa4f9491030e1021c2b2b7421b2b38d01acd16734a2bfdc7/numpy-2.3.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:62b2198c438058a20b6704351b35a1d7db881812d8512d67a69c9de1f18ca05f", size = 5116555, upload_time = "2025-10-15T16:17:07.499Z" }, + { url = "https://files.pythonhosted.org/packages/bf/04/82c1467d86f47eee8a19a464c92f90a9bb68ccf14a54c5224d7031241ffb/numpy-2.3.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:9d729d60f8d53a7361707f4b68a9663c968882dd4f09e0d58c044c8bf5faee7b", size = 6643581, upload_time = "2025-10-15T16:17:09.774Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d3/c79841741b837e293f48bd7db89d0ac7a4f2503b382b78a790ef1dc778a5/numpy-2.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd0c630cf256b0a7fd9d0a11c9413b42fef5101219ce6ed5a09624f5a65392c7", size = 14299186, upload_time = "2025-10-15T16:17:11.937Z" }, + { url = "https://files.pythonhosted.org/packages/e8/7e/4a14a769741fbf237eec5a12a2cbc7a4c4e061852b6533bcb9e9a796c908/numpy-2.3.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5e081bc082825f8b139f9e9fe42942cb4054524598aaeb177ff476cc76d09d2", size = 16638601, upload_time = "2025-10-15T16:17:14.391Z" }, + { url = "https://files.pythonhosted.org/packages/93/87/1c1de269f002ff0a41173fe01dcc925f4ecff59264cd8f96cf3b60d12c9b/numpy-2.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15fb27364ed84114438fff8aaf998c9e19adbeba08c0b75409f8c452a8692c52", size = 16074219, upload_time = "2025-10-15T16:17:17.058Z" }, + { url = "https://files.pythonhosted.org/packages/cd/28/18f72ee77408e40a76d691001ae599e712ca2a47ddd2c4f695b16c65f077/numpy-2.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:85d9fb2d8cd998c84d13a79a09cc0c1091648e848e4e6249b0ccd7f6b487fa26", size = 18576702, upload_time = "2025-10-15T16:17:19.379Z" }, + { url = "https://files.pythonhosted.org/packages/c3/76/95650169b465ececa8cf4b2e8f6df255d4bf662775e797ade2025cc51ae6/numpy-2.3.4-cp314-cp314-win32.whl", hash = "sha256:e73d63fd04e3a9d6bc187f5455d81abfad05660b212c8804bf3b407e984cd2bc", size = 6337136, upload_time = "2025-10-15T16:17:22.886Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/a231a5c43ede5d6f77ba4a91e915a87dea4aeea76560ba4d2bf185c683f0/numpy-2.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:3da3491cee49cf16157e70f607c03a217ea6647b1cea4819c4f48e53d49139b9", size = 12920542, upload_time = "2025-10-15T16:17:24.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/0c/ae9434a888f717c5ed2ff2393b3f344f0ff6f1c793519fa0c540461dc530/numpy-2.3.4-cp314-cp314-win_arm64.whl", hash = "sha256:6d9cd732068e8288dbe2717177320723ccec4fb064123f0caf9bbd90ab5be868", size = 10480213, upload_time = "2025-10-15T16:17:26.935Z" }, + { url = "https://files.pythonhosted.org/packages/83/4b/c4a5f0841f92536f6b9592694a5b5f68c9ab37b775ff342649eadf9055d3/numpy-2.3.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:22758999b256b595cf0b1d102b133bb61866ba5ceecf15f759623b64c020c9ec", size = 21052280, upload_time = "2025-10-15T16:17:29.638Z" }, + { url = "https://files.pythonhosted.org/packages/3e/80/90308845fc93b984d2cc96d83e2324ce8ad1fd6efea81b324cba4b673854/numpy-2.3.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9cb177bc55b010b19798dc5497d540dea67fd13a8d9e882b2dae71de0cf09eb3", size = 14302930, upload_time = "2025-10-15T16:17:32.384Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4e/07439f22f2a3b247cec4d63a713faae55e1141a36e77fb212881f7cda3fb/numpy-2.3.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0f2bcc76f1e05e5ab58893407c63d90b2029908fa41f9f1cc51eecce936c3365", size = 5231504, upload_time = "2025-10-15T16:17:34.515Z" }, + { url = "https://files.pythonhosted.org/packages/ab/de/1e11f2547e2fe3d00482b19721855348b94ada8359aef5d40dd57bfae9df/numpy-2.3.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:8dc20bde86802df2ed8397a08d793da0ad7a5fd4ea3ac85d757bf5dd4ad7c252", size = 6739405, upload_time = "2025-10-15T16:17:36.128Z" }, + { url = "https://files.pythonhosted.org/packages/3b/40/8cd57393a26cebe2e923005db5134a946c62fa56a1087dc7c478f3e30837/numpy-2.3.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e199c087e2aa71c8f9ce1cb7a8e10677dc12457e7cc1be4798632da37c3e86e", size = 14354866, upload_time = "2025-10-15T16:17:38.884Z" }, + { url = "https://files.pythonhosted.org/packages/93/39/5b3510f023f96874ee6fea2e40dfa99313a00bf3ab779f3c92978f34aace/numpy-2.3.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85597b2d25ddf655495e2363fe044b0ae999b75bc4d630dc0d886484b03a5eb0", size = 16703296, upload_time = "2025-10-15T16:17:41.564Z" }, + { url = "https://files.pythonhosted.org/packages/41/0d/19bb163617c8045209c1996c4e427bccbc4bbff1e2c711f39203c8ddbb4a/numpy-2.3.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:04a69abe45b49c5955923cf2c407843d1c85013b424ae8a560bba16c92fe44a0", size = 16136046, upload_time = "2025-10-15T16:17:43.901Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c1/6dba12fdf68b02a21ac411c9df19afa66bed2540f467150ca64d246b463d/numpy-2.3.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e1708fac43ef8b419c975926ce1eaf793b0c13b7356cfab6ab0dc34c0a02ac0f", size = 18652691, upload_time = "2025-10-15T16:17:46.247Z" }, + { url = "https://files.pythonhosted.org/packages/f8/73/f85056701dbbbb910c51d846c58d29fd46b30eecd2b6ba760fc8b8a1641b/numpy-2.3.4-cp314-cp314t-win32.whl", hash = "sha256:863e3b5f4d9915aaf1b8ec79ae560ad21f0b8d5e3adc31e73126491bb86dee1d", size = 6485782, upload_time = "2025-10-15T16:17:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/17/90/28fa6f9865181cb817c2471ee65678afa8a7e2a1fb16141473d5fa6bacc3/numpy-2.3.4-cp314-cp314t-win_amd64.whl", hash = "sha256:962064de37b9aef801d33bc579690f8bfe6c5e70e29b61783f60bcba838a14d6", size = 13113301, upload_time = "2025-10-15T16:17:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/54/23/08c002201a8e7e1f9afba93b97deceb813252d9cfd0d3351caed123dcf97/numpy-2.3.4-cp314-cp314t-win_arm64.whl", hash = "sha256:8b5a9a39c45d852b62693d9b3f3e0fe052541f804296ff401a72a1b60edafb29", size = 10547532, upload_time = "2025-10-15T16:17:53.48Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b6/64898f51a86ec88ca1257a59c1d7fd077b60082a119affefcdf1dd0df8ca/numpy-2.3.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6e274603039f924c0fe5cb73438fa9246699c78a6df1bd3decef9ae592ae1c05", size = 21131552, upload_time = "2025-10-15T16:17:55.845Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4c/f135dc6ebe2b6a3c77f4e4838fa63d350f85c99462012306ada1bd4bc460/numpy-2.3.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d149aee5c72176d9ddbc6803aef9c0f6d2ceeea7626574fc68518da5476fa346", size = 14377796, upload_time = "2025-10-15T16:17:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a4/f33f9c23fcc13dd8412fc8614559b5b797e0aba9d8e01dfa8bae10c84004/numpy-2.3.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:6d34ed9db9e6395bb6cd33286035f73a59b058169733a9db9f85e650b88df37e", size = 5306904, upload_time = "2025-10-15T16:18:00.596Z" }, + { url = "https://files.pythonhosted.org/packages/28/af/c44097f25f834360f9fb960fa082863e0bad14a42f36527b2a121abdec56/numpy-2.3.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:fdebe771ca06bb8d6abce84e51dca9f7921fe6ad34a0c914541b063e9a68928b", size = 6819682, upload_time = "2025-10-15T16:18:02.32Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8c/cd283b54c3c2b77e188f63e23039844f56b23bba1712318288c13fe86baf/numpy-2.3.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e92defe6c08211eb77902253b14fe5b480ebc5112bc741fd5e9cd0608f847", size = 14422300, upload_time = "2025-10-15T16:18:04.271Z" }, + { url = "https://files.pythonhosted.org/packages/b0/f0/8404db5098d92446b3e3695cf41c6f0ecb703d701cb0b7566ee2177f2eee/numpy-2.3.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13b9062e4f5c7ee5c7e5be96f29ba71bc5a37fed3d1d77c37390ae00724d296d", size = 16760806, upload_time = "2025-10-15T16:18:06.668Z" }, + { url = "https://files.pythonhosted.org/packages/95/8e/2844c3959ce9a63acc7c8e50881133d86666f0420bcde695e115ced0920f/numpy-2.3.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:81b3a59793523e552c4a96109dde028aa4448ae06ccac5a76ff6532a85558a7f", size = 12973130, upload_time = "2025-10-15T16:18:09.397Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload_time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload_time = "2025-04-19T11:48:57.875Z" }, +] + +[[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, upload_time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload_time = "2025-05-15T12:30:06.134Z" }, +] + +[[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, upload_time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload_time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.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/07/56/f013048ac4bc4c1d9be45afd4ab209ea62822fb1598f40687e6bf45dcea4/pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8", size = 1564125, upload_time = "2025-11-12T13:05:09.333Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad", size = 373668, upload_time = "2025-11-12T13:05:07.379Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload_time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload_time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload_time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload_time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload_time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload_time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload_time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload_time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload_time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload_time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload_time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload_time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload_time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload_time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload_time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload_time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload_time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload_time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload_time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload_time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload_time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload_time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload_time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload_time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload_time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload_time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload_time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload_time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload_time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload_time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload_time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload_time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload_time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload_time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload_time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload_time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload_time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload_time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload_time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload_time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload_time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload_time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload_time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload_time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload_time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload_time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload_time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload_time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload_time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload_time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/fa/fbb67a5780ae0f704876cb8ac92d6d76da41da4dc72b7ed3565ab18f2f52/ruff-0.14.5.tar.gz", hash = "sha256:8d3b48d7d8aad423d3137af7ab6c8b1e38e4de104800f0d596990f6ada1a9fc1", size = 5615944, upload_time = "2025-11-13T19:58:51.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/31/c07e9c535248d10836a94e4f4e8c5a31a1beed6f169b31405b227872d4f4/ruff-0.14.5-py3-none-linux_armv6l.whl", hash = "sha256:f3b8248123b586de44a8018bcc9fefe31d23dda57a34e6f0e1e53bd51fd63594", size = 13171630, upload_time = "2025-11-13T19:57:54.894Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/283c62516dca697cd604c2796d1487396b7a436b2f0ecc3fd412aca470e0/ruff-0.14.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f7a75236570318c7a30edd7f5491945f0169de738d945ca8784500b517163a72", size = 13413925, upload_time = "2025-11-13T19:57:59.181Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/aa319f4afc22cb6fcba2b9cdfc0f03bbf747e59ab7a8c5e90173857a1361/ruff-0.14.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d146132d1ee115f8802356a2dc9a634dbf58184c51bff21f313e8cd1c74899a", size = 12574040, upload_time = "2025-11-13T19:58:02.056Z" }, + { url = "https://files.pythonhosted.org/packages/f9/7f/cb5845fcc7c7e88ed57f58670189fc2ff517fe2134c3821e77e29fd3b0c8/ruff-0.14.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2380596653dcd20b057794d55681571a257a42327da8894b93bbd6111aa801f", size = 13009755, upload_time = "2025-11-13T19:58:05.172Z" }, + { url = "https://files.pythonhosted.org/packages/21/d2/bcbedbb6bcb9253085981730687ddc0cc7b2e18e8dc13cf4453de905d7a0/ruff-0.14.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2d1fa985a42b1f075a098fa1ab9d472b712bdb17ad87a8ec86e45e7fa6273e68", size = 12937641, upload_time = "2025-11-13T19:58:08.345Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/e25de28a572bdd60ffc6bb71fc7fd25a94ec6a076942e372437649cbb02a/ruff-0.14.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88f0770d42b7fa02bbefddde15d235ca3aa24e2f0137388cc15b2dcbb1f7c7a7", size = 13610854, upload_time = "2025-11-13T19:58:11.419Z" }, + { url = "https://files.pythonhosted.org/packages/7d/24/43bb3fd23ecee9861970978ea1a7a63e12a204d319248a7e8af539984280/ruff-0.14.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3676cb02b9061fee7294661071c4709fa21419ea9176087cb77e64410926eb78", size = 15061088, upload_time = "2025-11-13T19:58:14.551Z" }, + { url = "https://files.pythonhosted.org/packages/23/44/a022f288d61c2f8c8645b24c364b719aee293ffc7d633a2ca4d116b9c716/ruff-0.14.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b595bedf6bc9cab647c4a173a61acf4f1ac5f2b545203ba82f30fcb10b0318fb", size = 14734717, upload_time = "2025-11-13T19:58:17.518Z" }, + { url = "https://files.pythonhosted.org/packages/58/81/5c6ba44de7e44c91f68073e0658109d8373b0590940efe5bd7753a2585a3/ruff-0.14.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f55382725ad0bdb2e8ee2babcbbfb16f124f5a59496a2f6a46f1d9d99d93e6e2", size = 14028812, upload_time = "2025-11-13T19:58:20.533Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ef/41a8b60f8462cb320f68615b00299ebb12660097c952c600c762078420f8/ruff-0.14.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7497d19dce23976bdaca24345ae131a1d38dcfe1b0850ad8e9e6e4fa321a6e19", size = 13825656, upload_time = "2025-11-13T19:58:23.345Z" }, + { url = "https://files.pythonhosted.org/packages/7c/00/207e5de737fdb59b39eb1fac806904fe05681981b46d6a6db9468501062e/ruff-0.14.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:410e781f1122d6be4f446981dd479470af86537fb0b8857f27a6e872f65a38e4", size = 13959922, upload_time = "2025-11-13T19:58:26.537Z" }, + { url = "https://files.pythonhosted.org/packages/bc/7e/fa1f5c2776db4be405040293618846a2dece5c70b050874c2d1f10f24776/ruff-0.14.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c01be527ef4c91a6d55e53b337bfe2c0f82af024cc1a33c44792d6844e2331e1", size = 12932501, upload_time = "2025-11-13T19:58:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/67/d8/d86bf784d693a764b59479a6bbdc9515ae42c340a5dc5ab1dabef847bfaa/ruff-0.14.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f66e9bb762e68d66e48550b59c74314168ebb46199886c5c5aa0b0fbcc81b151", size = 12927319, upload_time = "2025-11-13T19:58:32.923Z" }, + { url = "https://files.pythonhosted.org/packages/ac/de/ee0b304d450ae007ce0cb3e455fe24fbcaaedae4ebaad6c23831c6663651/ruff-0.14.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d93be8f1fa01022337f1f8f3bcaa7ffee2d0b03f00922c45c2207954f351f465", size = 13206209, upload_time = "2025-11-13T19:58:35.952Z" }, + { url = "https://files.pythonhosted.org/packages/33/aa/193ca7e3a92d74f17d9d5771a765965d2cf42c86e6f0fd95b13969115723/ruff-0.14.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c135d4b681f7401fe0e7312017e41aba9b3160861105726b76cfa14bc25aa367", size = 13953709, upload_time = "2025-11-13T19:58:39.002Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f1/7119e42aa1d3bf036ffc9478885c2e248812b7de9abea4eae89163d2929d/ruff-0.14.5-py3-none-win32.whl", hash = "sha256:c83642e6fccfb6dea8b785eb9f456800dcd6a63f362238af5fc0c83d027dd08b", size = 12925808, upload_time = "2025-11-13T19:58:42.779Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9d/7c0a255d21e0912114784e4a96bf62af0618e2190cae468cd82b13625ad2/ruff-0.14.5-py3-none-win_amd64.whl", hash = "sha256:9d55d7af7166f143c94eae1db3312f9ea8f95a4defef1979ed516dbb38c27621", size = 14331546, upload_time = "2025-11-13T19:58:45.691Z" }, + { url = "https://files.pythonhosted.org/packages/e5/80/69756670caedcf3b9be597a6e12276a6cf6197076eb62aad0c608f8efce0/ruff-0.14.5-py3-none-win_arm64.whl", hash = "sha256:4b700459d4649e2594b31f20a9de33bc7c19976d4746d8d0798ad959621d64a4", size = 13433331, upload_time = "2025-11-13T19:58:48.434Z" }, +] + +[[package]] +name = "scipy" +version = "1.16.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0a/ca/d8ace4f98322d01abcd52d381134344bf7b431eba7ed8b42bdea5a3c2ac9/scipy-1.16.3.tar.gz", hash = "sha256:01e87659402762f43bd2fee13370553a17ada367d42e7487800bf2916535aecb", size = 30597883, upload_time = "2025-10-28T17:38:54.068Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/5f/6f37d7439de1455ce9c5a556b8d1db0979f03a796c030bafdf08d35b7bf9/scipy-1.16.3-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:40be6cf99e68b6c4321e9f8782e7d5ff8265af28ef2cd56e9c9b2638fa08ad97", size = 36630881, upload_time = "2025-10-28T17:31:47.104Z" }, + { url = "https://files.pythonhosted.org/packages/7c/89/d70e9f628749b7e4db2aa4cd89735502ff3f08f7b9b27d2e799485987cd9/scipy-1.16.3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:8be1ca9170fcb6223cc7c27f4305d680ded114a1567c0bd2bfcbf947d1b17511", size = 28941012, upload_time = "2025-10-28T17:31:53.411Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a8/0e7a9a6872a923505dbdf6bb93451edcac120363131c19013044a1e7cb0c/scipy-1.16.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:bea0a62734d20d67608660f69dcda23e7f90fb4ca20974ab80b6ed40df87a005", size = 20931935, upload_time = "2025-10-28T17:31:57.361Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c7/020fb72bd79ad798e4dbe53938543ecb96b3a9ac3fe274b7189e23e27353/scipy-1.16.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:2a207a6ce9c24f1951241f4693ede2d393f59c07abc159b2cb2be980820e01fb", size = 23534466, upload_time = "2025-10-28T17:32:01.875Z" }, + { url = "https://files.pythonhosted.org/packages/be/a0/668c4609ce6dbf2f948e167836ccaf897f95fb63fa231c87da7558a374cd/scipy-1.16.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:532fb5ad6a87e9e9cd9c959b106b73145a03f04c7d57ea3e6f6bb60b86ab0876", size = 33593618, upload_time = "2025-10-28T17:32:06.902Z" }, + { url = "https://files.pythonhosted.org/packages/ca/6e/8942461cf2636cdae083e3eb72622a7fbbfa5cf559c7d13ab250a5dbdc01/scipy-1.16.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0151a0749efeaaab78711c78422d413c583b8cdd2011a3c1d6c794938ee9fdb2", size = 35899798, upload_time = "2025-10-28T17:32:12.665Z" }, + { url = "https://files.pythonhosted.org/packages/79/e8/d0f33590364cdbd67f28ce79368b373889faa4ee959588beddf6daef9abe/scipy-1.16.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b7180967113560cca57418a7bc719e30366b47959dd845a93206fbed693c867e", size = 36226154, upload_time = "2025-10-28T17:32:17.961Z" }, + { url = "https://files.pythonhosted.org/packages/39/c1/1903de608c0c924a1749c590064e65810f8046e437aba6be365abc4f7557/scipy-1.16.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:deb3841c925eeddb6afc1e4e4a45e418d19ec7b87c5df177695224078e8ec733", size = 38878540, upload_time = "2025-10-28T17:32:23.907Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d0/22ec7036ba0b0a35bccb7f25ab407382ed34af0b111475eb301c16f8a2e5/scipy-1.16.3-cp311-cp311-win_amd64.whl", hash = "sha256:53c3844d527213631e886621df5695d35e4f6a75f620dca412bcd292f6b87d78", size = 38722107, upload_time = "2025-10-28T17:32:29.921Z" }, + { url = "https://files.pythonhosted.org/packages/7b/60/8a00e5a524bb3bf8898db1650d350f50e6cffb9d7a491c561dc9826c7515/scipy-1.16.3-cp311-cp311-win_arm64.whl", hash = "sha256:9452781bd879b14b6f055b26643703551320aa8d79ae064a71df55c00286a184", size = 25506272, upload_time = "2025-10-28T17:32:34.577Z" }, + { url = "https://files.pythonhosted.org/packages/40/41/5bf55c3f386b1643812f3a5674edf74b26184378ef0f3e7c7a09a7e2ca7f/scipy-1.16.3-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:81fc5827606858cf71446a5e98715ba0e11f0dbc83d71c7409d05486592a45d6", size = 36659043, upload_time = "2025-10-28T17:32:40.285Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0f/65582071948cfc45d43e9870bf7ca5f0e0684e165d7c9ef4e50d783073eb/scipy-1.16.3-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:c97176013d404c7346bf57874eaac5187d969293bf40497140b0a2b2b7482e07", size = 28898986, upload_time = "2025-10-28T17:32:45.325Z" }, + { url = "https://files.pythonhosted.org/packages/96/5e/36bf3f0ac298187d1ceadde9051177d6a4fe4d507e8f59067dc9dd39e650/scipy-1.16.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:2b71d93c8a9936046866acebc915e2af2e292b883ed6e2cbe5c34beb094b82d9", size = 20889814, upload_time = "2025-10-28T17:32:49.277Z" }, + { url = "https://files.pythonhosted.org/packages/80/35/178d9d0c35394d5d5211bbff7ac4f2986c5488b59506fef9e1de13ea28d3/scipy-1.16.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:3d4a07a8e785d80289dfe66b7c27d8634a773020742ec7187b85ccc4b0e7b686", size = 23565795, upload_time = "2025-10-28T17:32:53.337Z" }, + { url = "https://files.pythonhosted.org/packages/fa/46/d1146ff536d034d02f83c8afc3c4bab2eddb634624d6529a8512f3afc9da/scipy-1.16.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0553371015692a898e1aa858fed67a3576c34edefa6b7ebdb4e9dde49ce5c203", size = 33349476, upload_time = "2025-10-28T17:32:58.353Z" }, + { url = "https://files.pythonhosted.org/packages/79/2e/415119c9ab3e62249e18c2b082c07aff907a273741b3f8160414b0e9193c/scipy-1.16.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:72d1717fd3b5e6ec747327ce9bda32d5463f472c9dce9f54499e81fbd50245a1", size = 35676692, upload_time = "2025-10-28T17:33:03.88Z" }, + { url = "https://files.pythonhosted.org/packages/27/82/df26e44da78bf8d2aeaf7566082260cfa15955a5a6e96e6a29935b64132f/scipy-1.16.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1fb2472e72e24d1530debe6ae078db70fb1605350c88a3d14bc401d6306dbffe", size = 36019345, upload_time = "2025-10-28T17:33:09.773Z" }, + { url = "https://files.pythonhosted.org/packages/82/31/006cbb4b648ba379a95c87262c2855cd0d09453e500937f78b30f02fa1cd/scipy-1.16.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5192722cffe15f9329a3948c4b1db789fbb1f05c97899187dcf009b283aea70", size = 38678975, upload_time = "2025-10-28T17:33:15.809Z" }, + { url = "https://files.pythonhosted.org/packages/c2/7f/acbd28c97e990b421af7d6d6cd416358c9c293fc958b8529e0bd5d2a2a19/scipy-1.16.3-cp312-cp312-win_amd64.whl", hash = "sha256:56edc65510d1331dae01ef9b658d428e33ed48b4f77b1d51caf479a0253f96dc", size = 38555926, upload_time = "2025-10-28T17:33:21.388Z" }, + { url = "https://files.pythonhosted.org/packages/ce/69/c5c7807fd007dad4f48e0a5f2153038dc96e8725d3345b9ee31b2b7bed46/scipy-1.16.3-cp312-cp312-win_arm64.whl", hash = "sha256:a8a26c78ef223d3e30920ef759e25625a0ecdd0d60e5a8818b7513c3e5384cf2", size = 25463014, upload_time = "2025-10-28T17:33:25.975Z" }, + { url = "https://files.pythonhosted.org/packages/72/f1/57e8327ab1508272029e27eeef34f2302ffc156b69e7e233e906c2a5c379/scipy-1.16.3-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:d2ec56337675e61b312179a1ad124f5f570c00f920cc75e1000025451b88241c", size = 36617856, upload_time = "2025-10-28T17:33:31.375Z" }, + { url = "https://files.pythonhosted.org/packages/44/13/7e63cfba8a7452eb756306aa2fd9b37a29a323b672b964b4fdeded9a3f21/scipy-1.16.3-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:16b8bc35a4cc24db80a0ec836a9286d0e31b2503cb2fd7ff7fb0e0374a97081d", size = 28874306, upload_time = "2025-10-28T17:33:36.516Z" }, + { url = "https://files.pythonhosted.org/packages/15/65/3a9400efd0228a176e6ec3454b1fa998fbbb5a8defa1672c3f65706987db/scipy-1.16.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:5803c5fadd29de0cf27fa08ccbfe7a9e5d741bf63e4ab1085437266f12460ff9", size = 20865371, upload_time = "2025-10-28T17:33:42.094Z" }, + { url = "https://files.pythonhosted.org/packages/33/d7/eda09adf009a9fb81827194d4dd02d2e4bc752cef16737cc4ef065234031/scipy-1.16.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:b81c27fc41954319a943d43b20e07c40bdcd3ff7cf013f4fb86286faefe546c4", size = 23524877, upload_time = "2025-10-28T17:33:48.483Z" }, + { url = "https://files.pythonhosted.org/packages/7d/6b/3f911e1ebc364cb81320223a3422aab7d26c9c7973109a9cd0f27c64c6c0/scipy-1.16.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0c3b4dd3d9b08dbce0f3440032c52e9e2ab9f96ade2d3943313dfe51a7056959", size = 33342103, upload_time = "2025-10-28T17:33:56.495Z" }, + { url = "https://files.pythonhosted.org/packages/21/f6/4bfb5695d8941e5c570a04d9fcd0d36bce7511b7d78e6e75c8f9791f82d0/scipy-1.16.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7dc1360c06535ea6116a2220f760ae572db9f661aba2d88074fe30ec2aa1ff88", size = 35697297, upload_time = "2025-10-28T17:34:04.722Z" }, + { url = "https://files.pythonhosted.org/packages/04/e1/6496dadbc80d8d896ff72511ecfe2316b50313bfc3ebf07a3f580f08bd8c/scipy-1.16.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:663b8d66a8748051c3ee9c96465fb417509315b99c71550fda2591d7dd634234", size = 36021756, upload_time = "2025-10-28T17:34:13.482Z" }, + { url = "https://files.pythonhosted.org/packages/fe/bd/a8c7799e0136b987bda3e1b23d155bcb31aec68a4a472554df5f0937eef7/scipy-1.16.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eab43fae33a0c39006a88096cd7b4f4ef545ea0447d250d5ac18202d40b6611d", size = 38696566, upload_time = "2025-10-28T17:34:22.384Z" }, + { url = "https://files.pythonhosted.org/packages/cd/01/1204382461fcbfeb05b6161b594f4007e78b6eba9b375382f79153172b4d/scipy-1.16.3-cp313-cp313-win_amd64.whl", hash = "sha256:062246acacbe9f8210de8e751b16fc37458213f124bef161a5a02c7a39284304", size = 38529877, upload_time = "2025-10-28T17:35:51.076Z" }, + { url = "https://files.pythonhosted.org/packages/7f/14/9d9fbcaa1260a94f4bb5b64ba9213ceb5d03cd88841fe9fd1ffd47a45b73/scipy-1.16.3-cp313-cp313-win_arm64.whl", hash = "sha256:50a3dbf286dbc7d84f176f9a1574c705f277cb6565069f88f60db9eafdbe3ee2", size = 25455366, upload_time = "2025-10-28T17:35:59.014Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a3/9ec205bd49f42d45d77f1730dbad9ccf146244c1647605cf834b3a8c4f36/scipy-1.16.3-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:fb4b29f4cf8cc5a8d628bc8d8e26d12d7278cd1f219f22698a378c3d67db5e4b", size = 37027931, upload_time = "2025-10-28T17:34:31.451Z" }, + { url = "https://files.pythonhosted.org/packages/25/06/ca9fd1f3a4589cbd825b1447e5db3a8ebb969c1eaf22c8579bd286f51b6d/scipy-1.16.3-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:8d09d72dc92742988b0e7750bddb8060b0c7079606c0d24a8cc8e9c9c11f9079", size = 29400081, upload_time = "2025-10-28T17:34:39.087Z" }, + { url = "https://files.pythonhosted.org/packages/6a/56/933e68210d92657d93fb0e381683bc0e53a965048d7358ff5fbf9e6a1b17/scipy-1.16.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:03192a35e661470197556de24e7cb1330d84b35b94ead65c46ad6f16f6b28f2a", size = 21391244, upload_time = "2025-10-28T17:34:45.234Z" }, + { url = "https://files.pythonhosted.org/packages/a8/7e/779845db03dc1418e215726329674b40576879b91814568757ff0014ad65/scipy-1.16.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:57d01cb6f85e34f0946b33caa66e892aae072b64b034183f3d87c4025802a119", size = 23929753, upload_time = "2025-10-28T17:34:51.793Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4b/f756cf8161d5365dcdef9e5f460ab226c068211030a175d2fc7f3f41ca64/scipy-1.16.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:96491a6a54e995f00a28a3c3badfff58fd093bf26cd5fb34a2188c8c756a3a2c", size = 33496912, upload_time = "2025-10-28T17:34:59.8Z" }, + { url = "https://files.pythonhosted.org/packages/09/b5/222b1e49a58668f23839ca1542a6322bb095ab8d6590d4f71723869a6c2c/scipy-1.16.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cd13e354df9938598af2be05822c323e97132d5e6306b83a3b4ee6724c6e522e", size = 35802371, upload_time = "2025-10-28T17:35:08.173Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8d/5964ef68bb31829bde27611f8c9deeac13764589fe74a75390242b64ca44/scipy-1.16.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:63d3cdacb8a824a295191a723ee5e4ea7768ca5ca5f2838532d9f2e2b3ce2135", size = 36190477, upload_time = "2025-10-28T17:35:16.7Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f2/b31d75cb9b5fa4dd39a0a931ee9b33e7f6f36f23be5ef560bf72e0f92f32/scipy-1.16.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e7efa2681ea410b10dde31a52b18b0154d66f2485328830e45fdf183af5aefc6", size = 38796678, upload_time = "2025-10-28T17:35:26.354Z" }, + { url = "https://files.pythonhosted.org/packages/b4/1e/b3723d8ff64ab548c38d87055483714fefe6ee20e0189b62352b5e015bb1/scipy-1.16.3-cp313-cp313t-win_amd64.whl", hash = "sha256:2d1ae2cf0c350e7705168ff2429962a89ad90c2d49d1dd300686d8b2a5af22fc", size = 38640178, upload_time = "2025-10-28T17:35:35.304Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f3/d854ff38789aca9b0cc23008d607ced9de4f7ab14fa1ca4329f86b3758ca/scipy-1.16.3-cp313-cp313t-win_arm64.whl", hash = "sha256:0c623a54f7b79dd88ef56da19bc2873afec9673a48f3b85b18e4d402bdd29a5a", size = 25803246, upload_time = "2025-10-28T17:35:42.155Z" }, + { url = "https://files.pythonhosted.org/packages/99/f6/99b10fd70f2d864c1e29a28bbcaa0c6340f9d8518396542d9ea3b4aaae15/scipy-1.16.3-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:875555ce62743e1d54f06cdf22c1e0bc47b91130ac40fe5d783b6dfa114beeb6", size = 36606469, upload_time = "2025-10-28T17:36:08.741Z" }, + { url = "https://files.pythonhosted.org/packages/4d/74/043b54f2319f48ea940dd025779fa28ee360e6b95acb7cd188fad4391c6b/scipy-1.16.3-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:bb61878c18a470021fb515a843dc7a76961a8daceaaaa8bad1332f1bf4b54657", size = 28872043, upload_time = "2025-10-28T17:36:16.599Z" }, + { url = "https://files.pythonhosted.org/packages/4d/e1/24b7e50cc1c4ee6ffbcb1f27fe9f4c8b40e7911675f6d2d20955f41c6348/scipy-1.16.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2622206f5559784fa5c4b53a950c3c7c1cf3e84ca1b9c4b6c03f062f289ca26", size = 20862952, upload_time = "2025-10-28T17:36:22.966Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3a/3e8c01a4d742b730df368e063787c6808597ccb38636ed821d10b39ca51b/scipy-1.16.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7f68154688c515cdb541a31ef8eb66d8cd1050605be9dcd74199cbd22ac739bc", size = 23508512, upload_time = "2025-10-28T17:36:29.731Z" }, + { url = "https://files.pythonhosted.org/packages/1f/60/c45a12b98ad591536bfe5330cb3cfe1850d7570259303563b1721564d458/scipy-1.16.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8b3c820ddb80029fe9f43d61b81d8b488d3ef8ca010d15122b152db77dc94c22", size = 33413639, upload_time = "2025-10-28T17:36:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/71/bc/35957d88645476307e4839712642896689df442f3e53b0fa016ecf8a3357/scipy-1.16.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d3837938ae715fc0fe3c39c0202de3a8853aff22ca66781ddc2ade7554b7e2cc", size = 35704729, upload_time = "2025-10-28T17:36:46.547Z" }, + { url = "https://files.pythonhosted.org/packages/3b/15/89105e659041b1ca11c386e9995aefacd513a78493656e57789f9d9eab61/scipy-1.16.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aadd23f98f9cb069b3bd64ddc900c4d277778242e961751f77a8cb5c4b946fb0", size = 36086251, upload_time = "2025-10-28T17:36:55.161Z" }, + { url = "https://files.pythonhosted.org/packages/1a/87/c0ea673ac9c6cc50b3da2196d860273bc7389aa69b64efa8493bdd25b093/scipy-1.16.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b7c5f1bda1354d6a19bc6af73a649f8285ca63ac6b52e64e658a5a11d4d69800", size = 38716681, upload_time = "2025-10-28T17:37:04.1Z" }, + { url = "https://files.pythonhosted.org/packages/91/06/837893227b043fb9b0d13e4bd7586982d8136cb249ffb3492930dab905b8/scipy-1.16.3-cp314-cp314-win_amd64.whl", hash = "sha256:e5d42a9472e7579e473879a1990327830493a7047506d58d73fc429b84c1d49d", size = 39358423, upload_time = "2025-10-28T17:38:20.005Z" }, + { url = "https://files.pythonhosted.org/packages/95/03/28bce0355e4d34a7c034727505a02d19548549e190bedd13a721e35380b7/scipy-1.16.3-cp314-cp314-win_arm64.whl", hash = "sha256:6020470b9d00245926f2d5bb93b119ca0340f0d564eb6fbaad843eaebf9d690f", size = 26135027, upload_time = "2025-10-28T17:38:24.966Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6f/69f1e2b682efe9de8fe9f91040f0cd32f13cfccba690512ba4c582b0bc29/scipy-1.16.3-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:e1d27cbcb4602680a49d787d90664fa4974063ac9d4134813332a8c53dbe667c", size = 37028379, upload_time = "2025-10-28T17:37:14.061Z" }, + { url = "https://files.pythonhosted.org/packages/7c/2d/e826f31624a5ebbab1cd93d30fd74349914753076ed0593e1d56a98c4fb4/scipy-1.16.3-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:9b9c9c07b6d56a35777a1b4cc8966118fb16cfd8daf6743867d17d36cfad2d40", size = 29400052, upload_time = "2025-10-28T17:37:21.709Z" }, + { url = "https://files.pythonhosted.org/packages/69/27/d24feb80155f41fd1f156bf144e7e049b4e2b9dd06261a242905e3bc7a03/scipy-1.16.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:3a4c460301fb2cffb7f88528f30b3127742cff583603aa7dc964a52c463b385d", size = 21391183, upload_time = "2025-10-28T17:37:29.559Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d3/1b229e433074c5738a24277eca520a2319aac7465eea7310ea6ae0e98ae2/scipy-1.16.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f667a4542cc8917af1db06366d3f78a5c8e83badd56409f94d1eac8d8d9133fa", size = 23930174, upload_time = "2025-10-28T17:37:36.306Z" }, + { url = "https://files.pythonhosted.org/packages/16/9d/d9e148b0ec680c0f042581a2be79a28a7ab66c0c4946697f9e7553ead337/scipy-1.16.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f379b54b77a597aa7ee5e697df0d66903e41b9c85a6dd7946159e356319158e8", size = 33497852, upload_time = "2025-10-28T17:37:42.228Z" }, + { url = "https://files.pythonhosted.org/packages/2f/22/4e5f7561e4f98b7bea63cf3fd7934bff1e3182e9f1626b089a679914d5c8/scipy-1.16.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4aff59800a3b7f786b70bfd6ab551001cb553244988d7d6b8299cb1ea653b353", size = 35798595, upload_time = "2025-10-28T17:37:48.102Z" }, + { url = "https://files.pythonhosted.org/packages/83/42/6644d714c179429fc7196857866f219fef25238319b650bb32dde7bf7a48/scipy-1.16.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:da7763f55885045036fabcebd80144b757d3db06ab0861415d1c3b7c69042146", size = 36186269, upload_time = "2025-10-28T17:37:53.72Z" }, + { url = "https://files.pythonhosted.org/packages/ac/70/64b4d7ca92f9cf2e6fc6aaa2eecf80bb9b6b985043a9583f32f8177ea122/scipy-1.16.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ffa6eea95283b2b8079b821dc11f50a17d0571c92b43e2b5b12764dc5f9b285d", size = 38802779, upload_time = "2025-10-28T17:37:59.393Z" }, + { url = "https://files.pythonhosted.org/packages/61/82/8d0e39f62764cce5ffd5284131e109f07cf8955aef9ab8ed4e3aa5e30539/scipy-1.16.3-cp314-cp314t-win_amd64.whl", hash = "sha256:d9f48cafc7ce94cf9b15c6bffdc443a81a27bf7075cf2dcd5c8b40f85d10c4e7", size = 39471128, upload_time = "2025-10-28T17:38:05.259Z" }, + { url = "https://files.pythonhosted.org/packages/64/47/a494741db7280eae6dc033510c319e34d42dd41b7ac0c7ead39354d1a2b5/scipy-1.16.3-cp314-cp314t-win_arm64.whl", hash = "sha256:21d9d6b197227a12dcbf9633320a4e34c6b0e51c57268df255a0942983bac562", size = 26464127, upload_time = "2025-10-28T17:38:11.34Z" }, +] + +[[package]] +name = "tomli" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload_time = "2025-10-08T22:01:47.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload_time = "2025-10-08T22:01:00.137Z" }, + { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload_time = "2025-10-08T22:01:01.63Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload_time = "2025-10-08T22:01:02.543Z" }, + { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload_time = "2025-10-08T22:01:03.836Z" }, + { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload_time = "2025-10-08T22:01:04.834Z" }, + { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload_time = "2025-10-08T22:01:05.84Z" }, + { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload_time = "2025-10-08T22:01:06.896Z" }, + { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload_time = "2025-10-08T22:01:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload_time = "2025-10-08T22:01:09.082Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload_time = "2025-10-08T22:01:10.266Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload_time = "2025-10-08T22:01:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload_time = "2025-10-08T22:01:12.498Z" }, + { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload_time = "2025-10-08T22:01:13.551Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload_time = "2025-10-08T22:01:14.614Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload_time = "2025-10-08T22:01:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload_time = "2025-10-08T22:01:16.51Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload_time = "2025-10-08T22:01:17.964Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload_time = "2025-10-08T22:01:18.959Z" }, + { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload_time = "2025-10-08T22:01:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload_time = "2025-10-08T22:01:21.164Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload_time = "2025-10-08T22:01:22.417Z" }, + { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload_time = "2025-10-08T22:01:23.859Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload_time = "2025-10-08T22:01:24.893Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload_time = "2025-10-08T22:01:26.153Z" }, + { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload_time = "2025-10-08T22:01:27.06Z" }, + { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload_time = "2025-10-08T22:01:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload_time = "2025-10-08T22:01:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload_time = "2025-10-08T22:01:31.98Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload_time = "2025-10-08T22:01:32.989Z" }, + { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload_time = "2025-10-08T22:01:34.052Z" }, + { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload_time = "2025-10-08T22:01:35.082Z" }, + { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload_time = "2025-10-08T22:01:36.057Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload_time = "2025-10-08T22:01:37.27Z" }, + { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload_time = "2025-10-08T22:01:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload_time = "2025-10-08T22:01:39.712Z" }, + { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload_time = "2025-10-08T22:01:40.773Z" }, + { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload_time = "2025-10-08T22:01:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload_time = "2025-10-08T22:01:43.177Z" }, + { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload_time = "2025-10-08T22:01:44.233Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload_time = "2025-10-08T22:01:45.234Z" }, + { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload_time = "2025-10-08T22:01:46.04Z" }, +] + +[[package]] +name = "ty" +version = "0.0.1a26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/39/b4b4ecb6ca6d7e937fa56f0b92a8f48d7719af8fe55bdbf667638e9f93e2/ty-0.0.1a26.tar.gz", hash = "sha256:65143f8efeb2da1644821b710bf6b702a31ddcf60a639d5a576db08bded91db4", size = 4432154, upload_time = "2025-11-10T18:02:30.142Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/6a/661833ecacc4d994f7e30a7f1307bfd3a4a91392a6b03fb6a018723e75b8/ty-0.0.1a26-py3-none-linux_armv6l.whl", hash = "sha256:09208dca99bb548e9200136d4d42618476bfe1f4d2066511f2c8e2e4dfeced5e", size = 9173869, upload_time = "2025-11-10T18:01:46.012Z" }, + { url = "https://files.pythonhosted.org/packages/66/a8/32ea50f064342de391a7267f84349287e2f1c2eb0ad4811d6110916179d6/ty-0.0.1a26-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:91d12b66c91a1b82e698a2aa73fe043a1a9da83ff0dfd60b970500bee0963b91", size = 8973420, upload_time = "2025-11-10T18:01:49.32Z" }, + { url = "https://files.pythonhosted.org/packages/d1/f6/6659d55940cd5158a6740ae46a65be84a7ee9167738033a9b1259c36eef5/ty-0.0.1a26-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5bc6dfcea5477c81ad01d6a29ebc9bfcbdb21c34664f79c9e1b84be7aa8f289", size = 8528888, upload_time = "2025-11-10T18:01:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/79/c9/4cbe7295013cc412b4f100b509aaa21982c08c59764a2efa537ead049345/ty-0.0.1a26-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40e5d15635e9918924138e8d3fb1cbf80822dfb8dc36ea8f3e72df598c0c4bea", size = 8801867, upload_time = "2025-11-10T18:01:53.888Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b3/25099b219a6444c4b29f175784a275510c1cd85a23a926d687ab56915027/ty-0.0.1a26-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:86dc147ed0790c7c8fd3f0d6c16c3c5135b01e99c440e89c6ca1e0e592bb6682", size = 8975519, upload_time = "2025-11-10T18:01:56.231Z" }, + { url = "https://files.pythonhosted.org/packages/73/3e/3ad570f4f592cb1d11982dd2c426c90d2aa9f3d38bf77a7e2ce8aa614302/ty-0.0.1a26-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fbe0e07c9d5e624edfc79a468f2ef191f9435581546a5bb6b92713ddc86ad4a6", size = 9331932, upload_time = "2025-11-10T18:01:58.476Z" }, + { url = "https://files.pythonhosted.org/packages/04/fa/62c72eead0302787f9cc0d613fc671107afeecdaf76ebb04db8f91bb9f7e/ty-0.0.1a26-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:0dcebbfe9f24b43d98a078f4a41321ae7b08bea40f5c27d81394b3f54e9f7fb5", size = 9921353, upload_time = "2025-11-10T18:02:00.749Z" }, + { url = "https://files.pythonhosted.org/packages/6c/1f/3b329c4b60d878704e09eb9d05467f911f188e699961c044b75932893e0a/ty-0.0.1a26-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0901b75afc7738224ffc98bbc8ea03a20f167a2a83a4b23a6550115e8b3ddbc6", size = 9700800, upload_time = "2025-11-10T18:02:03.544Z" }, + { url = "https://files.pythonhosted.org/packages/92/24/13fcba20dd86a7c3f83c814279aa3eb6a29c5f1b38a3b3a4a0fd22159189/ty-0.0.1a26-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4788f34d384c132977958d76fef7f274f8d181b22e33933c4d16cff2bb5ca3b9", size = 9728289, upload_time = "2025-11-10T18:02:06.386Z" }, + { url = "https://files.pythonhosted.org/packages/40/7a/798894ff0b948425570b969be35e672693beeb6b852815b7340bc8de1575/ty-0.0.1a26-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b98851c11c560ce63cd972ed9728aa079d9cf40483f2cdcf3626a55849bfe107", size = 9279735, upload_time = "2025-11-10T18:02:09.425Z" }, + { url = "https://files.pythonhosted.org/packages/1a/54/71261cc1b8dc7d3c4ad92a83b4d1681f5cb7ea5965ebcbc53311ae8c6424/ty-0.0.1a26-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c20b4625a20059adecd86fe2c4df87cd6115fea28caee45d3bdcf8fb83d29510", size = 8767428, upload_time = "2025-11-10T18:02:11.956Z" }, + { url = "https://files.pythonhosted.org/packages/8e/07/b248b73a640badba2b301e6845699b7dd241f40a321b9b1bce684d440f70/ty-0.0.1a26-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d9909e96276f8d16382d285db92ae902174cae842aa953003ec0c06642db2f8a", size = 9009170, upload_time = "2025-11-10T18:02:14.878Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/ec8353f2bb7fd2f41bca6070b29ecb58e2de9af043e649678b8c132d5439/ty-0.0.1a26-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a76d649ceefe9baa9bbae97d217bee076fd8eeb2a961f66f1dff73cc70af4ac8", size = 9119215, upload_time = "2025-11-10T18:02:18.329Z" }, + { url = "https://files.pythonhosted.org/packages/70/48/db49fe1b7e66edf90dc285869043f99c12aacf7a99c36ee760e297bac6d5/ty-0.0.1a26-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a0ee0f6366bcf70fae114e714d45335cacc8daa936037441e02998a9110b7a29", size = 9398655, upload_time = "2025-11-10T18:02:21.031Z" }, + { url = "https://files.pythonhosted.org/packages/10/f8/d869492bdbb21ae8cf4c99b02f20812bbbf49aa187cfeb387dfaa03036a8/ty-0.0.1a26-py3-none-win32.whl", hash = "sha256:86689b90024810cac7750bf0c6e1652e4b4175a9de7b82b8b1583202aeb47287", size = 8645669, upload_time = "2025-11-10T18:02:23.23Z" }, + { url = "https://files.pythonhosted.org/packages/b4/18/8a907575d2b335afee7556cb92233ebb5efcefe17752fc9dcab21cffb23b/ty-0.0.1a26-py3-none-win_amd64.whl", hash = "sha256:829e6e6dbd7d9d370f97b2398b4804552554bdcc2d298114fed5e2ea06cbc05c", size = 9442975, upload_time = "2025-11-10T18:02:25.68Z" }, + { url = "https://files.pythonhosted.org/packages/e9/22/af92dcfdd84b78dd97ac6b7154d6a763781f04a400140444885c297cc213/ty-0.0.1a26-py3-none-win_arm64.whl", hash = "sha256:b8f431c784d4cf5b4195a3521b2eca9c15902f239b91154cb920da33f943c62b", size = 8958958, upload_time = "2025-11-10T18:02:28.071Z" }, +] diff --git a/packages/@n8n/ai-workflow-builder.ee/package.json b/packages/@n8n/ai-workflow-builder.ee/package.json index a1b7796d665..8157197b946 100644 --- a/packages/@n8n/ai-workflow-builder.ee/package.json +++ b/packages/@n8n/ai-workflow-builder.ee/package.json @@ -27,7 +27,11 @@ "eval:csv": "tsx evaluations --prompts-csv", "eval:langsmith": "USE_LANGSMITH_EVAL=true tsx evaluations", "eval:generate": "GENERATE_TEST_CASES=true tsx evaluations", - "eval:categorize": "tsx scripts/categorize-prompts.ts" + "eval:categorize": "tsx scripts/categorize-prompts.ts", + "python:check": "cd evaluations/programmatic/python && just check", + "python:test": "cd evaluations/programmatic/python && just test", + "python:lint": "cd evaluations/programmatic/python && just lint", + "python:format": "cd evaluations/programmatic/python && just format" }, "main": "dist/index.js", "module": "src/index.ts",