first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,477 @@
import { mock } from 'jest-mock-extended';
import type { SimpleWorkflow } from '@/types';
import { evaluateAgentPrompt } from './agent-prompt';
describe('evaluateAgentPrompt', () => {
it('should return no violations for empty workflow', () => {
const workflow = mock<SimpleWorkflow>({
nodes: [],
connections: {},
});
const result = evaluateAgentPrompt(workflow);
expect(result.violations).toHaveLength(0);
});
it('should return no violations for workflow without agent nodes', () => {
const workflow = mock<SimpleWorkflow>({
nodes: [
{
id: '1',
name: 'Chat Trigger',
type: 'n8n-nodes-base.chatTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
{
id: '2',
name: 'Code Node',
type: 'n8n-nodes-base.code',
typeVersion: 1,
position: [100, 0],
parameters: {},
},
],
connections: {},
});
const result = evaluateAgentPrompt(workflow);
expect(result.violations).toHaveLength(0);
});
it('should return violation for agent node without expression in prompt', () => {
const workflow = mock<SimpleWorkflow>({
nodes: [
{
id: '1',
name: 'AI Agent',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 2,
position: [0, 0],
parameters: {
text: 'This is a static prompt without expressions',
options: {
systemMessage: 'You are a helpful agent.',
},
},
},
],
connections: {},
});
const result = evaluateAgentPrompt(workflow);
expect(result.violations).toHaveLength(1);
expect(result.violations[0]).toEqual({
type: 'major',
name: expect.any(String),
description:
'Agent node "AI Agent" has no expression in its prompt field. This likely means it failed to use chatInput or dynamic context',
pointsDeducted: 20,
});
});
it('should return no violations for agent node with expression in prompt', () => {
const workflow = mock<SimpleWorkflow>({
nodes: [
{
id: '1',
name: 'AI Agent',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 2,
position: [0, 0],
parameters: {
text: '=Process this request: {{ $json.chatInput }}',
options: {
systemMessage: 'You are a helpful agent.',
},
},
},
],
connections: {},
});
const result = evaluateAgentPrompt(workflow);
expect(result.violations).toHaveLength(0);
});
it('should handle different expression formats', () => {
const workflow = mock<SimpleWorkflow>({
nodes: [
{
id: '1',
name: 'Agent 1',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 2,
position: [0, 0],
parameters: {
text: '=Process: {{ $json.input }}',
options: {
systemMessage: 'You are a helpful agent.',
},
},
},
{
id: '2',
name: 'Agent 2',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 2,
position: [100, 0],
parameters: {
text: "=Process: {{$('Chat Trigger'.params.chatInput)}}",
options: {
systemMessage: 'You are a helpful agent.',
},
},
},
{
id: '3',
name: 'Agent 3',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 2,
position: [200, 0],
parameters: {
text: '={{ $json.chatInput }}',
options: {
systemMessage: 'You are a helpful agent.',
},
},
},
],
connections: {},
});
const result = evaluateAgentPrompt(workflow);
expect(result.violations).toHaveLength(0);
});
it('should not check agent nodes with promptType set to auto', () => {
const workflow = mock<SimpleWorkflow>({
nodes: [
{
id: '1',
name: 'AI Agent',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 2,
position: [0, 0],
parameters: {
promptType: 'auto',
text: 'This would normally trigger a violation',
},
},
],
connections: {},
});
const result = evaluateAgentPrompt(workflow);
expect(result.violations).toHaveLength(0);
});
it('should not check agent nodes with promptType set to guardrails', () => {
const workflow = mock<SimpleWorkflow>({
nodes: [
{
id: '1',
name: 'AI Agent',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 2,
position: [0, 0],
parameters: {
promptType: 'guardrails',
text: 'This would normally trigger a violation',
},
},
],
connections: {},
});
const result = evaluateAgentPrompt(workflow);
expect(result.violations).toHaveLength(0);
});
it('should not check agent nodes with promptType set to guardrails', () => {
const workflow = mock<SimpleWorkflow>({
nodes: [
{
id: '1',
name: 'AI Agent',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 2,
position: [0, 0],
parameters: {
promptType: 'guardrails',
text: 'This would normally trigger a violation',
},
},
],
connections: {},
});
const result = evaluateAgentPrompt(workflow);
expect(result.violations).toHaveLength(0);
});
it('should check agent nodes with promptType set to define', () => {
const workflow = mock<SimpleWorkflow>({
nodes: [
{
id: '1',
name: 'AI Agent',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 2,
position: [0, 0],
parameters: {
promptType: 'define',
text: 'Static text without expressions',
options: {
systemMessage: 'You are a helpful agent.',
},
},
},
],
connections: {},
});
const result = evaluateAgentPrompt(workflow);
expect(result.violations).toHaveLength(1);
expect(result.violations[0].pointsDeducted).toBe(20);
});
it('should handle missing parameters gracefully', () => {
const workflow = mock<SimpleWorkflow>({
nodes: [
{
id: '1',
name: 'AI Agent',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 2,
position: [0, 0],
parameters: {},
},
],
connections: {},
});
const result = evaluateAgentPrompt(workflow);
// Should have violations for: no expression + no systemMessage
expect(result.violations.length).toBeGreaterThanOrEqual(1);
expect(result.violations.some((v) => v.type === 'major')).toBe(true);
});
it('should detect multiple agents with issues', () => {
const workflow = mock<SimpleWorkflow>({
nodes: [
{
id: '1',
name: 'Agent 1',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 2,
position: [0, 0],
parameters: {
text: 'No expression here',
options: {
systemMessage: 'You are a helpful agent.',
},
},
},
{
id: '2',
name: 'Agent 2',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 2,
position: [100, 0],
parameters: {
text: 'Also no expression',
options: {
systemMessage: 'You are a helpful agent.',
},
},
},
{
id: '3',
name: 'Agent 3',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 2,
position: [200, 0],
parameters: {
text: '=Has expression: {{ $json.input }}',
options: {
systemMessage: 'You are a helpful agent.',
},
},
},
],
connections: {},
});
const result = evaluateAgentPrompt(workflow);
expect(result.violations).toHaveLength(2);
expect(result.violations[0].description).toContain('Agent 1');
expect(result.violations[1].description).toContain('Agent 2');
});
describe('System Message Separation', () => {
it('should flag agent with no systemMessage as major violation', () => {
const workflow = mock<SimpleWorkflow>({
nodes: [
{
id: '1',
name: 'Orchestrator Agent',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 3,
position: [0, 0],
parameters: {
promptType: 'define',
text: '=You are an orchestrator agent that coordinates specialized agents. Your task is to: 1) Call Research Agent 2) Call Fact-Check Agent. The research topic is: {{ $json.researchTopic }}',
},
},
],
connections: {},
});
const result = evaluateAgentPrompt(workflow);
// Should have major violation for missing systemMessage
expect(result.violations.length).toBeGreaterThan(0);
const systemMessageViolation = result.violations.find((v) =>
v.description.includes('no system message'),
);
expect(systemMessageViolation).toBeDefined();
expect(systemMessageViolation?.type).toBe('major');
expect(systemMessageViolation?.pointsDeducted).toBe(25);
});
it('should not flag agent when it has proper systemMessage', () => {
const workflow = mock<SimpleWorkflow>({
nodes: [
{
id: '1',
name: 'Agent',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 3,
position: [0, 0],
parameters: {
promptType: 'define',
text: '=You are an agent. Your task is to process: {{ $json.data }}',
options: {
systemMessage: 'You are a helpful agent.',
},
},
},
],
connections: {},
});
const result = evaluateAgentPrompt(workflow);
// Should not have any system message violations
const systemMessageViolations = result.violations.filter((v) =>
v.description.includes('system message'),
);
expect(systemMessageViolations).toHaveLength(0);
});
it('should pass for properly separated agent configuration', () => {
const workflow = mock<SimpleWorkflow>({
nodes: [
{
id: '1',
name: 'Orchestrator Agent',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 3,
position: [0, 0],
parameters: {
promptType: 'define',
text: '=The research topic is: {{ $json.researchTopic }}',
options: {
systemMessage:
'You are an orchestrator agent that coordinates specialized agents.\n\nYour task is to:\n1. Call the Research Agent Tool\n2. Call the Fact-Check Agent Tool\n3. Generate a report',
},
},
},
],
connections: {},
});
const result = evaluateAgentPrompt(workflow);
// Should have no violations
expect(result.violations).toHaveLength(0);
});
it('should handle agents with expressions in text and proper systemMessage', () => {
const testCases = [
{ text: "=You're analyzing: {{ $json.input }}" }, // Contains "you're" but has systemMessage
{ text: '=Process this data: {{ $json.data }}' },
{ text: '=User question: {{ $json.chatInput }}' },
{ text: '=Analyze for topic: {{ $json.researchTopic }}' },
];
testCases.forEach(({ text }, index) => {
const workflow = mock<SimpleWorkflow>({
nodes: [
{
id: `test-${index}`,
name: `Test Agent ${index}`,
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 3,
position: [0, 0],
parameters: {
promptType: 'define',
text,
options: {
systemMessage: 'You are a helpful assistant.',
},
},
},
],
connections: {},
});
const result = evaluateAgentPrompt(workflow);
// Should have no violations since systemMessage is present
expect(result.violations).toHaveLength(0);
});
});
it('should flag major violation when agent has no systemMessage', () => {
const workflow = mock<SimpleWorkflow>({
nodes: [
{
id: '1',
name: 'Agent',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 3,
position: [0, 0],
parameters: {
promptType: 'define',
text: '=Process: {{ $json.input }}',
},
},
],
connections: {},
});
const result = evaluateAgentPrompt(workflow);
const noSystemMessageViolation = result.violations.find((v) =>
v.description.includes('no system message'),
);
expect(noSystemMessageViolation).toBeDefined();
expect(noSystemMessageViolation?.type).toBe('major');
expect(noSystemMessageViolation?.pointsDeducted).toBe(25);
});
});
});
@@ -0,0 +1,11 @@
import type { SimpleWorkflow } from '@/types';
import { validateAgentPrompt } from '@/validation/checks';
import type { SingleEvaluatorResult } from '@/validation/types';
import { calcSingleEvaluatorScore } from '../score';
export function evaluateAgentPrompt(workflow: SimpleWorkflow): SingleEvaluatorResult {
const violations = validateAgentPrompt(workflow);
return { violations, score: calcSingleEvaluatorScore({ violations }) };
}
@@ -0,0 +1,15 @@
import type { INodeTypeDescription } from 'n8n-workflow';
import type { SimpleWorkflow } from '@/types';
import { validateConnections } from '@/validation/checks';
import type { SingleEvaluatorResult } from '@/validation/types';
import { calcSingleEvaluatorScore } from '../score';
export function evaluateConnections(
workflow: SimpleWorkflow,
nodeTypes: INodeTypeDescription[],
): SingleEvaluatorResult {
const violations = validateConnections(workflow, nodeTypes);
return { violations, score: calcSingleEvaluatorScore({ violations }) };
}
@@ -0,0 +1,242 @@
import { mock } from 'jest-mock-extended';
import type { INodeParameters } from 'n8n-workflow';
import type { SimpleWorkflow } from '@/types';
import { validateCredentials } from '@/validation/checks/credentials';
// Helper types
interface HeaderParam {
name: string;
value: string;
}
interface QueryParam {
name: string;
value: string;
}
interface Assignment {
name: string;
value: string;
type?: string;
}
interface HttpRequestNodeOptions {
name?: string;
id?: string;
headers?: HeaderParam[];
queryParams?: QueryParam[];
extraParams?: Record<string, unknown>;
}
interface SetNodeOptions {
name?: string;
id?: string;
assignments?: Assignment[];
}
// Helper functions
function createHttpRequestNode(options: HttpRequestNodeOptions = {}): SimpleWorkflow['nodes'][0] {
const { name = 'HTTP Request', id = '1', headers, queryParams, extraParams = {} } = options;
const parameters: Record<string, unknown> = {
method: 'GET',
url: 'https://api.example.com/data',
...extraParams,
};
if (headers) {
parameters.sendHeaders = true;
parameters.headerParameters = { parameters: headers };
}
if (queryParams) {
parameters.sendQuery = true;
parameters.queryParameters = { parameters: queryParams };
}
return {
id,
name,
type: 'n8n-nodes-base.httpRequest',
parameters: parameters as INodeParameters,
typeVersion: 4,
position: [0, 0],
};
}
function createSetNode(options: SetNodeOptions = {}): SimpleWorkflow['nodes'][0] {
const { name = 'Set', id = '1', assignments = [] } = options;
const parameters: Record<string, unknown> =
assignments.length > 0
? {
assignments: {
assignments: assignments.map((a) => ({ ...a, type: a.type ?? 'string' })),
},
}
: {};
return {
id,
name,
type: 'n8n-nodes-base.set',
parameters: parameters as INodeParameters,
typeVersion: 3,
position: [0, 0],
};
}
function createWorkflow(nodes: SimpleWorkflow['nodes']): SimpleWorkflow {
return mock<SimpleWorkflow>({
name: 'Test Workflow',
nodes,
connections: {},
});
}
describe('validateCredentials', () => {
describe('HTTP Request node validation', () => {
it('should flag hardcoded Authorization header', () => {
const workflow = createWorkflow([
createHttpRequestNode({
headers: [{ name: 'Authorization', value: 'Bearer sk_test_1234567890abcdef' }],
}),
]);
const violations = validateCredentials(workflow);
expect(violations).toContainEqual(
expect.objectContaining({
name: 'http-request-hardcoded-credentials',
type: 'minor',
}),
);
expect(violations[0].description).toContain('Authorization');
});
it('should flag hardcoded X-API-Key header', () => {
const workflow = createWorkflow([
createHttpRequestNode({
headers: [{ name: 'X-API-Key', value: 'my-secret-api-key-12345' }],
}),
]);
const violations = validateCredentials(workflow);
expect(violations).toContainEqual(
expect.objectContaining({
name: 'http-request-hardcoded-credentials',
type: 'minor',
}),
);
expect(violations[0].description).toContain('X-API-Key');
});
it('should allow Authorization header with expression', () => {
const workflow = createWorkflow([
createHttpRequestNode({
headers: [{ name: 'Authorization', value: '={{ $json.token }}' }],
}),
]);
const violations = validateCredentials(workflow);
expect(violations).toEqual([]);
});
it('should allow non-sensitive headers with hardcoded values', () => {
const workflow = createWorkflow([
createHttpRequestNode({
headers: [
{ name: 'Content-Type', value: 'application/json' },
{ name: 'Accept', value: 'application/json' },
],
}),
]);
const violations = validateCredentials(workflow);
expect(violations).toEqual([]);
});
it('should flag credential-like query parameters with hardcoded values', () => {
const workflow = createWorkflow([
createHttpRequestNode({
queryParams: [{ name: 'api_key', value: 'my-secret-key-12345' }],
}),
]);
const violations = validateCredentials(workflow);
expect(violations).toContainEqual(
expect.objectContaining({
name: 'http-request-hardcoded-credentials',
type: 'minor',
}),
);
expect(violations[0].description).toContain('api_key');
});
it('should allow query parameters with expressions', () => {
const workflow = createWorkflow([
createHttpRequestNode({
queryParams: [{ name: 'api_key', value: '={{ $json.apiKey }}' }],
}),
]);
const violations = validateCredentials(workflow);
expect(violations).toEqual([]);
});
it('should handle HTTP Request node without parameters', () => {
const workflow = createWorkflow([createHttpRequestNode()]);
const violations = validateCredentials(workflow);
expect(violations).toEqual([]);
});
});
describe('Set node validation', () => {
it.each([
['api_key', 'sk_test_12345'],
['access_token', 'ya29.a0AfB_byC...'],
['password', 'my-secret-password'],
['secret', 'top-secret-value'],
])('should flag field named "%s"', (fieldName, fieldValue) => {
const workflow = createWorkflow([
createSetNode({
assignments: [{ name: fieldName, value: fieldValue }],
}),
]);
const violations = validateCredentials(workflow);
expect(violations).toContainEqual(
expect.objectContaining({
name: 'set-node-credential-field',
type: 'minor',
}),
);
expect(violations[0].description).toContain(fieldName);
});
it('should allow normal field names', () => {
const workflow = createWorkflow([
createSetNode({
assignments: [
{ name: 'user_id', value: '12345' },
{ name: 'status', value: 'active' },
{ name: 'email', value: 'user@example.com' },
],
}),
]);
const violations = validateCredentials(workflow);
expect(violations).toEqual([]);
});
});
});
@@ -0,0 +1,10 @@
import type { SimpleWorkflow } from '@/types';
import { validateCredentials } from '@/validation/checks';
import type { SingleEvaluatorResult } from '@/validation/types';
import { calcSingleEvaluatorScore } from '../score';
export function evaluateCredentials(workflow: SimpleWorkflow): SingleEvaluatorResult {
const violations = validateCredentials(workflow);
return { violations, score: calcSingleEvaluatorScore({ violations }) };
}
@@ -0,0 +1,15 @@
import type { INodeTypeDescription } from 'n8n-workflow';
import type { SimpleWorkflow } from '@/types';
import { validateFromAi } from '@/validation/checks';
import type { SingleEvaluatorResult } from '@/validation/types';
import { calcSingleEvaluatorScore } from '../score';
export function evaluateFromAi(
workflow: SimpleWorkflow,
nodeTypes: INodeTypeDescription[],
): SingleEvaluatorResult {
const violations = validateFromAi(workflow, nodeTypes);
return { violations, score: calcSingleEvaluatorScore({ violations }) };
}
@@ -0,0 +1,114 @@
/**
* Graph validation evaluator - validates the internal graph structure
* of generated TypeScript SDK code using workflow-sdk's validate() method.
*/
import { parseWorkflowCodeToBuilder } from '@n8n/workflow-sdk';
import { stripImportStatements } from '@/code-builder/utils/extract-code';
import type {
ProgrammaticViolation,
ProgrammaticViolationName,
SingleEvaluatorResult,
} from '@/validation/types';
/**
* Convert SDK validation code (SCREAMING_SNAKE_CASE) to violation name (graph-kebab-case).
* E.g., "NO_NODES" -> "graph-no-nodes", "DISCONNECTED_NODE" -> "graph-disconnected-node"
*/
function codeToViolationName(code: string): ProgrammaticViolationName {
const kebab = code.toLowerCase().replace(/_/g, '-');
return `graph-${kebab}` as ProgrammaticViolationName;
}
/**
* Get violation type from a validation issue.
* Uses the violationLevel from the issue if present, otherwise defaults to 'minor'.
*/
function getViolationType(issue: {
violationLevel?: 'critical' | 'major' | 'minor';
}): 'critical' | 'major' | 'minor' {
return issue.violationLevel ?? 'minor';
}
/**
* Get points deducted for a violation based on its type
*/
function getPointsDeducted(type: 'critical' | 'major' | 'minor'): number {
switch (type) {
case 'critical':
return 25;
case 'major':
return 15;
case 'minor':
return 5;
}
}
/**
* Evaluate generated TypeScript SDK code using graph validation.
* Returns violations for any graph structure issues found.
*
* @param generatedCode - The TypeScript SDK code to validate
* @returns Evaluation result with score and violations
*/
export function evaluateGraphValidation(generatedCode: string | undefined): SingleEvaluatorResult {
// If no code provided, skip evaluation
if (!generatedCode) {
return {
score: 1, // Neutral - not applicable
violations: [],
};
}
const violations: ProgrammaticViolation[] = [];
try {
// Strip import statements before parsing (Acorn uses sourceType: 'script')
const cleanCode = stripImportStatements(generatedCode);
// Parse code to WorkflowBuilder
const builder = parseWorkflowCodeToBuilder(cleanCode);
// Run graph validation
const validation = builder.validate();
// Convert errors to violations
for (const error of validation.errors) {
const violationType = getViolationType(error);
violations.push({
name: codeToViolationName(error.code),
type: violationType,
description: error.message,
pointsDeducted: getPointsDeducted(violationType),
});
}
// Convert warnings to violations
for (const warning of validation.warnings) {
const violationType = getViolationType(warning);
violations.push({
name: codeToViolationName(warning.code),
type: violationType,
description: warning.message,
pointsDeducted: getPointsDeducted(violationType),
});
}
} catch (error) {
// Code parsing failed - this is a critical violation
violations.push({
name: 'graph-parse-error',
type: 'critical',
description: `Failed to parse code for graph validation: ${error instanceof Error ? error.message : String(error)}`,
pointsDeducted: 25,
});
}
// Calculate score (start at 100, deduct points)
const totalDeducted = violations.reduce((sum, v) => sum + v.pointsDeducted, 0);
const score = Math.max(0, 100 - totalDeducted) / 100;
return {
score,
violations,
};
}
@@ -0,0 +1,11 @@
export * from './agent-prompt';
export * from './connections';
export * from './credentials';
export * from './from-ai';
export * from './graph-validation';
export * from './nodes';
export * from './parameters';
export * from './tools';
export * from './trigger';
export * from './node-usage';
export * from './workflow-similarity';
@@ -0,0 +1,10 @@
import type { SimpleWorkflow } from '@/types';
import { validateWebhookResponse } from '@/validation/checks';
import type { SingleEvaluatorResult } from '@/validation/types';
import { calcSingleEvaluatorScore } from '../score';
export function evaluateNodeUsage(workflow: SimpleWorkflow): SingleEvaluatorResult {
const violations = validateWebhookResponse(workflow);
return { violations, score: calcSingleEvaluatorScore({ violations }) };
}
@@ -0,0 +1,290 @@
import { mock } from 'jest-mock-extended';
import type { INodeTypeDescription } from 'n8n-workflow';
import type { SimpleWorkflow } from '@/types';
import { evaluateNodes } from './nodes';
describe('evaluateNodes', () => {
const mockNodeTypes: INodeTypeDescription[] = [
mock<INodeTypeDescription>({
name: 'n8n-nodes-base.manualTrigger',
displayName: 'Manual Trigger',
group: ['trigger'],
inputs: [],
outputs: ['main'],
}),
mock<INodeTypeDescription>({
name: 'n8n-nodes-base.executeWorkflowTrigger',
displayName: 'Execute Workflow Trigger',
group: ['trigger'],
inputs: [],
outputs: ['main'],
maxNodes: 1,
}),
mock<INodeTypeDescription>({
name: 'n8n-nodes-base.code',
displayName: 'Code',
group: ['transform'],
inputs: ['main'],
outputs: ['main'],
}),
];
describe('basic validation', () => {
it('should detect workflow with no nodes', () => {
const workflow = mock<SimpleWorkflow>({
name: 'Empty Workflow',
nodes: [],
connections: {},
});
const result = evaluateNodes(workflow, mockNodeTypes);
expect(result.violations).toContainEqual(
expect.objectContaining({ description: 'Workflow has no nodes' }),
);
});
it('should accept workflow with nodes', () => {
const workflow = mock<SimpleWorkflow>({
name: 'Valid Workflow',
nodes: [
{
id: '1',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
parameters: {},
typeVersion: 1,
position: [0, 0],
},
],
connections: {},
});
const result = evaluateNodes(workflow, mockNodeTypes);
expect(result.violations).toEqual([]);
});
});
describe('maxNodes validation', () => {
it('should accept workflow with nodes within maxNodes limit', () => {
const workflow = mock<SimpleWorkflow>({
name: 'Single Execute Workflow Trigger',
nodes: [
{
id: '1',
name: 'Execute Workflow Trigger',
type: 'n8n-nodes-base.executeWorkflowTrigger',
parameters: {},
typeVersion: 1,
position: [0, 0],
},
{
id: '2',
name: 'Code',
type: 'n8n-nodes-base.code',
parameters: {},
typeVersion: 1,
position: [200, 0],
},
],
connections: {},
});
const result = evaluateNodes(workflow, mockNodeTypes);
expect(result.violations).toEqual([]);
});
it('should detect workflow exceeding maxNodes limit', () => {
const workflow = mock<SimpleWorkflow>({
name: 'Multiple Execute Workflow Triggers',
nodes: [
{
id: '1',
name: 'Execute Workflow Trigger 1',
type: 'n8n-nodes-base.executeWorkflowTrigger',
parameters: {},
typeVersion: 1,
position: [0, 0],
},
{
id: '2',
name: 'Execute Workflow Trigger 2',
type: 'n8n-nodes-base.executeWorkflowTrigger',
parameters: {},
typeVersion: 1,
position: [0, 200],
},
{
id: '3',
name: 'Code',
type: 'n8n-nodes-base.code',
parameters: {},
typeVersion: 1,
position: [200, 0],
},
],
connections: {},
});
const result = evaluateNodes(workflow, mockNodeTypes);
expect(result.violations).toContainEqual(
expect.objectContaining({
name: 'workflow-exceeds-max-nodes-limit',
type: 'critical',
}),
);
});
it('should include count and limit in violation description', () => {
const workflow = mock<SimpleWorkflow>({
name: 'Multiple Execute Workflow Triggers',
nodes: [
{
id: '1',
name: 'First Trigger',
type: 'n8n-nodes-base.executeWorkflowTrigger',
parameters: {},
typeVersion: 1,
position: [0, 0],
},
{
id: '2',
name: 'Second Trigger',
type: 'n8n-nodes-base.executeWorkflowTrigger',
parameters: {},
typeVersion: 1,
position: [0, 200],
},
],
connections: {},
});
const result = evaluateNodes(workflow, mockNodeTypes);
const violation = result.violations.find(
(v) => v.name === 'workflow-exceeds-max-nodes-limit',
);
expect(violation?.description).toContain('1'); // maxNodes limit
expect(violation?.description).toContain('2'); // actual count
expect(violation?.description).toContain('Execute Workflow Trigger');
});
it('should allow multiple nodes when maxNodes is not set', () => {
const workflow = mock<SimpleWorkflow>({
name: 'Multiple Code Nodes',
nodes: [
{
id: '1',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
parameters: {},
typeVersion: 1,
position: [0, 0],
},
{
id: '2',
name: 'Code 1',
type: 'n8n-nodes-base.code',
parameters: {},
typeVersion: 1,
position: [200, 0],
},
{
id: '3',
name: 'Code 2',
type: 'n8n-nodes-base.code',
parameters: {},
typeVersion: 1,
position: [400, 0],
},
{
id: '4',
name: 'Code 3',
type: 'n8n-nodes-base.code',
parameters: {},
typeVersion: 1,
position: [600, 0],
},
],
connections: {},
});
const result = evaluateNodes(workflow, mockNodeTypes);
expect(result.violations).toEqual([]);
});
it('should validate maxNodes for different node types independently', () => {
const nodeTypesWithMaxNodes: INodeTypeDescription[] = [
...mockNodeTypes,
mock<INodeTypeDescription>({
name: 'n8n-nodes-base.limitedNode',
displayName: 'Limited Node',
group: ['transform'],
inputs: ['main'],
outputs: ['main'],
maxNodes: 2,
}),
];
const workflow = mock<SimpleWorkflow>({
name: 'Multiple Limited Nodes',
nodes: [
{
id: '1',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
parameters: {},
typeVersion: 1,
position: [0, 0],
},
{
id: '2',
name: 'Limited 1',
type: 'n8n-nodes-base.limitedNode',
parameters: {},
typeVersion: 1,
position: [200, 0],
},
{
id: '3',
name: 'Limited 2',
type: 'n8n-nodes-base.limitedNode',
parameters: {},
typeVersion: 1,
position: [400, 0],
},
],
connections: {},
});
const result = evaluateNodes(workflow, nodeTypesWithMaxNodes);
expect(result.violations).toEqual([]);
// Now add a third limited node (exceeding maxNodes: 2)
const workflowExceeding = mock<SimpleWorkflow>({
name: 'Too Many Limited Nodes',
nodes: [
...workflow.nodes,
{
id: '4',
name: 'Limited 3',
type: 'n8n-nodes-base.limitedNode',
parameters: {},
typeVersion: 1,
position: [600, 0],
},
],
connections: {},
});
const resultExceeding = evaluateNodes(workflowExceeding, nodeTypesWithMaxNodes);
expect(resultExceeding.violations).toContainEqual(
expect.objectContaining({
name: 'workflow-exceeds-max-nodes-limit',
description: expect.stringContaining('2'),
}),
);
});
});
});
@@ -0,0 +1,15 @@
import type { INodeTypeDescription } from 'n8n-workflow';
import type { SimpleWorkflow } from '@/types';
import { validateNodes } from '@/validation/checks';
import type { SingleEvaluatorResult } from '@/validation/types';
import { calcSingleEvaluatorScore } from '../score';
export function evaluateNodes(
workflow: SimpleWorkflow,
nodeTypes: INodeTypeDescription[],
): SingleEvaluatorResult {
const violations = validateNodes(workflow, nodeTypes);
return { violations, score: calcSingleEvaluatorScore({ violations }) };
}
@@ -0,0 +1,690 @@
import type { INodeParameters, INodeTypeDescription } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import type { SimpleWorkflow } from '@/types';
import { validateParameters } from '@/validation/checks/parameters';
function createNodeType(
type: string,
properties: INodeTypeDescription['properties'] = [],
version: number | number[] = 1,
): INodeTypeDescription {
return {
name: type,
displayName: type.split('.').pop() ?? type,
group: ['transform'],
version,
description: 'Test node',
defaults: { name: type.split('.').pop() ?? type },
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
properties,
} as INodeTypeDescription;
}
function createNode(
type: string,
parameters: Record<string, unknown> = {},
options: { name?: string; id?: string; typeVersion?: number } = {},
): SimpleWorkflow['nodes'][0] {
const { name = 'Test Node', id = '1', typeVersion = 1 } = options;
return {
id,
name,
type,
parameters: parameters as INodeParameters,
typeVersion,
position: [0, 0],
};
}
function createWorkflow(nodes: SimpleWorkflow['nodes']): SimpleWorkflow {
return { name: 'Test Workflow', nodes, connections: {} };
}
describe('validateParameters', () => {
describe('node-missing-required-parameter', () => {
it.each([
['empty string default', ''],
['undefined default', undefined],
])('should flag missing required parameter with %s', (_, defaultValue) => {
const nodeType = createNodeType('n8n-nodes-base.test', [
{
displayName: 'API Key',
name: 'apiKey',
type: 'string',
default: defaultValue as string,
required: true,
},
]);
const workflow = createWorkflow([createNode('n8n-nodes-base.test', {})]);
const violations = validateParameters(workflow, [nodeType]);
expect(violations).toContainEqual(
expect.objectContaining({
name: 'node-missing-required-parameter',
type: 'critical',
pointsDeducted: 50,
metadata: expect.objectContaining({
nodeName: 'Test Node',
nodeType: 'n8n-nodes-base.test',
parameterName: 'apiKey',
}),
}),
);
});
it('should NOT flag required parameter with meaningful default or when value is provided', () => {
const nodeType = createNodeType('n8n-nodes-base.test', [
{
displayName: 'Method',
name: 'method',
type: 'options',
default: 'GET',
required: true,
options: [
{ name: 'GET', value: 'GET' },
{ name: 'POST', value: 'POST' },
],
},
{
displayName: 'API Key',
name: 'apiKey',
type: 'string',
default: '',
required: true,
},
]);
const workflow = createWorkflow([
createNode('n8n-nodes-base.test', { apiKey: 'my-key' }), // method uses default, apiKey provided
]);
const violations = validateParameters(workflow, [nodeType]);
expect(violations).toHaveLength(0);
});
it('should respect displayOptions for resource/operation', () => {
const nodeType = createNodeType('n8n-nodes-base.test', [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
default: 'user',
options: [
{ name: 'User', value: 'user' },
{ name: 'Post', value: 'post' },
],
},
{
displayName: 'Post ID',
name: 'postId',
type: 'string',
default: '',
required: true,
displayOptions: { show: { resource: ['post'] } },
},
]);
// Resource is 'user', so postId should not be required
const workflowUser = createWorkflow([
createNode('n8n-nodes-base.test', { resource: 'user' }),
]);
expect(validateParameters(workflowUser, [nodeType])).toHaveLength(0);
// Resource is 'post', so postId IS required
const workflowPost = createWorkflow([
createNode('n8n-nodes-base.test', { resource: 'post' }),
]);
expect(validateParameters(workflowPost, [nodeType])).toContainEqual(
expect.objectContaining({
name: 'node-missing-required-parameter',
metadata: expect.objectContaining({ parameterName: 'postId' }),
}),
);
});
it.each(['collection', 'fixedCollection', 'credentialsSelect'] as const)(
'should skip %s type parameters',
(type) => {
const nodeType = createNodeType('n8n-nodes-base.test', [
{
displayName: 'Options',
name: 'options',
type,
default: {},
required: true,
options: [],
},
]);
const workflow = createWorkflow([createNode('n8n-nodes-base.test', {})]);
expect(validateParameters(workflow, [nodeType])).toHaveLength(0);
},
);
});
describe('node-invalid-options-value', () => {
it('should flag invalid options value with metadata', () => {
const nodeType = createNodeType('n8n-nodes-base.test', [
{
displayName: 'Method',
name: 'method',
type: 'options',
default: 'GET',
options: [
{ name: 'GET', value: 'GET' },
{ name: 'POST', value: 'POST' },
],
},
]);
const workflow = createWorkflow([createNode('n8n-nodes-base.test', { method: 'INVALID' })]);
const violations = validateParameters(workflow, [nodeType]);
expect(violations).toContainEqual(
expect.objectContaining({
name: 'node-invalid-options-value',
type: 'critical',
pointsDeducted: 50,
metadata: expect.objectContaining({
parameterName: 'method',
invalidValue: 'INVALID',
}),
}),
);
});
it('should NOT flag valid options value', () => {
const nodeType = createNodeType('n8n-nodes-base.test', [
{
displayName: 'Method',
name: 'method',
type: 'options',
default: 'GET',
options: [
{ name: 'GET', value: 'GET' },
{ name: 'POST', value: 'POST' },
],
},
]);
const workflow = createWorkflow([createNode('n8n-nodes-base.test', { method: 'POST' })]);
expect(validateParameters(workflow, [nodeType])).toHaveLength(0);
});
it.each([
['dynamic loadOptionsMethod', { channel: 'any-value' }, { loadOptionsMethod: 'getChannels' }],
['expression values', { method: '={{ $json.method }}' }, undefined],
['undefined values', {}, undefined],
])('should skip validation for %s', (_, params, typeOptions) => {
const nodeType = createNodeType('n8n-nodes-base.test', [
{
displayName: 'Field',
name: Object.keys(params)[0] ?? 'method',
type: 'options',
default: 'GET',
typeOptions,
options: [
{ name: 'GET', value: 'GET' },
{ name: 'POST', value: 'POST' },
],
},
]);
const workflow = createWorkflow([createNode('n8n-nodes-base.test', params)]);
expect(validateParameters(workflow, [nodeType])).toHaveLength(0);
});
it('should handle non-string option values (numeric, boolean)', () => {
const nodeType = createNodeType('n8n-nodes-base.test', [
{
displayName: 'Priority',
name: 'priority',
type: 'options',
default: 1,
options: [
{ name: 'Low', value: 1 },
{ name: 'High', value: 3 },
],
},
{
displayName: 'Enabled',
name: 'enabled',
type: 'options',
default: true,
options: [
{ name: 'Yes', value: true },
{ name: 'No', value: false },
],
},
]);
// Invalid numeric value
const workflowNumeric = createWorkflow([
createNode('n8n-nodes-base.test', { priority: 5 }, { id: '1', name: 'Node 1' }),
]);
expect(validateParameters(workflowNumeric, [nodeType])).toContainEqual(
expect.objectContaining({ name: 'node-invalid-options-value' }),
);
// Invalid boolean value (string instead of boolean)
const workflowBoolean = createWorkflow([
createNode('n8n-nodes-base.test', { enabled: 'yes' }, { id: '2', name: 'Node 2' }),
]);
expect(validateParameters(workflowBoolean, [nodeType])).toContainEqual(
expect.objectContaining({ name: 'node-invalid-options-value' }),
);
// Valid boolean value should pass
const workflowValidBoolean = createWorkflow([
createNode('n8n-nodes-base.test', { enabled: false }, { id: '3', name: 'Node 3' }),
]);
expect(validateParameters(workflowValidBoolean, [nodeType])).toHaveLength(0);
});
});
describe('edge cases', () => {
it('should validate multiple nodes independently', () => {
const nodeType = createNodeType('n8n-nodes-base.test', [
{ displayName: 'Field', name: 'field', type: 'string', default: '', required: true },
]);
const workflow = createWorkflow([
createNode('n8n-nodes-base.test', { field: 'valid' }, { id: '1', name: 'Node 1' }),
createNode('n8n-nodes-base.test', {}, { id: '2', name: 'Node 2' }),
createNode('n8n-nodes-base.test', { field: 'valid' }, { id: '3', name: 'Node 3' }),
]);
const violations = validateParameters(workflow, [nodeType]);
expect(violations).toHaveLength(1);
expect(violations[0].metadata?.nodeName).toBe('Node 2');
});
it('should respect displayOptions for boolean parameters (e.g., sshTunnel)', () => {
const nodeType = createNodeType('n8n-nodes-base.test', [
{
displayName: 'SSH Tunnel',
name: 'sshTunnel',
type: 'boolean',
default: false,
},
{
displayName: 'SSH Host',
name: 'sshHost',
type: 'string',
default: '',
required: true,
displayOptions: { show: { sshTunnel: [true] } },
},
{
displayName: 'Regular Field',
name: 'regularField',
type: 'string',
default: '',
required: true,
displayOptions: { hide: { sshTunnel: [true] } },
},
]);
// sshTunnel is false, so sshHost should be hidden (not required)
// but regularField is shown (required)
const workflowTunnelOff = createWorkflow([
createNode('n8n-nodes-base.test', { sshTunnel: false, regularField: 'value' }),
]);
expect(validateParameters(workflowTunnelOff, [nodeType])).toHaveLength(0);
// sshTunnel is true, so sshHost is shown (required) and regularField is hidden
// Missing sshHost should trigger violation
const workflowTunnelOn = createWorkflow([
createNode('n8n-nodes-base.test', { sshTunnel: true }),
]);
const violations = validateParameters(workflowTunnelOn, [nodeType]);
expect(violations).toContainEqual(
expect.objectContaining({
name: 'node-missing-required-parameter',
metadata: expect.objectContaining({ parameterName: 'sshHost' }),
}),
);
// regularField should NOT be flagged since it's hidden when sshTunnel is true
expect(violations).not.toContainEqual(
expect.objectContaining({
metadata: expect.objectContaining({ parameterName: 'regularField' }),
}),
);
// sshTunnel is true and sshHost is provided - should pass
const workflowTunnelOnValid = createWorkflow([
createNode('n8n-nodes-base.test', { sshTunnel: true, sshHost: 'localhost' }),
]);
expect(validateParameters(workflowTunnelOnValid, [nodeType])).toHaveLength(0);
});
it('should handle node with different version via @version displayOptions', () => {
const nodeType = createNodeType(
'n8n-nodes-base.test',
[
{
displayName: 'V1 Field',
name: 'v1Field',
type: 'string',
default: '',
required: true,
displayOptions: { show: { '@version': [1] } },
},
],
[1, 2],
);
// Node is version 2, so v1Field should be hidden
const workflow = createWorkflow([createNode('n8n-nodes-base.test', {}, { typeVersion: 2 })]);
expect(validateParameters(workflow, [nodeType])).toHaveLength(0);
});
it('should respect displayOptions for mode parameter (e.g., Vector Store nodes)', () => {
const nodeType = createNodeType('n8n-nodes-langchain.vectorStore', [
{
displayName: 'Mode',
name: 'mode',
type: 'options',
default: 'retrieve',
options: [
{ name: 'Retrieve', value: 'retrieve' },
{ name: 'Insert', value: 'insert' },
{ name: 'Retrieve as Tool', value: 'retrieve-as-tool' },
],
},
{
displayName: 'Description',
name: 'toolDescription',
type: 'string',
default: '',
required: true,
displayOptions: { show: { mode: ['retrieve-as-tool'] } },
},
]);
// Mode is 'retrieve', so toolDescription should NOT be required
const workflowRetrieve = createWorkflow([
createNode('n8n-nodes-langchain.vectorStore', { mode: 'retrieve' }),
]);
expect(validateParameters(workflowRetrieve, [nodeType])).toHaveLength(0);
// Mode is 'retrieve-as-tool', so toolDescription IS required
const workflowTool = createWorkflow([
createNode('n8n-nodes-langchain.vectorStore', { mode: 'retrieve-as-tool' }),
]);
expect(validateParameters(workflowTool, [nodeType])).toContainEqual(
expect.objectContaining({
name: 'node-missing-required-parameter',
metadata: expect.objectContaining({ parameterName: 'toolDescription' }),
}),
);
});
it('should respect displayOptions for authentication parameter', () => {
const nodeType = createNodeType('n8n-nodes-base.discord', [
{
displayName: 'Authentication',
name: 'authentication',
type: 'options',
default: 'botToken',
options: [
{ name: 'Bot Token', value: 'botToken' },
{ name: 'Webhook', value: 'webhook' },
],
},
// Bot token operation (includes send, getAll)
{
displayName: 'Operation',
name: 'operation',
type: 'options',
default: 'send',
displayOptions: { show: { authentication: ['botToken'] } },
options: [
{ name: 'Send', value: 'send' },
{ name: 'Get All', value: 'getAll' },
],
},
// Webhook operation (only sendLegacy)
{
displayName: 'Operation',
name: 'operation',
type: 'options',
default: 'sendLegacy',
displayOptions: { show: { authentication: ['webhook'] } },
options: [{ name: 'Send', value: 'sendLegacy' }],
},
]);
// Using botToken auth with 'send' operation - should be valid
const workflowBotSend = createWorkflow([
createNode('n8n-nodes-base.discord', { authentication: 'botToken', operation: 'send' }),
]);
expect(validateParameters(workflowBotSend, [nodeType])).toHaveLength(0);
// Using botToken auth with 'getAll' operation - should be valid
const workflowBotGetAll = createWorkflow([
createNode('n8n-nodes-base.discord', { authentication: 'botToken', operation: 'getAll' }),
]);
expect(validateParameters(workflowBotGetAll, [nodeType])).toHaveLength(0);
// Using webhook auth with 'sendLegacy' operation - should be valid
const workflowWebhook = createWorkflow([
createNode('n8n-nodes-base.discord', {
authentication: 'webhook',
operation: 'sendLegacy',
}),
]);
expect(validateParameters(workflowWebhook, [nodeType])).toHaveLength(0);
// Using webhook auth with 'send' operation - should be invalid
// (send is only valid for botToken, not webhook)
const workflowWebhookInvalid = createWorkflow([
createNode('n8n-nodes-base.discord', { authentication: 'webhook', operation: 'send' }),
]);
expect(validateParameters(workflowWebhookInvalid, [nodeType])).toContainEqual(
expect.objectContaining({
name: 'node-invalid-options-value',
metadata: expect.objectContaining({ parameterName: 'operation', invalidValue: 'send' }),
}),
);
});
});
describe('real-world workflow scenarios', () => {
it('should validate Discord nodes from Eco chatbot workflow (botToken + send/getAll)', () => {
// Simulates the Discord node structure with multiple operation properties
// for different authentication methods
const discordNodeType = createNodeType(
'n8n-nodes-base.discord',
[
{
displayName: 'Authentication',
name: 'authentication',
type: 'options',
default: 'botToken',
options: [
{ name: 'Bot Token', value: 'botToken' },
{ name: 'OAuth2', value: 'oAuth2' },
{ name: 'Webhook', value: 'webhook' },
],
},
{
displayName: 'Resource',
name: 'resource',
type: 'options',
default: 'message',
displayOptions: { show: { authentication: ['botToken', 'oAuth2'] } },
options: [
{ name: 'Message', value: 'message' },
{ name: 'Channel', value: 'channel' },
],
},
// Message operations for botToken/oAuth2
{
displayName: 'Operation',
name: 'operation',
type: 'options',
default: 'send',
displayOptions: {
show: { resource: ['message'], authentication: ['botToken', 'oAuth2'] },
},
options: [
{ name: 'Send', value: 'send' },
{ name: 'Get All', value: 'getAll' },
{ name: 'Delete', value: 'deleteMessage' },
],
},
// Webhook operation (different options)
{
displayName: 'Operation',
name: 'operation',
type: 'options',
default: 'sendLegacy',
displayOptions: { show: { authentication: ['webhook'] } },
options: [{ name: 'Send', value: 'sendLegacy' }],
},
],
[1, 2], // Include version 2 to match node's typeVersion
);
const getMessagesWorkflow = createWorkflow([
createNode(
'n8n-nodes-base.discord',
{ authentication: 'botToken', resource: 'message', operation: 'getAll' },
{ name: 'Get Discord Messages', typeVersion: 2 },
),
]);
expect(validateParameters(getMessagesWorkflow, [discordNodeType])).toHaveLength(0);
// "Send Discord Response" node from workflow
const sendResponseWorkflow = createWorkflow([
createNode(
'n8n-nodes-base.discord',
{ authentication: 'botToken', resource: 'message', operation: 'send' },
{ name: 'Send Discord Response', typeVersion: 2 },
),
]);
expect(validateParameters(sendResponseWorkflow, [discordNodeType])).toHaveLength(0);
});
it('should validate Vector Store nodes from Eco chatbot workflow', () => {
// Simulates the Vector Store In Memory node structure
const vectorStoreNodeType = createNodeType(
'@n8n/n8n-nodes-langchain.vectorStoreInMemory',
[
{
displayName: 'Operation Mode',
name: 'mode',
type: 'options',
default: 'retrieve',
options: [
{ name: 'Get Many', value: 'load' },
{ name: 'Insert Documents', value: 'insert' },
{ name: 'Retrieve Documents (As Vector Store)', value: 'retrieve' },
{ name: 'Retrieve Documents (As Tool)', value: 'retrieve-as-tool' },
{ name: 'Update Documents', value: 'update' },
],
},
{
displayName: 'Description',
name: 'toolDescription',
type: 'string',
default: '',
required: true,
displayOptions: { show: { mode: ['retrieve-as-tool'] } },
},
{
displayName: 'Prompt',
name: 'prompt',
type: 'string',
default: '',
required: true,
displayOptions: { show: { mode: ['load'] } },
},
{
displayName: 'ID',
name: 'id',
type: 'string',
default: '',
required: true,
displayOptions: { show: { mode: ['update'] } },
},
],
[1, 1.1, 1.2, 1.3], // Include version 1.3 to match node's typeVersion
);
// "Vector Store - Load Rules" node (mode: insert) - no toolDescription required
const loadRulesWorkflow = createWorkflow([
createNode(
'@n8n/n8n-nodes-langchain.vectorStoreInMemory',
{ mode: 'insert', clearStore: false },
{ name: 'Vector Store - Load Rules', typeVersion: 1.3 },
),
]);
expect(validateParameters(loadRulesWorkflow, [vectorStoreNodeType])).toHaveLength(0);
// "Vector Store - Query Tool" node (mode: retrieve-as-tool) with toolDescription provided
const queryToolWorkflow = createWorkflow([
createNode(
'@n8n/n8n-nodes-langchain.vectorStoreInMemory',
{
mode: 'retrieve-as-tool',
toolDescription:
'Search the Eco community ruleset documents to find relevant rules and regulations.',
topK: 5,
},
{ name: 'Vector Store - Query Tool', typeVersion: 1.3 },
),
]);
expect(validateParameters(queryToolWorkflow, [vectorStoreNodeType])).toHaveLength(0);
// Vector Store in 'retrieve-as-tool' mode WITHOUT toolDescription - should fail
const missingDescriptionWorkflow = createWorkflow([
createNode(
'@n8n/n8n-nodes-langchain.vectorStoreInMemory',
{ mode: 'retrieve-as-tool', topK: 5 },
{ name: 'Vector Store Missing Description', typeVersion: 1.3 },
),
]);
expect(validateParameters(missingDescriptionWorkflow, [vectorStoreNodeType])).toContainEqual(
expect.objectContaining({
name: 'node-missing-required-parameter',
metadata: expect.objectContaining({
nodeName: 'Vector Store Missing Description',
parameterName: 'toolDescription',
}),
}),
);
// Vector Store in 'load' mode WITHOUT prompt - should fail
const missingPromptWorkflow = createWorkflow([
createNode(
'@n8n/n8n-nodes-langchain.vectorStoreInMemory',
{ mode: 'load' },
{ name: 'Vector Store Missing Prompt', typeVersion: 1.3 },
),
]);
expect(validateParameters(missingPromptWorkflow, [vectorStoreNodeType])).toContainEqual(
expect.objectContaining({
name: 'node-missing-required-parameter',
metadata: expect.objectContaining({
nodeName: 'Vector Store Missing Prompt',
parameterName: 'prompt',
}),
}),
);
// Vector Store in 'retrieve' mode - no extra required params
const retrieveWorkflow = createWorkflow([
createNode(
'@n8n/n8n-nodes-langchain.vectorStoreInMemory',
{ mode: 'retrieve' },
{ name: 'Vector Store Retrieve', typeVersion: 1.3 },
),
]);
expect(validateParameters(retrieveWorkflow, [vectorStoreNodeType])).toHaveLength(0);
});
});
});
@@ -0,0 +1,15 @@
import type { INodeTypeDescription } from 'n8n-workflow';
import type { SimpleWorkflow } from '@/types';
import { validateParameters } from '@/validation/checks';
import type { SingleEvaluatorResult } from '@/validation/types';
import { calcSingleEvaluatorScore } from '../score';
export function evaluateParameters(
workflow: SimpleWorkflow,
nodeTypes: INodeTypeDescription[],
): SingleEvaluatorResult {
const violations = validateParameters(workflow, nodeTypes);
return { violations, score: calcSingleEvaluatorScore({ violations }) };
}
@@ -0,0 +1,16 @@
import type { INodeTypeDescription } from 'n8n-workflow';
import type { SimpleWorkflow } from '@/types';
import { validateTools } from '@/validation/checks';
import type { SingleEvaluatorResult } from '@/validation/types';
import { calcSingleEvaluatorScore } from '../score';
export function evaluateTools(
workflow: SimpleWorkflow,
nodeTypes: INodeTypeDescription[],
): SingleEvaluatorResult {
const violations = validateTools(workflow, nodeTypes);
return { violations, score: calcSingleEvaluatorScore({ violations }) };
}
@@ -0,0 +1,219 @@
import { mock } from 'jest-mock-extended';
import type { INodeTypeDescription } from 'n8n-workflow';
import type { SimpleWorkflow } from '@/types';
import { evaluateTrigger } from './trigger';
describe('evaluateTrigger', () => {
const mockNodeTypes: INodeTypeDescription[] = [
mock<INodeTypeDescription>({
name: 'n8n-nodes-base.manualTrigger',
displayName: 'Manual Trigger',
group: ['trigger'],
inputs: [],
outputs: ['main'],
}),
mock<INodeTypeDescription>({
name: 'n8n-nodes-base.webhookTrigger',
displayName: 'Webhook Trigger',
group: ['trigger'],
inputs: [],
outputs: ['main'],
}),
mock<INodeTypeDescription>({
name: 'n8n-nodes-base.scheduleTrigger',
displayName: 'Schedule Trigger',
group: ['trigger'],
inputs: [],
outputs: ['main'],
}),
mock<INodeTypeDescription>({
name: 'n8n-nodes-base.executeWorkflowTrigger',
displayName: 'Execute Workflow Trigger',
group: ['trigger'],
inputs: [],
outputs: ['main'],
maxNodes: 1,
}),
mock<INodeTypeDescription>({
name: 'n8n-nodes-base.code',
displayName: 'Code',
group: ['transform'],
inputs: ['main'],
outputs: ['main'],
}),
mock<INodeTypeDescription>({
name: 'n8n-nodes-base.httpRequest',
displayName: 'HTTP Request',
group: ['transform'],
inputs: ['main'],
outputs: ['main'],
}),
mock<INodeTypeDescription>({
name: 'n8n-nodes-base.set',
displayName: 'Set',
group: ['input'],
inputs: ['main'],
outputs: ['main'],
}),
];
describe('basic trigger validation', () => {
it('should return no violations for workflow with no nodes', () => {
const workflow = mock<SimpleWorkflow>({
name: 'Empty Workflow',
nodes: [],
connections: {},
});
const result = evaluateTrigger(workflow, mockNodeTypes);
expect(result.violations).toEqual([]);
});
it('should detect workflow with no trigger nodes', () => {
const workflow = mock<SimpleWorkflow>({
name: 'No Trigger Workflow',
nodes: [
{
id: '1',
name: 'Code',
type: 'n8n-nodes-base.code',
parameters: {},
typeVersion: 1,
position: [0, 0],
},
{
id: '2',
name: 'HTTP Request',
type: 'n8n-nodes-base.httpRequest',
parameters: {},
typeVersion: 1,
position: [200, 0],
},
],
connections: {},
});
const result = evaluateTrigger(workflow, mockNodeTypes);
expect(result.violations).toContainEqual(
expect.objectContaining({
description: 'Workflow must have at least one trigger node to start execution',
}),
);
});
it('should accept workflow with one trigger node', () => {
const workflow = mock<SimpleWorkflow>({
name: 'Valid Workflow',
nodes: [
{
id: '1',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
parameters: {},
typeVersion: 1,
position: [0, 0],
},
{
id: '2',
name: 'Code',
type: 'n8n-nodes-base.code',
parameters: {},
typeVersion: 1,
position: [200, 0],
},
],
connections: {},
});
const result = evaluateTrigger(workflow, mockNodeTypes);
expect(result.violations).toEqual([]);
});
});
describe('edge cases', () => {
it('should handle unknown node types gracefully', () => {
const workflow = mock<SimpleWorkflow>({
name: 'Unknown Node Workflow',
nodes: [
{
id: '1',
name: 'Unknown Trigger',
type: 'n8n-nodes-base.unknownTrigger',
parameters: {},
typeVersion: 1,
position: [0, 0],
},
{
id: '2',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
parameters: {},
typeVersion: 1,
position: [200, 0],
},
],
connections: {},
});
const result = evaluateTrigger(workflow, mockNodeTypes);
expect(result.violations).toEqual([]);
});
it('should handle mixed trigger and non-trigger nodes', () => {
const workflow = mock<SimpleWorkflow>({
name: 'Mixed Workflow',
nodes: [
{
id: '1',
name: 'Set Data',
type: 'n8n-nodes-base.set',
parameters: {},
typeVersion: 1,
position: [0, 0],
},
{
id: '2',
name: 'Webhook',
type: 'n8n-nodes-base.webhookTrigger',
parameters: {},
typeVersion: 1,
position: [200, 0],
},
{
id: '3',
name: 'Process',
type: 'n8n-nodes-base.code',
parameters: {},
typeVersion: 1,
position: [400, 0],
},
{
id: '4',
name: 'Manual',
type: 'n8n-nodes-base.manualTrigger',
parameters: {},
typeVersion: 1,
position: [0, 200],
},
{
id: '5',
name: 'HTTP Call',
type: 'n8n-nodes-base.httpRequest',
parameters: {},
typeVersion: 1,
position: [600, 0],
},
],
connections: {},
});
const result = evaluateTrigger(workflow, mockNodeTypes);
expect(result.violations).toEqual([]);
});
});
});
@@ -0,0 +1,15 @@
import type { INodeTypeDescription } from 'n8n-workflow';
import type { SimpleWorkflow } from '@/types';
import { validateTrigger } from '@/validation/checks';
import type { SingleEvaluatorResult } from '@/validation/types';
import { calcSingleEvaluatorScore } from '../score';
export function evaluateTrigger(
workflow: SimpleWorkflow,
nodeTypes: INodeTypeDescription[],
): SingleEvaluatorResult {
const violations = validateTrigger(workflow, nodeTypes);
return { violations, score: calcSingleEvaluatorScore({ violations }) };
}
@@ -0,0 +1,312 @@
import { mock } from 'jest-mock-extended';
import type { SimpleWorkflow } from '@/types';
import {
evaluateWorkflowSimilarity,
evaluateWorkflowSimilarityMultiple,
} from './workflow-similarity';
// Mock node modules before any imports
jest.mock('node:child_process', () => ({
execFile: jest.fn(),
}));
// Create the mock inside the factory - must use var for proper hoisting with jest.mock
// eslint-disable-next-line no-var
var mockExecFileAsync: jest.Mock;
jest.mock('node:util', () => {
const mockFn = jest.fn();
// Store reference so tests can access it
mockExecFileAsync = mockFn;
return {
promisify: jest.fn(() => mockFn),
};
});
jest.mock('node:fs/promises');
describe('evaluateWorkflowSimilarity', () => {
const generatedWorkflow = mock<SimpleWorkflow>({
name: 'Generated',
nodes: [
{
id: '1',
name: 'Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
],
connections: {},
});
const groundTruthWorkflow = mock<SimpleWorkflow>({
name: 'Ground Truth',
nodes: [
{
id: '1',
name: 'Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
{
id: '2',
name: 'Code',
type: 'n8n-nodes-base.code',
typeVersion: 1,
position: [200, 0],
parameters: {},
},
],
connections: {},
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('successful evaluation', () => {
it('should parse Python output and map violations correctly', async () => {
const mockPythonOutput = JSON.stringify({
similarity_score: 0.75,
edit_cost: 25,
max_possible_cost: 100,
top_edits: [
{
type: 'node_insert',
description: 'Missing node: Code',
cost: 15,
priority: 'major',
node_name: 'Code',
},
{
type: 'edge_delete',
description: 'Extra connection from Trigger to Code',
cost: 10,
priority: 'minor',
},
],
metadata: {
generated_nodes: 1,
ground_truth_nodes: 2,
config_name: 'standard',
},
});
mockExecFileAsync.mockResolvedValue({ stdout: mockPythonOutput, stderr: '' });
const result = await evaluateWorkflowSimilarity(generatedWorkflow, groundTruthWorkflow);
expect(result.score).toBe(0.75);
expect(result.violations).toHaveLength(2);
expect(result.violations[0]).toEqual({
name: 'workflow-similarity-node-insert',
type: 'major',
description: 'Missing node: Code',
pointsDeducted: 15,
});
expect(result.violations[1]).toEqual({
name: 'workflow-similarity-edge-delete',
type: 'minor',
description: 'Extra connection from Trigger to Code',
pointsDeducted: 10,
});
});
it('should handle all edit types correctly', async () => {
const mockPythonOutput = JSON.stringify({
similarity_score: 0.5,
edit_cost: 50,
max_possible_cost: 100,
top_edits: [
{ type: 'node_insert', description: 'Insert', cost: 10, priority: 'major' },
{ type: 'node_delete', description: 'Delete', cost: 10, priority: 'major' },
{ type: 'node_substitute', description: 'Substitute', cost: 10, priority: 'major' },
{ type: 'edge_insert', description: 'Edge insert', cost: 5, priority: 'minor' },
{ type: 'edge_delete', description: 'Edge delete', cost: 5, priority: 'minor' },
{ type: 'edge_substitute', description: 'Edge substitute', cost: 10, priority: 'major' },
],
metadata: {
generated_nodes: 2,
ground_truth_nodes: 2,
config_name: 'standard',
},
});
mockExecFileAsync.mockResolvedValue({ stdout: mockPythonOutput, stderr: '' });
const result = await evaluateWorkflowSimilarity(generatedWorkflow, groundTruthWorkflow);
expect(result.violations).toHaveLength(6);
expect(result.violations[0].name).toBe('workflow-similarity-node-insert');
expect(result.violations[1].name).toBe('workflow-similarity-node-delete');
expect(result.violations[2].name).toBe('workflow-similarity-node-substitute');
expect(result.violations[3].name).toBe('workflow-similarity-edge-insert');
expect(result.violations[4].name).toBe('workflow-similarity-edge-delete');
expect(result.violations[5].name).toBe('workflow-similarity-edge-substitute');
});
it('should round cost values to integers', async () => {
const mockPythonOutput = JSON.stringify({
similarity_score: 0.85,
edit_cost: 15.7,
max_possible_cost: 100,
top_edits: [
{
type: 'node_insert',
description: 'Missing node',
cost: 15.7,
priority: 'major',
},
],
metadata: {
generated_nodes: 1,
ground_truth_nodes: 2,
config_name: 'standard',
},
});
mockExecFileAsync.mockResolvedValue({ stdout: mockPythonOutput, stderr: '' });
const result = await evaluateWorkflowSimilarity(generatedWorkflow, groundTruthWorkflow);
expect(result.violations[0].pointsDeducted).toBe(16);
});
it('should pass custom preset to Python script', async () => {
const mockPythonOutput = JSON.stringify({
similarity_score: 0.9,
edit_cost: 10,
max_possible_cost: 100,
top_edits: [],
metadata: {
generated_nodes: 1,
ground_truth_nodes: 1,
config_name: 'lenient',
},
});
mockExecFileAsync.mockResolvedValue({ stdout: mockPythonOutput, stderr: '' });
await evaluateWorkflowSimilarity(generatedWorkflow, groundTruthWorkflow, 'lenient');
// Verify the preset was passed to the Python script
expect(mockExecFileAsync).toHaveBeenCalledWith(
'uvx',
expect.arrayContaining(['--preset', 'lenient']),
expect.any(Object),
);
});
});
describe('error handling', () => {
it('should handle uvx command not found error', async () => {
const error = Object.assign(new Error('Command not found'), { code: 'ENOENT' });
mockExecFileAsync.mockRejectedValue(error);
await expect(
evaluateWorkflowSimilarity(generatedWorkflow, groundTruthWorkflow),
).rejects.toThrow('uvx command not found');
});
it('should handle timeout error', async () => {
const error = Object.assign(new Error('Timeout'), { killed: true });
mockExecFileAsync.mockRejectedValue(error);
await expect(
evaluateWorkflowSimilarity(generatedWorkflow, groundTruthWorkflow),
).rejects.toThrow('Workflow comparison timed out');
});
it('should handle Python script errors with empty output', async () => {
const error = Object.assign(new Error('Python error'), {
stdout: '',
stderr: 'Something went wrong',
code: 1,
});
mockExecFileAsync.mockRejectedValue(error);
await expect(
evaluateWorkflowSimilarity(generatedWorkflow, groundTruthWorkflow),
).rejects.toThrow('Workflow similarity evaluation failed');
});
it('should accept non-zero exit code if Python outputs valid JSON', async () => {
const mockPythonOutput = JSON.stringify({
similarity_score: 0.3,
edit_cost: 70,
max_possible_cost: 100,
top_edits: [
{
type: 'node_delete',
description: 'Major difference',
cost: 70,
priority: 'critical',
},
],
metadata: {
generated_nodes: 1,
ground_truth_nodes: 2,
config_name: 'standard',
},
});
const error = Object.assign(new Error('Non-zero exit'), {
stdout: mockPythonOutput,
stderr: 'Warning: similarity below threshold',
code: 1,
});
mockExecFileAsync.mockRejectedValue(error);
const result = await evaluateWorkflowSimilarity(generatedWorkflow, groundTruthWorkflow);
expect(result.score).toBe(0.3);
expect(result.violations).toHaveLength(1);
expect(result.violations[0].name).toBe('workflow-similarity-node-delete');
});
});
describe('evaluateWorkflowSimilarityMultiple', () => {
it('should return result with highest similarity score', async () => {
const referenceWorkflows = [
mock<SimpleWorkflow>({ name: 'Ref1', nodes: [], connections: {} }),
mock<SimpleWorkflow>({ name: 'Ref2', nodes: [], connections: {} }),
mock<SimpleWorkflow>({ name: 'Ref3', nodes: [], connections: {} }),
];
let callCount = 0;
mockExecFileAsync.mockImplementation(async () => {
callCount++;
const score = callCount === 2 ? 0.9 : 0.5; // Second call has highest score
const mockOutput = JSON.stringify({
similarity_score: score,
edit_cost: 10,
max_possible_cost: 100,
top_edits: [],
metadata: { generated_nodes: 1, ground_truth_nodes: 1, config_name: 'standard' },
});
return { stdout: mockOutput, stderr: '' };
});
const result = await evaluateWorkflowSimilarityMultiple(
generatedWorkflow,
referenceWorkflows,
);
expect(result.score).toBe(0.9);
expect(mockExecFileAsync).toHaveBeenCalledTimes(3);
});
it('should throw error when no reference workflows provided', async () => {
await expect(evaluateWorkflowSimilarityMultiple(generatedWorkflow, [])).rejects.toThrow(
'At least one reference workflow is required',
);
});
});
});
@@ -0,0 +1,254 @@
import { execFile } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { writeFile, unlink } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { promisify } from 'node:util';
import type { SimpleWorkflow } from '@/types';
import type { ProgrammaticViolationName, SingleEvaluatorResult } from '@/validation/types';
const execFileAsync = promisify(execFile);
interface ExecError extends Error {
code?: string;
killed?: boolean;
stdout?: string;
stderr?: string;
}
function isExecError(error: unknown): error is ExecError {
return error instanceof Error;
}
/**
* Map Python edit types to violation names
*/
function mapEditTypeToViolationName(editType: string): ProgrammaticViolationName {
const mapping: Record<string, ProgrammaticViolationName> = {
node_insert: 'workflow-similarity-node-insert',
node_delete: 'workflow-similarity-node-delete',
node_substitute: 'workflow-similarity-node-substitute',
edge_insert: 'workflow-similarity-edge-insert',
edge_delete: 'workflow-similarity-edge-delete',
edge_substitute: 'workflow-similarity-edge-substitute',
};
return mapping[editType] ?? 'workflow-similarity-node-substitute';
}
interface WorkflowSimilarityResult {
similarity_score: number; // 0-1
edit_cost: number;
max_possible_cost: number;
top_edits: Array<{
type:
| 'node_insert'
| 'node_delete'
| 'node_substitute'
| 'edge_insert'
| 'edge_delete'
| 'edge_substitute';
description: string;
cost: number;
priority: 'critical' | 'major' | 'minor';
node_name?: string;
}>;
metadata: {
generated_nodes: number;
ground_truth_nodes: number;
generated_nodes_after_filter?: number;
ground_truth_nodes_after_filter?: number;
config_name: string;
config_description?: string;
};
}
function isWorkflowSimilarityResult(value: unknown): value is WorkflowSimilarityResult {
if (typeof value !== 'object' || value === null) {
return false;
}
const obj = value as Record<string, unknown>;
return (
typeof obj.similarity_score === 'number' &&
typeof obj.edit_cost === 'number' &&
typeof obj.max_possible_cost === 'number' &&
Array.isArray(obj.top_edits) &&
typeof obj.metadata === 'object' &&
obj.metadata !== null
);
}
/**
* Evaluate workflow similarity using Python graph edit distance algorithm.
* Compares against a single reference workflow.
*
* @param generatedWorkflow - Workflow generated by AI
* @param groundTruthWorkflow - Reference workflow to compare against
* @param configPreset - Built-in preset to use ('strict' | 'standard' | 'lenient')
* @param customConfigPath - Optional path to custom configuration file
* @returns SingleEvaluatorResult with violations and score
*/
export async function evaluateWorkflowSimilarity(
generatedWorkflow: SimpleWorkflow,
groundTruthWorkflow: SimpleWorkflow,
configPreset: 'strict' | 'standard' | 'lenient' = 'standard',
customConfigPath?: string,
): Promise<SingleEvaluatorResult> {
const tmpDir = tmpdir();
const uniqueId = randomUUID();
const generatedPath = join(tmpDir, `n8n-workflow-generated-${uniqueId}.json`);
const groundTruthPath = join(tmpDir, `n8n-workflow-groundtruth-${uniqueId}.json`);
let stdout = '';
let stderr = '';
try {
// Write workflows to temp files
await Promise.all([
writeFile(generatedPath, JSON.stringify(generatedWorkflow)),
writeFile(groundTruthPath, JSON.stringify(groundTruthWorkflow)),
]);
// Build command arguments
const pythonScriptDir = join(__dirname, '..', 'python');
const args = [
'--from',
pythonScriptDir,
'python',
'-m',
'src.compare_workflows',
generatedPath,
groundTruthPath,
'--output-format',
'json',
];
// Add config argument
if (customConfigPath) {
args.push('--config', customConfigPath);
} else {
args.push('--preset', configPreset);
}
// Run Python script using uvx
try {
const result = await execFileAsync('uvx', args, {
cwd: pythonScriptDir, // Set working directory to Python project
timeout: 30000, // 30 second timeout
maxBuffer: 1024 * 1024 * 10, // 10MB buffer
});
stdout = result.stdout;
stderr = result.stderr;
} catch (execError) {
// Python script may exit with non-zero code if similarity is below threshold
// But it still outputs valid JSON, so we should use it
if (isExecError(execError)) {
stdout = execError.stdout ?? '';
stderr = execError.stderr ?? '';
}
// Only throw if we don't have valid output
if (!stdout || stdout.trim() === '') {
throw execError;
}
}
// Check if stdout is empty
if (!stdout || stdout.trim() === '') {
throw new Error(
`Python script produced no output. stderr: ${stderr || 'none'}. Command: uvx ${args.join(' ')}`,
);
}
// Parse result
const parsed: unknown = JSON.parse(stdout);
if (!isWorkflowSimilarityResult(parsed)) {
throw new Error(
`Invalid response from Python script. Expected WorkflowSimilarityResult shape but got: ${stdout.slice(0, 200)}`,
);
}
// Convert Python result to SingleEvaluatorResult format
const violations = parsed.top_edits.map((edit) => ({
name: mapEditTypeToViolationName(edit.type),
type: edit.priority,
description: edit.description,
pointsDeducted: Math.round(edit.cost),
}));
return {
violations,
score: parsed.similarity_score,
};
} catch (error) {
// Handle specific error cases
if (isExecError(error)) {
if (error.killed) {
// Timeout error
throw new Error(
'Workflow comparison timed out (graphs too complex). Consider using a simpler comparison or increasing timeout.',
);
}
if (error.code === 'ENOENT') {
throw new Error(
'uvx command not found. Please install uv: https://docs.astral.sh/uv/getting-started/installation/',
);
}
// Re-throw with more context
throw new Error(`Workflow similarity evaluation failed: ${error.message}`);
}
// Handle non-Error thrown values
throw new Error(`Workflow similarity evaluation failed: ${String(error)}`);
} finally {
// Cleanup temp files (don't throw on cleanup errors)
await Promise.allSettled([unlink(generatedPath), unlink(groundTruthPath)]);
}
}
/**
* Evaluate workflow similarity against multiple reference workflows.
* Returns the result with the highest similarity score.
*
* @param generatedWorkflow - Workflow generated by AI
* @param referenceWorkflows - Array of reference workflows to compare against
* @param configPreset - Built-in preset to use ('strict' | 'standard' | 'lenient')
* @param customConfigPath - Optional path to custom configuration file
* @returns SingleEvaluatorResult with violations and score from the best match
*/
export async function evaluateWorkflowSimilarityMultiple(
generatedWorkflow: SimpleWorkflow,
referenceWorkflows: SimpleWorkflow[],
configPreset: 'strict' | 'standard' | 'lenient' = 'standard',
customConfigPath?: string,
): Promise<SingleEvaluatorResult> {
if (referenceWorkflows.length === 0) {
throw new Error('At least one reference workflow is required');
}
// Compare against all reference workflows in parallel
const results = await Promise.all(
referenceWorkflows.map(
async (refWorkflow) =>
await evaluateWorkflowSimilarity(
generatedWorkflow,
refWorkflow,
configPreset,
customConfigPath,
),
),
);
// Find the result with the highest similarity score
const bestResult = results.reduce((best, current) =>
current.score > best.score ? current : best,
);
return bestResult;
}
@@ -0,0 +1,101 @@
import type { INodeTypeDescription } from 'n8n-workflow';
import type { ProgrammaticEvaluationInput, ProgrammaticViolation } from '@/validation/types';
import {
evaluateConnections,
evaluateCredentials,
evaluateGraphValidation,
evaluateNodes,
evaluateParameters,
evaluateTools,
evaluateAgentPrompt,
evaluateFromAi,
evaluateTrigger,
evaluateNodeUsage,
} from './evaluators';
import {
evaluateWorkflowSimilarity,
evaluateWorkflowSimilarityMultiple,
} from './evaluators/workflow-similarity';
import { calculateOverallScore } from './score';
export async function programmaticEvaluation(
input: ProgrammaticEvaluationInput,
nodeTypes: INodeTypeDescription[],
) {
const { generatedWorkflow, referenceWorkflows, generatedCode, preset = 'standard' } = input;
const connectionsEvaluationResult = evaluateConnections(generatedWorkflow, nodeTypes);
const nodesEvaluationResult = evaluateNodes(generatedWorkflow, nodeTypes);
const triggerEvaluationResult = evaluateTrigger(generatedWorkflow, nodeTypes);
const agentPromptEvaluationResult = evaluateAgentPrompt(generatedWorkflow);
const toolsEvaluationResult = evaluateTools(generatedWorkflow, nodeTypes);
const fromAiEvaluationResult = evaluateFromAi(generatedWorkflow, nodeTypes);
const credentialsEvaluationResult = evaluateCredentials(generatedWorkflow);
const graphValidationResult = evaluateGraphValidation(generatedCode);
const nodeUsageEvaluationResult = evaluateNodeUsage(generatedWorkflow);
const parametersEvaluationResult = evaluateParameters(generatedWorkflow, nodeTypes);
// Workflow similarity evaluation
let similarityEvaluationResult = null;
if (referenceWorkflows && referenceWorkflows.length > 0) {
try {
if (referenceWorkflows.length === 1) {
similarityEvaluationResult = await evaluateWorkflowSimilarity(
generatedWorkflow,
referenceWorkflows[0],
preset,
);
} else {
similarityEvaluationResult = await evaluateWorkflowSimilarityMultiple(
generatedWorkflow,
referenceWorkflows,
preset,
);
}
} catch (error) {
// Fallback to neutral result if similarity check fails - error captured in violation
const violation: ProgrammaticViolation = {
name: 'workflow-similarity-evaluation-failed',
type: 'critical',
description: `Similarity evaluation failed: ${(error as Error).message}`,
pointsDeducted: 0,
};
similarityEvaluationResult = {
violations: [violation],
score: 0,
};
}
}
const overallScore = calculateOverallScore({
connections: connectionsEvaluationResult,
nodes: nodesEvaluationResult,
trigger: triggerEvaluationResult,
agentPrompt: agentPromptEvaluationResult,
tools: toolsEvaluationResult,
fromAi: fromAiEvaluationResult,
credentials: credentialsEvaluationResult,
nodeUsage: nodeUsageEvaluationResult,
parameters: parametersEvaluationResult,
similarity: similarityEvaluationResult,
graphValidation: graphValidationResult,
});
return {
overallScore,
connections: connectionsEvaluationResult,
nodes: nodesEvaluationResult,
trigger: triggerEvaluationResult,
agentPrompt: agentPromptEvaluationResult,
tools: toolsEvaluationResult,
fromAi: fromAiEvaluationResult,
credentials: credentialsEvaluationResult,
nodeUsage: nodeUsageEvaluationResult,
parameters: parametersEvaluationResult,
similarity: similarityEvaluationResult,
graphValidation: graphValidationResult,
};
}
@@ -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
@@ -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
}
]
]
}
}
}
@@ -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
}
]
]
}
}
}
@@ -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
}
]
]
}
}
}
@@ -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
}
]
]
}
}
}
@@ -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
}
]
]
}
}
}
@@ -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
}
]
]
}
}
}
@@ -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/
@@ -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",
]
@@ -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",
]
@@ -0,0 +1,8 @@
"""
Allow running as a module: python -m compare_workflows
"""
from compare_workflows import main
if __name__ == "__main__":
main()
@@ -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)
@@ -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}")
@@ -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.3
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: 20
group_by: "priority"
include_explanations: true
include_suggestions: true
@@ -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
@@ -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
@@ -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
@@ -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,
}
@@ -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
@@ -0,0 +1 @@
"""Tests for n8n workflow comparison"""
@@ -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
@@ -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"]
)
@@ -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" },
]
@@ -0,0 +1,81 @@
import type { ProgrammaticEvaluationResult, SingleEvaluatorResult } from '@/validation/types';
/**
* Importance tiers for score weighting.
* Higher tier = more impact on overall score.
*/
const IMPORTANCE_TIER = {
high: 3,
medium: 2,
low: 1,
} as const;
type ImportanceTier = keyof typeof IMPORTANCE_TIER;
/**
* Category importance assignments.
* - high: Critical for workflow correctness (connections, trigger, parameters, graphValidation)
* - medium: Important for quality (agentPrompt, tools, similarity)
* - low: Nice to have (fromAi, nodeUsage)
*/
const CATEGORY_IMPORTANCE: Record<string, ImportanceTier> = {
connections: 'high',
trigger: 'high',
parameters: 'high',
graphValidation: 'high',
agentPrompt: 'medium',
tools: 'medium',
similarity: 'medium',
fromAi: 'low',
nodeUsage: 'low',
};
/**
* Calculate weights from importance tiers.
* Each category gets a weight proportional to its tier value.
*/
function calculateWeightsFromTiers(categories: string[]): Record<string, number> {
const tierValues = categories.map((cat) => IMPORTANCE_TIER[CATEGORY_IMPORTANCE[cat] ?? 'low']);
const totalTierValue = tierValues.reduce((sum, val) => sum + val, 0);
const weights: Record<string, number> = {};
for (const category of categories) {
const tierValue = IMPORTANCE_TIER[CATEGORY_IMPORTANCE[category] ?? 'low'];
weights[category] = tierValue / totalTierValue;
}
return weights;
}
export function calculateOverallScore(
evaluatorResults: Omit<ProgrammaticEvaluationResult, 'overallScore'>,
): number {
// Determine which categories to include
const applicableCategories = Object.keys(evaluatorResults).filter(
(k) => k !== 'similarity' || evaluatorResults.similarity !== null,
);
// Calculate weights based on importance tiers
const weights = calculateWeightsFromTiers(applicableCategories);
const total = applicableCategories.reduce((acc, category) => {
const result = evaluatorResults[category as keyof typeof evaluatorResults];
if (result && typeof result === 'object' && 'score' in result) {
return acc + result.score * (weights[category] || 0);
}
return acc;
}, 0);
return total;
}
export function calcSingleEvaluatorScore(
result: Pick<SingleEvaluatorResult, 'violations'>,
): number {
const totalPointsDeducted = result.violations.reduce(
(acc, violation) => acc + violation.pointsDeducted,
0,
);
return Math.max(0, 100 - totalPointsDeducted) / 100;
}