From b96e8ca0fbfaf336462dcc0d0a86b8fc49b76e54 Mon Sep 17 00:00:00 2001 From: Marvin Date: Sun, 25 Jan 2026 18:31:37 +0100 Subject: [PATCH 01/96] kgcore as external dep --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 09e3183..ca838be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ "jsonpath-ng>=1.7.0", "SPARQLWrapper>=2.0.0", "redis>=7.0.0", + "kgcore @ git+https://github.com/Vehnem/kgcore.git", ] [project.optional-dependencies] @@ -38,7 +39,7 @@ dev = ["pytest", "pytest-mock", "pytest-cov", "ruff", "black"] [tool.setuptools.packages.find] where = ["src"] -include = ["kgpipe*", "kgcore*", "kgback*"] +include = ["kgpipe*"] [tool.setuptools.package-dir] "" = "src" From 108fcb001e4844eee1a9ea3dfbe70b1f8787084d Mon Sep 17 00:00:00 2001 From: Marvin Date: Mon, 26 Jan 2026 22:11:58 +0100 Subject: [PATCH 02/96] init kgpipe parameter subpackage: extraction api and methods --- src/kgpipe_parameters/README.md | 25 + src/kgpipe_parameters/extraction/__init__.py | 63 ++ src/kgpipe_parameters/extraction/base.py | 85 ++ src/kgpipe_parameters/extraction/models.py | 84 ++ .../extraction/param_miner.py | 957 ++++++++++++++++++ src/kgpipe_parameters/extraction/patterns.py | 130 +++ src/kgpipe_parameters/extraction/utils.py | 237 +++++ 7 files changed, 1581 insertions(+) create mode 100644 src/kgpipe_parameters/README.md create mode 100644 src/kgpipe_parameters/extraction/__init__.py create mode 100644 src/kgpipe_parameters/extraction/base.py create mode 100644 src/kgpipe_parameters/extraction/models.py create mode 100644 src/kgpipe_parameters/extraction/param_miner.py create mode 100644 src/kgpipe_parameters/extraction/patterns.py create mode 100644 src/kgpipe_parameters/extraction/utils.py diff --git a/src/kgpipe_parameters/README.md b/src/kgpipe_parameters/README.md new file mode 100644 index 0000000..692f8ff --- /dev/null +++ b/src/kgpipe_parameters/README.md @@ -0,0 +1,25 @@ +# KGpipe Parameters + +1. Extract/Find configuration Parameters for a Task T and its implementations I +2. Match and cluster configuration parameters +3. Find best configuration parameters + + +## TODOs + +- [ ] Allow adding parameters to KgTask +- [ ] Describe in SysKg + + +## Parameter Mining + +Methods to find parameter or settings for codeing libraries, CLI, or remote APIs(Http) + +Inputs +- api documentation +- code files + +Methods +- regex +- llm + diff --git a/src/kgpipe_parameters/extraction/__init__.py b/src/kgpipe_parameters/extraction/__init__.py new file mode 100644 index 0000000..b336bdc --- /dev/null +++ b/src/kgpipe_parameters/extraction/__init__.py @@ -0,0 +1,63 @@ +""" +Parameter extraction module for mining configuration parameters from various sources. +""" + +from .param_miner import ( + ParameterMiner, + CLIExtractor, + PythonLibExtractor, + HTTPAPIExtractor, + DockerExtractor, + LLMCLIExtractor, + LLMPythonExtractor, + LLMHTTPExtractor, + LLMDockerExtractor, +) +from .models import ( + RawParameter, + ExtractionResult, + SourceType, + ExtractionMethod, +) +from .base import ( + BaseExtractor, + RegexExtractor, + LLMExtractor, +) +from .utils import ( + to_parameter_model, + normalize_parameter_name, + parse_default_value, + infer_parameter_type, + extract_constraints, +) + +__all__ = [ + # Main class + "ParameterMiner", + # Extractors + "CLIExtractor", + "PythonLibExtractor", + "HTTPAPIExtractor", + "DockerExtractor", + "LLMCLIExtractor", + "LLMPythonExtractor", + "LLMHTTPExtractor", + "LLMDockerExtractor", + # Base classes + "BaseExtractor", + "RegexExtractor", + "LLMExtractor", + # Models + "RawParameter", + "ExtractionResult", + "SourceType", + "ExtractionMethod", + # Utilities + "to_parameter_model", + "normalize_parameter_name", + "parse_default_value", + "infer_parameter_type", + "extract_constraints", +] + diff --git a/src/kgpipe_parameters/extraction/base.py b/src/kgpipe_parameters/extraction/base.py new file mode 100644 index 0000000..b9c492c --- /dev/null +++ b/src/kgpipe_parameters/extraction/base.py @@ -0,0 +1,85 @@ +""" +Base classes for parameter extractors. +""" + +from abc import ABC, abstractmethod +from typing import List, Optional +from .models import RawParameter, ExtractionResult, SourceType, ExtractionMethod + + +class BaseExtractor(ABC): + """Abstract base class for all parameter extractors.""" + + def __init__(self, source_type: SourceType): + self.source_type = source_type + + @abstractmethod + def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionResult: + """ + Extract parameters from the given source. + + Args: + source: Source content (text, file path, etc.) + tool_name: Optional name of the tool being analyzed + + Returns: + ExtractionResult containing extracted parameters + """ + pass + + +class RegexExtractor(BaseExtractor): + """Base class for regex-based parameter extraction.""" + + def __init__(self, source_type: SourceType, patterns: Optional[dict] = None): + super().__init__(source_type) + self.patterns = patterns or {} + + @abstractmethod + def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionResult: + """Extract parameters using regex patterns.""" + pass + + def _apply_patterns(self, text: str) -> List[RawParameter]: + """ + Apply regex patterns to extract parameters. + Subclasses should override this with their specific pattern matching logic. + """ + return [] + + +class LLMExtractor(BaseExtractor): + """Base class for LLM-based parameter extraction.""" + + def __init__(self, source_type: SourceType, llm_client=None): + super().__init__(source_type) + self.llm_client = llm_client + if llm_client is None: + try: + from kgpipe_llm.common.core import LLMClient, get_client_from_env + self.llm_client = get_client_from_env() + except ImportError: + raise ImportError( + "LLM extraction requires kgpipe_llm. " + "Install it or provide an LLMClient instance." + ) + + @abstractmethod + def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionResult: + """Extract parameters using LLM.""" + pass + + def _create_prompt(self, source: str, tool_name: Optional[str] = None) -> str: + """ + Create a prompt for LLM extraction. + Subclasses should override this with their specific prompt template. + """ + return f"Extract configuration parameters from:\n\n{source}" + + def _parse_llm_response(self, response: dict) -> List[RawParameter]: + """ + Parse LLM response into RawParameter objects. + Subclasses should override this with their specific parsing logic. + """ + return [] + diff --git a/src/kgpipe_parameters/extraction/models.py b/src/kgpipe_parameters/extraction/models.py new file mode 100644 index 0000000..393787b --- /dev/null +++ b/src/kgpipe_parameters/extraction/models.py @@ -0,0 +1,84 @@ +""" +Pydantic models for raw parameter extraction results. +""" + +from typing import List, Optional, Dict, Any, Union +from pydantic import BaseModel, Field, ConfigDict +from datetime import datetime +from enum import Enum + + +class SourceType(str, Enum): + """Types of sources for parameter extraction.""" + CLI = "cli" + PYTHON_LIB = "python_lib" + HTTP_API = "http_api" + DOCKER = "docker" + UNKNOWN = "unknown" + + +class ExtractionMethod(str, Enum): + """Methods used for parameter extraction.""" + REGEX = "regex" + LLM = "llm" + AUTO = "auto" + + +class RawParameter(BaseModel): + """ + Intermediate representation of an extracted parameter. + This is the raw extraction result before conversion to Parameter model. + """ + name: str = Field(..., description="Normalized parameter name") + native_keys: List[str] = Field(default_factory=list, description="Original parameter names/flags from source") + description: Optional[str] = Field(None, description="Parameter description/documentation") + type_hint: Optional[str] = Field(None, description="Type hint or type name from source") + default_value: Optional[Union[str, int, float, bool]] = Field(None, description="Default value if present") + required: bool = Field(False, description="Whether parameter is required") + constraints: Dict[str, Any] = Field(default_factory=dict, description="Constraints like min, max, allowed_values") + source: str = Field(..., description="Source text or file path where parameter was found") + provenance: Dict[str, Any] = Field(default_factory=dict, description="Additional metadata about extraction") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "name": "threshold", + "native_keys": ["--threshold", "-t", "THRESHOLD"], + "description": "Matching threshold value", + "type_hint": "float", + "default_value": 0.5, + "required": False, + "constraints": {"minimum": 0.0, "maximum": 1.0}, + "source": "tool.py --help", + "provenance": {"line_number": 42, "extraction_method": "regex"} + } + } + ) + + +class ExtractionResult(BaseModel): + """ + Container for extracted parameters with metadata. + """ + tool_name: str = Field(..., description="Name of the tool/library being analyzed") + source_type: SourceType = Field(..., description="Type of source (CLI, Python, API, Docker)") + extraction_method: ExtractionMethod = Field(..., description="Method used for extraction") + parameters: List[RawParameter] = Field(default_factory=list, description="List of extracted parameters") + timestamp: datetime = Field(default_factory=datetime.now, description="When extraction was performed") + metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional metadata about the extraction") + errors: List[str] = Field(default_factory=list, description="Any errors encountered during extraction") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "tool_name": "paris_matcher", + "source_type": "cli", + "extraction_method": "regex", + "parameters": [], + "timestamp": "2024-01-01T00:00:00", + "metadata": {"source_file": "paris --help"}, + "errors": [] + } + } + ) + diff --git a/src/kgpipe_parameters/extraction/param_miner.py b/src/kgpipe_parameters/extraction/param_miner.py new file mode 100644 index 0000000..970b099 --- /dev/null +++ b/src/kgpipe_parameters/extraction/param_miner.py @@ -0,0 +1,957 @@ +""" +Parameter mining/extraction from various sources (CLI, Python, HTTP APIs, Docker). +""" + +import re +import ast +import json +import yaml +from pathlib import Path +from typing import List, Optional, Dict, Any, Union +from .models import ( + RawParameter, ExtractionResult, SourceType, ExtractionMethod +) +from .base import RegexExtractor, LLMExtractor +from .patterns import get_patterns, CLI_PATTERNS, PYTHON_PATTERNS, DOCKER_PATTERNS +from .utils import normalize_parameter_name, parse_default_value, infer_parameter_type + + +class CLIExtractor(RegexExtractor): + """Extract parameters from CLI help output.""" + + def __init__(self, use_llm: bool = False, llm_client=None): + super().__init__(SourceType.CLI, CLI_PATTERNS) + self.use_llm = use_llm + if use_llm: + self.llm_extractor = LLMCLIExtractor(llm_client) + + def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionResult: + """Extract parameters from CLI help text.""" + if self.use_llm: + return self.llm_extractor.extract(source, tool_name) + + parameters = [] + errors = [] + + try: + lines = source.split('\n') + current_param = None + + for line in lines: + # Skip usage lines (they contain brackets and are not actual parameter descriptions) + if line.strip().startswith("usage:") or (line.strip().startswith("[") and "]" in line and "optional" not in line.lower() and "arguments" not in line.lower()): + continue + + # Match long flags: --param or --param=VALUE + long_match = CLI_PATTERNS["long_flag"].search(line) + if long_match: + param_name = long_match.group(1) + # Don't use group(2) from usage line - it's the placeholder, not default + default_val = None + + normalized = normalize_parameter_name(param_name) + native_keys = [f"--{param_name}"] + + # Check for short form on same line (but not -h from usage line) + short_match = CLI_PATTERNS["short_flag"].search(line) + if short_match and short_match.group(1) != 'h': # Skip -h help flag + native_keys.append(f"-{short_match.group(1)}") + + # Extract description - skip placeholder if present + # Pattern: --param PLACEHOLDER Description text + # We want to skip the PLACEHOLDER (uppercase word) if it exists + desc_match = re.search(rf"--{param_name}\s+(?:[A-Z_]+\s+)?(.+)", line) + if not desc_match: + # Fallback: just get everything after the flag + desc_match = re.search(r"--[^\s]+\s+(.+)", line) + description = desc_match.group(1).strip() if desc_match else None + + # Check if required + required = CLI_PATTERNS["required"].search(line) is not None + + # Extract default value from description line (not usage line) + default_match = CLI_PATTERNS["default_value"].search(line) + if default_match: + default_val = default_match.group(1).strip() + + # Extract type hint + type_match = CLI_PATTERNS["type_hint"].search(line) + type_hint = type_match.group(1) if type_match else None + + current_param = RawParameter( + name=normalized, + native_keys=native_keys, + description=description, + type_hint=type_hint, + default_value=parse_default_value(default_val) if default_val else None, + required=required, + source=line, + provenance={"line": lines.index(line) + 1} + ) + parameters.append(current_param) + + # Match short flags: -p + elif CLI_PATTERNS["short_flag"].search(line) and not long_match: + short_match = CLI_PATTERNS["short_flag"].search(line) + param_name = short_match.group(1) + normalized = normalize_parameter_name(param_name) + + current_param = RawParameter( + name=normalized, + native_keys=[f"-{param_name}"], + description=None, + source=line, + provenance={"line": lines.index(line) + 1} + ) + parameters.append(current_param) + + # If we have a current param, try to extract description from continuation lines + elif current_param and line.strip() and not line.strip().startswith('-'): + if not current_param.description: + current_param.description = line.strip() + else: + current_param.description += " " + line.strip() + + except Exception as e: + errors.append(f"Error extracting CLI parameters: {str(e)}") + + return ExtractionResult( + tool_name=tool_name or "unknown_cli_tool", + source_type=SourceType.CLI, + extraction_method=ExtractionMethod.REGEX, + parameters=parameters, + errors=errors + ) + + +class LLMCLIExtractor(LLMExtractor): + """LLM-based CLI parameter extraction.""" + + def __init__(self, llm_client=None): + super().__init__(SourceType.CLI, llm_client) + + def _create_prompt(self, source: str, tool_name: Optional[str] = None) -> str: + return f"""Extract all configuration parameters from the following CLI help output. +For each parameter, identify: +- Parameter name (normalized, without -- or -) +- Native keys/flags (--flag, -f, etc.) +- Description +- Type (if mentioned) +- Default value (if mentioned) +- Whether it's required or optional + +CLI Help Output: +{source} + +Return a JSON object with a 'parameters' array. Each parameter should have: name, native_keys, description, type_hint, default_value, required.""" + + def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionResult: + """Extract parameters using LLM.""" + from pydantic import BaseModel + from typing import List as TypingList + + class ParameterSchema(BaseModel): + name: str + native_keys: TypingList[str] + description: Optional[str] = None + type_hint: Optional[str] = None + default_value: Optional[Union[str, int, float, bool]] = None + required: bool = False + + class ExtractionSchema(BaseModel): + parameters: TypingList[ParameterSchema] + + try: + prompt = self._create_prompt(source, tool_name) + response = self.llm_client.send_prompt(prompt, ExtractionSchema) + + parameters = [] + if "parameters" in response: + for param_data in response["parameters"]: + raw_param = RawParameter( + name=normalize_parameter_name(param_data["name"]), + native_keys=param_data.get("native_keys", []), + description=param_data.get("description"), + type_hint=param_data.get("type_hint"), + default_value=param_data.get("default_value"), + required=param_data.get("required", False), + source=source[:100], # First 100 chars + provenance={"method": "llm"} + ) + parameters.append(raw_param) + + return ExtractionResult( + tool_name=tool_name or "unknown_cli_tool", + source_type=SourceType.CLI, + extraction_method=ExtractionMethod.LLM, + parameters=parameters + ) + except Exception as e: + return ExtractionResult( + tool_name=tool_name or "unknown_cli_tool", + source_type=SourceType.CLI, + extraction_method=ExtractionMethod.LLM, + parameters=[], + errors=[f"LLM extraction failed: {str(e)}"] + ) + + +class PythonLibExtractor(RegexExtractor): + """Extract parameters from Python code (functions, classes, docstrings).""" + + def __init__(self, use_llm: bool = False, llm_client=None): + super().__init__(SourceType.PYTHON_LIB, PYTHON_PATTERNS) + self.use_llm = use_llm + if use_llm: + self.llm_extractor = LLMPythonExtractor(llm_client) + + def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionResult: + """Extract parameters from Python source code.""" + if self.use_llm: + return self.llm_extractor.extract(source, tool_name) + + parameters = [] + errors = [] + + try: + # Try to parse as Python AST + try: + tree = ast.parse(source) + parameters.extend(self._extract_from_ast(tree, source)) + except SyntaxError: + # If not valid Python, try regex-based extraction + parameters.extend(self._extract_from_regex(source)) + + except Exception as e: + errors.append(f"Error extracting Python parameters: {str(e)}") + + return ExtractionResult( + tool_name=tool_name or "unknown_python_lib", + source_type=SourceType.PYTHON_LIB, + extraction_method=ExtractionMethod.REGEX, + parameters=parameters, + errors=errors + ) + + def _extract_from_ast(self, tree: ast.AST, source: str) -> List[RawParameter]: + """Extract parameters from Python AST.""" + parameters = [] + + class ParameterVisitor(ast.NodeVisitor): + def __init__(self): + self.params = [] + self.source_lines = source.split('\n') + + def visit_FunctionDef(self, node): + # Extract function parameters + for arg in node.args.args: + if arg.arg == 'self': + continue + + # Get type hint + type_hint = None + if arg.annotation: + type_hint = ast.unparse(arg.annotation) if hasattr(ast, 'unparse') else str(arg.annotation) + + # Get default value + default_val = None + default_idx = len(node.args.args) - len(node.args.defaults) + if arg in node.args.args[default_idx:]: + default_node = node.args.defaults[node.args.args[default_idx:].index(arg)] + if hasattr(ast, 'unparse'): + default_val = ast.unparse(default_node) + else: + default_val = ast.literal_eval(default_node) if isinstance(default_node, (ast.Constant, ast.Str, ast.Num)) else None + + # Extract docstring info + description = None + if ast.get_docstring(node): + docstring = ast.get_docstring(node) + # Look for :param arg: description + param_pattern = re.compile(rf":param\s+{arg.arg}:\s*(.+?)(?=\n|:param|$)", re.MULTILINE) + match = param_pattern.search(docstring) + if match: + description = match.group(1).strip() + + param = RawParameter( + name=normalize_parameter_name(arg.arg), + native_keys=[arg.arg], + description=description, + type_hint=type_hint, + default_value=parse_default_value(default_val) if default_val else None, + required=default_val is None, + source=f"{node.name}()", + provenance={"function": node.name, "line": node.lineno} + ) + self.params.append(param) + + self.generic_visit(node) + + def visit_ClassDef(self, node): + # Extract class attributes (for dataclasses, Pydantic models, etc.) + for item in node.body: + if isinstance(item, ast.AnnAssign): + # Annotated assignment: name: type = default + if isinstance(item.target, ast.Name): + attr_name = item.target.id + + # Get type hint + type_hint = None + if item.annotation: + type_hint = ast.unparse(item.annotation) if hasattr(ast, 'unparse') else str(item.annotation) + + # Get default value + default_val = None + if item.value: + if hasattr(ast, 'unparse'): + default_val = ast.unparse(item.value) + else: + try: + default_val = ast.literal_eval(item.value) + except (ValueError, TypeError): + default_val = None + + param = RawParameter( + name=normalize_parameter_name(attr_name), + native_keys=[attr_name], + description=None, + type_hint=type_hint, + default_value=parse_default_value(str(default_val)) if default_val is not None else None, + required=default_val is None, + source=f"{node.name}.{attr_name}", + provenance={"class": node.name, "line": item.lineno if hasattr(item, 'lineno') else node.lineno} + ) + self.params.append(param) + elif isinstance(item, ast.Assign): + # Regular assignment: name = value (might be in dataclass) + for target in item.targets: + if isinstance(target, ast.Name): + attr_name = target.id + # Try to get value + default_val = None + if item.value: + try: + default_val = ast.literal_eval(item.value) + except (ValueError, TypeError): + default_val = None + + param = RawParameter( + name=normalize_parameter_name(attr_name), + native_keys=[attr_name], + description=None, + type_hint=None, + default_value=parse_default_value(str(default_val)) if default_val is not None else None, + required=False, + source=f"{node.name}.{attr_name}", + provenance={"class": node.name, "line": item.lineno} + ) + self.params.append(param) + + self.generic_visit(node) + + visitor = ParameterVisitor() + visitor.visit(tree) + return visitor.params + + def _extract_from_regex(self, source: str) -> List[RawParameter]: + """Fallback regex-based extraction.""" + parameters = [] + + # Extract function parameters + func_pattern = re.compile(r"def\s+\w+\s*\(([^)]+)\)", re.MULTILINE) + for match in func_pattern.finditer(source): + params_str = match.group(1) + for param_match in PYTHON_PATTERNS["function_param"].finditer(params_str): + param_name = param_match.group(1) + type_hint = param_match.group(2).strip() if param_match.group(2) else None + default_val = param_match.group(3).strip() if param_match.group(3) else None + + parameters.append(RawParameter( + name=normalize_parameter_name(param_name), + native_keys=[param_name], + type_hint=type_hint, + default_value=parse_default_value(default_val) if default_val else None, + required=default_val is None, + source=match.group(0), + provenance={"method": "regex"} + )) + + return parameters + + +class LLMPythonExtractor(LLMExtractor): + """LLM-based Python parameter extraction.""" + + def __init__(self, llm_client=None): + super().__init__(SourceType.PYTHON_LIB, llm_client) + + def _create_prompt(self, source: str, tool_name: Optional[str] = None) -> str: + return f"""Extract all configuration parameters from the following Python code. +Look for: +- Function parameters with type hints and defaults +- Class attributes with type annotations +- Configuration classes (dataclasses, Pydantic models) +- Environment variables + +Python Code: +{source} + +Return a JSON object with a 'parameters' array. Each parameter should have: name, native_keys, description, type_hint, default_value, required.""" + + def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionResult: + """Extract parameters using LLM.""" + from pydantic import BaseModel + from typing import List as TypingList + + class ParameterSchema(BaseModel): + name: str + native_keys: TypingList[str] + description: Optional[str] = None + type_hint: Optional[str] = None + default_value: Optional[Union[str, int, float, bool]] = None + required: bool = False + + class ExtractionSchema(BaseModel): + parameters: TypingList[ParameterSchema] + + try: + prompt = self._create_prompt(source, tool_name) + response = self.llm_client.send_prompt(prompt, ExtractionSchema) + + parameters = [] + if "parameters" in response: + for param_data in response["parameters"]: + raw_param = RawParameter( + name=normalize_parameter_name(param_data["name"]), + native_keys=param_data.get("native_keys", []), + description=param_data.get("description"), + type_hint=param_data.get("type_hint"), + default_value=param_data.get("default_value"), + required=param_data.get("required", False), + source=source[:200], # First 200 chars + provenance={"method": "llm"} + ) + parameters.append(raw_param) + + return ExtractionResult( + tool_name=tool_name or "unknown_python_lib", + source_type=SourceType.PYTHON_LIB, + extraction_method=ExtractionMethod.LLM, + parameters=parameters + ) + except Exception as e: + return ExtractionResult( + tool_name=tool_name or "unknown_python_lib", + source_type=SourceType.PYTHON_LIB, + extraction_method=ExtractionMethod.LLM, + parameters=[], + errors=[f"LLM extraction failed: {str(e)}"] + ) + + +class HTTPAPIExtractor(RegexExtractor): + """Extract parameters from HTTP API documentation (OpenAPI, Swagger, etc.).""" + + def __init__(self, use_llm: bool = False, llm_client=None): + super().__init__(SourceType.HTTP_API, {}) + self.use_llm = use_llm + if use_llm: + self.llm_extractor = LLMHTTPExtractor(llm_client) + + def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionResult: + """Extract parameters from API documentation.""" + if self.use_llm: + return self.llm_extractor.extract(source, tool_name) + + parameters = [] + errors = [] + + try: + # Try to parse as OpenAPI/Swagger spec + spec = None + try: + # Try JSON first + if source.strip().startswith('{'): + spec = json.loads(source) + else: + # Try YAML + spec = yaml.safe_load(source) + + # Check if it looks like OpenAPI/Swagger spec + if spec and isinstance(spec, dict) and ("openapi" in spec or "swagger" in spec or "paths" in spec): + parameters.extend(self._extract_from_openapi(spec)) + else: + # Not a valid spec, try regex-based extraction + parameters.extend(self._extract_from_docs(source)) + except (json.JSONDecodeError, yaml.YAMLError): + # If parsing fails, try regex-based extraction + parameters.extend(self._extract_from_docs(source)) + + except Exception as e: + errors.append(f"Error extracting API parameters: {str(e)}") + + return ExtractionResult( + tool_name=tool_name or "unknown_api", + source_type=SourceType.HTTP_API, + extraction_method=ExtractionMethod.REGEX, + parameters=parameters, + errors=errors + ) + + def _extract_from_openapi(self, spec: dict) -> List[RawParameter]: + """Extract parameters from OpenAPI specification.""" + parameters = [] + + # Extract from paths + paths = spec.get("paths", {}) + for path, methods in paths.items(): + for method, operation in methods.items(): + # Path parameters + for param in operation.get("parameters", []): + param_name = param.get("name", "") + param_schema = param.get("schema", {}) + + raw_param = RawParameter( + name=normalize_parameter_name(param_name), + native_keys=[param_name], + description=param.get("description"), + type_hint=param_schema.get("type"), + default_value=param_schema.get("default"), + required=param.get("required", False), + source=f"{method.upper()} {path}", + provenance={"location": "path", "method": method} + ) + parameters.append(raw_param) + + # Request body parameters + request_body = operation.get("requestBody", {}) + content = request_body.get("content", {}) + for content_type, schema_obj in content.items(): + schema = schema_obj.get("schema", {}) + if "properties" in schema: + for prop_name, prop_schema in schema["properties"].items(): + raw_param = RawParameter( + name=normalize_parameter_name(prop_name), + native_keys=[prop_name], + description=prop_schema.get("description"), + type_hint=prop_schema.get("type"), + default_value=prop_schema.get("default"), + required=prop_name in schema.get("required", []), + source=f"{method.upper()} {path} (body)", + provenance={"location": "body", "method": method} + ) + parameters.append(raw_param) + + return parameters + + def _extract_from_docs(self, source: str) -> List[RawParameter]: + """Extract parameters from unstructured API documentation.""" + parameters = [] + # Basic regex extraction for common patterns + # This is a simplified version - LLM would be better for complex docs + return parameters + + +class LLMHTTPExtractor(LLMExtractor): + """LLM-based HTTP API parameter extraction.""" + + def __init__(self, llm_client=None): + super().__init__(SourceType.HTTP_API, llm_client) + + def _create_prompt(self, source: str, tool_name: Optional[str] = None) -> str: + return f"""Extract all API parameters from the following API documentation or specification. +Look for: +- Query parameters +- Path parameters +- Request body parameters +- Header parameters + +API Documentation: +{source} + +Return a JSON object with a 'parameters' array. Each parameter should have: name, native_keys, description, type_hint, default_value, required.""" + + def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionResult: + """Extract parameters using LLM.""" + from pydantic import BaseModel + from typing import List as TypingList + + class ParameterSchema(BaseModel): + name: str + native_keys: TypingList[str] + description: Optional[str] = None + type_hint: Optional[str] = None + default_value: Optional[Union[str, int, float, bool]] = None + required: bool = False + + class ExtractionSchema(BaseModel): + parameters: TypingList[ParameterSchema] + + try: + prompt = self._create_prompt(source, tool_name) + response = self.llm_client.send_prompt(prompt, ExtractionSchema) + + parameters = [] + if "parameters" in response: + for param_data in response["parameters"]: + raw_param = RawParameter( + name=normalize_parameter_name(param_data["name"]), + native_keys=param_data.get("native_keys", []), + description=param_data.get("description"), + type_hint=param_data.get("type_hint"), + default_value=param_data.get("default_value"), + required=param_data.get("required", False), + source=source[:200], + provenance={"method": "llm"} + ) + parameters.append(raw_param) + + return ExtractionResult( + tool_name=tool_name or "unknown_api", + source_type=SourceType.HTTP_API, + extraction_method=ExtractionMethod.LLM, + parameters=parameters + ) + except Exception as e: + return ExtractionResult( + tool_name=tool_name or "unknown_api", + source_type=SourceType.HTTP_API, + extraction_method=ExtractionMethod.LLM, + parameters=[], + errors=[f"LLM extraction failed: {str(e)}"] + ) + + +class DockerExtractor(RegexExtractor): + """Extract parameters from Docker configurations (Dockerfile, docker-compose.yml).""" + + def __init__(self, use_llm: bool = False, llm_client=None): + super().__init__(SourceType.DOCKER, DOCKER_PATTERNS) + self.use_llm = use_llm + if use_llm: + self.llm_extractor = LLMDockerExtractor(llm_client) + + def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionResult: + """Extract parameters from Docker configuration.""" + if self.use_llm: + return self.llm_extractor.extract(source, tool_name) + + parameters = [] + errors = [] + + try: + # Check if it's a Dockerfile or docker-compose.yml + if "FROM" in source or "RUN" in source: + # Dockerfile + parameters.extend(self._extract_from_dockerfile(source)) + elif "version:" in source or "services:" in source: + # docker-compose.yml + try: + compose = yaml.safe_load(source) + parameters.extend(self._extract_from_compose(compose)) + except yaml.YAMLError: + parameters.extend(self._extract_from_dockerfile(source)) + else: + parameters.extend(self._extract_from_dockerfile(source)) + + except Exception as e: + errors.append(f"Error extracting Docker parameters: {str(e)}") + + return ExtractionResult( + tool_name=tool_name or "unknown_docker", + source_type=SourceType.DOCKER, + extraction_method=ExtractionMethod.REGEX, + parameters=parameters, + errors=errors + ) + + def _extract_from_dockerfile(self, source: str) -> List[RawParameter]: + """Extract ENV and ARG declarations from Dockerfile.""" + parameters = [] + lines = source.split('\n') + + for line in lines: + # ENV declarations + env_match = DOCKER_PATTERNS["env_declaration"].search(line) + if env_match: + var_name = env_match.group(1) + var_value = env_match.group(2) if env_match.group(2) else None + + parameters.append(RawParameter( + name=normalize_parameter_name(var_name), + native_keys=[var_name], + description=f"Environment variable: {var_name}", + default_value=parse_default_value(var_value) if var_value else None, + required=False, + source=line, + provenance={"type": "ENV", "line": lines.index(line) + 1} + )) + + # ARG declarations + arg_match = DOCKER_PATTERNS["arg_declaration"].search(line) + if arg_match: + var_name = arg_match.group(1) + var_value = arg_match.group(2) if arg_match.group(2) else None + + parameters.append(RawParameter( + name=normalize_parameter_name(var_name), + native_keys=[var_name], + description=f"Build argument: {var_name}", + default_value=parse_default_value(var_value) if var_value else None, + required=False, + source=line, + provenance={"type": "ARG", "line": lines.index(line) + 1} + )) + + return parameters + + def _extract_from_compose(self, compose: dict) -> List[RawParameter]: + """Extract environment variables from docker-compose.yml.""" + parameters = [] + + services = compose.get("services", {}) + for service_name, service_config in services.items(): + env = service_config.get("environment", {}) + if isinstance(env, dict): + for var_name, var_value in env.items(): + parameters.append(RawParameter( + name=normalize_parameter_name(var_name), + native_keys=[var_name], + description=f"Environment variable for service {service_name}", + default_value=parse_default_value(str(var_value)) if var_value else None, + required=False, + source=f"services.{service_name}.environment", + provenance={"service": service_name, "type": "environment"} + )) + + return parameters + + +class LLMDockerExtractor(LLMExtractor): + """LLM-based Docker parameter extraction.""" + + def __init__(self, llm_client=None): + super().__init__(SourceType.DOCKER, llm_client) + + def _create_prompt(self, source: str, tool_name: Optional[str] = None) -> str: + return f"""Extract all configuration parameters from the following Docker configuration. +Look for: +- ENV variables +- ARG build arguments +- Environment variables in docker-compose.yml +- Volume mounts and port mappings that could be parameterized + +Docker Configuration: +{source} + +Return a JSON object with a 'parameters' array. Each parameter should have: name, native_keys, description, type_hint, default_value, required.""" + + def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionResult: + """Extract parameters using LLM.""" + from pydantic import BaseModel + from typing import List as TypingList + + class ParameterSchema(BaseModel): + name: str + native_keys: TypingList[str] + description: Optional[str] = None + type_hint: Optional[str] = None + default_value: Optional[Union[str, int, float, bool]] = None + required: bool = False + + class ExtractionSchema(BaseModel): + parameters: TypingList[ParameterSchema] + + try: + prompt = self._create_prompt(source, tool_name) + response = self.llm_client.send_prompt(prompt, ExtractionSchema) + + parameters = [] + if "parameters" in response: + for param_data in response["parameters"]: + raw_param = RawParameter( + name=normalize_parameter_name(param_data["name"]), + native_keys=param_data.get("native_keys", []), + description=param_data.get("description"), + type_hint=param_data.get("type_hint"), + default_value=param_data.get("default_value"), + required=param_data.get("required", False), + source=source[:200], + provenance={"method": "llm"} + ) + parameters.append(raw_param) + + return ExtractionResult( + tool_name=tool_name or "unknown_docker", + source_type=SourceType.DOCKER, + extraction_method=ExtractionMethod.LLM, + parameters=parameters + ) + except Exception as e: + return ExtractionResult( + tool_name=tool_name or "unknown_docker", + source_type=SourceType.DOCKER, + extraction_method=ExtractionMethod.LLM, + parameters=[], + errors=[f"LLM extraction failed: {str(e)}"] + ) + + +class ParameterMiner: + """ + Main class for parameter extraction from various sources. + Provides unified interface for extracting configuration parameters. + """ + + def __init__(self, llm_client=None): + """ + Initialize ParameterMiner. + + Args: + llm_client: Optional LLMClient instance for LLM-based extraction + """ + self.llm_client = llm_client + self.extractors = { + SourceType.CLI: CLIExtractor(use_llm=False), + SourceType.PYTHON_LIB: PythonLibExtractor(use_llm=False), + SourceType.HTTP_API: HTTPAPIExtractor(use_llm=False), + SourceType.DOCKER: DockerExtractor(use_llm=False), + } + + def extract_parameters( + self, + source: Union[str, Path], + source_type: Optional[SourceType] = None, + method: ExtractionMethod = ExtractionMethod.AUTO, + tool_name: Optional[str] = None + ) -> ExtractionResult: + """ + Extract parameters from a source. + + Args: + source: Source content (text, file path, etc.) + source_type: Type of source (auto-detected if None) + method: Extraction method ('regex', 'llm', or 'auto') + tool_name: Optional name of the tool being analyzed + + Returns: + ExtractionResult containing extracted parameters + """ + # Read file if Path provided + if isinstance(source, Path): + source_path = source + source = source_path.read_text() + if not tool_name: + tool_name = source_path.stem + elif isinstance(source, str): + # Only treat as file path if it's a short string without newlines + # and actually exists as a file + if len(source) < 260 and '\n' not in source and Path(source).exists(): + source_path = Path(source) + source = source_path.read_text() + if not tool_name: + tool_name = source_path.stem + + # Auto-detect source type if not provided + if source_type is None: + source_type = self._detect_source_type(source) + + # Select extraction method + if method == ExtractionMethod.AUTO: + # Try regex first, fallback to LLM if available + try: + if source_type == SourceType.UNKNOWN: + # For unknown source types, try CLI extractor as fallback + extractor = self.extractors[SourceType.CLI] + else: + extractor = self.extractors[source_type] + result = extractor.extract(source, tool_name) + # If regex extraction found few/no parameters and LLM is available, try LLM + if len(result.parameters) == 0 and self.llm_client: + method = ExtractionMethod.LLM + else: + return result + except (KeyError, Exception): + if self.llm_client: + method = ExtractionMethod.LLM + else: + # Return empty result for unknown types + return ExtractionResult( + tool_name=tool_name or "unknown", + source_type=source_type, + extraction_method=ExtractionMethod.REGEX, + parameters=[], + errors=[f"No extractor available for source type: {source_type}"] + ) + + # Use LLM if requested or as fallback + if method == ExtractionMethod.LLM: + if not self.llm_client: + raise ValueError("LLM extraction requires an LLMClient instance") + + # Create LLM extractor for the source type + llm_extractors = { + SourceType.CLI: LLMCLIExtractor(self.llm_client), + SourceType.PYTHON_LIB: LLMPythonExtractor(self.llm_client), + SourceType.HTTP_API: LLMHTTPExtractor(self.llm_client), + SourceType.DOCKER: LLMDockerExtractor(self.llm_client), + } + extractor = llm_extractors.get(source_type) + if extractor: + return extractor.extract(source, tool_name) + + # Use regex extractor + extractor = self.extractors[source_type] + return extractor.extract(source, tool_name) + + def _detect_source_type(self, source: str) -> SourceType: + """Auto-detect source type from content.""" + source_lower = source.lower() + + # Check for CLI help patterns + if any(x in source_lower for x in ["usage:", "options:", "--help", "arguments:"]): + return SourceType.CLI + + # Check for Python code + if any(x in source for x in ["def ", "class ", "import ", "@"]): + try: + ast.parse(source) + return SourceType.PYTHON_LIB + except SyntaxError: + pass + + # Check for OpenAPI/Swagger + if any(x in source for x in ['"openapi"', '"swagger"', "paths:", "components:"]): + return SourceType.HTTP_API + + # Check for Docker + if any(x in source for x in ["FROM ", "ENV ", "ARG ", "docker-compose", "services:"]): + return SourceType.DOCKER + + return SourceType.UNKNOWN + + def to_parameter_model(self, raw_param: RawParameter): + """ + Convert RawParameter to Parameter model. + + Args: + raw_param: RawParameter instance + + Returns: + Parameter model instance + """ + from .utils import to_parameter_model + return to_parameter_model(raw_param) + + def to_json(self, result: ExtractionResult) -> str: + """ + Convert ExtractionResult to JSON string. + + Args: + result: ExtractionResult instance + + Returns: + JSON string representation + """ + return result.model_dump_json(indent=2) + diff --git a/src/kgpipe_parameters/extraction/patterns.py b/src/kgpipe_parameters/extraction/patterns.py new file mode 100644 index 0000000..b6daf97 --- /dev/null +++ b/src/kgpipe_parameters/extraction/patterns.py @@ -0,0 +1,130 @@ +""" +Regex patterns for parameter extraction from various sources. +""" + +import re +from typing import Dict, List, Tuple, Optional + + +# CLI argument patterns +CLI_PATTERNS = { + # Long form: --param, --param=VALUE, --param VALUE + "long_flag": re.compile(r"--([a-zA-Z][a-zA-Z0-9_-]*)(?:[=\s]+([^\s]+))?"), + # Short form: -p, -p VALUE, -pVALUE + "short_flag": re.compile(r"-([a-zA-Z])(?:\s+([^\s]+))?"), + # Combined: -p, --param + "combined_flag": re.compile(r"(-[a-zA-Z]|--[a-zA-Z][a-zA-Z0-9_-]+)"), + # Description lines (common in help output) + "description": re.compile(r"^\s+([^\s]+(?:\s+[^\s]+)*)\s+(.+)$"), + # Required/optional indicators + "required": re.compile(r"(required|mandatory|must)", re.IGNORECASE), + "optional": re.compile(r"(optional|\[optional\]|\[default)", re.IGNORECASE), + # Default values: [default: value], (default: value), default=value + # Match: (default: 0.5) -> capture "0.5", [default: value] -> capture "value", default=value -> capture "value" + # The pattern matches "default:" or "default=" and captures the value until closing bracket/paren or end + "default_value": re.compile(r"default[=:]\s*([^\])]+?)(?:\]|\)|$)", re.IGNORECASE), + # Type hints: , [str], (float) + "type_hint": re.compile(r"[<\[\(]([a-zA-Z]+)[>\]\)]"), +} + +# Python code patterns +PYTHON_PATTERNS = { + # Function parameter: param: type = default + "function_param": re.compile(r"(\w+)(?:\s*:\s*([^=]+))?(?:\s*=\s*([^,)]+))?"), + # Type hints: param: int, param: Optional[str] = None + "type_annotation": re.compile(r":\s*([^=,)]+)"), + # Default values in function signatures + "default_in_sig": re.compile(r"=\s*([^,)]+)"), + # Docstring parameter descriptions: :param name: description + "docstring_param": re.compile(r":param\s+(\w+):\s*(.+?)(?=\n|:param|$)", re.MULTILINE), + # Docstring type: :type name: type + "docstring_type": re.compile(r":type\s+(\w+):\s*([^\n]+)"), + # Class attributes with type hints + "class_attr": re.compile(r"(\w+)\s*:\s*([^=\n]+)(?:\s*=\s*([^\n]+))?"), + # Environment variable assignments: VAR = value + "env_var": re.compile(r"([A-Z_][A-Z0-9_]*)\s*=\s*(.+)"), +} + +# HTTP API patterns +API_PATTERNS = { + # Query parameters: ?param=value + "query_param": re.compile(r"[?&]([^=&]+)(?:=([^&]+))?"), + # Path parameters: /{param}/ + "path_param": re.compile(r"/\{([^}]+)\}/"), + # Header parameters: X-Header-Name: value + "header": re.compile(r"([A-Z][a-zA-Z0-9-]+):\s*(.+)"), + # JSON schema properties + "json_property": re.compile(r'"([^"]+)":\s*\{[^}]*"type":\s*"([^"]+)"'), + # OpenAPI parameter definitions + "openapi_param": re.compile(r'"([^"]+)":\s*\{[^}]*"in":\s*"([^"]+)"'), +} + +# Docker patterns +DOCKER_PATTERNS = { + # ENV variable: ENV VAR=value or ENV VAR value + "env_declaration": re.compile(r"ENV\s+([A-Z_][A-Z0-9_]*)(?:\s*=\s*|\s+)(.+)", re.IGNORECASE), + # ARG declaration: ARG VAR[=default] + "arg_declaration": re.compile(r"ARG\s+([A-Z_][A-Z0-9_]*)(?:\s*=\s*([^\s]+))?", re.IGNORECASE), + # Environment variable in docker-compose: VAR: value + "compose_env": re.compile(r"([A-Z_][A-Z0-9_]*)\s*:\s*(.+)"), + # Volume mounts: -v /host:/container + "volume_mount": re.compile(r"-v\s+([^:\s]+):([^:\s]+)"), + # Port mappings: -p HOST:CONTAINER + "port_mapping": re.compile(r"-p\s+(\d+):(\d+)"), +} + +# Common patterns for all sources +COMMON_PATTERNS = { + # Numeric constraints: min=0, max=100 + "min_max": re.compile(r"(?:min|minimum)[=:]\s*([0-9.]+).*(?:max|maximum)[=:]\s*([0-9.]+)", re.IGNORECASE), + # Allowed values: choices=[a, b, c] or enum: [a, b, c] + "allowed_values": re.compile(r"(?:choices|enum|options)[=:]\s*\[([^\]]+)\]", re.IGNORECASE), + # Boolean flags: true/false, yes/no, 1/0 + "boolean": re.compile(r"(true|false|yes|no|1|0)", re.IGNORECASE), + # Numeric types: int, float, number + "numeric": re.compile(r"(int|integer|float|number|double)", re.IGNORECASE), + # String types: str, string, text + "string": re.compile(r"(str|string|text)", re.IGNORECASE), +} + + +def get_patterns(source_type: str) -> Dict[str, re.Pattern]: + """ + Get regex patterns for a specific source type. + + Args: + source_type: One of 'cli', 'python', 'api', 'docker' + + Returns: + Dictionary of compiled regex patterns + """ + patterns_map = { + "cli": CLI_PATTERNS, + "python": PYTHON_PATTERNS, + "api": API_PATTERNS, + "docker": DOCKER_PATTERNS, + } + return patterns_map.get(source_type.lower(), {}) + + +def match_pattern(text: str, pattern: re.Pattern, group_names: Optional[List[str]] = None) -> List[Dict[str, str]]: + """ + Match a pattern against text and return structured results. + + Args: + text: Text to search + pattern: Compiled regex pattern + group_names: Optional names for capture groups + + Returns: + List of dictionaries with match information + """ + matches = [] + for match in pattern.finditer(text): + groups = match.groups() + if group_names and len(group_names) == len(groups): + matches.append(dict(zip(group_names, groups))) + else: + matches.append({"match": match.group(0), "groups": groups}) + return matches + diff --git a/src/kgpipe_parameters/extraction/utils.py b/src/kgpipe_parameters/extraction/utils.py new file mode 100644 index 0000000..9bb6876 --- /dev/null +++ b/src/kgpipe_parameters/extraction/utils.py @@ -0,0 +1,237 @@ +""" +Utility functions for parameter extraction and conversion. +""" + +import re +from typing import Optional, Union, List, Any, Dict +from .models import RawParameter +from kgpipe.common.model.configuration import Parameter, ParameterType + + +def infer_parameter_type(type_hint: Optional[str], default_value: Any = None) -> ParameterType: + """ + Infer ParameterType from type hint string or default value. + + Args: + type_hint: Type hint string (e.g., "int", "float", "str", "bool") + default_value: Default value to infer type from if type_hint is None + + Returns: + ParameterType enum value + """ + if type_hint: + type_hint_lower = type_hint.lower().strip() + + # Check for boolean + if any(x in type_hint_lower for x in ["bool", "boolean"]): + return ParameterType.boolean + + # Check for integer + if any(x in type_hint_lower for x in ["int", "integer"]): + return ParameterType.integer + + # Check for float/number + if any(x in type_hint_lower for x in ["float", "number", "double", "decimal"]): + return ParameterType.number + + # Check for array/list + if any(x in type_hint_lower for x in ["list", "array", "[]", "List"]): + return ParameterType.array + + # Check for object/dict + if any(x in type_hint_lower for x in ["dict", "object", "Dict", "{}"]): + return ParameterType.object + + # Check for enum + if "enum" in type_hint_lower or "choice" in type_hint_lower: + return ParameterType.enum + + # Infer from default value + if default_value is not None: + if isinstance(default_value, bool): + return ParameterType.boolean + elif isinstance(default_value, int): + return ParameterType.integer + elif isinstance(default_value, float): + return ParameterType.number + elif isinstance(default_value, list): + return ParameterType.array + elif isinstance(default_value, dict): + return ParameterType.object + + # Default to string + return ParameterType.string + + +def parse_default_value(value_str: Optional[str]) -> Optional[Union[str, int, float, bool]]: + """ + Parse a default value string into appropriate Python type. + + Args: + value_str: String representation of default value + + Returns: + Parsed value (str, int, float, or bool) or None + """ + if value_str is None: + return None + + value_str = value_str.strip().strip('"').strip("'") + + # Try boolean + if value_str.lower() in ["true", "false", "yes", "no", "1", "0"]: + return value_str.lower() in ["true", "yes", "1"] + + # Try integer + try: + if value_str.isdigit() or (value_str.startswith("-") and value_str[1:].isdigit()): + return int(value_str) + except ValueError: + pass + + # Try float + try: + return float(value_str) + except ValueError: + pass + + # Return as string + return value_str + + +def normalize_parameter_name(name: str) -> str: + """ + Normalize parameter name to a standard format. + + Args: + name: Original parameter name (may include --, -, etc.) + + Returns: + Normalized name (lowercase, underscores instead of hyphens) + """ + # Remove leading dashes and spaces + name = name.lstrip("-").lstrip() + + # Replace hyphens with underscores + name = name.replace("-", "_") + + # Convert to lowercase + name = name.lower() + + # Remove special characters except underscores + name = re.sub(r"[^a-z0-9_]", "", name) + + return name + + +def extract_constraints(description: Optional[str], type_hint: Optional[str] = None) -> Dict[str, Any]: + """ + Extract constraints (min, max, allowed_values) from description or type hint. + + Args: + description: Parameter description text + type_hint: Type hint string + + Returns: + Dictionary with constraint information + """ + constraints = {} + + if not description: + return constraints + + # Extract min/max values - try combined first, then separate + min_max_pattern = re.compile(r"(?:min|minimum)[=:]\s*([0-9.]+).*(?:max|maximum)[=:]\s*([0-9.]+)", re.IGNORECASE) + min_max_match = min_max_pattern.search(description) + if min_max_match: + constraints["minimum"] = float(min_max_match.group(1)) + constraints["maximum"] = float(min_max_match.group(2)) + + # Try separate min and max (even if combined pattern didn't match) + # More flexible pattern to handle "Minimum value: 10" or "min: 10" formats + min_pattern = re.compile(r"(?:min|minimum)(?:\s+value)?[=:]\s*([0-9.]+)", re.IGNORECASE) + max_pattern = re.compile(r"(?:max|maximum)(?:\s+value)?[=:]\s*([0-9.]+)", re.IGNORECASE) + min_match = min_pattern.search(description) + max_match = max_pattern.search(description) + if min_match and "minimum" not in constraints: + constraints["minimum"] = float(min_match.group(1)) + if max_match and "maximum" not in constraints: + constraints["maximum"] = float(max_match.group(1)) + + # Extract allowed values / choices + choices_pattern = re.compile(r"(?:choices|enum|options|allowed)[=:]\s*\[([^\]]+)\]", re.IGNORECASE) + choices_match = choices_pattern.search(description) + if choices_match: + choices_str = choices_match.group(1) + # Split by comma and clean up + choices = [c.strip().strip('"').strip("'") for c in choices_str.split(",")] + constraints["allowed_values"] = choices + + return constraints + + +def to_parameter_model(raw_param: RawParameter) -> Parameter: + """ + Convert a RawParameter to a Parameter model. + + Args: + raw_param: RawParameter instance + + Returns: + Parameter model instance + """ + # Infer parameter type + param_type = infer_parameter_type(raw_param.type_hint, raw_param.default_value) + + # Parse default value + default_val = raw_param.default_value + if isinstance(default_val, str): + default_val = parse_default_value(default_val) + + # Ensure default value matches the inferred type + if default_val is None: + # Set appropriate default based on type + if param_type == ParameterType.boolean: + default_val = False + elif param_type == ParameterType.integer: + default_val = 0 + elif param_type == ParameterType.number: + default_val = 0.0 + elif param_type == ParameterType.string: + default_val = "" + elif param_type == ParameterType.array: + default_val = [] + elif param_type == ParameterType.object: + default_val = {} + + # Extract constraints + constraints = extract_constraints(raw_param.description, raw_param.type_hint) + constraints.update(raw_param.constraints) + + # Get allowed values + allowed_values = constraints.get("allowed_values", []) + if allowed_values: + # Convert to appropriate types + typed_allowed = [] + for val in allowed_values: + parsed = parse_default_value(str(val)) + typed_allowed.append(parsed if parsed is not None else str(val)) + allowed_values = typed_allowed + + # Ensure native_keys includes the name + native_keys = list(raw_param.native_keys) + if raw_param.name not in native_keys: + native_keys.insert(0, raw_param.name) + + return Parameter( + name=raw_param.name, + native_keys=native_keys, + datatype=param_type, + default_value=default_val, + required=raw_param.required, + allowed_values=allowed_values, + minimum=constraints.get("minimum"), + maximum=constraints.get("maximum"), + unit=constraints.get("unit"), + ) + From cf2c854956954bc5ba93980d483936c498fe6cc7 Mon Sep 17 00:00:00 2001 From: Marvin Date: Mon, 26 Jan 2026 22:12:29 +0100 Subject: [PATCH 03/96] added tests to kgpipe_paramters for extraction testing with some testdata --- src/kgpipe_parameters/tests/__init__.py | 4 + src/kgpipe_parameters/tests/conftest.py | 117 ++++ .../tests/test_data/api/openapi_spec.yaml | 42 ++ .../tests/test_data/api/swagger_spec.json | 43 ++ .../tests/test_data/cli/argparse_help.txt | 8 + .../tests/test_data/cli/click_help.txt | 8 + .../tests/test_data/cli/simple_help.txt | 8 + .../tests/test_data/docker/Dockerfile | 14 + .../tests/test_data/docker/docker-compose.yml | 23 + .../test_data/python/dataclass_config.py | 12 + .../test_data/python/function_with_params.py | 16 + .../tests/test_data/python/pydantic_model.py | 11 + .../tests/test_paramters_extraction.py | 507 ++++++++++++++++++ 13 files changed, 813 insertions(+) create mode 100644 src/kgpipe_parameters/tests/__init__.py create mode 100644 src/kgpipe_parameters/tests/conftest.py create mode 100644 src/kgpipe_parameters/tests/test_data/api/openapi_spec.yaml create mode 100644 src/kgpipe_parameters/tests/test_data/api/swagger_spec.json create mode 100644 src/kgpipe_parameters/tests/test_data/cli/argparse_help.txt create mode 100644 src/kgpipe_parameters/tests/test_data/cli/click_help.txt create mode 100644 src/kgpipe_parameters/tests/test_data/cli/simple_help.txt create mode 100644 src/kgpipe_parameters/tests/test_data/docker/Dockerfile create mode 100644 src/kgpipe_parameters/tests/test_data/docker/docker-compose.yml create mode 100644 src/kgpipe_parameters/tests/test_data/python/dataclass_config.py create mode 100644 src/kgpipe_parameters/tests/test_data/python/function_with_params.py create mode 100644 src/kgpipe_parameters/tests/test_data/python/pydantic_model.py create mode 100644 src/kgpipe_parameters/tests/test_paramters_extraction.py diff --git a/src/kgpipe_parameters/tests/__init__.py b/src/kgpipe_parameters/tests/__init__.py new file mode 100644 index 0000000..c5a603a --- /dev/null +++ b/src/kgpipe_parameters/tests/__init__.py @@ -0,0 +1,4 @@ +""" +Tests for parameter extraction module. +""" + diff --git a/src/kgpipe_parameters/tests/conftest.py b/src/kgpipe_parameters/tests/conftest.py new file mode 100644 index 0000000..9c33cad --- /dev/null +++ b/src/kgpipe_parameters/tests/conftest.py @@ -0,0 +1,117 @@ +""" +Pytest fixtures for parameter extraction tests. +""" + +import pytest +from pathlib import Path +from unittest.mock import Mock, MagicMock +from typing import Dict, Any + + +def get_test_data_path(relative_path: str) -> Path: + """Get path to test data file.""" + test_dir = Path(__file__).parent + path = test_dir / "test_data" / relative_path + if not path.exists(): + raise FileNotFoundError(f"Test data path {path} does not exist") + return path + + +@pytest.fixture +def test_data_dir(): + """Fixture for test data directory.""" + return Path(__file__).parent / "test_data" + + +@pytest.fixture +def cli_help_argparse(): + """Fixture for argparse CLI help text.""" + path = get_test_data_path("cli/argparse_help.txt") + return path.read_text() + + +@pytest.fixture +def cli_help_click(): + """Fixture for click CLI help text.""" + path = get_test_data_path("cli/click_help.txt") + return path.read_text() + + +@pytest.fixture +def cli_help_simple(): + """Fixture for simple CLI help text.""" + path = get_test_data_path("cli/simple_help.txt") + return path.read_text() + + +@pytest.fixture +def python_function_code(): + """Fixture for Python function code.""" + path = get_test_data_path("python/function_with_params.py") + return path.read_text() + + +@pytest.fixture +def python_dataclass_code(): + """Fixture for Python dataclass code.""" + path = get_test_data_path("python/dataclass_config.py") + return path.read_text() + + +@pytest.fixture +def python_pydantic_code(): + """Fixture for Python Pydantic model code.""" + path = get_test_data_path("python/pydantic_model.py") + return path.read_text() + + +@pytest.fixture +def openapi_spec(): + """Fixture for OpenAPI specification.""" + path = get_test_data_path("api/openapi_spec.yaml") + return path.read_text() + + +@pytest.fixture +def swagger_spec(): + """Fixture for Swagger specification.""" + path = get_test_data_path("api/swagger_spec.json") + return path.read_text() + + +@pytest.fixture +def dockerfile_content(): + """Fixture for Dockerfile content.""" + path = get_test_data_path("docker/Dockerfile") + return path.read_text() + + +@pytest.fixture +def docker_compose_content(): + """Fixture for docker-compose.yml content.""" + path = get_test_data_path("docker/docker-compose.yml") + return path.read_text() + + +@pytest.fixture +def mock_llm_client(): + """Fixture for mocked LLM client.""" + mock_client = Mock() + + # Mock response structure + mock_response = { + "parameters": [ + { + "name": "threshold", + "native_keys": ["--threshold", "-t"], + "description": "Matching threshold", + "type_hint": "float", + "default_value": 0.5, + "required": False + } + ] + } + + mock_client.send_prompt = Mock(return_value=mock_response) + return mock_client + diff --git a/src/kgpipe_parameters/tests/test_data/api/openapi_spec.yaml b/src/kgpipe_parameters/tests/test_data/api/openapi_spec.yaml new file mode 100644 index 0000000..cc3b967 --- /dev/null +++ b/src/kgpipe_parameters/tests/test_data/api/openapi_spec.yaml @@ -0,0 +1,42 @@ +openapi: 3.0.0 +info: + title: Matching API + version: 1.0.0 +paths: + /api/match: + post: + summary: Match entities + parameters: + - name: threshold + in: query + schema: + type: number + default: 0.5 + minimum: 0.0 + maximum: 1.0 + description: Matching threshold + - name: max_results + in: query + schema: + type: integer + default: 100 + description: Maximum number of results + requestBody: + content: + application/json: + schema: + type: object + required: + - input_file + properties: + input_file: + type: string + description: Input file path + output_file: + type: string + description: Output file path + verbose: + type: boolean + default: false + description: Enable verbose logging + diff --git a/src/kgpipe_parameters/tests/test_data/api/swagger_spec.json b/src/kgpipe_parameters/tests/test_data/api/swagger_spec.json new file mode 100644 index 0000000..899f978 --- /dev/null +++ b/src/kgpipe_parameters/tests/test_data/api/swagger_spec.json @@ -0,0 +1,43 @@ +{ + "swagger": "2.0", + "info": { + "title": "Matching API", + "version": "1.0.0" + }, + "paths": { + "/api/match": { + "post": { + "parameters": [ + { + "name": "threshold", + "in": "query", + "type": "number", + "default": 0.5, + "minimum": 0.0, + "maximum": 1.0, + "description": "Matching threshold" + }, + { + "name": "input_file", + "in": "body", + "schema": { + "type": "object", + "required": ["input_file"], + "properties": { + "input_file": { + "type": "string", + "description": "Input file path" + }, + "output_file": { + "type": "string", + "description": "Output file path" + } + } + } + } + ] + } + } + } +} + diff --git a/src/kgpipe_parameters/tests/test_data/cli/argparse_help.txt b/src/kgpipe_parameters/tests/test_data/cli/argparse_help.txt new file mode 100644 index 0000000..e958f50 --- /dev/null +++ b/src/kgpipe_parameters/tests/test_data/cli/argparse_help.txt @@ -0,0 +1,8 @@ +usage: tool.py [-h] [--threshold THRESHOLD] [--output OUTPUT] [--verbose] + +optional arguments: + -h, --help show this help message and exit + --threshold THRESHOLD Matching threshold (default: 0.5) + --output OUTPUT Output file path (required) + --verbose Enable verbose logging + diff --git a/src/kgpipe_parameters/tests/test_data/cli/click_help.txt b/src/kgpipe_parameters/tests/test_data/cli/click_help.txt new file mode 100644 index 0000000..8e9d948 --- /dev/null +++ b/src/kgpipe_parameters/tests/test_data/cli/click_help.txt @@ -0,0 +1,8 @@ +Usage: tool.py [OPTIONS] + +Options: + --threshold FLOAT Matching threshold [default: 0.5] + --output TEXT Output file path (required) + --verbose Enable verbose logging + --help Show this message and exit. + diff --git a/src/kgpipe_parameters/tests/test_data/cli/simple_help.txt b/src/kgpipe_parameters/tests/test_data/cli/simple_help.txt new file mode 100644 index 0000000..bf078f9 --- /dev/null +++ b/src/kgpipe_parameters/tests/test_data/cli/simple_help.txt @@ -0,0 +1,8 @@ +Usage: matcher [OPTIONS] + + --threshold VALUE Matching threshold (0.0-1.0) [default: 0.5] + --input FILE Input file path (required) + --output FILE Output file path + --max-results INT Maximum number of results [default: 100] + --verbose Enable verbose output + diff --git a/src/kgpipe_parameters/tests/test_data/docker/Dockerfile b/src/kgpipe_parameters/tests/test_data/docker/Dockerfile new file mode 100644 index 0000000..1a99c0b --- /dev/null +++ b/src/kgpipe_parameters/tests/test_data/docker/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.9 + +ARG BUILD_VERSION=latest +ARG THRESHOLD=0.5 + +ENV THRESHOLD=${THRESHOLD} +ENV OUTPUT_DIR=/output +ENV MAX_RESULTS=100 +ENV VERBOSE=false + +WORKDIR /app +COPY . . +CMD ["python", "app.py"] + diff --git a/src/kgpipe_parameters/tests/test_data/docker/docker-compose.yml b/src/kgpipe_parameters/tests/test_data/docker/docker-compose.yml new file mode 100644 index 0000000..3ecdc59 --- /dev/null +++ b/src/kgpipe_parameters/tests/test_data/docker/docker-compose.yml @@ -0,0 +1,23 @@ +version: '3.8' + +services: + matcher: + image: matcher:latest + environment: + THRESHOLD: 0.5 + OUTPUT_DIR: /output + MAX_RESULTS: 100 + VERBOSE: "false" + volumes: + - ./data:/data + ports: + - "8080:8080" + + processor: + image: processor:latest + environment: + INPUT_DIR: /input + BATCH_SIZE: 50 + depends_on: + - matcher + diff --git a/src/kgpipe_parameters/tests/test_data/python/dataclass_config.py b/src/kgpipe_parameters/tests/test_data/python/dataclass_config.py new file mode 100644 index 0000000..716c2a1 --- /dev/null +++ b/src/kgpipe_parameters/tests/test_data/python/dataclass_config.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass +from typing import Optional + +@dataclass +class MatchingConfig: + """Configuration for matching operations.""" + threshold: float = 0.5 + input_file: str + output_file: Optional[str] = None + verbose: bool = False + max_results: int = 100 + diff --git a/src/kgpipe_parameters/tests/test_data/python/function_with_params.py b/src/kgpipe_parameters/tests/test_data/python/function_with_params.py new file mode 100644 index 0000000..be93fce --- /dev/null +++ b/src/kgpipe_parameters/tests/test_data/python/function_with_params.py @@ -0,0 +1,16 @@ +def process_data( + input_file: str, + threshold: float = 0.5, + verbose: bool = False, + max_results: int = 100 +) -> None: + """ + Process data with configurable parameters. + + :param input_file: Path to input file (required) + :param threshold: Matching threshold (default: 0.5, min: 0.0, max: 1.0) + :param verbose: Enable verbose logging + :param max_results: Maximum number of results (default: 100) + """ + pass + diff --git a/src/kgpipe_parameters/tests/test_data/python/pydantic_model.py b/src/kgpipe_parameters/tests/test_data/python/pydantic_model.py new file mode 100644 index 0000000..75a27bc --- /dev/null +++ b/src/kgpipe_parameters/tests/test_data/python/pydantic_model.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel, Field +from typing import Optional + +class MatchingConfig(BaseModel): + """Configuration for matching operations.""" + threshold: float = Field(default=0.5, ge=0.0, le=1.0, description="Matching threshold") + input_file: str = Field(..., description="Input file path (required)") + output_file: Optional[str] = Field(default=None, description="Output file path") + verbose: bool = Field(default=False, description="Enable verbose logging") + max_results: int = Field(default=100, ge=1, description="Maximum number of results") + diff --git a/src/kgpipe_parameters/tests/test_paramters_extraction.py b/src/kgpipe_parameters/tests/test_paramters_extraction.py new file mode 100644 index 0000000..0571d65 --- /dev/null +++ b/src/kgpipe_parameters/tests/test_paramters_extraction.py @@ -0,0 +1,507 @@ +""" +Comprehensive tests for parameter extraction module. +""" + +import pytest +from pathlib import Path +from unittest.mock import Mock, patch + +from kgpipe_parameters.extraction import ( + ParameterMiner, + CLIExtractor, + PythonLibExtractor, + HTTPAPIExtractor, + DockerExtractor, + RawParameter, + ExtractionResult, + SourceType, + ExtractionMethod, +) +from kgpipe_parameters.extraction.utils import ( + normalize_parameter_name, + parse_default_value, + infer_parameter_type, + extract_constraints, + to_parameter_model, +) +from kgpipe.common.model.configuration import Parameter, ParameterType + + +# ============================================================================= +# Utility Function Tests +# ============================================================================= + +class TestUtils: + """Tests for utility functions.""" + + def test_normalize_parameter_name(self): + """Test parameter name normalization.""" + assert normalize_parameter_name("--threshold") == "threshold" + assert normalize_parameter_name("-t") == "t" + assert normalize_parameter_name("threshold") == "threshold" + assert normalize_parameter_name("THRESHOLD") == "threshold" + assert normalize_parameter_name("max-results") == "max_results" + assert normalize_parameter_name("camelCase") == "camelcase" + + def test_parse_default_value(self): + """Test parsing of default values.""" + assert parse_default_value("0.5") == 0.5 + assert parse_default_value("100") == 100 + assert parse_default_value("true") is True + assert parse_default_value("false") is False + assert parse_default_value("yes") is True + assert parse_default_value("no") is False + assert parse_default_value("hello") == "hello" + assert parse_default_value('"hello"') == "hello" + assert parse_default_value("'world'") == "world" + assert parse_default_value(None) is None + + def test_infer_parameter_type(self): + """Test type inference from type hints and default values.""" + # From type hints + assert infer_parameter_type("int") == ParameterType.integer + assert infer_parameter_type("float") == ParameterType.number + assert infer_parameter_type("str") == ParameterType.string + assert infer_parameter_type("bool") == ParameterType.boolean + assert infer_parameter_type("List[str]") == ParameterType.array + assert infer_parameter_type("Dict[str, Any]") == ParameterType.object + assert infer_parameter_type("enum") == ParameterType.enum + + # From default values + assert infer_parameter_type(None, 42) == ParameterType.integer + assert infer_parameter_type(None, 3.14) == ParameterType.number + assert infer_parameter_type(None, "text") == ParameterType.string + assert infer_parameter_type(None, True) == ParameterType.boolean + assert infer_parameter_type(None, []) == ParameterType.array + assert infer_parameter_type(None, {}) == ParameterType.object + + # Default to string + assert infer_parameter_type(None, None) == ParameterType.string + + def test_extract_constraints(self): + """Test constraint extraction from descriptions.""" + desc1 = "Threshold value (min: 0.0, max: 1.0)" + constraints1 = extract_constraints(desc1) + assert constraints1["minimum"] == 0.0 + assert constraints1["maximum"] == 1.0 + + desc2 = "Choices: [option1, option2, option3]" + constraints2 = extract_constraints(desc2) + assert "allowed_values" in constraints2 + assert len(constraints2["allowed_values"]) == 3 + + desc3 = "Minimum value: 10" + constraints3 = extract_constraints(desc3) + assert constraints3["minimum"] == 10.0 + + desc4 = "Maximum value: 100" + constraints4 = extract_constraints(desc4) + assert constraints4["maximum"] == 100.0 + + def test_to_parameter_model(self): + """Test conversion from RawParameter to Parameter model.""" + raw_param = RawParameter( + name="threshold", + native_keys=["--threshold", "-t"], + description="Matching threshold (min: 0.0, max: 1.0)", + type_hint="float", + default_value=0.5, + required=False, + source="test", + ) + + param = to_parameter_model(raw_param) + + assert isinstance(param, Parameter) + assert param.name == "threshold" + assert "--threshold" in param.native_keys + assert param.datatype == ParameterType.number + assert param.default_value == 0.5 + assert param.required is False + assert param.minimum == 0.0 + assert param.maximum == 1.0 + + +# ============================================================================= +# CLI Extractor Tests +# ============================================================================= + +class TestCLIExtractor: + """Tests for CLI parameter extraction.""" + + def test_cli_extractor_basic(self, cli_help_simple): + """Test basic CLI parameter extraction.""" + extractor = CLIExtractor() + result = extractor.extract(cli_help_simple, "matcher") + + assert isinstance(result, ExtractionResult) + assert result.source_type == SourceType.CLI + assert result.extraction_method == ExtractionMethod.REGEX + assert len(result.parameters) > 0 + + # Check that threshold parameter was extracted + threshold_params = [p for p in result.parameters if "threshold" in p.name] + assert len(threshold_params) > 0 + + def test_cli_extractor_with_defaults(self, cli_help_argparse): + """Test extraction of parameters with default values.""" + extractor = CLIExtractor() + result = extractor.extract(cli_help_argparse, "tool") + + # Find threshold parameter with default + threshold_params = [p for p in result.parameters if "threshold" in p.name] + if threshold_params: + param = threshold_params[0] + assert param.default_value == 0.5 or param.default_value == "0.5" + + def test_cli_extractor_required_flags(self, cli_help_argparse): + """Test detection of required vs optional parameters.""" + extractor = CLIExtractor() + result = extractor.extract(cli_help_argparse, "tool") + + # Check for required parameters + output_params = [p for p in result.parameters if "output" in p.name] + if output_params: + # Output is marked as required in the test data + param = output_params[0] + # The extractor should detect "required" in description + + def test_cli_extractor_multiple_flags(self, cli_help_click): + """Test extraction of both long and short flags.""" + extractor = CLIExtractor() + result = extractor.extract(cli_help_click, "tool") + + # Check that parameters have native_keys + for param in result.parameters: + assert len(param.native_keys) > 0 + + def test_cli_extractor_description(self, cli_help_simple): + """Test extraction of parameter descriptions.""" + extractor = CLIExtractor() + result = extractor.extract(cli_help_simple, "matcher") + + # Check that descriptions are extracted + params_with_desc = [p for p in result.parameters if p.description] + assert len(params_with_desc) > 0 + + +# ============================================================================= +# Python Extractor Tests +# ============================================================================= + +class TestPythonExtractor: + """Tests for Python parameter extraction.""" + + def test_python_extractor_function_params(self, python_function_code): + """Test extraction from function signatures.""" + extractor = PythonLibExtractor() + result = extractor.extract(python_function_code, "process_data") + + assert isinstance(result, ExtractionResult) + assert result.source_type == SourceType.PYTHON_LIB + assert len(result.parameters) > 0 + + # Check for expected parameters + param_names = [p.name for p in result.parameters] + assert "input_file" in param_names or "inputfile" in param_names + assert "threshold" in param_names + + def test_python_extractor_type_hints(self, python_function_code): + """Test extraction of type hints.""" + extractor = PythonLibExtractor() + result = extractor.extract(python_function_code, "process_data") + + # Check that type hints are extracted + params_with_types = [p for p in result.parameters if p.type_hint] + assert len(params_with_types) > 0 + + def test_python_extractor_docstrings(self, python_function_code): + """Test extraction from docstrings.""" + extractor = PythonLibExtractor() + result = extractor.extract(python_function_code, "process_data") + + # Check that descriptions from docstrings are extracted + params_with_desc = [p for p in result.parameters if p.description] + assert len(params_with_desc) > 0 + + def test_python_extractor_dataclass(self, python_dataclass_code): + """Test extraction from dataclass attributes.""" + extractor = PythonLibExtractor() + result = extractor.extract(python_dataclass_code, "MatchingConfig") + + assert len(result.parameters) > 0 + param_names = [p.name for p in result.parameters] + assert "threshold" in param_names or "input_file" in param_names + + def test_python_extractor_pydantic_model(self, python_pydantic_code): + """Test extraction from Pydantic models.""" + extractor = PythonLibExtractor() + result = extractor.extract(python_pydantic_code, "MatchingConfig") + + assert len(result.parameters) > 0 + param_names = [p.name for p in result.parameters] + assert "threshold" in param_names or "input_file" in param_names + + def test_python_extractor_ast_parsing(self, python_function_code): + """Test AST-based extraction.""" + extractor = PythonLibExtractor() + result = extractor.extract(python_function_code, "process_data") + + # AST parsing should work for valid Python code + assert result.extraction_method == ExtractionMethod.REGEX + assert len(result.parameters) > 0 + + +# ============================================================================= +# HTTP API Extractor Tests +# ============================================================================= + +class TestHTTPAPIExtractor: + """Tests for HTTP API parameter extraction.""" + + def test_api_extractor_openapi_spec(self, openapi_spec): + """Test extraction from OpenAPI YAML.""" + extractor = HTTPAPIExtractor() + result = extractor.extract(openapi_spec, "matching_api") + + assert isinstance(result, ExtractionResult) + assert result.source_type == SourceType.HTTP_API + assert len(result.parameters) > 0 + + # Check for expected parameters + param_names = [p.name for p in result.parameters] + assert "threshold" in param_names or "input_file" in param_names + + def test_api_extractor_swagger_spec(self, swagger_spec): + """Test extraction from Swagger JSON.""" + extractor = HTTPAPIExtractor() + result = extractor.extract(swagger_spec, "matching_api") + + assert len(result.parameters) > 0 + param_names = [p.name for p in result.parameters] + assert "threshold" in param_names or "input_file" in param_names + + def test_api_extractor_path_params(self, openapi_spec): + """Test path parameter extraction.""" + extractor = HTTPAPIExtractor() + result = extractor.extract(openapi_spec, "matching_api") + + # OpenAPI spec has query params, not path params in our test data + # But we should still extract parameters + assert len(result.parameters) > 0 + + def test_api_extractor_query_params(self, openapi_spec): + """Test query parameter extraction.""" + extractor = HTTPAPIExtractor() + result = extractor.extract(openapi_spec, "matching_api") + + # Check for query parameters + query_params = [p for p in result.parameters if "threshold" in p.name or "max_results" in p.name] + assert len(query_params) > 0 + + def test_api_extractor_request_body(self, openapi_spec): + """Test request body parameter extraction.""" + extractor = HTTPAPIExtractor() + result = extractor.extract(openapi_spec, "matching_api") + + # Check for request body parameters + body_params = [p for p in result.parameters if "input_file" in p.name or "output_file" in p.name] + assert len(body_params) > 0 + + +# ============================================================================= +# Docker Extractor Tests +# ============================================================================= + +class TestDockerExtractor: + """Tests for Docker parameter extraction.""" + + def test_docker_extractor_env_vars(self, dockerfile_content): + """Test ENV variable extraction from Dockerfile.""" + extractor = DockerExtractor() + result = extractor.extract(dockerfile_content, "dockerfile") + + assert isinstance(result, ExtractionResult) + assert result.source_type == SourceType.DOCKER + assert len(result.parameters) > 0 + + # Check for ENV variables + env_params = [p for p in result.parameters if "THRESHOLD" in p.native_keys or "threshold" in p.name] + assert len(env_params) > 0 + + def test_docker_extractor_args(self, dockerfile_content): + """Test ARG extraction from Dockerfile.""" + extractor = DockerExtractor() + result = extractor.extract(dockerfile_content, "dockerfile") + + # Check for ARG declarations + arg_params = [p for p in result.parameters if "BUILD_VERSION" in p.native_keys or "build_version" in p.name] + assert len(arg_params) > 0 + + def test_docker_extractor_compose_env(self, docker_compose_content): + """Test environment variable extraction from docker-compose.yml.""" + extractor = DockerExtractor() + result = extractor.extract(docker_compose_content, "docker_compose") + + assert len(result.parameters) > 0 + + # Check for environment variables + env_params = [p for p in result.parameters if "THRESHOLD" in p.native_keys or "threshold" in p.name] + assert len(env_params) > 0 + + def test_docker_extractor_multiple_services(self, docker_compose_content): + """Test extraction from multiple services.""" + extractor = DockerExtractor() + result = extractor.extract(docker_compose_content, "docker_compose") + + # Should extract from both matcher and processor services + assert len(result.parameters) > 0 + + +# ============================================================================= +# ParameterMiner Integration Tests +# ============================================================================= + +class TestParameterMiner: + """Integration tests for ParameterMiner.""" + + def test_parameter_miner_auto_detect_cli(self, cli_help_simple): + """Test auto-detection of CLI source.""" + miner = ParameterMiner() + result = miner.extract_parameters(cli_help_simple, method=ExtractionMethod.AUTO) + + assert result.source_type == SourceType.CLI + + def test_parameter_miner_auto_detect_python(self, python_function_code): + """Test auto-detection of Python source.""" + miner = ParameterMiner() + result = miner.extract_parameters(python_function_code, method=ExtractionMethod.AUTO) + + assert result.source_type == SourceType.PYTHON_LIB + + def test_parameter_miner_auto_detect_api(self, openapi_spec): + """Test auto-detection of API source.""" + miner = ParameterMiner() + result = miner.extract_parameters(openapi_spec, method=ExtractionMethod.AUTO) + + assert result.source_type == SourceType.HTTP_API + + def test_parameter_miner_auto_detect_docker(self, dockerfile_content): + """Test auto-detection of Docker source.""" + miner = ParameterMiner() + result = miner.extract_parameters(dockerfile_content, method=ExtractionMethod.AUTO) + + assert result.source_type == SourceType.DOCKER + + def test_parameter_miner_file_path(self, test_data_dir): + """Test extraction from file path.""" + miner = ParameterMiner() + cli_file = test_data_dir / "cli" / "simple_help.txt" + result = miner.extract_parameters(str(cli_file), method=ExtractionMethod.AUTO) + + assert result.source_type == SourceType.CLI + assert result.tool_name == "simple_help" + + def test_parameter_miner_method_auto(self, cli_help_simple): + """Test auto method selection (regex → LLM fallback).""" + miner = ParameterMiner() + result = miner.extract_parameters(cli_help_simple, method=ExtractionMethod.AUTO) + + # Should use regex by default + assert result.extraction_method == ExtractionMethod.REGEX + + def test_parameter_miner_to_json(self, cli_help_simple): + """Test JSON output conversion.""" + miner = ParameterMiner() + result = miner.extract_parameters(cli_help_simple, method=ExtractionMethod.AUTO) + + json_output = miner.to_json(result) + assert isinstance(json_output, str) + assert "parameters" in json_output or '"parameters"' in json_output + + def test_parameter_miner_to_parameter_model(self, cli_help_simple): + """Test Parameter model conversion.""" + miner = ParameterMiner() + result = miner.extract_parameters(cli_help_simple, method=ExtractionMethod.AUTO) + + if result.parameters: + param_model = miner.to_parameter_model(result.parameters[0]) + assert isinstance(param_model, Parameter) + assert param_model.name is not None + assert param_model.datatype is not None + + +# ============================================================================= +# Error Handling Tests +# ============================================================================= + +class TestErrorHandling: + """Tests for error handling.""" + + def test_extractor_invalid_source(self): + """Test handling of invalid source content.""" + extractor = CLIExtractor() + result = extractor.extract("This is not valid CLI help", "test") + + # Should not crash, but may return empty or minimal results + assert isinstance(result, ExtractionResult) + + def test_extractor_empty_source(self): + """Test handling of empty source.""" + extractor = CLIExtractor() + result = extractor.extract("", "test") + + assert isinstance(result, ExtractionResult) + assert len(result.parameters) == 0 + + def test_extractor_malformed_spec(self): + """Test handling of malformed specifications.""" + extractor = HTTPAPIExtractor() + result = extractor.extract("{ invalid json }", "test") + + assert isinstance(result, ExtractionResult) + # Should handle gracefully, may have errors + assert len(result.errors) >= 0 + + def test_parameter_miner_unknown_source_type(self): + """Test handling of unknown source types.""" + miner = ParameterMiner() + result = miner.extract_parameters("Random text that doesn't match any pattern", method=ExtractionMethod.AUTO) + + assert isinstance(result, ExtractionResult) + # Should default to UNKNOWN or handle gracefully + assert result.source_type in [SourceType.UNKNOWN, SourceType.CLI, SourceType.PYTHON_LIB] + + +# ============================================================================= +# LLM Extractor Tests (Optional - Mock LLM) +# ============================================================================= + +class TestLLMExtractor: + """Tests for LLM-based extraction (with mocked LLM client).""" + + def test_llm_extractor_cli(self, cli_help_simple, mock_llm_client): + """Test LLM-based CLI extraction.""" + from kgpipe_parameters.extraction.param_miner import LLMCLIExtractor + + extractor = LLMCLIExtractor(mock_llm_client) + result = extractor.extract(cli_help_simple, "test_tool") + + assert isinstance(result, ExtractionResult) + assert result.extraction_method == ExtractionMethod.LLM + # Mock should return parameters + assert len(result.parameters) > 0 + + def test_llm_extractor_fallback(self, cli_help_simple, mock_llm_client): + """Test fallback from regex to LLM when regex fails.""" + miner = ParameterMiner(llm_client=mock_llm_client) + + # Use a source that regex might struggle with + result = miner.extract_parameters( + cli_help_simple, + method=ExtractionMethod.AUTO + ) + + # Should try regex first, but if it fails and LLM is available, use LLM + assert isinstance(result, ExtractionResult) + From 107fb4aef564ce0a6cebabea02999786cfc2dc36 Mon Sep 17 00:00:00 2001 From: Marvin Hofer Date: Tue, 27 Jan 2026 22:57:38 +0100 Subject: [PATCH 04/96] init explorer app --- experiments/explorer/README.md | 39 ++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 experiments/explorer/README.md diff --git a/experiments/explorer/README.md b/experiments/explorer/README.md new file mode 100644 index 0000000..fa17f59 --- /dev/null +++ b/experiments/explorer/README.md @@ -0,0 +1,39 @@ +# KGpipe Explorer + +A static web application for exploring the KGpipe framework's System Knowledge Graph (PipeKG) and pipeline execution results. The explorer provides an interactive interface to browse registered tasks, pipelines, metrics, and evaluation results without executing pipelines. + +## Overview + +The KGpipe Explorer is designed to visualize and navigate the meta knowledge graph that KGpipe maintains internally. This System KG tracks: + +- **Tasks**: Registered integration tasks with their specifications, input/output formats, and categories +- **Pipelines**: Pipeline definitions and their composition of tasks +- **Metrics**: Evaluation metrics and quality measurements +- **Execution Results**: Results from pipeline runs and their associated metadata + +## Purpose + +The explorer enables users to: + +- Discover available tasks and their capabilities +- Understand pipeline structures and task dependencies +- Review evaluation metrics and execution results +- Explore relationships between tasks, pipelines, and data formats +- Navigate the System KG structure through an intuitive interface + +## System Knowledge Graph + +The explorer operates on the PipeKG (Meta Knowledge Graph) that KGpipe maintains internally. For detailed information about the System KG structure, query capabilities, and SPARQL examples, see the [Meta KG documentation](../../docs/metakg.md). + +## Design Principles + +- **Static**: The explorer works with pre-generated System KG data and execution results. It does not execute pipelines or modify the framework state. +- **Read-only**: All exploration is read-only, ensuring no accidental modifications to pipeline definitions or execution results. +- **Interactive**: Provides an intuitive interface for navigating the complex relationships in the System KG. + +## Architecture + +The explorer consumes static RDF data from the System KG and presents it through a web-based interface, allowing users to query and visualize the knowledge graph structure without requiring direct SPARQL knowledge. + +## Backlog +- decide on framwork and src structure \ No newline at end of file From dcfad181b9c5320794633cec4e7656aa3a3ba24a Mon Sep 17 00:00:00 2001 From: Marvin Date: Thu, 5 Feb 2026 23:38:00 +0100 Subject: [PATCH 05/96] init param extractors and small experiment --- experiments/param-opti/.gitignore | 2 + experiments/param-opti/README.md | 100 +++ .../param-opti/input/corenlp_openie/repo.url | 1 + experiments/param-opti/input/paris/cli.txt | 10 + experiments/param-opti/input/paris/repo.url | 1 + experiments/param-opti/run_experiment.py | 27 + .../param-opti/src/param_opti/__init__.py | 13 + .../param-opti/src/param_opti/__main__.py | 106 +++ .../param-opti/src/param_opti/experiment.py | 466 ++++++++++ experiments/param-opti/src/param_opti/tool.py | 92 ++ src/kgpipe_parameters/__init__.py | 25 + src/kgpipe_parameters/extraction/__init__.py | 5 +- .../extraction/extractors/__init__.py | 25 + .../extraction/extractors/cli.py | 193 +++++ .../extraction/extractors/docker.py | 188 ++++ .../extraction/extractors/http_api.py | 186 ++++ .../extraction/extractors/python_lib.py | 267 ++++++ .../extraction/param_miner.py | 817 +----------------- .../kgpipe_parameter_explorer.py | 1 + 19 files changed, 1732 insertions(+), 793 deletions(-) create mode 100644 experiments/param-opti/.gitignore create mode 100644 experiments/param-opti/README.md create mode 100644 experiments/param-opti/input/corenlp_openie/repo.url create mode 100644 experiments/param-opti/input/paris/cli.txt create mode 100644 experiments/param-opti/input/paris/repo.url create mode 100644 experiments/param-opti/run_experiment.py create mode 100644 experiments/param-opti/src/param_opti/__init__.py create mode 100644 experiments/param-opti/src/param_opti/__main__.py create mode 100644 experiments/param-opti/src/param_opti/experiment.py create mode 100644 experiments/param-opti/src/param_opti/tool.py create mode 100644 src/kgpipe_parameters/__init__.py create mode 100644 src/kgpipe_parameters/extraction/extractors/__init__.py create mode 100644 src/kgpipe_parameters/extraction/extractors/cli.py create mode 100644 src/kgpipe_parameters/extraction/extractors/docker.py create mode 100644 src/kgpipe_parameters/extraction/extractors/http_api.py create mode 100644 src/kgpipe_parameters/extraction/extractors/python_lib.py create mode 100644 src/kgpipe_parameters/visualization/kgpipe_parameter_explorer.py diff --git a/experiments/param-opti/.gitignore b/experiments/param-opti/.gitignore new file mode 100644 index 0000000..3d632f9 --- /dev/null +++ b/experiments/param-opti/.gitignore @@ -0,0 +1,2 @@ +output/ +repos/ \ No newline at end of file diff --git a/experiments/param-opti/README.md b/experiments/param-opti/README.md new file mode 100644 index 0000000..8c62e8c --- /dev/null +++ b/experiments/param-opti/README.md @@ -0,0 +1,100 @@ +# Parameter Optimization Experiment + +This experiment extracts and analyzes configuration parameters from open-source data integration tools using the `kgpipe_parameters` extraction module. + +## Directory Structure + +``` +param-opti/ +├── input/ # Tool definitions +│ ├── paris/ +│ │ ├── repo.url # Git repository URL +│ │ └── cli.txt # CLI help output (optional) +│ └── corenlp_openie/ +│ └── repo.url +├── repos/ # Cloned repositories (auto-populated) +├── output/ # Extraction results (JSON) +├── src/ +│ └── param_opti/ # Experiment code +└── run_experiment.py # Main entry point +``` + +## Usage + +### Run full experiment + +```bash +# From kgpipe root (with venv activated) +cd experiments/param-opti +python run_experiment.py +``` + +### Run for specific tool + +```bash +python run_experiment.py --tool paris +python run_experiment.py --tool paris corenlp_openie +``` + +### Skip repository cloning + +```bash +python run_experiment.py --no-clone +``` + +### Use LLM-based extraction (requires kgpipe_llm) + +```bash +python run_experiment.py --use-llm +``` + +## Adding New Tools + +1. Create a folder in `input/` with the tool name +2. Add `repo.url` with the Git repository URL +3. Optionally add `cli.txt` with CLI help output +4. Optionally add `config.json` for additional settings: + +```json +{ + "language": "python", + "main_files": ["src/main.py", "cli.py"] +} +``` + +## Output Format + +Results are saved as JSON files in `output/`: + +```json +{ + "tool_name": "paris", + "timestamp": "2024-...", + "sources": [ + { + "source_type": "cli", + "file_path": "input/paris/cli.txt", + "parameters_count": 5 + } + ], + "parameters": [ + { + "name": "threshold", + "native_keys": ["--threshold"], + "description": "Matching threshold", + "type_hint": "float", + "default_value": 0.5, + "_source": "cli" + } + ], + "summary": { + "total_parameters": 5, + "total_sources": 1, + "total_errors": 0 + } +} +``` + +A `_summary.json` file is also generated with aggregate statistics. + + diff --git a/experiments/param-opti/input/corenlp_openie/repo.url b/experiments/param-opti/input/corenlp_openie/repo.url new file mode 100644 index 0000000..9ccaf57 --- /dev/null +++ b/experiments/param-opti/input/corenlp_openie/repo.url @@ -0,0 +1 @@ +https://github.com/stanfordnlp/CoreNLP.git \ No newline at end of file diff --git a/experiments/param-opti/input/paris/cli.txt b/experiments/param-opti/input/paris/cli.txt new file mode 100644 index 0000000..edeca1b --- /dev/null +++ b/experiments/param-opti/input/paris/cli.txt @@ -0,0 +1,10 @@ +Paris + +You can specify a file that has no content. +PARIS will ask for the necessary data and store it in . + +Paris + +Shorthand for the previous form. + +Paris diff --git a/experiments/param-opti/input/paris/repo.url b/experiments/param-opti/input/paris/repo.url new file mode 100644 index 0000000..d3f2731 --- /dev/null +++ b/experiments/param-opti/input/paris/repo.url @@ -0,0 +1 @@ +https://github.com/dig-team/PARIS.git \ No newline at end of file diff --git a/experiments/param-opti/run_experiment.py b/experiments/param-opti/run_experiment.py new file mode 100644 index 0000000..e9c5d9f --- /dev/null +++ b/experiments/param-opti/run_experiment.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +""" +Quick script to run the parameter extraction experiment. + +This script can be run directly from the param-opti directory: + python run_experiment.py + python run_experiment.py --tool paris + python run_experiment.py --no-clone +""" + +import sys +from pathlib import Path + +# Add src to path +src_path = Path(__file__).parent / "src" +sys.path.insert(0, str(src_path)) + +# Also ensure kgpipe is importable +kgpipe_src = Path(__file__).parent.parent.parent / "src" +sys.path.insert(0, str(kgpipe_src)) + +from param_opti.__main__ import main + +if __name__ == "__main__": + sys.exit(main()) + + diff --git a/experiments/param-opti/src/param_opti/__init__.py b/experiments/param-opti/src/param_opti/__init__.py new file mode 100644 index 0000000..0feb54e --- /dev/null +++ b/experiments/param-opti/src/param_opti/__init__.py @@ -0,0 +1,13 @@ +""" +Parameter Optimization Experiment Package. + +This package provides tools for extracting and analyzing configuration parameters +from open-source data integration tools. +""" + +from .experiment import ParameterExtractionExperiment +from .tool import ToolDefinition + +__all__ = ["ParameterExtractionExperiment", "ToolDefinition"] + + diff --git a/experiments/param-opti/src/param_opti/__main__.py b/experiments/param-opti/src/param_opti/__main__.py new file mode 100644 index 0000000..61a7e71 --- /dev/null +++ b/experiments/param-opti/src/param_opti/__main__.py @@ -0,0 +1,106 @@ +""" +Command-line entry point for parameter extraction experiment. + +Usage: + python -m param_opti [--tool TOOL_NAME] [--no-clone] [--use-llm] +""" + +import argparse +import sys +from pathlib import Path + +from .experiment import ParameterExtractionExperiment + + +def get_project_root() -> Path: + """Get the param-opti project root directory.""" + return Path(__file__).parent.parent.parent + + +def main(): + parser = argparse.ArgumentParser( + description="Extract configuration parameters from data integration tools" + ) + parser.add_argument( + "--tool", "-t", + type=str, + nargs="*", + help="Specific tool(s) to process (default: all)" + ) + parser.add_argument( + "--no-clone", + action="store_true", + help="Skip cloning repositories" + ) + parser.add_argument( + "--use-llm", + action="store_true", + help="Use LLM-based extraction as fallback" + ) + parser.add_argument( + "--input-dir", + type=Path, + default=None, + help="Input directory with tool definitions" + ) + parser.add_argument( + "--output-dir", + type=Path, + default=None, + help="Output directory for results" + ) + parser.add_argument( + "--repos-dir", + type=Path, + default=None, + help="Directory for cloned repositories" + ) + + args = parser.parse_args() + + # Determine directories + project_root = get_project_root() + input_dir = args.input_dir or project_root / "input" + output_dir = args.output_dir or project_root / "output" + repos_dir = args.repos_dir or project_root / "repos" + + # Initialize LLM client if requested + llm_client = None + if args.use_llm: + try: + from kgpipe_llm.common.core import LLMClient + llm_client = LLMClient() + print("LLM client initialized") + except ImportError: + print("Warning: kgpipe_llm not available, proceeding without LLM") + + # Create and run experiment + experiment = ParameterExtractionExperiment( + input_dir=input_dir, + output_dir=output_dir, + repos_dir=repos_dir, + clone_repos=not args.no_clone, + use_llm=args.use_llm, + llm_client=llm_client, + ) + + results = experiment.run(tool_names=args.tool) + + # Print summary + print("\n" + "=" * 60) + print("Results Summary") + print("=" * 60) + for name, result in results.items(): + status = "✓" if not result.errors else "⚠" + print(f"{status} {name}: {len(result.parameters)} parameters from {len(result.sources)} sources") + if result.errors: + for err in result.errors[:3]: # Show first 3 errors + print(f" Error: {err}") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) + + diff --git a/experiments/param-opti/src/param_opti/experiment.py b/experiments/param-opti/src/param_opti/experiment.py new file mode 100644 index 0000000..bcb85da --- /dev/null +++ b/experiments/param-opti/src/param_opti/experiment.py @@ -0,0 +1,466 @@ +""" +Main experiment runner for parameter extraction. +""" + +import json +import subprocess +import logging +from datetime import datetime +from pathlib import Path +from typing import List, Optional, Dict, Any +from dataclasses import dataclass, field, asdict + +from .tool import ToolDefinition + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + + +@dataclass +class ExtractionSource: + """Represents a source that was used for extraction.""" + source_type: str # cli, python, docker, readme, etc. + file_path: Optional[str] = None + content_preview: Optional[str] = None + parameters_count: int = 0 + + +@dataclass +class ToolExtractionResult: + """Result of parameter extraction for a single tool.""" + tool_name: str + timestamp: str + sources: List[ExtractionSource] = field(default_factory=list) + parameters: List[Dict[str, Any]] = field(default_factory=list) + errors: List[str] = field(default_factory=list) + metadata: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for JSON serialization.""" + return { + "tool_name": self.tool_name, + "timestamp": self.timestamp, + "sources": [asdict(s) for s in self.sources], + "parameters": self.parameters, + "errors": self.errors, + "metadata": self.metadata, + "summary": { + "total_parameters": len(self.parameters), + "total_sources": len(self.sources), + "total_errors": len(self.errors), + } + } + + +class ParameterExtractionExperiment: + """ + Main experiment class for extracting parameters from tools. + + This class orchestrates the parameter extraction process: + 1. Discovers tools from input folder + 2. Clones repositories if needed + 3. Applies extractors to various sources (CLI, Python, Docker, etc.) + 4. Aggregates and saves results + """ + + def __init__( + self, + input_dir: Path, + output_dir: Path, + repos_dir: Path, + clone_repos: bool = True, + use_llm: bool = False, + llm_client: Optional[Any] = None, + ): + """ + Initialize the experiment. + + Args: + input_dir: Directory containing tool definitions + output_dir: Directory for output results + repos_dir: Directory for cloned repositories + clone_repos: Whether to clone repositories + use_llm: Whether to use LLM-based extraction as fallback + llm_client: Optional LLM client instance + """ + self.input_dir = Path(input_dir) + self.output_dir = Path(output_dir) + self.repos_dir = Path(repos_dir) + self.clone_repos = clone_repos + self.use_llm = use_llm + self.llm_client = llm_client + + # Create directories + self.output_dir.mkdir(parents=True, exist_ok=True) + self.repos_dir.mkdir(parents=True, exist_ok=True) + + # Initialize miner lazily + self._miner = None + + @property + def miner(self): + """Lazy initialization of ParameterMiner.""" + if self._miner is None: + from kgpipe_parameters.extraction import ParameterMiner + self._miner = ParameterMiner(llm_client=self.llm_client) + return self._miner + + def discover_tools(self) -> List[ToolDefinition]: + """ + Discover all tool definitions in the input directory. + + Returns: + List of ToolDefinition instances + """ + tools = [] + for folder in sorted(self.input_dir.iterdir()): + if folder.is_dir() and not folder.name.startswith("."): + try: + tool = ToolDefinition.from_folder(folder) + tools.append(tool) + logger.info(f"Discovered tool: {tool}") + except Exception as e: + logger.warning(f"Failed to load tool from {folder}: {e}") + + logger.info(f"Discovered {len(tools)} tools") + return tools + + def clone_repository(self, tool: ToolDefinition) -> Optional[Path]: + """ + Clone a tool's repository if not already present. + + Args: + tool: Tool definition with repo URL + + Returns: + Path to the cloned repository, or None if failed + """ + if not tool.has_repo(): + logger.warning(f"No repository URL for {tool.name}") + return None + + repo_path = self.repos_dir / tool.name + + if repo_path.exists(): + logger.info(f"Repository already exists: {repo_path}") + return repo_path + + logger.info(f"Cloning {tool.repo_url} to {repo_path}") + try: + result = subprocess.run( + ["git", "clone", "--depth", "1", tool.repo_url, str(repo_path)], + capture_output=True, + text=True, + timeout=300, # 5 minute timeout + ) + if result.returncode == 0: + logger.info(f"Successfully cloned {tool.name}") + return repo_path + else: + logger.error(f"Git clone failed: {result.stderr}") + return None + except subprocess.TimeoutExpired: + logger.error(f"Git clone timed out for {tool.name}") + return None + except Exception as e: + logger.error(f"Failed to clone {tool.name}: {e}") + return None + + def extract_from_cli(self, tool: ToolDefinition) -> Optional[Dict[str, Any]]: + """ + Extract parameters from CLI help output. + + Args: + tool: Tool definition with CLI help + + Returns: + Extraction result dictionary, or None if no CLI help + """ + if not tool.has_cli_help(): + return None + + logger.info(f"Extracting from CLI help for {tool.name}") + from kgpipe_parameters.extraction import SourceType + + result = self.miner.extract_parameters( + source=tool.cli_help, + source_type=SourceType.CLI, + tool_name=tool.name, + ) + + return { + "source_type": "cli", + "source_file": str(tool.input_path / "cli.txt"), + "result": json.loads(result.model_dump_json()), + } + + def extract_from_repo(self, tool: ToolDefinition, repo_path: Path) -> List[Dict[str, Any]]: + """ + Extract parameters from repository files. + + Args: + tool: Tool definition + repo_path: Path to cloned repository + + Returns: + List of extraction result dictionaries + """ + results = [] + + # Find Python files + python_files = list(repo_path.rglob("*.py")) + logger.info(f"Found {len(python_files)} Python files in {tool.name}") + + # Prioritize main/config/cli files + priority_patterns = [ + "main", "cli", "config", "settings", "args", "params", "options", + "__main__", "run", "train", "evaluate" + ] + + def priority_score(path: Path) -> int: + name = path.stem.lower() + for i, pattern in enumerate(priority_patterns): + if pattern in name: + return i + return len(priority_patterns) + + python_files.sort(key=priority_score) + + # Extract from top Python files (limit to avoid overwhelming) + from kgpipe_parameters.extraction import SourceType + + for py_file in python_files[:20]: # Process top 20 files + try: + content = py_file.read_text(errors="ignore") + if len(content) < 100: # Skip very small files + continue + + # Skip test files + if "test" in str(py_file).lower(): + continue + + result = self.miner.extract_parameters( + source=content, + source_type=SourceType.PYTHON_LIB, + tool_name=f"{tool.name}/{py_file.name}", + ) + + if result.parameters: + results.append({ + "source_type": "python", + "source_file": str(py_file.relative_to(repo_path)), + "result": json.loads(result.model_dump_json()), + }) + logger.info(f" Extracted {len(result.parameters)} params from {py_file.name}") + except Exception as e: + logger.warning(f" Failed to process {py_file}: {e}") + + # Find Dockerfiles + for dockerfile in repo_path.rglob("Dockerfile*"): + try: + content = dockerfile.read_text(errors="ignore") + result = self.miner.extract_parameters( + source=content, + source_type=SourceType.DOCKER, + tool_name=f"{tool.name}/Dockerfile", + ) + + if result.parameters: + results.append({ + "source_type": "docker", + "source_file": str(dockerfile.relative_to(repo_path)), + "result": json.loads(result.model_dump_json()), + }) + logger.info(f" Extracted {len(result.parameters)} params from {dockerfile.name}") + except Exception as e: + logger.warning(f" Failed to process {dockerfile}: {e}") + + # Find docker-compose files + for compose_file in repo_path.rglob("docker-compose*.y*ml"): + try: + content = compose_file.read_text(errors="ignore") + result = self.miner.extract_parameters( + source=content, + source_type=SourceType.DOCKER, + tool_name=f"{tool.name}/docker-compose", + ) + + if result.parameters: + results.append({ + "source_type": "docker", + "source_file": str(compose_file.relative_to(repo_path)), + "result": json.loads(result.model_dump_json()), + }) + logger.info(f" Extracted {len(result.parameters)} params from {compose_file.name}") + except Exception as e: + logger.warning(f" Failed to process {compose_file}: {e}") + + return results + + def process_tool(self, tool: ToolDefinition) -> ToolExtractionResult: + """ + Process a single tool and extract all parameters. + + Args: + tool: Tool definition to process + + Returns: + ToolExtractionResult with all extracted parameters + """ + logger.info(f"Processing tool: {tool.name}") + + result = ToolExtractionResult( + tool_name=tool.name, + timestamp=datetime.now().isoformat(), + metadata={ + "repo_url": tool.repo_url, + "has_cli_help": tool.has_cli_help(), + "config": tool.config, + } + ) + + # Extract from CLI help + if tool.has_cli_help(): + try: + cli_result = self.extract_from_cli(tool) + if cli_result: + params = cli_result["result"].get("parameters", []) + result.sources.append(ExtractionSource( + source_type="cli", + file_path=cli_result["source_file"], + content_preview=tool.cli_help[:200] if tool.cli_help else None, + parameters_count=len(params), + )) + for p in params: + p["_source"] = "cli" + result.parameters.append(p) + except Exception as e: + result.errors.append(f"CLI extraction failed: {str(e)}") + logger.error(f"CLI extraction failed for {tool.name}: {e}") + + # Clone and extract from repository + if self.clone_repos and tool.has_repo(): + repo_path = self.clone_repository(tool) + if repo_path: + try: + repo_results = self.extract_from_repo(tool, repo_path) + for r in repo_results: + params = r["result"].get("parameters", []) + result.sources.append(ExtractionSource( + source_type=r["source_type"], + file_path=r["source_file"], + parameters_count=len(params), + )) + for p in params: + p["_source"] = f"{r['source_type']}:{r['source_file']}" + result.parameters.append(p) + except Exception as e: + result.errors.append(f"Repository extraction failed: {str(e)}") + logger.error(f"Repository extraction failed for {tool.name}: {e}") + + logger.info(f"Completed {tool.name}: {len(result.parameters)} parameters from {len(result.sources)} sources") + return result + + def save_result(self, result: ToolExtractionResult) -> Path: + """ + Save extraction result to output directory. + + Args: + result: Extraction result to save + + Returns: + Path to saved file + """ + output_file = self.output_dir / f"{result.tool_name}.json" + + with open(output_file, "w") as f: + json.dump(result.to_dict(), f, indent=2, default=str) + + logger.info(f"Saved result to {output_file}") + return output_file + + def run(self, tool_names: Optional[List[str]] = None) -> Dict[str, ToolExtractionResult]: + """ + Run the experiment for all or selected tools. + + Args: + tool_names: Optional list of tool names to process (all if None) + + Returns: + Dictionary mapping tool names to their extraction results + """ + logger.info("=" * 60) + logger.info("Starting Parameter Extraction Experiment") + logger.info("=" * 60) + + # Discover tools + tools = self.discover_tools() + + # Filter if specific tools requested + if tool_names: + tools = [t for t in tools if t.name in tool_names] + logger.info(f"Filtered to {len(tools)} tools: {[t.name for t in tools]}") + + # Process each tool + results = {} + for tool in tools: + try: + result = self.process_tool(tool) + self.save_result(result) + results[tool.name] = result + except Exception as e: + logger.error(f"Failed to process {tool.name}: {e}") + results[tool.name] = ToolExtractionResult( + tool_name=tool.name, + timestamp=datetime.now().isoformat(), + errors=[str(e)], + ) + + # Generate summary + self._generate_summary(results) + + logger.info("=" * 60) + logger.info("Experiment Complete") + logger.info("=" * 60) + + return results + + def _generate_summary(self, results: Dict[str, ToolExtractionResult]) -> None: + """Generate and save experiment summary.""" + summary = { + "timestamp": datetime.now().isoformat(), + "total_tools": len(results), + "tools": {} + } + + total_params = 0 + total_sources = 0 + total_errors = 0 + + for name, result in results.items(): + summary["tools"][name] = { + "parameters": len(result.parameters), + "sources": len(result.sources), + "errors": len(result.errors), + } + total_params += len(result.parameters) + total_sources += len(result.sources) + total_errors += len(result.errors) + + summary["totals"] = { + "parameters": total_params, + "sources": total_sources, + "errors": total_errors, + } + + summary_file = self.output_dir / "_summary.json" + with open(summary_file, "w") as f: + json.dump(summary, f, indent=2) + + logger.info(f"Summary: {total_params} parameters from {total_sources} sources ({total_errors} errors)") + + diff --git a/experiments/param-opti/src/param_opti/tool.py b/experiments/param-opti/src/param_opti/tool.py new file mode 100644 index 0000000..eb16aab --- /dev/null +++ b/experiments/param-opti/src/param_opti/tool.py @@ -0,0 +1,92 @@ +""" +Tool definition model for parameter extraction experiments. +""" + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional, List, Dict, Any +import json + + +@dataclass +class ToolDefinition: + """ + Represents a tool to be analyzed for parameter extraction. + + A tool is defined by a folder containing: + - repo.url: Git repository URL + - cli.txt: (optional) CLI help output + - readme.md: (optional) README content + - config.json: (optional) Additional configuration + """ + name: str + input_path: Path + repo_url: Optional[str] = None + cli_help: Optional[str] = None + readme_content: Optional[str] = None + config: Dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_folder(cls, folder_path: Path) -> "ToolDefinition": + """ + Load a tool definition from a folder. + + Args: + folder_path: Path to the tool definition folder + + Returns: + ToolDefinition instance + """ + name = folder_path.name + + # Load repo URL + repo_url = None + repo_url_file = folder_path / "repo.url" + if repo_url_file.exists(): + repo_url = repo_url_file.read_text().strip() + + # Load CLI help + cli_help = None + cli_file = folder_path / "cli.txt" + if cli_file.exists(): + cli_help = cli_file.read_text() + + # Load README + readme_content = None + for readme_name in ["readme.md", "README.md", "readme.txt", "README.txt"]: + readme_file = folder_path / readme_name + if readme_file.exists(): + readme_content = readme_file.read_text() + break + + # Load config + config = {} + config_file = folder_path / "config.json" + if config_file.exists(): + config = json.loads(config_file.read_text()) + + return cls( + name=name, + input_path=folder_path, + repo_url=repo_url, + cli_help=cli_help, + readme_content=readme_content, + config=config, + ) + + def has_repo(self) -> bool: + """Check if this tool has a repository URL.""" + return self.repo_url is not None and len(self.repo_url) > 0 + + def has_cli_help(self) -> bool: + """Check if this tool has CLI help output.""" + return self.cli_help is not None and len(self.cli_help) > 0 + + def get_language(self) -> Optional[str]: + """Get the primary language of the tool (from config or auto-detect).""" + return self.config.get("language") + + def __repr__(self) -> str: + return f"ToolDefinition(name={self.name!r}, repo={self.has_repo()}, cli={self.has_cli_help()})" + + diff --git a/src/kgpipe_parameters/__init__.py b/src/kgpipe_parameters/__init__.py new file mode 100644 index 0000000..74e5e43 --- /dev/null +++ b/src/kgpipe_parameters/__init__.py @@ -0,0 +1,25 @@ +""" +KGpipe Parameters subpackage for analyzing and optimizing parameters for data integration tasks. + +This package provides functionality to: +1. Extract/Find configuration Parameters for a Task T and its implementations I +2. Match and cluster configuration parameters +3. Find best configuration parameters +""" + +from .extraction import ( + ParameterMiner, + RawParameter, + ExtractionResult, + SourceType, + ExtractionMethod, +) + +__all__ = [ + "ParameterMiner", + "RawParameter", + "ExtractionResult", + "SourceType", + "ExtractionMethod", +] + diff --git a/src/kgpipe_parameters/extraction/__init__.py b/src/kgpipe_parameters/extraction/__init__.py index b336bdc..13ba837 100644 --- a/src/kgpipe_parameters/extraction/__init__.py +++ b/src/kgpipe_parameters/extraction/__init__.py @@ -2,8 +2,8 @@ Parameter extraction module for mining configuration parameters from various sources. """ -from .param_miner import ( - ParameterMiner, +from .param_miner import ParameterMiner +from .extractors import ( CLIExtractor, PythonLibExtractor, HTTPAPIExtractor, @@ -60,4 +60,3 @@ "infer_parameter_type", "extract_constraints", ] - diff --git a/src/kgpipe_parameters/extraction/extractors/__init__.py b/src/kgpipe_parameters/extraction/extractors/__init__.py new file mode 100644 index 0000000..a760f37 --- /dev/null +++ b/src/kgpipe_parameters/extraction/extractors/__init__.py @@ -0,0 +1,25 @@ +""" +Extractor implementations for different source types. +""" + +from .cli import CLIExtractor, LLMCLIExtractor +from .python_lib import PythonLibExtractor, LLMPythonExtractor +from .http_api import HTTPAPIExtractor, LLMHTTPExtractor +from .docker import DockerExtractor, LLMDockerExtractor + +__all__ = [ + # CLI + "CLIExtractor", + "LLMCLIExtractor", + # Python + "PythonLibExtractor", + "LLMPythonExtractor", + # HTTP API + "HTTPAPIExtractor", + "LLMHTTPExtractor", + # Docker + "DockerExtractor", + "LLMDockerExtractor", +] + + diff --git a/src/kgpipe_parameters/extraction/extractors/cli.py b/src/kgpipe_parameters/extraction/extractors/cli.py new file mode 100644 index 0000000..82ee8ba --- /dev/null +++ b/src/kgpipe_parameters/extraction/extractors/cli.py @@ -0,0 +1,193 @@ +""" +CLI parameter extraction from help output. +""" + +import re +from typing import Optional, Union + +from ..models import RawParameter, ExtractionResult, SourceType, ExtractionMethod +from ..base import RegexExtractor, LLMExtractor +from ..patterns import CLI_PATTERNS +from ..utils import normalize_parameter_name, parse_default_value + + +class CLIExtractor(RegexExtractor): + """Extract parameters from CLI help output.""" + + def __init__(self, use_llm: bool = False, llm_client=None): + super().__init__(SourceType.CLI, CLI_PATTERNS) + self.use_llm = use_llm + if use_llm: + self.llm_extractor = LLMCLIExtractor(llm_client) + + def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionResult: + """Extract parameters from CLI help text.""" + if self.use_llm: + return self.llm_extractor.extract(source, tool_name) + + parameters = [] + errors = [] + + try: + lines = source.split('\n') + current_param = None + + for line in lines: + # Skip usage lines (they contain brackets and are not actual parameter descriptions) + if line.strip().startswith("usage:") or (line.strip().startswith("[") and "]" in line and "optional" not in line.lower() and "arguments" not in line.lower()): + continue + + # Match long flags: --param or --param=VALUE + long_match = CLI_PATTERNS["long_flag"].search(line) + if long_match: + param_name = long_match.group(1) + # Don't use group(2) from usage line - it's the placeholder, not default + default_val = None + + normalized = normalize_parameter_name(param_name) + native_keys = [f"--{param_name}"] + + # Check for short form on same line (but not -h from usage line) + short_match = CLI_PATTERNS["short_flag"].search(line) + if short_match and short_match.group(1) != 'h': # Skip -h help flag + native_keys.append(f"-{short_match.group(1)}") + + # Extract description - skip placeholder if present + # Pattern: --param PLACEHOLDER Description text + # We want to skip the PLACEHOLDER (uppercase word) if it exists + desc_match = re.search(rf"--{param_name}\s+(?:[A-Z_]+\s+)?(.+)", line) + if not desc_match: + # Fallback: just get everything after the flag + desc_match = re.search(r"--[^\s]+\s+(.+)", line) + description = desc_match.group(1).strip() if desc_match else None + + # Check if required + required = CLI_PATTERNS["required"].search(line) is not None + + # Extract default value from description line (not usage line) + default_match = CLI_PATTERNS["default_value"].search(line) + if default_match: + default_val = default_match.group(1).strip() + + # Extract type hint + type_match = CLI_PATTERNS["type_hint"].search(line) + type_hint = type_match.group(1) if type_match else None + + current_param = RawParameter( + name=normalized, + native_keys=native_keys, + description=description, + type_hint=type_hint, + default_value=parse_default_value(default_val) if default_val else None, + required=required, + source=line, + provenance={"line": lines.index(line) + 1} + ) + parameters.append(current_param) + + # Match short flags: -p + elif CLI_PATTERNS["short_flag"].search(line) and not long_match: + short_match = CLI_PATTERNS["short_flag"].search(line) + param_name = short_match.group(1) + normalized = normalize_parameter_name(param_name) + + current_param = RawParameter( + name=normalized, + native_keys=[f"-{param_name}"], + description=None, + source=line, + provenance={"line": lines.index(line) + 1} + ) + parameters.append(current_param) + + # If we have a current param, try to extract description from continuation lines + elif current_param and line.strip() and not line.strip().startswith('-'): + if not current_param.description: + current_param.description = line.strip() + else: + current_param.description += " " + line.strip() + + except Exception as e: + errors.append(f"Error extracting CLI parameters: {str(e)}") + + return ExtractionResult( + tool_name=tool_name or "unknown_cli_tool", + source_type=SourceType.CLI, + extraction_method=ExtractionMethod.REGEX, + parameters=parameters, + errors=errors + ) + + +class LLMCLIExtractor(LLMExtractor): + """LLM-based CLI parameter extraction.""" + + def __init__(self, llm_client=None): + super().__init__(SourceType.CLI, llm_client) + + def _create_prompt(self, source: str, tool_name: Optional[str] = None) -> str: + return f"""Extract all configuration parameters from the following CLI help output. +For each parameter, identify: +- Parameter name (normalized, without -- or -) +- Native keys/flags (--flag, -f, etc.) +- Description +- Type (if mentioned) +- Default value (if mentioned) +- Whether it's required or optional + +CLI Help Output: +{source} + +Return a JSON object with a 'parameters' array. Each parameter should have: name, native_keys, description, type_hint, default_value, required.""" + + def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionResult: + """Extract parameters using LLM.""" + from pydantic import BaseModel + from typing import List as TypingList + + class ParameterSchema(BaseModel): + name: str + native_keys: TypingList[str] + description: Optional[str] = None + type_hint: Optional[str] = None + default_value: Optional[Union[str, int, float, bool]] = None + required: bool = False + + class ExtractionSchema(BaseModel): + parameters: TypingList[ParameterSchema] + + try: + prompt = self._create_prompt(source, tool_name) + response = self.llm_client.send_prompt(prompt, ExtractionSchema) + + parameters = [] + if "parameters" in response: + for param_data in response["parameters"]: + raw_param = RawParameter( + name=normalize_parameter_name(param_data["name"]), + native_keys=param_data.get("native_keys", []), + description=param_data.get("description"), + type_hint=param_data.get("type_hint"), + default_value=param_data.get("default_value"), + required=param_data.get("required", False), + source=source[:100], # First 100 chars + provenance={"method": "llm"} + ) + parameters.append(raw_param) + + return ExtractionResult( + tool_name=tool_name or "unknown_cli_tool", + source_type=SourceType.CLI, + extraction_method=ExtractionMethod.LLM, + parameters=parameters + ) + except Exception as e: + return ExtractionResult( + tool_name=tool_name or "unknown_cli_tool", + source_type=SourceType.CLI, + extraction_method=ExtractionMethod.LLM, + parameters=[], + errors=[f"LLM extraction failed: {str(e)}"] + ) + + diff --git a/src/kgpipe_parameters/extraction/extractors/docker.py b/src/kgpipe_parameters/extraction/extractors/docker.py new file mode 100644 index 0000000..e26b85a --- /dev/null +++ b/src/kgpipe_parameters/extraction/extractors/docker.py @@ -0,0 +1,188 @@ +""" +Docker parameter extraction from Dockerfile and docker-compose.yml. +""" + +import yaml +from typing import List, Optional, Union + +from ..models import RawParameter, ExtractionResult, SourceType, ExtractionMethod +from ..base import RegexExtractor, LLMExtractor +from ..patterns import DOCKER_PATTERNS +from ..utils import normalize_parameter_name, parse_default_value + + +class DockerExtractor(RegexExtractor): + """Extract parameters from Docker configurations (Dockerfile, docker-compose.yml).""" + + def __init__(self, use_llm: bool = False, llm_client=None): + super().__init__(SourceType.DOCKER, DOCKER_PATTERNS) + self.use_llm = use_llm + if use_llm: + self.llm_extractor = LLMDockerExtractor(llm_client) + + def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionResult: + """Extract parameters from Docker configuration.""" + if self.use_llm: + return self.llm_extractor.extract(source, tool_name) + + parameters = [] + errors = [] + + try: + # Check if it's a Dockerfile or docker-compose.yml + if "FROM" in source or "RUN" in source: + # Dockerfile + parameters.extend(self._extract_from_dockerfile(source)) + elif "version:" in source or "services:" in source: + # docker-compose.yml + try: + compose = yaml.safe_load(source) + parameters.extend(self._extract_from_compose(compose)) + except yaml.YAMLError: + parameters.extend(self._extract_from_dockerfile(source)) + else: + parameters.extend(self._extract_from_dockerfile(source)) + + except Exception as e: + errors.append(f"Error extracting Docker parameters: {str(e)}") + + return ExtractionResult( + tool_name=tool_name or "unknown_docker", + source_type=SourceType.DOCKER, + extraction_method=ExtractionMethod.REGEX, + parameters=parameters, + errors=errors + ) + + def _extract_from_dockerfile(self, source: str) -> List[RawParameter]: + """Extract ENV and ARG declarations from Dockerfile.""" + parameters = [] + lines = source.split('\n') + + for line in lines: + # ENV declarations + env_match = DOCKER_PATTERNS["env_declaration"].search(line) + if env_match: + var_name = env_match.group(1) + var_value = env_match.group(2) if env_match.group(2) else None + + parameters.append(RawParameter( + name=normalize_parameter_name(var_name), + native_keys=[var_name], + description=f"Environment variable: {var_name}", + default_value=parse_default_value(var_value) if var_value else None, + required=False, + source=line, + provenance={"type": "ENV", "line": lines.index(line) + 1} + )) + + # ARG declarations + arg_match = DOCKER_PATTERNS["arg_declaration"].search(line) + if arg_match: + var_name = arg_match.group(1) + var_value = arg_match.group(2) if arg_match.group(2) else None + + parameters.append(RawParameter( + name=normalize_parameter_name(var_name), + native_keys=[var_name], + description=f"Build argument: {var_name}", + default_value=parse_default_value(var_value) if var_value else None, + required=False, + source=line, + provenance={"type": "ARG", "line": lines.index(line) + 1} + )) + + return parameters + + def _extract_from_compose(self, compose: dict) -> List[RawParameter]: + """Extract environment variables from docker-compose.yml.""" + parameters = [] + + services = compose.get("services", {}) + for service_name, service_config in services.items(): + env = service_config.get("environment", {}) + if isinstance(env, dict): + for var_name, var_value in env.items(): + parameters.append(RawParameter( + name=normalize_parameter_name(var_name), + native_keys=[var_name], + description=f"Environment variable for service {service_name}", + default_value=parse_default_value(str(var_value)) if var_value else None, + required=False, + source=f"services.{service_name}.environment", + provenance={"service": service_name, "type": "environment"} + )) + + return parameters + + +class LLMDockerExtractor(LLMExtractor): + """LLM-based Docker parameter extraction.""" + + def __init__(self, llm_client=None): + super().__init__(SourceType.DOCKER, llm_client) + + def _create_prompt(self, source: str, tool_name: Optional[str] = None) -> str: + return f"""Extract all configuration parameters from the following Docker configuration. +Look for: +- ENV variables +- ARG build arguments +- Environment variables in docker-compose.yml +- Volume mounts and port mappings that could be parameterized + +Docker Configuration: +{source} + +Return a JSON object with a 'parameters' array. Each parameter should have: name, native_keys, description, type_hint, default_value, required.""" + + def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionResult: + """Extract parameters using LLM.""" + from pydantic import BaseModel + from typing import List as TypingList + + class ParameterSchema(BaseModel): + name: str + native_keys: TypingList[str] + description: Optional[str] = None + type_hint: Optional[str] = None + default_value: Optional[Union[str, int, float, bool]] = None + required: bool = False + + class ExtractionSchema(BaseModel): + parameters: TypingList[ParameterSchema] + + try: + prompt = self._create_prompt(source, tool_name) + response = self.llm_client.send_prompt(prompt, ExtractionSchema) + + parameters = [] + if "parameters" in response: + for param_data in response["parameters"]: + raw_param = RawParameter( + name=normalize_parameter_name(param_data["name"]), + native_keys=param_data.get("native_keys", []), + description=param_data.get("description"), + type_hint=param_data.get("type_hint"), + default_value=param_data.get("default_value"), + required=param_data.get("required", False), + source=source[:200], + provenance={"method": "llm"} + ) + parameters.append(raw_param) + + return ExtractionResult( + tool_name=tool_name or "unknown_docker", + source_type=SourceType.DOCKER, + extraction_method=ExtractionMethod.LLM, + parameters=parameters + ) + except Exception as e: + return ExtractionResult( + tool_name=tool_name or "unknown_docker", + source_type=SourceType.DOCKER, + extraction_method=ExtractionMethod.LLM, + parameters=[], + errors=[f"LLM extraction failed: {str(e)}"] + ) + + diff --git a/src/kgpipe_parameters/extraction/extractors/http_api.py b/src/kgpipe_parameters/extraction/extractors/http_api.py new file mode 100644 index 0000000..35591ed --- /dev/null +++ b/src/kgpipe_parameters/extraction/extractors/http_api.py @@ -0,0 +1,186 @@ +""" +HTTP API parameter extraction from OpenAPI/Swagger specs and documentation. +""" + +import json +import yaml +from typing import List, Optional, Union + +from ..models import RawParameter, ExtractionResult, SourceType, ExtractionMethod +from ..base import RegexExtractor, LLMExtractor +from ..utils import normalize_parameter_name + + +class HTTPAPIExtractor(RegexExtractor): + """Extract parameters from HTTP API documentation (OpenAPI, Swagger, etc.).""" + + def __init__(self, use_llm: bool = False, llm_client=None): + super().__init__(SourceType.HTTP_API, {}) + self.use_llm = use_llm + if use_llm: + self.llm_extractor = LLMHTTPExtractor(llm_client) + + def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionResult: + """Extract parameters from API documentation.""" + if self.use_llm: + return self.llm_extractor.extract(source, tool_name) + + parameters = [] + errors = [] + + try: + # Try to parse as OpenAPI/Swagger spec + spec = None + try: + # Try JSON first + if source.strip().startswith('{'): + spec = json.loads(source) + else: + # Try YAML + spec = yaml.safe_load(source) + + # Check if it looks like OpenAPI/Swagger spec + if spec and isinstance(spec, dict) and ("openapi" in spec or "swagger" in spec or "paths" in spec): + parameters.extend(self._extract_from_openapi(spec)) + else: + # Not a valid spec, try regex-based extraction + parameters.extend(self._extract_from_docs(source)) + except (json.JSONDecodeError, yaml.YAMLError): + # If parsing fails, try regex-based extraction + parameters.extend(self._extract_from_docs(source)) + + except Exception as e: + errors.append(f"Error extracting API parameters: {str(e)}") + + return ExtractionResult( + tool_name=tool_name or "unknown_api", + source_type=SourceType.HTTP_API, + extraction_method=ExtractionMethod.REGEX, + parameters=parameters, + errors=errors + ) + + def _extract_from_openapi(self, spec: dict) -> List[RawParameter]: + """Extract parameters from OpenAPI specification.""" + parameters = [] + + # Extract from paths + paths = spec.get("paths", {}) + for path, methods in paths.items(): + for method, operation in methods.items(): + # Path parameters + for param in operation.get("parameters", []): + param_name = param.get("name", "") + param_schema = param.get("schema", {}) + + raw_param = RawParameter( + name=normalize_parameter_name(param_name), + native_keys=[param_name], + description=param.get("description"), + type_hint=param_schema.get("type"), + default_value=param_schema.get("default"), + required=param.get("required", False), + source=f"{method.upper()} {path}", + provenance={"location": "path", "method": method} + ) + parameters.append(raw_param) + + # Request body parameters + request_body = operation.get("requestBody", {}) + content = request_body.get("content", {}) + for content_type, schema_obj in content.items(): + schema = schema_obj.get("schema", {}) + if "properties" in schema: + for prop_name, prop_schema in schema["properties"].items(): + raw_param = RawParameter( + name=normalize_parameter_name(prop_name), + native_keys=[prop_name], + description=prop_schema.get("description"), + type_hint=prop_schema.get("type"), + default_value=prop_schema.get("default"), + required=prop_name in schema.get("required", []), + source=f"{method.upper()} {path} (body)", + provenance={"location": "body", "method": method} + ) + parameters.append(raw_param) + + return parameters + + def _extract_from_docs(self, source: str) -> List[RawParameter]: + """Extract parameters from unstructured API documentation.""" + parameters = [] + # Basic regex extraction for common patterns + # This is a simplified version - LLM would be better for complex docs + return parameters + + +class LLMHTTPExtractor(LLMExtractor): + """LLM-based HTTP API parameter extraction.""" + + def __init__(self, llm_client=None): + super().__init__(SourceType.HTTP_API, llm_client) + + def _create_prompt(self, source: str, tool_name: Optional[str] = None) -> str: + return f"""Extract all API parameters from the following API documentation or specification. +Look for: +- Query parameters +- Path parameters +- Request body parameters +- Header parameters + +API Documentation: +{source} + +Return a JSON object with a 'parameters' array. Each parameter should have: name, native_keys, description, type_hint, default_value, required.""" + + def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionResult: + """Extract parameters using LLM.""" + from pydantic import BaseModel + from typing import List as TypingList + + class ParameterSchema(BaseModel): + name: str + native_keys: TypingList[str] + description: Optional[str] = None + type_hint: Optional[str] = None + default_value: Optional[Union[str, int, float, bool]] = None + required: bool = False + + class ExtractionSchema(BaseModel): + parameters: TypingList[ParameterSchema] + + try: + prompt = self._create_prompt(source, tool_name) + response = self.llm_client.send_prompt(prompt, ExtractionSchema) + + parameters = [] + if "parameters" in response: + for param_data in response["parameters"]: + raw_param = RawParameter( + name=normalize_parameter_name(param_data["name"]), + native_keys=param_data.get("native_keys", []), + description=param_data.get("description"), + type_hint=param_data.get("type_hint"), + default_value=param_data.get("default_value"), + required=param_data.get("required", False), + source=source[:200], + provenance={"method": "llm"} + ) + parameters.append(raw_param) + + return ExtractionResult( + tool_name=tool_name or "unknown_api", + source_type=SourceType.HTTP_API, + extraction_method=ExtractionMethod.LLM, + parameters=parameters + ) + except Exception as e: + return ExtractionResult( + tool_name=tool_name or "unknown_api", + source_type=SourceType.HTTP_API, + extraction_method=ExtractionMethod.LLM, + parameters=[], + errors=[f"LLM extraction failed: {str(e)}"] + ) + + diff --git a/src/kgpipe_parameters/extraction/extractors/python_lib.py b/src/kgpipe_parameters/extraction/extractors/python_lib.py new file mode 100644 index 0000000..a492109 --- /dev/null +++ b/src/kgpipe_parameters/extraction/extractors/python_lib.py @@ -0,0 +1,267 @@ +""" +Python library parameter extraction from source code. +""" + +import re +import ast +from typing import List, Optional, Union + +from ..models import RawParameter, ExtractionResult, SourceType, ExtractionMethod +from ..base import RegexExtractor, LLMExtractor +from ..patterns import PYTHON_PATTERNS +from ..utils import normalize_parameter_name, parse_default_value + + +class PythonLibExtractor(RegexExtractor): + """Extract parameters from Python code (functions, classes, docstrings).""" + + def __init__(self, use_llm: bool = False, llm_client=None): + super().__init__(SourceType.PYTHON_LIB, PYTHON_PATTERNS) + self.use_llm = use_llm + if use_llm: + self.llm_extractor = LLMPythonExtractor(llm_client) + + def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionResult: + """Extract parameters from Python source code.""" + if self.use_llm: + return self.llm_extractor.extract(source, tool_name) + + parameters = [] + errors = [] + + try: + # Try to parse as Python AST + try: + tree = ast.parse(source) + parameters.extend(self._extract_from_ast(tree, source)) + except SyntaxError: + # If not valid Python, try regex-based extraction + parameters.extend(self._extract_from_regex(source)) + + except Exception as e: + errors.append(f"Error extracting Python parameters: {str(e)}") + + return ExtractionResult( + tool_name=tool_name or "unknown_python_lib", + source_type=SourceType.PYTHON_LIB, + extraction_method=ExtractionMethod.REGEX, + parameters=parameters, + errors=errors + ) + + def _extract_from_ast(self, tree: ast.AST, source: str) -> List[RawParameter]: + """Extract parameters from Python AST.""" + parameters = [] + + class ParameterVisitor(ast.NodeVisitor): + def __init__(self): + self.params = [] + self.source_lines = source.split('\n') + + def visit_FunctionDef(self, node): + # Extract function parameters + for arg in node.args.args: + if arg.arg == 'self': + continue + + # Get type hint + type_hint = None + if arg.annotation: + type_hint = ast.unparse(arg.annotation) if hasattr(ast, 'unparse') else str(arg.annotation) + + # Get default value + default_val = None + default_idx = len(node.args.args) - len(node.args.defaults) + if arg in node.args.args[default_idx:]: + default_node = node.args.defaults[node.args.args[default_idx:].index(arg)] + if hasattr(ast, 'unparse'): + default_val = ast.unparse(default_node) + else: + default_val = ast.literal_eval(default_node) if isinstance(default_node, (ast.Constant, ast.Str, ast.Num)) else None + + # Extract docstring info + description = None + if ast.get_docstring(node): + docstring = ast.get_docstring(node) + # Look for :param arg: description + param_pattern = re.compile(rf":param\s+{arg.arg}:\s*(.+?)(?=\n|:param|$)", re.MULTILINE) + match = param_pattern.search(docstring) + if match: + description = match.group(1).strip() + + param = RawParameter( + name=normalize_parameter_name(arg.arg), + native_keys=[arg.arg], + description=description, + type_hint=type_hint, + default_value=parse_default_value(default_val) if default_val else None, + required=default_val is None, + source=f"{node.name}()", + provenance={"function": node.name, "line": node.lineno} + ) + self.params.append(param) + + self.generic_visit(node) + + def visit_ClassDef(self, node): + # Extract class attributes (for dataclasses, Pydantic models, etc.) + for item in node.body: + if isinstance(item, ast.AnnAssign): + # Annotated assignment: name: type = default + if isinstance(item.target, ast.Name): + attr_name = item.target.id + + # Get type hint + type_hint = None + if item.annotation: + type_hint = ast.unparse(item.annotation) if hasattr(ast, 'unparse') else str(item.annotation) + + # Get default value + default_val = None + if item.value: + if hasattr(ast, 'unparse'): + default_val = ast.unparse(item.value) + else: + try: + default_val = ast.literal_eval(item.value) + except (ValueError, TypeError): + default_val = None + + param = RawParameter( + name=normalize_parameter_name(attr_name), + native_keys=[attr_name], + description=None, + type_hint=type_hint, + default_value=parse_default_value(str(default_val)) if default_val is not None else None, + required=default_val is None, + source=f"{node.name}.{attr_name}", + provenance={"class": node.name, "line": item.lineno if hasattr(item, 'lineno') else node.lineno} + ) + self.params.append(param) + elif isinstance(item, ast.Assign): + # Regular assignment: name = value (might be in dataclass) + for target in item.targets: + if isinstance(target, ast.Name): + attr_name = target.id + # Try to get value + default_val = None + if item.value: + try: + default_val = ast.literal_eval(item.value) + except (ValueError, TypeError): + default_val = None + + param = RawParameter( + name=normalize_parameter_name(attr_name), + native_keys=[attr_name], + description=None, + type_hint=None, + default_value=parse_default_value(str(default_val)) if default_val is not None else None, + required=False, + source=f"{node.name}.{attr_name}", + provenance={"class": node.name, "line": item.lineno} + ) + self.params.append(param) + + self.generic_visit(node) + + visitor = ParameterVisitor() + visitor.visit(tree) + return visitor.params + + def _extract_from_regex(self, source: str) -> List[RawParameter]: + """Fallback regex-based extraction.""" + parameters = [] + + # Extract function parameters + func_pattern = re.compile(r"def\s+\w+\s*\(([^)]+)\)", re.MULTILINE) + for match in func_pattern.finditer(source): + params_str = match.group(1) + for param_match in PYTHON_PATTERNS["function_param"].finditer(params_str): + param_name = param_match.group(1) + type_hint = param_match.group(2).strip() if param_match.group(2) else None + default_val = param_match.group(3).strip() if param_match.group(3) else None + + parameters.append(RawParameter( + name=normalize_parameter_name(param_name), + native_keys=[param_name], + type_hint=type_hint, + default_value=parse_default_value(default_val) if default_val else None, + required=default_val is None, + source=match.group(0), + provenance={"method": "regex"} + )) + + return parameters + + +class LLMPythonExtractor(LLMExtractor): + """LLM-based Python parameter extraction.""" + + def __init__(self, llm_client=None): + super().__init__(SourceType.PYTHON_LIB, llm_client) + + def _create_prompt(self, source: str, tool_name: Optional[str] = None) -> str: + return f"""Extract all configuration parameters from the following Python code. +Look for: +- Function parameters with type hints and defaults +- Class attributes with type annotations +- Configuration classes (dataclasses, Pydantic models) +- Environment variables + +Python Code: +{source} + +Return a JSON object with a 'parameters' array. Each parameter should have: name, native_keys, description, type_hint, default_value, required.""" + + def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionResult: + """Extract parameters using LLM.""" + from pydantic import BaseModel + from typing import List as TypingList + + class ParameterSchema(BaseModel): + name: str + native_keys: TypingList[str] + description: Optional[str] = None + type_hint: Optional[str] = None + default_value: Optional[Union[str, int, float, bool]] = None + required: bool = False + + class ExtractionSchema(BaseModel): + parameters: TypingList[ParameterSchema] + + try: + prompt = self._create_prompt(source, tool_name) + response = self.llm_client.send_prompt(prompt, ExtractionSchema) + + parameters = [] + if "parameters" in response: + for param_data in response["parameters"]: + raw_param = RawParameter( + name=normalize_parameter_name(param_data["name"]), + native_keys=param_data.get("native_keys", []), + description=param_data.get("description"), + type_hint=param_data.get("type_hint"), + default_value=param_data.get("default_value"), + required=param_data.get("required", False), + source=source[:200], # First 200 chars + provenance={"method": "llm"} + ) + parameters.append(raw_param) + + return ExtractionResult( + tool_name=tool_name or "unknown_python_lib", + source_type=SourceType.PYTHON_LIB, + extraction_method=ExtractionMethod.LLM, + parameters=parameters + ) + except Exception as e: + return ExtractionResult( + tool_name=tool_name or "unknown_python_lib", + source_type=SourceType.PYTHON_LIB, + extraction_method=ExtractionMethod.LLM, + parameters=[], + errors=[f"LLM extraction failed: {str(e)}"] + ) + + diff --git a/src/kgpipe_parameters/extraction/param_miner.py b/src/kgpipe_parameters/extraction/param_miner.py index 970b099..1e00bd4 100644 --- a/src/kgpipe_parameters/extraction/param_miner.py +++ b/src/kgpipe_parameters/extraction/param_miner.py @@ -1,800 +1,38 @@ """ Parameter mining/extraction from various sources (CLI, Python, HTTP APIs, Docker). + +This module provides the main ParameterMiner class for unified parameter extraction. +Individual extractors are implemented in the extractors/ submodule. """ -import re import ast -import json -import yaml from pathlib import Path -from typing import List, Optional, Dict, Any, Union -from .models import ( - RawParameter, ExtractionResult, SourceType, ExtractionMethod +from typing import Optional, Union + +from .models import RawParameter, ExtractionResult, SourceType, ExtractionMethod +from .extractors import ( + CLIExtractor, + PythonLibExtractor, + HTTPAPIExtractor, + DockerExtractor, + LLMCLIExtractor, + LLMPythonExtractor, + LLMHTTPExtractor, + LLMDockerExtractor, ) -from .base import RegexExtractor, LLMExtractor -from .patterns import get_patterns, CLI_PATTERNS, PYTHON_PATTERNS, DOCKER_PATTERNS -from .utils import normalize_parameter_name, parse_default_value, infer_parameter_type - - -class CLIExtractor(RegexExtractor): - """Extract parameters from CLI help output.""" - - def __init__(self, use_llm: bool = False, llm_client=None): - super().__init__(SourceType.CLI, CLI_PATTERNS) - self.use_llm = use_llm - if use_llm: - self.llm_extractor = LLMCLIExtractor(llm_client) - - def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionResult: - """Extract parameters from CLI help text.""" - if self.use_llm: - return self.llm_extractor.extract(source, tool_name) - - parameters = [] - errors = [] - - try: - lines = source.split('\n') - current_param = None - - for line in lines: - # Skip usage lines (they contain brackets and are not actual parameter descriptions) - if line.strip().startswith("usage:") or (line.strip().startswith("[") and "]" in line and "optional" not in line.lower() and "arguments" not in line.lower()): - continue - - # Match long flags: --param or --param=VALUE - long_match = CLI_PATTERNS["long_flag"].search(line) - if long_match: - param_name = long_match.group(1) - # Don't use group(2) from usage line - it's the placeholder, not default - default_val = None - - normalized = normalize_parameter_name(param_name) - native_keys = [f"--{param_name}"] - - # Check for short form on same line (but not -h from usage line) - short_match = CLI_PATTERNS["short_flag"].search(line) - if short_match and short_match.group(1) != 'h': # Skip -h help flag - native_keys.append(f"-{short_match.group(1)}") - - # Extract description - skip placeholder if present - # Pattern: --param PLACEHOLDER Description text - # We want to skip the PLACEHOLDER (uppercase word) if it exists - desc_match = re.search(rf"--{param_name}\s+(?:[A-Z_]+\s+)?(.+)", line) - if not desc_match: - # Fallback: just get everything after the flag - desc_match = re.search(r"--[^\s]+\s+(.+)", line) - description = desc_match.group(1).strip() if desc_match else None - - # Check if required - required = CLI_PATTERNS["required"].search(line) is not None - - # Extract default value from description line (not usage line) - default_match = CLI_PATTERNS["default_value"].search(line) - if default_match: - default_val = default_match.group(1).strip() - - # Extract type hint - type_match = CLI_PATTERNS["type_hint"].search(line) - type_hint = type_match.group(1) if type_match else None - - current_param = RawParameter( - name=normalized, - native_keys=native_keys, - description=description, - type_hint=type_hint, - default_value=parse_default_value(default_val) if default_val else None, - required=required, - source=line, - provenance={"line": lines.index(line) + 1} - ) - parameters.append(current_param) - - # Match short flags: -p - elif CLI_PATTERNS["short_flag"].search(line) and not long_match: - short_match = CLI_PATTERNS["short_flag"].search(line) - param_name = short_match.group(1) - normalized = normalize_parameter_name(param_name) - - current_param = RawParameter( - name=normalized, - native_keys=[f"-{param_name}"], - description=None, - source=line, - provenance={"line": lines.index(line) + 1} - ) - parameters.append(current_param) - - # If we have a current param, try to extract description from continuation lines - elif current_param and line.strip() and not line.strip().startswith('-'): - if not current_param.description: - current_param.description = line.strip() - else: - current_param.description += " " + line.strip() - - except Exception as e: - errors.append(f"Error extracting CLI parameters: {str(e)}") - - return ExtractionResult( - tool_name=tool_name or "unknown_cli_tool", - source_type=SourceType.CLI, - extraction_method=ExtractionMethod.REGEX, - parameters=parameters, - errors=errors - ) - - -class LLMCLIExtractor(LLMExtractor): - """LLM-based CLI parameter extraction.""" - - def __init__(self, llm_client=None): - super().__init__(SourceType.CLI, llm_client) - - def _create_prompt(self, source: str, tool_name: Optional[str] = None) -> str: - return f"""Extract all configuration parameters from the following CLI help output. -For each parameter, identify: -- Parameter name (normalized, without -- or -) -- Native keys/flags (--flag, -f, etc.) -- Description -- Type (if mentioned) -- Default value (if mentioned) -- Whether it's required or optional - -CLI Help Output: -{source} - -Return a JSON object with a 'parameters' array. Each parameter should have: name, native_keys, description, type_hint, default_value, required.""" - - def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionResult: - """Extract parameters using LLM.""" - from pydantic import BaseModel - from typing import List as TypingList - - class ParameterSchema(BaseModel): - name: str - native_keys: TypingList[str] - description: Optional[str] = None - type_hint: Optional[str] = None - default_value: Optional[Union[str, int, float, bool]] = None - required: bool = False - - class ExtractionSchema(BaseModel): - parameters: TypingList[ParameterSchema] - - try: - prompt = self._create_prompt(source, tool_name) - response = self.llm_client.send_prompt(prompt, ExtractionSchema) - - parameters = [] - if "parameters" in response: - for param_data in response["parameters"]: - raw_param = RawParameter( - name=normalize_parameter_name(param_data["name"]), - native_keys=param_data.get("native_keys", []), - description=param_data.get("description"), - type_hint=param_data.get("type_hint"), - default_value=param_data.get("default_value"), - required=param_data.get("required", False), - source=source[:100], # First 100 chars - provenance={"method": "llm"} - ) - parameters.append(raw_param) - - return ExtractionResult( - tool_name=tool_name or "unknown_cli_tool", - source_type=SourceType.CLI, - extraction_method=ExtractionMethod.LLM, - parameters=parameters - ) - except Exception as e: - return ExtractionResult( - tool_name=tool_name or "unknown_cli_tool", - source_type=SourceType.CLI, - extraction_method=ExtractionMethod.LLM, - parameters=[], - errors=[f"LLM extraction failed: {str(e)}"] - ) - - -class PythonLibExtractor(RegexExtractor): - """Extract parameters from Python code (functions, classes, docstrings).""" - - def __init__(self, use_llm: bool = False, llm_client=None): - super().__init__(SourceType.PYTHON_LIB, PYTHON_PATTERNS) - self.use_llm = use_llm - if use_llm: - self.llm_extractor = LLMPythonExtractor(llm_client) - - def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionResult: - """Extract parameters from Python source code.""" - if self.use_llm: - return self.llm_extractor.extract(source, tool_name) - - parameters = [] - errors = [] - - try: - # Try to parse as Python AST - try: - tree = ast.parse(source) - parameters.extend(self._extract_from_ast(tree, source)) - except SyntaxError: - # If not valid Python, try regex-based extraction - parameters.extend(self._extract_from_regex(source)) - - except Exception as e: - errors.append(f"Error extracting Python parameters: {str(e)}") - - return ExtractionResult( - tool_name=tool_name or "unknown_python_lib", - source_type=SourceType.PYTHON_LIB, - extraction_method=ExtractionMethod.REGEX, - parameters=parameters, - errors=errors - ) - - def _extract_from_ast(self, tree: ast.AST, source: str) -> List[RawParameter]: - """Extract parameters from Python AST.""" - parameters = [] - - class ParameterVisitor(ast.NodeVisitor): - def __init__(self): - self.params = [] - self.source_lines = source.split('\n') - - def visit_FunctionDef(self, node): - # Extract function parameters - for arg in node.args.args: - if arg.arg == 'self': - continue - - # Get type hint - type_hint = None - if arg.annotation: - type_hint = ast.unparse(arg.annotation) if hasattr(ast, 'unparse') else str(arg.annotation) - - # Get default value - default_val = None - default_idx = len(node.args.args) - len(node.args.defaults) - if arg in node.args.args[default_idx:]: - default_node = node.args.defaults[node.args.args[default_idx:].index(arg)] - if hasattr(ast, 'unparse'): - default_val = ast.unparse(default_node) - else: - default_val = ast.literal_eval(default_node) if isinstance(default_node, (ast.Constant, ast.Str, ast.Num)) else None - - # Extract docstring info - description = None - if ast.get_docstring(node): - docstring = ast.get_docstring(node) - # Look for :param arg: description - param_pattern = re.compile(rf":param\s+{arg.arg}:\s*(.+?)(?=\n|:param|$)", re.MULTILINE) - match = param_pattern.search(docstring) - if match: - description = match.group(1).strip() - - param = RawParameter( - name=normalize_parameter_name(arg.arg), - native_keys=[arg.arg], - description=description, - type_hint=type_hint, - default_value=parse_default_value(default_val) if default_val else None, - required=default_val is None, - source=f"{node.name}()", - provenance={"function": node.name, "line": node.lineno} - ) - self.params.append(param) - - self.generic_visit(node) - - def visit_ClassDef(self, node): - # Extract class attributes (for dataclasses, Pydantic models, etc.) - for item in node.body: - if isinstance(item, ast.AnnAssign): - # Annotated assignment: name: type = default - if isinstance(item.target, ast.Name): - attr_name = item.target.id - - # Get type hint - type_hint = None - if item.annotation: - type_hint = ast.unparse(item.annotation) if hasattr(ast, 'unparse') else str(item.annotation) - - # Get default value - default_val = None - if item.value: - if hasattr(ast, 'unparse'): - default_val = ast.unparse(item.value) - else: - try: - default_val = ast.literal_eval(item.value) - except (ValueError, TypeError): - default_val = None - - param = RawParameter( - name=normalize_parameter_name(attr_name), - native_keys=[attr_name], - description=None, - type_hint=type_hint, - default_value=parse_default_value(str(default_val)) if default_val is not None else None, - required=default_val is None, - source=f"{node.name}.{attr_name}", - provenance={"class": node.name, "line": item.lineno if hasattr(item, 'lineno') else node.lineno} - ) - self.params.append(param) - elif isinstance(item, ast.Assign): - # Regular assignment: name = value (might be in dataclass) - for target in item.targets: - if isinstance(target, ast.Name): - attr_name = target.id - # Try to get value - default_val = None - if item.value: - try: - default_val = ast.literal_eval(item.value) - except (ValueError, TypeError): - default_val = None - - param = RawParameter( - name=normalize_parameter_name(attr_name), - native_keys=[attr_name], - description=None, - type_hint=None, - default_value=parse_default_value(str(default_val)) if default_val is not None else None, - required=False, - source=f"{node.name}.{attr_name}", - provenance={"class": node.name, "line": item.lineno} - ) - self.params.append(param) - - self.generic_visit(node) - - visitor = ParameterVisitor() - visitor.visit(tree) - return visitor.params - - def _extract_from_regex(self, source: str) -> List[RawParameter]: - """Fallback regex-based extraction.""" - parameters = [] - - # Extract function parameters - func_pattern = re.compile(r"def\s+\w+\s*\(([^)]+)\)", re.MULTILINE) - for match in func_pattern.finditer(source): - params_str = match.group(1) - for param_match in PYTHON_PATTERNS["function_param"].finditer(params_str): - param_name = param_match.group(1) - type_hint = param_match.group(2).strip() if param_match.group(2) else None - default_val = param_match.group(3).strip() if param_match.group(3) else None - - parameters.append(RawParameter( - name=normalize_parameter_name(param_name), - native_keys=[param_name], - type_hint=type_hint, - default_value=parse_default_value(default_val) if default_val else None, - required=default_val is None, - source=match.group(0), - provenance={"method": "regex"} - )) - - return parameters - - -class LLMPythonExtractor(LLMExtractor): - """LLM-based Python parameter extraction.""" - - def __init__(self, llm_client=None): - super().__init__(SourceType.PYTHON_LIB, llm_client) - - def _create_prompt(self, source: str, tool_name: Optional[str] = None) -> str: - return f"""Extract all configuration parameters from the following Python code. -Look for: -- Function parameters with type hints and defaults -- Class attributes with type annotations -- Configuration classes (dataclasses, Pydantic models) -- Environment variables - -Python Code: -{source} - -Return a JSON object with a 'parameters' array. Each parameter should have: name, native_keys, description, type_hint, default_value, required.""" - - def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionResult: - """Extract parameters using LLM.""" - from pydantic import BaseModel - from typing import List as TypingList - - class ParameterSchema(BaseModel): - name: str - native_keys: TypingList[str] - description: Optional[str] = None - type_hint: Optional[str] = None - default_value: Optional[Union[str, int, float, bool]] = None - required: bool = False - - class ExtractionSchema(BaseModel): - parameters: TypingList[ParameterSchema] - - try: - prompt = self._create_prompt(source, tool_name) - response = self.llm_client.send_prompt(prompt, ExtractionSchema) - - parameters = [] - if "parameters" in response: - for param_data in response["parameters"]: - raw_param = RawParameter( - name=normalize_parameter_name(param_data["name"]), - native_keys=param_data.get("native_keys", []), - description=param_data.get("description"), - type_hint=param_data.get("type_hint"), - default_value=param_data.get("default_value"), - required=param_data.get("required", False), - source=source[:200], # First 200 chars - provenance={"method": "llm"} - ) - parameters.append(raw_param) - - return ExtractionResult( - tool_name=tool_name or "unknown_python_lib", - source_type=SourceType.PYTHON_LIB, - extraction_method=ExtractionMethod.LLM, - parameters=parameters - ) - except Exception as e: - return ExtractionResult( - tool_name=tool_name or "unknown_python_lib", - source_type=SourceType.PYTHON_LIB, - extraction_method=ExtractionMethod.LLM, - parameters=[], - errors=[f"LLM extraction failed: {str(e)}"] - ) - -class HTTPAPIExtractor(RegexExtractor): - """Extract parameters from HTTP API documentation (OpenAPI, Swagger, etc.).""" - - def __init__(self, use_llm: bool = False, llm_client=None): - super().__init__(SourceType.HTTP_API, {}) - self.use_llm = use_llm - if use_llm: - self.llm_extractor = LLMHTTPExtractor(llm_client) - - def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionResult: - """Extract parameters from API documentation.""" - if self.use_llm: - return self.llm_extractor.extract(source, tool_name) - - parameters = [] - errors = [] - - try: - # Try to parse as OpenAPI/Swagger spec - spec = None - try: - # Try JSON first - if source.strip().startswith('{'): - spec = json.loads(source) - else: - # Try YAML - spec = yaml.safe_load(source) - - # Check if it looks like OpenAPI/Swagger spec - if spec and isinstance(spec, dict) and ("openapi" in spec or "swagger" in spec or "paths" in spec): - parameters.extend(self._extract_from_openapi(spec)) - else: - # Not a valid spec, try regex-based extraction - parameters.extend(self._extract_from_docs(source)) - except (json.JSONDecodeError, yaml.YAMLError): - # If parsing fails, try regex-based extraction - parameters.extend(self._extract_from_docs(source)) - - except Exception as e: - errors.append(f"Error extracting API parameters: {str(e)}") - - return ExtractionResult( - tool_name=tool_name or "unknown_api", - source_type=SourceType.HTTP_API, - extraction_method=ExtractionMethod.REGEX, - parameters=parameters, - errors=errors - ) - - def _extract_from_openapi(self, spec: dict) -> List[RawParameter]: - """Extract parameters from OpenAPI specification.""" - parameters = [] - - # Extract from paths - paths = spec.get("paths", {}) - for path, methods in paths.items(): - for method, operation in methods.items(): - # Path parameters - for param in operation.get("parameters", []): - param_name = param.get("name", "") - param_schema = param.get("schema", {}) - - raw_param = RawParameter( - name=normalize_parameter_name(param_name), - native_keys=[param_name], - description=param.get("description"), - type_hint=param_schema.get("type"), - default_value=param_schema.get("default"), - required=param.get("required", False), - source=f"{method.upper()} {path}", - provenance={"location": "path", "method": method} - ) - parameters.append(raw_param) - - # Request body parameters - request_body = operation.get("requestBody", {}) - content = request_body.get("content", {}) - for content_type, schema_obj in content.items(): - schema = schema_obj.get("schema", {}) - if "properties" in schema: - for prop_name, prop_schema in schema["properties"].items(): - raw_param = RawParameter( - name=normalize_parameter_name(prop_name), - native_keys=[prop_name], - description=prop_schema.get("description"), - type_hint=prop_schema.get("type"), - default_value=prop_schema.get("default"), - required=prop_name in schema.get("required", []), - source=f"{method.upper()} {path} (body)", - provenance={"location": "body", "method": method} - ) - parameters.append(raw_param) - - return parameters - - def _extract_from_docs(self, source: str) -> List[RawParameter]: - """Extract parameters from unstructured API documentation.""" - parameters = [] - # Basic regex extraction for common patterns - # This is a simplified version - LLM would be better for complex docs - return parameters - - -class LLMHTTPExtractor(LLMExtractor): - """LLM-based HTTP API parameter extraction.""" - - def __init__(self, llm_client=None): - super().__init__(SourceType.HTTP_API, llm_client) - - def _create_prompt(self, source: str, tool_name: Optional[str] = None) -> str: - return f"""Extract all API parameters from the following API documentation or specification. -Look for: -- Query parameters -- Path parameters -- Request body parameters -- Header parameters - -API Documentation: -{source} - -Return a JSON object with a 'parameters' array. Each parameter should have: name, native_keys, description, type_hint, default_value, required.""" - - def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionResult: - """Extract parameters using LLM.""" - from pydantic import BaseModel - from typing import List as TypingList - - class ParameterSchema(BaseModel): - name: str - native_keys: TypingList[str] - description: Optional[str] = None - type_hint: Optional[str] = None - default_value: Optional[Union[str, int, float, bool]] = None - required: bool = False - - class ExtractionSchema(BaseModel): - parameters: TypingList[ParameterSchema] - - try: - prompt = self._create_prompt(source, tool_name) - response = self.llm_client.send_prompt(prompt, ExtractionSchema) - - parameters = [] - if "parameters" in response: - for param_data in response["parameters"]: - raw_param = RawParameter( - name=normalize_parameter_name(param_data["name"]), - native_keys=param_data.get("native_keys", []), - description=param_data.get("description"), - type_hint=param_data.get("type_hint"), - default_value=param_data.get("default_value"), - required=param_data.get("required", False), - source=source[:200], - provenance={"method": "llm"} - ) - parameters.append(raw_param) - - return ExtractionResult( - tool_name=tool_name or "unknown_api", - source_type=SourceType.HTTP_API, - extraction_method=ExtractionMethod.LLM, - parameters=parameters - ) - except Exception as e: - return ExtractionResult( - tool_name=tool_name or "unknown_api", - source_type=SourceType.HTTP_API, - extraction_method=ExtractionMethod.LLM, - parameters=[], - errors=[f"LLM extraction failed: {str(e)}"] - ) - - -class DockerExtractor(RegexExtractor): - """Extract parameters from Docker configurations (Dockerfile, docker-compose.yml).""" - - def __init__(self, use_llm: bool = False, llm_client=None): - super().__init__(SourceType.DOCKER, DOCKER_PATTERNS) - self.use_llm = use_llm - if use_llm: - self.llm_extractor = LLMDockerExtractor(llm_client) - - def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionResult: - """Extract parameters from Docker configuration.""" - if self.use_llm: - return self.llm_extractor.extract(source, tool_name) - - parameters = [] - errors = [] - - try: - # Check if it's a Dockerfile or docker-compose.yml - if "FROM" in source or "RUN" in source: - # Dockerfile - parameters.extend(self._extract_from_dockerfile(source)) - elif "version:" in source or "services:" in source: - # docker-compose.yml - try: - compose = yaml.safe_load(source) - parameters.extend(self._extract_from_compose(compose)) - except yaml.YAMLError: - parameters.extend(self._extract_from_dockerfile(source)) - else: - parameters.extend(self._extract_from_dockerfile(source)) - - except Exception as e: - errors.append(f"Error extracting Docker parameters: {str(e)}") - - return ExtractionResult( - tool_name=tool_name or "unknown_docker", - source_type=SourceType.DOCKER, - extraction_method=ExtractionMethod.REGEX, - parameters=parameters, - errors=errors - ) - - def _extract_from_dockerfile(self, source: str) -> List[RawParameter]: - """Extract ENV and ARG declarations from Dockerfile.""" - parameters = [] - lines = source.split('\n') - - for line in lines: - # ENV declarations - env_match = DOCKER_PATTERNS["env_declaration"].search(line) - if env_match: - var_name = env_match.group(1) - var_value = env_match.group(2) if env_match.group(2) else None - - parameters.append(RawParameter( - name=normalize_parameter_name(var_name), - native_keys=[var_name], - description=f"Environment variable: {var_name}", - default_value=parse_default_value(var_value) if var_value else None, - required=False, - source=line, - provenance={"type": "ENV", "line": lines.index(line) + 1} - )) - - # ARG declarations - arg_match = DOCKER_PATTERNS["arg_declaration"].search(line) - if arg_match: - var_name = arg_match.group(1) - var_value = arg_match.group(2) if arg_match.group(2) else None - - parameters.append(RawParameter( - name=normalize_parameter_name(var_name), - native_keys=[var_name], - description=f"Build argument: {var_name}", - default_value=parse_default_value(var_value) if var_value else None, - required=False, - source=line, - provenance={"type": "ARG", "line": lines.index(line) + 1} - )) - - return parameters - - def _extract_from_compose(self, compose: dict) -> List[RawParameter]: - """Extract environment variables from docker-compose.yml.""" - parameters = [] - - services = compose.get("services", {}) - for service_name, service_config in services.items(): - env = service_config.get("environment", {}) - if isinstance(env, dict): - for var_name, var_value in env.items(): - parameters.append(RawParameter( - name=normalize_parameter_name(var_name), - native_keys=[var_name], - description=f"Environment variable for service {service_name}", - default_value=parse_default_value(str(var_value)) if var_value else None, - required=False, - source=f"services.{service_name}.environment", - provenance={"service": service_name, "type": "environment"} - )) - - return parameters - - -class LLMDockerExtractor(LLMExtractor): - """LLM-based Docker parameter extraction.""" - - def __init__(self, llm_client=None): - super().__init__(SourceType.DOCKER, llm_client) - - def _create_prompt(self, source: str, tool_name: Optional[str] = None) -> str: - return f"""Extract all configuration parameters from the following Docker configuration. -Look for: -- ENV variables -- ARG build arguments -- Environment variables in docker-compose.yml -- Volume mounts and port mappings that could be parameterized - -Docker Configuration: -{source} - -Return a JSON object with a 'parameters' array. Each parameter should have: name, native_keys, description, type_hint, default_value, required.""" - - def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionResult: - """Extract parameters using LLM.""" - from pydantic import BaseModel - from typing import List as TypingList - - class ParameterSchema(BaseModel): - name: str - native_keys: TypingList[str] - description: Optional[str] = None - type_hint: Optional[str] = None - default_value: Optional[Union[str, int, float, bool]] = None - required: bool = False - - class ExtractionSchema(BaseModel): - parameters: TypingList[ParameterSchema] - - try: - prompt = self._create_prompt(source, tool_name) - response = self.llm_client.send_prompt(prompt, ExtractionSchema) - - parameters = [] - if "parameters" in response: - for param_data in response["parameters"]: - raw_param = RawParameter( - name=normalize_parameter_name(param_data["name"]), - native_keys=param_data.get("native_keys", []), - description=param_data.get("description"), - type_hint=param_data.get("type_hint"), - default_value=param_data.get("default_value"), - required=param_data.get("required", False), - source=source[:200], - provenance={"method": "llm"} - ) - parameters.append(raw_param) - - return ExtractionResult( - tool_name=tool_name or "unknown_docker", - source_type=SourceType.DOCKER, - extraction_method=ExtractionMethod.LLM, - parameters=parameters - ) - except Exception as e: - return ExtractionResult( - tool_name=tool_name or "unknown_docker", - source_type=SourceType.DOCKER, - extraction_method=ExtractionMethod.LLM, - parameters=[], - errors=[f"LLM extraction failed: {str(e)}"] - ) +# Re-export extractors for backwards compatibility +__all__ = [ + "ParameterMiner", + "CLIExtractor", + "PythonLibExtractor", + "HTTPAPIExtractor", + "DockerExtractor", + "LLMCLIExtractor", + "LLMPythonExtractor", + "LLMHTTPExtractor", + "LLMDockerExtractor", +] class ParameterMiner: @@ -954,4 +192,3 @@ def to_json(self, result: ExtractionResult) -> str: JSON string representation """ return result.model_dump_json(indent=2) - diff --git a/src/kgpipe_parameters/visualization/kgpipe_parameter_explorer.py b/src/kgpipe_parameters/visualization/kgpipe_parameter_explorer.py new file mode 100644 index 0000000..a59cc26 --- /dev/null +++ b/src/kgpipe_parameters/visualization/kgpipe_parameter_explorer.py @@ -0,0 +1 @@ +# Explorer for the extracted parameters \ No newline at end of file From c4aa0dad4265f330c3e183a9601d41fdd18fac91 Mon Sep 17 00:00:00 2001 From: Marvin Date: Fri, 20 Feb 2026 12:21:57 +0100 Subject: [PATCH 06/96] added readme extractor; added first clustering approach; added/improved preselection for LLM extractors --- experiments/param-opti/Agent.md | 29 ++ .../param-opti/input/valentine/repo.url | 1 + .../param-opti/spec/implementation_state.md | 104 ++++++ experiments/param-opti/spec/llmextractor.md | 154 +++++++++ .../param-opti/src/param_opti/__main__.py | 64 +++- .../param-opti/src/param_opti/experiment.py | 318 ++++++++++++++++- src/kgpipe_parameters/__init__.py | 17 + src/kgpipe_parameters/clustering/__init__.py | 20 ++ src/kgpipe_parameters/clustering/clusterer.py | 242 +++++++++++++ src/kgpipe_parameters/clustering/models.py | 114 ++++++ .../clustering/similarity.py | 96 ++++++ src/kgpipe_parameters/extraction/__init__.py | 13 + .../extraction/chunk_filter.py | 274 +++++++++++++++ .../extraction/extractors/__init__.py | 4 + .../extraction/extractors/python_lib.py | 200 ++++++++--- .../extraction/extractors/readme_doc.py | 320 +++++++++++++++++ src/kgpipe_parameters/extraction/models.py | 1 + .../extraction/param_miner.py | 10 + src/kgpipe_parameters/extraction/patterns.py | 26 ++ src/kgpipe_parameters/extraction/utils.py | 10 +- src/kgpipe_parameters/tests/conftest.py | 14 + .../tests/test_chunk_filter.py | 263 ++++++++++++++ .../tests/test_clustering.py | 324 ++++++++++++++++++ .../tests/test_data/readme/minimal_readme.md | 12 + .../tests/test_data/readme/tool_readme.md | 58 ++++ .../tests/test_paramters_extraction.py | 92 +++++ 26 files changed, 2689 insertions(+), 91 deletions(-) create mode 100644 experiments/param-opti/Agent.md create mode 100644 experiments/param-opti/input/valentine/repo.url create mode 100644 experiments/param-opti/spec/implementation_state.md create mode 100644 experiments/param-opti/spec/llmextractor.md create mode 100644 src/kgpipe_parameters/clustering/__init__.py create mode 100644 src/kgpipe_parameters/clustering/clusterer.py create mode 100644 src/kgpipe_parameters/clustering/models.py create mode 100644 src/kgpipe_parameters/clustering/similarity.py create mode 100644 src/kgpipe_parameters/extraction/chunk_filter.py create mode 100644 src/kgpipe_parameters/extraction/extractors/readme_doc.py create mode 100644 src/kgpipe_parameters/tests/test_chunk_filter.py create mode 100644 src/kgpipe_parameters/tests/test_clustering.py create mode 100644 src/kgpipe_parameters/tests/test_data/readme/minimal_readme.md create mode 100644 src/kgpipe_parameters/tests/test_data/readme/tool_readme.md diff --git a/experiments/param-opti/Agent.md b/experiments/param-opti/Agent.md new file mode 100644 index 0000000..0dd16a5 --- /dev/null +++ b/experiments/param-opti/Agent.md @@ -0,0 +1,29 @@ +# Tool Parameter Extraction + +An experiment to extract configuration (hyper)parameters from tools that perform data integration tasks and cluster them to show common options. + +The extraction and clustering code is under src/kgpipe_parameters +The experiment code using this is under experimets/param-opti + +Trace the implementation state under spec/ +Reuse its state and extend it for each new feature. +Tack issues under spec/fix_needed.md + +# Implementation Requirements +- Implement parameter extractors from + - docker doc + - python lib + - cli help + - http api doc + - repo Readme.md/Doc +- The extractors can use LLMs or rules/regex patterns +- Find similar parameters between the single tools implementing a cluster strategy + - using sentence transformer embeddings + - prompting llms with preselected terms + +# Success Criteria +- simple tests for each extractors +- a working experiment in experiments/param-opti +- a table with configuration parameters + +I want you to check missing features and just implement the next feature required now. \ No newline at end of file diff --git a/experiments/param-opti/input/valentine/repo.url b/experiments/param-opti/input/valentine/repo.url new file mode 100644 index 0000000..9a4dda0 --- /dev/null +++ b/experiments/param-opti/input/valentine/repo.url @@ -0,0 +1 @@ +https://github.com/delftdata/valentine.git \ No newline at end of file diff --git a/experiments/param-opti/spec/implementation_state.md b/experiments/param-opti/spec/implementation_state.md new file mode 100644 index 0000000..bbe865e --- /dev/null +++ b/experiments/param-opti/spec/implementation_state.md @@ -0,0 +1,104 @@ +# Implementation State + +Last updated: 2026-02-20 + +## Parameter Extractors + +| Source Type | Regex | LLM | Tests | Module | +|-----------------|-------|-----|-------|---------------------------------| +| CLI help | ✅ | ✅ | ✅ | `extractors/cli.py` | +| Python lib | ✅ | ✅ | ✅ | `extractors/python_lib.py` | +| HTTP API doc | ✅ | ✅ | ✅ | `extractors/http_api.py` | +| Docker doc | ✅ | ✅ | ✅ | `extractors/docker.py` | +| Repo README/Doc | ✅ | ✅ | ✅ | `extractors/readme_doc.py` | + +All extractors live under `src/kgpipe_parameters/extraction/extractors/`. + +## Core Infrastructure + +| Component | Status | Location | +|----------------------------|--------|-----------------------------------------------| +| Models | ✅ | `extraction/models.py` | +| Base classes | ✅ | `extraction/base.py` | +| Regex patterns | ✅ | `extraction/patterns.py` | +| Utilities | ✅ | `extraction/utils.py` | +| ParameterMiner | ✅ | `extraction/param_miner.py` | +| Auto source detect | ✅ | `param_miner._detect_source_type()` | +| Keyword chunk filter | ✅ | `extraction/chunk_filter.py` | + +## Keyword Chunk Filter + +Keyword-based pre-filter that scores chunks before they reach any extractor. +Counts parameter-signal keywords per language/file-type and skips files below +a configurable threshold. No embeddings, zero extra dependencies. + +| Language / Type | Keywords cover | Threshold | +|-----------------|---------------------------------------------------------|-----------| +| Python | argparse, click, dataclass, Field, os.environ, … | 2 | +| Java | @Option, @Parameter, getProperty, Properties, @Value,… | 1 | +| .properties | `=`, `:` | 1 | +| XML | ` ExtractionResult +``` + +## How LLMExtractor Works + +### 1. Initialization (`base.py`) + +```python +class LLMExtractor(BaseExtractor): + def __init__(self, source_type: SourceType, llm_client=None): + self.llm_client = llm_client + if llm_client is None: + from kgpipe_llm.common.core import get_client_from_env + self.llm_client = get_client_from_env() +``` + +If no `llm_client` is passed, the constructor tries to auto-create one via `get_client_from_env()`, which reads these environment variables: + +| Variable | Purpose | +|------------------------|-----------------------------------------| +| `LLM_ENDPOINT_URL` | API endpoint (Ollama or OpenAI-compat) | +| `DEFAULT_LLM_MODEL_NAME` | Model name (`gemma3:27B`, `gpt-4o`, …)| +| `OLLAMA_TOKEN` | Token for Ollama API | +| `OPENAI_TOKEN` | Token for OpenAI API | +| `LLM_SEED` | Optional reproducibility seed | +| `CONTEXT_WINDOW` | Max context window (default 16384) | + +The client auto-detects whether to use the **OpenAI** or **Ollama** backend based on the model name. + +### 2. Prompt Construction (`_create_prompt`) + +Each LLM extractor overrides `_create_prompt()` with a source-type-specific prompt template. For example, `LLMCLIExtractor`: + +``` +Extract all configuration parameters from the following CLI help output. +For each parameter, identify: +- Parameter name (normalized, without -- or -) +- Native keys/flags (--flag, -f, etc.) +- Description +- Type (if mentioned) +- Default value (if mentioned) +- Whether it's required or optional + +CLI Help Output: +{source} + +Return a JSON object with a 'parameters' array. Each parameter should have: +name, native_keys, description, type_hint, default_value, required. +``` + +Each source type adapts the prompt to mention the kind of content it expects (code blocks for README, ENV/ARG for Docker, function signatures for Python, etc.). + +### 3. Structured Output via Pydantic Schema + +The `extract()` method defines a Pydantic schema inline and passes it to `send_prompt()`: + +```python +class ParameterSchema(BaseModel): + name: str + native_keys: List[str] + description: Optional[str] = None + type_hint: Optional[str] = None + default_value: Optional[Union[str, int, float, bool]] = None + required: bool = False + +class ExtractionSchema(BaseModel): + parameters: List[ParameterSchema] + +response = self.llm_client.send_prompt(prompt, ExtractionSchema) +``` + +`LLMClient.send_prompt()` uses the Pydantic model's JSON schema to enforce structured output: +- **OpenAI backend**: uses tool/function calling (`openai_call_with_tool`) to get schema-conformant JSON. +- **Ollama backend**: passes the schema in the `format` field so the model outputs valid JSON. + +The response is always a `dict` with a `"parameters"` key containing a list of parameter objects. + +### 4. Response Parsing + +The returned dict is iterated and each entry is converted to a `RawParameter`: + +```python +for param_data in response["parameters"]: + raw_param = RawParameter( + name=normalize_parameter_name(param_data["name"]), + native_keys=param_data.get("native_keys", []), + description=param_data.get("description"), + type_hint=param_data.get("type_hint"), + default_value=param_data.get("default_value"), + required=param_data.get("required", False), + source=source[:200], + provenance={"method": "llm"}, + ) +``` + +The result is wrapped in an `ExtractionResult` with `extraction_method=ExtractionMethod.LLM`. + +### 5. Error Handling + +All LLM extractors catch exceptions and return an empty `ExtractionResult` with the error message in the `errors` list. This ensures a failed LLM call never crashes the extraction pipeline. + +## How ParameterMiner Uses LLM Extractors + +In `ParameterMiner.extract_parameters()`, the `method` argument controls dispatch: + +| Method | Behavior | +|-------------------------|-------------------------------------------------------| +| `ExtractionMethod.REGEX`| Always use the regex extractor. | +| `ExtractionMethod.LLM` | Always use the LLM extractor (raises if no client). | +| `ExtractionMethod.AUTO` | Try regex first. If it returns 0 parameters **and** an `llm_client` is available, fall back to the LLM extractor. | + +The experiment runner (`run_experiment.py --use-llm`) sets `use_llm=True`, which provides an `llm_client` to the `ParameterMiner`, enabling the AUTO fallback. + +## Existing LLM Extractors + +| Class | Source Type | Prompt Focus | +|--------------------------|-------------|-----------------------------------------------| +| `LLMCLIExtractor` | CLI | Flags, options, defaults from help output | +| `LLMPythonExtractor` | Python | Function params, class attrs, env vars | +| `LLMHTTPExtractor` | HTTP API | Query/path/body/header params from specs | +| `LLMDockerExtractor` | Docker | ENV, ARG, volumes, ports | +| `LLMReadmeDocExtractor` | README | Flags, env vars, config keys, tunable values | + +## Testing + +LLM extractors are tested with a mock client (`conftest.py::mock_llm_client`) that returns a canned response, so tests run without a live LLM endpoint. + diff --git a/experiments/param-opti/src/param_opti/__main__.py b/experiments/param-opti/src/param_opti/__main__.py index 61a7e71..eb5c10e 100644 --- a/experiments/param-opti/src/param_opti/__main__.py +++ b/experiments/param-opti/src/param_opti/__main__.py @@ -55,6 +55,22 @@ def main(): default=None, help="Directory for cloned repositories" ) + parser.add_argument( + "--cluster", + action="store_true", + help="Cluster parameters across tools after extraction" + ) + parser.add_argument( + "--cluster-only", + action="store_true", + help="Skip extraction, only cluster from existing output" + ) + parser.add_argument( + "--distance-threshold", + type=float, + default=0.55, + help="Cosine distance threshold for clustering (default: 0.55, lower = tighter)" + ) args = parser.parse_args() @@ -68,8 +84,8 @@ def main(): llm_client = None if args.use_llm: try: - from kgpipe_llm.common.core import LLMClient - llm_client = LLMClient() + from kgpipe_llm.common.core import get_client_from_env + llm_client = get_client_from_env() print("LLM client initialized") except ImportError: print("Warning: kgpipe_llm not available, proceeding without LLM") @@ -84,18 +100,40 @@ def main(): llm_client=llm_client, ) - results = experiment.run(tool_names=args.tool) + if not args.cluster_only: + results = experiment.run(tool_names=args.tool) + + # Print extraction summary + print("\n" + "=" * 60) + print("Extraction Summary") + print("=" * 60) + for name, result in results.items(): + status = "✓" if not result.errors else "⚠" + print(f"{status} {name}: {len(result.parameters)} parameters from {len(result.sources)} sources") + if result.errors: + for err in result.errors[:3]: + print(f" Error: {err}") - # Print summary - print("\n" + "=" * 60) - print("Results Summary") - print("=" * 60) - for name, result in results.items(): - status = "✓" if not result.errors else "⚠" - print(f"{status} {name}: {len(result.parameters)} parameters from {len(result.sources)} sources") - if result.errors: - for err in result.errors[:3]: # Show first 3 errors - print(f" Error: {err}") + # Clustering (after extraction, or standalone with --cluster-only) + if args.cluster or args.cluster_only: + print("\n" + "=" * 60) + print("Clustering Parameters") + print("=" * 60) + cluster_result = experiment.cluster_parameters( + distance_threshold=args.distance_threshold, + ) + if cluster_result: + cross_tool = cluster_result.cross_tool_clusters() + print(f" Total parameters: {cluster_result.n_parameters}") + print(f" Clusters: {cluster_result.n_clusters}") + print(f" Cross-tool clusters: {len(cross_tool)}") + if cross_tool: + print("\n Cross-tool clusters:") + for c in cross_tool[:15]: + tools_str = ", ".join(c.tools) + print(f" [{c.cluster_id}] {c.label!r} ({c.size()} params) — tools: {tools_str}") + print(f"\n Results saved to: {output_dir / '_clusters.json'}") + print(f" Table saved to: {output_dir / '_parameter_table.csv'}") return 0 diff --git a/experiments/param-opti/src/param_opti/experiment.py b/experiments/param-opti/src/param_opti/experiment.py index bcb85da..34773fc 100644 --- a/experiments/param-opti/src/param_opti/experiment.py +++ b/experiments/param-opti/src/param_opti/experiment.py @@ -198,10 +198,43 @@ def extract_from_cli(self, tool: ToolDefinition) -> Optional[Dict[str, Any]]: "result": json.loads(result.model_dump_json()), } + def extract_from_readme(self, tool: ToolDefinition) -> Optional[Dict[str, Any]]: + """ + Extract parameters from a README bundled with the tool input definition. + + Args: + tool: Tool definition with readme_content + + Returns: + Extraction result dictionary, or None if no README content + """ + if not tool.readme_content: + return None + + logger.info(f"Extracting from bundled README for {tool.name}") + from kgpipe_parameters.extraction import SourceType + + result = self.miner.extract_parameters( + source=tool.readme_content, + source_type=SourceType.README, + tool_name=tool.name, + ) + + return { + "source_type": "readme", + "source_file": str(tool.input_path / "readme.md"), + "result": json.loads(result.model_dump_json()), + } + def extract_from_repo(self, tool: ToolDefinition, repo_path: Path) -> List[Dict[str, Any]]: """ Extract parameters from repository files. + Scans Python, Java, .properties, .xml, Docker, and README/doc files. + A keyword-based chunk filter is applied first so that only files + containing parameter-signal keywords are sent to the extractors, + preventing noise from irrelevant source files. + Args: tool: Tool definition repo_path: Path to cloned repository @@ -209,16 +242,17 @@ def extract_from_repo(self, tool: ToolDefinition, repo_path: Path) -> List[Dict[ Returns: List of extraction result dictionaries """ - results = [] + from kgpipe_parameters.extraction import SourceType + from kgpipe_parameters.extraction.chunk_filter import has_parameter_signals, score_chunk - # Find Python files - python_files = list(repo_path.rglob("*.py")) - logger.info(f"Found {len(python_files)} Python files in {tool.name}") + results = [] - # Prioritize main/config/cli files + # ------------------------------------------------------------------ + # Helper: prioritize files whose names suggest config / CLI / params + # ------------------------------------------------------------------ priority_patterns = [ "main", "cli", "config", "settings", "args", "params", "options", - "__main__", "run", "train", "evaluate" + "__main__", "run", "train", "evaluate", "application", "setup", ] def priority_score(path: Path) -> int: @@ -228,19 +262,29 @@ def priority_score(path: Path) -> int: return i return len(priority_patterns) - python_files.sort(key=priority_score) + def _is_test_file(path: Path) -> bool: + """Return True for test / example files we want to skip.""" + low = str(path).lower() + return any(s in low for s in ["test", "/example", "/demo", "/sample"]) - # Extract from top Python files (limit to avoid overwhelming) - from kgpipe_parameters.extraction import SourceType + # ================================================================== + # 1. Python files + # ================================================================== + python_files = sorted(repo_path.rglob("*.py"), key=priority_score) + logger.info(f"Found {len(python_files)} Python files in {tool.name}") - for py_file in python_files[:20]: # Process top 20 files + accepted_py = 0 + for py_file in python_files: + if accepted_py >= 20: + break try: content = py_file.read_text(errors="ignore") - if len(content) < 100: # Skip very small files + if len(content) < 100 or _is_test_file(py_file): continue - # Skip test files - if "test" in str(py_file).lower(): + # ── Keyword chunk filter ── + if not has_parameter_signals(content, file_path=str(py_file)): + logger.debug(f" Skipped (no param signals): {py_file.name}") continue result = self.miner.extract_parameters( @@ -256,10 +300,122 @@ def priority_score(path: Path) -> int: "result": json.loads(result.model_dump_json()), }) logger.info(f" Extracted {len(result.parameters)} params from {py_file.name}") + accepted_py += 1 except Exception as e: logger.warning(f" Failed to process {py_file}: {e}") - # Find Dockerfiles + # ================================================================== + # 2. Java files + # ================================================================== + java_files = sorted(repo_path.rglob("*.java"), key=priority_score) + logger.info(f"Found {len(java_files)} Java files in {tool.name}") + + accepted_java = 0 + for java_file in java_files: + if accepted_java >= 20: + break + try: + content = java_file.read_text(errors="ignore") + if len(content) < 100 or _is_test_file(java_file): + continue + + # ── Keyword chunk filter ── + if not has_parameter_signals(content, file_path=str(java_file)): + logger.debug(f" Skipped (no param signals): {java_file.name}") + continue + + # Java config files are best handled by the README extractor + # (it picks up flag patterns, key-value pairs, etc.) + result = self.miner.extract_parameters( + source=content, + source_type=SourceType.README, + tool_name=f"{tool.name}/{java_file.name}", + ) + + if result.parameters: + results.append({ + "source_type": "java", + "source_file": str(java_file.relative_to(repo_path)), + "result": json.loads(result.model_dump_json()), + }) + logger.info(f" Extracted {len(result.parameters)} params from {java_file.name}") + accepted_java += 1 + except Exception as e: + logger.warning(f" Failed to process {java_file}: {e}") + + # ================================================================== + # 3. .properties files (Java native config format) + # ================================================================== + properties_files = list(repo_path.rglob("*.properties")) + logger.info(f"Found {len(properties_files)} .properties files in {tool.name}") + + for prop_file in properties_files[:15]: + try: + content = prop_file.read_text(errors="ignore") + if len(content) < 10 or _is_test_file(prop_file): + continue + + # .properties files are inherently config — always relevant + result = self.miner.extract_parameters( + source=content, + source_type=SourceType.README, # kv-pair patterns work well + tool_name=f"{tool.name}/{prop_file.name}", + ) + + if result.parameters: + results.append({ + "source_type": "properties", + "source_file": str(prop_file.relative_to(repo_path)), + "result": json.loads(result.model_dump_json()), + }) + logger.info(f" Extracted {len(result.parameters)} params from {prop_file.name}") + except Exception as e: + logger.warning(f" Failed to process {prop_file}: {e}") + + # ================================================================== + # 4. XML config files + # ================================================================== + xml_files = list(repo_path.rglob("*.xml")) + # Only keep files whose names suggest config, not build scripts + _xml_config_hints = [ + "config", "setting", "param", "property", "application", + "persistence", "context", "bean", + ] + xml_files = [ + f for f in xml_files + if any(h in f.stem.lower() for h in _xml_config_hints) + or has_parameter_signals( + f.read_text(errors="ignore")[:2000], + file_path=str(f), + ) + ] + logger.info(f"Found {len(xml_files)} XML config files in {tool.name}") + + for xml_file in xml_files[:10]: + try: + content = xml_file.read_text(errors="ignore") + if len(content) < 30 or _is_test_file(xml_file): + continue + + result = self.miner.extract_parameters( + source=content, + source_type=SourceType.README, + tool_name=f"{tool.name}/{xml_file.name}", + ) + + if result.parameters: + results.append({ + "source_type": "xml", + "source_file": str(xml_file.relative_to(repo_path)), + "result": json.loads(result.model_dump_json()), + }) + logger.info(f" Extracted {len(result.parameters)} params from {xml_file.name}") + except Exception as e: + logger.warning(f" Failed to process {xml_file}: {e}") + + # ================================================================== + # 5. Dockerfiles + # ================================================================== for dockerfile in repo_path.rglob("Dockerfile*"): try: content = dockerfile.read_text(errors="ignore") @@ -279,7 +435,9 @@ def priority_score(path: Path) -> int: except Exception as e: logger.warning(f" Failed to process {dockerfile}: {e}") - # Find docker-compose files + # ================================================================== + # 6. docker-compose files + # ================================================================== for compose_file in repo_path.rglob("docker-compose*.y*ml"): try: content = compose_file.read_text(errors="ignore") @@ -299,6 +457,61 @@ def priority_score(path: Path) -> int: except Exception as e: logger.warning(f" Failed to process {compose_file}: {e}") + # ================================================================== + # 7. README and documentation files + # ================================================================== + readme_patterns = ["README*", "readme*", "INSTALL*", "USAGE*", "CONFIGURATION*"] + doc_dirs = ["doc", "docs", "documentation"] + + readme_files: List[Path] = [] + for pattern in readme_patterns: + readme_files.extend(repo_path.glob(pattern)) + # Also pick up .md files scattered in the repo root (e.g. RunPARIS.md) + readme_files.extend(repo_path.glob("*.md")) + for doc_dir_name in doc_dirs: + doc_dir = repo_path / doc_dir_name + if doc_dir.is_dir(): + readme_files.extend(doc_dir.rglob("*.md")) + readme_files.extend(doc_dir.rglob("*.txt")) + readme_files.extend(doc_dir.rglob("*.rst")) + + # Deduplicate while preserving order + seen_readme: set = set() + unique_readmes: List[Path] = [] + for f in readme_files: + if f.resolve() not in seen_readme and f.is_file(): + seen_readme.add(f.resolve()) + unique_readmes.append(f) + + logger.info(f"Found {len(unique_readmes)} README/doc files in {tool.name}") + + for readme_file in unique_readmes[:15]: + try: + content = readme_file.read_text(errors="ignore") + if len(content) < 50: + continue + + # ── Keyword chunk filter for docs ── + if not has_parameter_signals(content, file_path=str(readme_file), threshold=1): + logger.debug(f" Skipped (no param signals): {readme_file.name}") + continue + + result = self.miner.extract_parameters( + source=content, + source_type=SourceType.README, + tool_name=f"{tool.name}/{readme_file.name}", + ) + + if result.parameters: + results.append({ + "source_type": "readme", + "source_file": str(readme_file.relative_to(repo_path)), + "result": json.loads(result.model_dump_json()), + }) + logger.info(f" Extracted {len(result.parameters)} params from {readme_file.name}") + except Exception as e: + logger.warning(f" Failed to process {readme_file}: {e}") + return results def process_tool(self, tool: ToolDefinition) -> ToolExtractionResult: @@ -342,10 +555,31 @@ def process_tool(self, tool: ToolDefinition) -> ToolExtractionResult: result.errors.append(f"CLI extraction failed: {str(e)}") logger.error(f"CLI extraction failed for {tool.name}: {e}") - # Clone and extract from repository - if self.clone_repos and tool.has_repo(): - repo_path = self.clone_repository(tool) - if repo_path: + # Extract from bundled README + if tool.readme_content: + try: + readme_result = self.extract_from_readme(tool) + if readme_result: + params = readme_result["result"].get("parameters", []) + result.sources.append(ExtractionSource( + source_type="readme", + file_path=readme_result["source_file"], + content_preview=tool.readme_content[:200] if tool.readme_content else None, + parameters_count=len(params), + )) + for p in params: + p["_source"] = "readme" + result.parameters.append(p) + except Exception as e: + result.errors.append(f"README extraction failed: {str(e)}") + logger.error(f"README extraction failed for {tool.name}: {e}") + + # Clone (if requested) and extract from repository + if tool.has_repo(): + repo_path = self.repos_dir / tool.name + if self.clone_repos: + repo_path = self.clone_repository(tool) + if repo_path and repo_path.exists(): try: repo_results = self.extract_from_repo(tool, repo_path) for r in repo_results: @@ -429,6 +663,52 @@ def run(self, tool_names: Optional[List[str]] = None) -> Dict[str, ToolExtractio return results + def cluster_parameters( + self, + model_name: str = "all-MiniLM-L6-v2", + distance_threshold: float = 0.55, + ) -> Optional[Any]: + """ + Cluster extracted parameters across all tools using sentence-transformer + embeddings and agglomerative clustering. + + This reads the per-tool JSON files already written to ``output_dir``, + embeds every parameter, and groups similar ones together. + + Args: + model_name: Sentence-transformer model identifier. + distance_threshold: Max cosine distance for merging (lower = tighter). + + Returns: + A ClusteringResult, or None if no parameters were found. + """ + from kgpipe_parameters.clustering import ParameterClusterer + + clusterer = ParameterClusterer( + model_name=model_name, + distance_threshold=distance_threshold, + ) + + result = clusterer.cluster_from_output_dir(self.output_dir) + + if result.n_clusters == 0: + logger.warning("Clustering produced 0 clusters") + return result + + # Persist results + clusterer.save_result(result, self.output_dir / "_clusters.json") + clusterer.save_table(result, self.output_dir / "_parameter_table.csv") + + # Log summary + cross_tool = result.cross_tool_clusters() + logger.info( + "Clustering: %d parameters → %d clusters (%d cross-tool)", + result.n_parameters, + result.n_clusters, + len(cross_tool), + ) + return result + def _generate_summary(self, results: Dict[str, ToolExtractionResult]) -> None: """Generate and save experiment summary.""" summary = { diff --git a/src/kgpipe_parameters/__init__.py b/src/kgpipe_parameters/__init__.py index 74e5e43..4d41245 100644 --- a/src/kgpipe_parameters/__init__.py +++ b/src/kgpipe_parameters/__init__.py @@ -13,13 +13,30 @@ ExtractionResult, SourceType, ExtractionMethod, + ReadmeDocExtractor, + LLMReadmeDocExtractor, +) + +from .clustering import ( + ParameterClusterer, + ParameterVector, + ParameterCluster, + ClusteringResult, ) __all__ = [ + # Extraction "ParameterMiner", "RawParameter", "ExtractionResult", "SourceType", "ExtractionMethod", + "ReadmeDocExtractor", + "LLMReadmeDocExtractor", + # Clustering + "ParameterClusterer", + "ParameterVector", + "ParameterCluster", + "ClusteringResult", ] diff --git a/src/kgpipe_parameters/clustering/__init__.py b/src/kgpipe_parameters/clustering/__init__.py new file mode 100644 index 0000000..7698ade --- /dev/null +++ b/src/kgpipe_parameters/clustering/__init__.py @@ -0,0 +1,20 @@ +""" +Parameter clustering module. + +Groups similar parameters across tools using sentence-transformer embeddings +and agglomerative clustering so that common configuration knobs are surfaced. +""" + +from .models import ParameterVector, ParameterCluster, ClusteringResult +from .similarity import embed_parameters, cosine_similarity_matrix +from .clusterer import ParameterClusterer + +__all__ = [ + "ParameterVector", + "ParameterCluster", + "ClusteringResult", + "embed_parameters", + "cosine_similarity_matrix", + "ParameterClusterer", +] + diff --git a/src/kgpipe_parameters/clustering/clusterer.py b/src/kgpipe_parameters/clustering/clusterer.py new file mode 100644 index 0000000..bd7d62c --- /dev/null +++ b/src/kgpipe_parameters/clustering/clusterer.py @@ -0,0 +1,242 @@ +""" +Main clustering logic. + +Loads extracted parameters from experiment JSON output, embeds them with +sentence-transformers, and applies agglomerative clustering to surface +groups of similar configuration knobs across tools. +""" + +from __future__ import annotations + +import json +import logging +from collections import Counter +from pathlib import Path +from typing import Any, Dict, List, Optional + +import numpy as np + +from .models import ParameterVector, ParameterCluster, ClusteringResult +from .similarity import DEFAULT_MODEL_NAME, embed_parameters + +logger = logging.getLogger(__name__) + + +class ParameterClusterer: + """ + Cluster extracted parameters by semantic similarity. + + Typical usage:: + + clusterer = ParameterClusterer() + result = clusterer.cluster_from_output_dir(Path("output/")) + for c in result.cross_tool_clusters(): + print(c.label, c.tools, c.size()) + """ + + def __init__( + self, + model_name: str = DEFAULT_MODEL_NAME, + distance_threshold: float = 0.55, + min_cluster_size: int = 1, + ): + """ + Parameters + ---------- + model_name : str + Sentence-transformer model to use for embeddings. + distance_threshold : float + Maximum cosine *distance* (1 − similarity) at which two + parameters are still merged into the same cluster. + Lower → tighter clusters. ``0.55`` is a good starting + point for short technical phrases. + min_cluster_size : int + Drop clusters smaller than this after clustering. + """ + self.model_name = model_name + self.distance_threshold = distance_threshold + self.min_cluster_size = min_cluster_size + self._model = None # lazy-loaded + + # ------------------------------------------------------------------ + # Loading helpers + # ------------------------------------------------------------------ + + @staticmethod + def load_parameters_from_json(path: Path) -> List[ParameterVector]: + """ + Load parameters from one tool's JSON output file. + + Expected format: the JSON written by + ``ToolExtractionResult.to_dict()`` — a dict with a + ``"parameters"`` list and a ``"tool_name"`` string. + """ + with open(path) as f: + data = json.load(f) + + tool_name = data.get("tool_name", path.stem) + vectors: List[ParameterVector] = [] + + for p in data.get("parameters", []): + pv = ParameterVector( + name=p.get("name", ""), + tool_name=tool_name, + native_keys=p.get("native_keys", []), + description=p.get("description"), + type_hint=p.get("type_hint"), + default_value=p.get("default_value"), + required=p.get("required", False), + source_label=p.get("_source", ""), + ) + vectors.append(pv) + + return vectors + + def load_from_output_dir(self, output_dir: Path) -> List[ParameterVector]: + """ + Load parameters from *all* tool JSON files in *output_dir*. + + Skips files whose name starts with ``_`` (e.g. ``_summary.json``). + """ + all_params: List[ParameterVector] = [] + for json_file in sorted(output_dir.glob("*.json")): + if json_file.name.startswith("_"): + continue + try: + params = self.load_parameters_from_json(json_file) + logger.info( + "Loaded %d parameters from %s", len(params), json_file.name + ) + all_params.extend(params) + except Exception as e: + logger.warning("Failed to load %s: %s", json_file, e) + + logger.info("Total parameters loaded: %d", len(all_params)) + return all_params + + # ------------------------------------------------------------------ + # Clustering + # ------------------------------------------------------------------ + + def cluster(self, parameters: List[ParameterVector]) -> ClusteringResult: + """ + Embed and cluster a list of parameters. + + Returns a ``ClusteringResult`` with numbered clusters. + """ + if not parameters: + return ClusteringResult( + model_name=self.model_name, + distance_threshold=self.distance_threshold, + ) + + # 1. Compute embeddings + if self._model is None: + from sentence_transformers import SentenceTransformer + + self._model = SentenceTransformer(self.model_name) + + embeddings = embed_parameters( + parameters, model_name=self.model_name, model=self._model + ) + + # 2. Agglomerative clustering with cosine distance + n = len(parameters) + + if n == 1: + # AgglomerativeClustering requires ≥ 2 samples; short-circuit. + labels = np.array([0]) + else: + from sklearn.cluster import AgglomerativeClustering + + sim_matrix = embeddings @ embeddings.T + np.clip(sim_matrix, -1.0, 1.0, out=sim_matrix) + dist_matrix = 1.0 - sim_matrix + + clustering_model = AgglomerativeClustering( + n_clusters=None, + metric="precomputed", + linkage="average", + distance_threshold=self.distance_threshold, + ) + labels = clustering_model.fit_predict(dist_matrix) + + # 3. Build ParameterCluster objects + cluster_map: Dict[int, List[int]] = {} + for idx, label in enumerate(labels): + cluster_map.setdefault(int(label), []).append(idx) + + clusters: List[ParameterCluster] = [] + for cid, member_indices in sorted(cluster_map.items()): + members = [parameters[i] for i in member_indices] + if len(members) < self.min_cluster_size: + continue + + tools = sorted(set(m.tool_name for m in members)) + centroid = embeddings[member_indices].mean(axis=0) + + # Label = most common parameter name in the cluster + name_counts = Counter(m.name for m in members) + label_name = name_counts.most_common(1)[0][0] + + clusters.append( + ParameterCluster( + cluster_id=cid, + label=label_name, + members=members, + tools=tools, + centroid=centroid.tolist(), + ) + ) + + # Sort: cross-tool first, then by size descending + clusters.sort(key=lambda c: (-int(c.is_cross_tool()), -c.size())) + + return ClusteringResult( + n_parameters=len(parameters), + n_clusters=len(clusters), + distance_threshold=self.distance_threshold, + model_name=self.model_name, + clusters=clusters, + ) + + def cluster_from_output_dir(self, output_dir: Path) -> ClusteringResult: + """Convenience: load + cluster in one call.""" + params = self.load_from_output_dir(output_dir) + return self.cluster(params) + + # ------------------------------------------------------------------ + # Output helpers + # ------------------------------------------------------------------ + + @staticmethod + def save_result(result: ClusteringResult, path: Path) -> None: + """Save clustering result as JSON.""" + # Strip large embedding lists to keep the file readable + data = result.model_dump() + for cluster in data.get("clusters", []): + cluster.pop("centroid", None) + for member in cluster.get("members", []): + member.pop("embedding", None) + + with open(path, "w") as f: + json.dump(data, f, indent=2, default=str) + logger.info("Saved clustering result to %s", path) + + @staticmethod + def save_table(result: ClusteringResult, path: Path) -> None: + """Save a flat CSV parameter table from clustering results.""" + import csv + + rows = result.to_table_rows() + if not rows: + logger.warning("No rows to write to table") + return + + fieldnames = list(rows[0].keys()) + with open(path, "w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(rows) + logger.info("Saved parameter table (%d rows) to %s", len(rows), path) + diff --git a/src/kgpipe_parameters/clustering/models.py b/src/kgpipe_parameters/clustering/models.py new file mode 100644 index 0000000..5570d36 --- /dev/null +++ b/src/kgpipe_parameters/clustering/models.py @@ -0,0 +1,114 @@ +""" +Data models for parameter clustering results. +""" + +from typing import List, Optional, Dict, Any +from pydantic import BaseModel, ConfigDict, Field +import numpy as np + + +class ParameterVector(BaseModel): + """A parameter together with its embedding and origin metadata.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + name: str = Field(..., description="Normalized parameter name") + tool_name: str = Field(..., description="Tool this parameter belongs to") + native_keys: List[str] = Field(default_factory=list) + description: Optional[str] = None + type_hint: Optional[str] = None + default_value: Optional[Any] = None + required: bool = False + source_label: str = Field( + "", description="Human-readable source (e.g. 'cli', 'readme:README.md')" + ) + # Embedding stored as plain list for JSON serialisation; converted to + # numpy array for computation. + embedding: Optional[List[float]] = Field( + None, description="Sentence-transformer embedding" + ) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + def text_for_embedding(self) -> str: + """Build the text representation used for embedding computation.""" + parts = [self.name.replace("_", " ")] + if self.description: + parts.append(self.description) + if self.native_keys: + parts.append(" ".join(self.native_keys)) + if self.type_hint: + parts.append(f"type: {self.type_hint}") + return " | ".join(parts) + + +class ParameterCluster(BaseModel): + """A cluster of similar parameters found across one or more tools.""" + + cluster_id: int = Field(..., description="Numeric cluster identifier") + label: str = Field( + "", description="Human-readable label (e.g. most common parameter name)" + ) + members: List[ParameterVector] = Field(default_factory=list) + tools: List[str] = Field( + default_factory=list, + description="Distinct tool names represented in this cluster", + ) + centroid: Optional[List[float]] = Field( + None, description="Mean embedding of the cluster" + ) + + def size(self) -> int: + return len(self.members) + + def is_cross_tool(self) -> bool: + """Return True if parameters from more than one tool are in this cluster.""" + return len(self.tools) > 1 + + +class ClusteringResult(BaseModel): + """Container for an entire clustering run.""" + + n_parameters: int = Field(0, description="Total parameters fed to clustering") + n_clusters: int = Field(0, description="Number of clusters produced") + distance_threshold: float = Field( + 0.0, description="Distance threshold used for clustering" + ) + model_name: str = Field("", description="Sentence-transformer model used") + clusters: List[ParameterCluster] = Field(default_factory=list) + metadata: Dict[str, Any] = Field(default_factory=dict) + + # ------------------------------------------------------------------ + # Convenience helpers + # ------------------------------------------------------------------ + def cross_tool_clusters(self) -> List[ParameterCluster]: + """Return only clusters that span more than one tool.""" + return [c for c in self.clusters if c.is_cross_tool()] + + def to_table_rows(self) -> List[Dict[str, Any]]: + """ + Flatten clusters into a list of rows suitable for a pandas DataFrame + or CSV export. + """ + rows: List[Dict[str, Any]] = [] + for cluster in self.clusters: + for member in cluster.members: + rows.append( + { + "cluster_id": cluster.cluster_id, + "cluster_label": cluster.label, + "cluster_size": cluster.size(), + "cross_tool": cluster.is_cross_tool(), + "tool": member.tool_name, + "parameter": member.name, + "native_keys": ", ".join(member.native_keys), + "description": member.description or "", + "type_hint": member.type_hint or "", + "default_value": member.default_value, + "required": member.required, + "source": member.source_label, + } + ) + return rows + diff --git a/src/kgpipe_parameters/clustering/similarity.py b/src/kgpipe_parameters/clustering/similarity.py new file mode 100644 index 0000000..5c52e0c --- /dev/null +++ b/src/kgpipe_parameters/clustering/similarity.py @@ -0,0 +1,96 @@ +""" +Embedding computation and similarity helpers for parameter clustering. + +Uses sentence-transformers to encode parameter descriptions into dense +vectors, then provides numpy-based cosine-similarity utilities. +""" + +from __future__ import annotations + +import logging +from typing import List, Optional + +import numpy as np + +from .models import ParameterVector + +logger = logging.getLogger(__name__) + +# Default lightweight model; works well for short technical phrases. +DEFAULT_MODEL_NAME = "all-MiniLM-L6-v2" + + +def _load_model(model_name: str): + """Load a SentenceTransformer model (cached after first call).""" + from sentence_transformers import SentenceTransformer + + logger.info("Loading sentence-transformer model: %s", model_name) + return SentenceTransformer(model_name) + + +def embed_parameters( + parameters: List[ParameterVector], + model_name: str = DEFAULT_MODEL_NAME, + batch_size: int = 64, + model: Optional[object] = None, +) -> np.ndarray: + """ + Compute embeddings for a list of ParameterVectors. + + Each parameter's ``text_for_embedding()`` is encoded via the + sentence-transformer *model_name*. The resulting embeddings are + stored back into each ``ParameterVector.embedding`` field **and** + returned as a (N, D) numpy array. + + Parameters + ---------- + parameters : list[ParameterVector] + Parameters to embed. + model_name : str + HuggingFace model identifier. + batch_size : int + Encoding batch size. + model : optional + Pre-loaded SentenceTransformer instance (avoids reloading). + + Returns + ------- + np.ndarray + Shape ``(len(parameters), embedding_dim)``. + """ + if not parameters: + return np.empty((0, 0)) + + if model is None: + model = _load_model(model_name) + + texts = [p.text_for_embedding() for p in parameters] + embeddings = model.encode(texts, batch_size=batch_size, show_progress_bar=False) + embeddings = np.asarray(embeddings, dtype=np.float32) + + # Normalise to unit length so cosine similarity = dot product. + norms = np.linalg.norm(embeddings, axis=1, keepdims=True) + norms[norms == 0] = 1.0 + embeddings = embeddings / norms + + for pv, emb in zip(parameters, embeddings): + pv.embedding = emb.tolist() + + return embeddings + + +def cosine_similarity_matrix(embeddings: np.ndarray) -> np.ndarray: + """ + Compute the pair-wise cosine similarity matrix. + + If the embeddings are already L2-normalised (as ``embed_parameters`` + produces), this is simply ``embeddings @ embeddings.T``. + """ + if embeddings.size == 0: + return np.empty((0, 0)) + # Ensure unit vectors + norms = np.linalg.norm(embeddings, axis=1, keepdims=True) + norms[norms == 0] = 1.0 + normed = embeddings / norms + return normed @ normed.T + diff --git a/src/kgpipe_parameters/extraction/__init__.py b/src/kgpipe_parameters/extraction/__init__.py index 13ba837..db5f283 100644 --- a/src/kgpipe_parameters/extraction/__init__.py +++ b/src/kgpipe_parameters/extraction/__init__.py @@ -8,10 +8,12 @@ PythonLibExtractor, HTTPAPIExtractor, DockerExtractor, + ReadmeDocExtractor, LLMCLIExtractor, LLMPythonExtractor, LLMHTTPExtractor, LLMDockerExtractor, + LLMReadmeDocExtractor, ) from .models import ( RawParameter, @@ -31,6 +33,11 @@ infer_parameter_type, extract_constraints, ) +from .chunk_filter import ( + score_chunk, + has_parameter_signals, + KEYWORD_SETS, +) __all__ = [ # Main class @@ -40,10 +47,12 @@ "PythonLibExtractor", "HTTPAPIExtractor", "DockerExtractor", + "ReadmeDocExtractor", "LLMCLIExtractor", "LLMPythonExtractor", "LLMHTTPExtractor", "LLMDockerExtractor", + "LLMReadmeDocExtractor", # Base classes "BaseExtractor", "RegexExtractor", @@ -59,4 +68,8 @@ "parse_default_value", "infer_parameter_type", "extract_constraints", + # Chunk filtering + "score_chunk", + "has_parameter_signals", + "KEYWORD_SETS", ] diff --git a/src/kgpipe_parameters/extraction/chunk_filter.py b/src/kgpipe_parameters/extraction/chunk_filter.py new file mode 100644 index 0000000..fc8feac --- /dev/null +++ b/src/kgpipe_parameters/extraction/chunk_filter.py @@ -0,0 +1,274 @@ +""" +Keyword-based chunk scoring for pre-filtering files before extraction. + +Counts parameter-signal keywords in a text chunk and returns a relevance +score. Files/chunks that score below a configurable threshold are skipped +entirely, preventing noise (e.g. Arabic segmenter scripts in CoreNLP) from +polluting both the regex and LLM extraction paths. + +No embeddings, no extra dependencies — pure keyword counting. +""" + +import re +from typing import Dict, List, Optional, Tuple + +__all__ = ["score_chunk", "has_parameter_signals", "KEYWORD_SETS"] + + +# ── Keyword sets per language / file-type ──────────────────────────────── + +_PYTHON_KEYWORDS: List[str] = [ + # argparse / click / typer + "argparse", + "add_argument", + "ArgumentParser", + "click.option", + "click.argument", + "click.command", + "typer.Option", + "typer.Argument", + # dataclass / pydantic + "@dataclass", + "Field(", + "BaseModel", + "BaseSettings", + # general config signals + "default=", + "default_factory", + "required=", + "choices=", + "type=", + "nargs=", + "help=", + "metavar=", + # plain constructor parameters (frameworks like valentine, etc.) + "def __init__(self,", + "self.__", + "self._", + # env vars + "os.environ", + "os.getenv", + "environ.get", + # configparser / yaml / json config + "configparser", + "ConfigParser", + "config.get", + "config[", + "yaml.load", + "yaml.safe_load", + "json.load", + # hydra / omegaconf + "@hydra.main", + "OmegaConf", + "DictConfig", +] + +_JAVA_KEYWORDS: List[str] = [ + # JCommander / picocli / commons-cli + "@Option", + "@Parameter", + "@CommandLine", + "@Command", + ".addOption(", + "Options(", + "new Option(", + "OptionBuilder", + "CommandLine", + # Java properties / config + "getProperty(", + "setProperty(", + "properties.get(", + "Properties", + ".properties", + "loadProperties", + "getConfig(", + "getString(", + "getInt(", + "getDouble(", + "getBoolean(", + # Spring + "@Value(", + "@ConfigurationProperties", + "@RequestParam", + "@PathVariable", + # general + "default:", + "DEFAULT_", + "CONFIG_", + "PARAM_", +] + +_PROPERTIES_KEYWORDS: List[str] = [ + # .properties files are inherently config + "=", + ":", +] + +_XML_KEYWORDS: List[str] = [ + " str: + """Guess the language/type from a file extension.""" + if not file_path: + return "generic" + # Handle Dockerfile* specially + lower = file_path.lower() + if "dockerfile" in lower or "docker-compose" in lower: + return "docker" + # Extension-based lookup + for ext, lang in _EXT_TO_LANG.items(): + if lower.endswith(ext): + return lang + return "generic" + + +def score_chunk( + text: str, + file_path: Optional[str] = None, + language: Optional[str] = None, +) -> Tuple[int, List[str]]: + """ + Score a text chunk by counting parameter-signal keyword hits. + + Args: + text: The text content to score. + file_path: Optional file path (used to auto-detect language). + language: Explicit language override (python, java, …). + If None, detected from *file_path*. + + Returns: + (score, matched_keywords) — score is the number of distinct keyword + matches found; matched_keywords lists which ones fired. + """ + if not text: + return 0, [] + + lang = language or _detect_language(file_path) + keywords = KEYWORD_SETS.get(lang, KEYWORD_SETS["generic"]) + + matched: List[str] = [] + for kw in keywords: + if kw in text: + matched.append(kw) + + return len(matched), matched + + +def has_parameter_signals( + text: str, + file_path: Optional[str] = None, + language: Optional[str] = None, + threshold: int = 2, +) -> bool: + """ + Return True if *text* contains at least *threshold* distinct + parameter-signal keywords. + + For .properties and .xml files the threshold is automatically lowered + to 1 because their content is inherently config-like. + + Args: + text: The text content to check. + file_path: Optional file path for language detection. + language: Explicit language override. + threshold: Minimum keyword hits required (default 2). + + Returns: + True if the chunk passes the keyword filter. + """ + lang = language or _detect_language(file_path) + + # .properties / .xml files are inherently config — lower bar. + # Java files with *any* annotation-style signal are worth inspecting. + if lang in ("properties", "xml", "java"): + threshold = min(threshold, 1) + + score, _ = score_chunk(text, file_path=file_path, language=lang) + return score >= threshold + diff --git a/src/kgpipe_parameters/extraction/extractors/__init__.py b/src/kgpipe_parameters/extraction/extractors/__init__.py index a760f37..c506246 100644 --- a/src/kgpipe_parameters/extraction/extractors/__init__.py +++ b/src/kgpipe_parameters/extraction/extractors/__init__.py @@ -6,6 +6,7 @@ from .python_lib import PythonLibExtractor, LLMPythonExtractor from .http_api import HTTPAPIExtractor, LLMHTTPExtractor from .docker import DockerExtractor, LLMDockerExtractor +from .readme_doc import ReadmeDocExtractor, LLMReadmeDocExtractor __all__ = [ # CLI @@ -20,6 +21,9 @@ # Docker "DockerExtractor", "LLMDockerExtractor", + # README / documentation + "ReadmeDocExtractor", + "LLMReadmeDocExtractor", ] diff --git a/src/kgpipe_parameters/extraction/extractors/python_lib.py b/src/kgpipe_parameters/extraction/extractors/python_lib.py index a492109..bb62cef 100644 --- a/src/kgpipe_parameters/extraction/extractors/python_lib.py +++ b/src/kgpipe_parameters/extraction/extractors/python_lib.py @@ -49,73 +49,79 @@ def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionRes errors=errors ) + # Type hints that almost certainly indicate I/O data, not configuration. + _IO_TYPE_HINTS = frozenset({ + "DataFrame", "pd.DataFrame", "pandas.DataFrame", + "ndarray", "np.ndarray", "numpy.ndarray", + "Series", "pd.Series", + "BaseTable", "BaseColumn", + "Table", "Column", + "Dataset", + "Pool", "Process", + "Iterator", "Generator", + "TextIO", "BinaryIO", "IO", + }) + + @classmethod + def _looks_like_io_param(cls, name: str, type_hint: Optional[str], has_default: bool) -> bool: + """ + Heuristic: return True if a function parameter is likely an I/O + argument rather than a tunable configuration knob. + + Rules: + - Parameters whose type hint is a known data type (DataFrame, ndarray, + BaseTable, etc.) are I/O. + - Required parameters (no default) of non-__init__ methods whose names + suggest data flow (source, target, input, output, data, table, path, + pool, etc.) are I/O. + """ + if type_hint: + # Check the raw type and any component of a composite hint + for io_type in cls._IO_TYPE_HINTS: + if io_type in type_hint: + return True + # Common I/O parameter name stems + io_name_hints = { + "source", "target", "input", "output", "data", + "table", "column", "pool", "file", "path", + "stream", "buffer", "reader", "writer", + } + normalized = name.lower().replace("_", "") + for h in io_name_hints: + if h in normalized: + # If it has a simple scalar default, it might still be config + if has_default: + return False + return True + return False + def _extract_from_ast(self, tree: ast.AST, source: str) -> List[RawParameter]: """Extract parameters from Python AST.""" parameters = [] - + extractor_cls = self # reference for nested class + class ParameterVisitor(ast.NodeVisitor): def __init__(self): self.params = [] self.source_lines = source.split('\n') - - def visit_FunctionDef(self, node): - # Extract function parameters - for arg in node.args.args: - if arg.arg == 'self': - continue - - # Get type hint - type_hint = None - if arg.annotation: - type_hint = ast.unparse(arg.annotation) if hasattr(ast, 'unparse') else str(arg.annotation) - - # Get default value - default_val = None - default_idx = len(node.args.args) - len(node.args.defaults) - if arg in node.args.args[default_idx:]: - default_node = node.args.defaults[node.args.args[default_idx:].index(arg)] - if hasattr(ast, 'unparse'): - default_val = ast.unparse(default_node) - else: - default_val = ast.literal_eval(default_node) if isinstance(default_node, (ast.Constant, ast.Str, ast.Num)) else None - - # Extract docstring info - description = None - if ast.get_docstring(node): - docstring = ast.get_docstring(node) - # Look for :param arg: description - param_pattern = re.compile(rf":param\s+{arg.arg}:\s*(.+?)(?=\n|:param|$)", re.MULTILINE) - match = param_pattern.search(docstring) - if match: - description = match.group(1).strip() - - param = RawParameter( - name=normalize_parameter_name(arg.arg), - native_keys=[arg.arg], - description=description, - type_hint=type_hint, - default_value=parse_default_value(default_val) if default_val else None, - required=default_val is None, - source=f"{node.name}()", - provenance={"function": node.name, "line": node.lineno} - ) - self.params.append(param) - - self.generic_visit(node) - + self._current_class = None + def visit_ClassDef(self, node): - # Extract class attributes (for dataclasses, Pydantic models, etc.) + prev_class = self._current_class + self._current_class = node.name + + # Extract class-level attributes (dataclasses, Pydantic models, etc.) for item in node.body: if isinstance(item, ast.AnnAssign): # Annotated assignment: name: type = default if isinstance(item.target, ast.Name): attr_name = item.target.id - + # Get type hint type_hint = None if item.annotation: type_hint = ast.unparse(item.annotation) if hasattr(ast, 'unparse') else str(item.annotation) - + # Get default value default_val = None if item.value: @@ -126,7 +132,7 @@ def visit_ClassDef(self, node): default_val = ast.literal_eval(item.value) except (ValueError, TypeError): default_val = None - + param = RawParameter( name=normalize_parameter_name(attr_name), native_keys=[attr_name], @@ -150,7 +156,7 @@ def visit_ClassDef(self, node): default_val = ast.literal_eval(item.value) except (ValueError, TypeError): default_val = None - + param = RawParameter( name=normalize_parameter_name(attr_name), native_keys=[attr_name], @@ -162,9 +168,95 @@ def visit_ClassDef(self, node): provenance={"class": node.name, "line": item.lineno} ) self.params.append(param) - + self.generic_visit(node) - + self._current_class = prev_class + + def visit_FunctionDef(self, node): + is_init = node.name == '__init__' + is_method = self._current_class is not None + class_name = self._current_class + # For non-__init__ methods inside a class, only keep params + # that look like configuration (have defaults and don't look + # like I/O data arguments). + skip_io = is_method and not is_init + + for arg in node.args.args: + if arg.arg in ('self', 'cls'): + continue + + # Get type hint + type_hint = None + if arg.annotation: + type_hint = ast.unparse(arg.annotation) if hasattr(ast, 'unparse') else str(arg.annotation) + + # Get default value + default_val = None + default_idx = len(node.args.args) - len(node.args.defaults) + if arg in node.args.args[default_idx:]: + default_node = node.args.defaults[node.args.args[default_idx:].index(arg)] + if hasattr(ast, 'unparse'): + default_val = ast.unparse(default_node) + else: + default_val = ast.literal_eval(default_node) if isinstance(default_node, (ast.Constant, ast.Str, ast.Num)) else None + + has_default = default_val is not None + + # ── I/O filter for non-constructor methods ── + if skip_io and extractor_cls._looks_like_io_param(arg.arg, type_hint, has_default): + continue + + # For non-__init__ methods, skip required params that + # have no default — they're almost always data args. + if skip_io and not has_default: + continue + + # Extract docstring info (Sphinx :param: and numpydoc styles) + description = None + if ast.get_docstring(node): + docstring = ast.get_docstring(node) + # Sphinx style — :param name: description + sphinx_pat = re.compile( + rf":param\s+{re.escape(arg.arg)}:\s*(.+?)(?=\n|:param|$)", + re.MULTILINE, + ) + m = sphinx_pat.search(docstring) + if m: + description = m.group(1).strip() + else: + # Numpydoc style — + # name : type + # Description text + numpydoc_pat = re.compile( + rf"^\s*{re.escape(arg.arg)}\s*(?::.*)?$\n((?:[ \t]+.+\n?)+)", + re.MULTILINE, + ) + m = numpydoc_pat.search(docstring) + if m: + # Merge continuation lines and strip indent + desc_lines = [l.strip() for l in m.group(1).splitlines() if l.strip()] + description = " ".join(desc_lines) + + func_label = f"{class_name}.{node.name}" if class_name else node.name + param = RawParameter( + name=normalize_parameter_name(arg.arg), + native_keys=[arg.arg], + description=description, + type_hint=type_hint, + default_value=parse_default_value(default_val) if default_val else None, + required=default_val is None, + source=f"{func_label}()", + provenance={ + "function": node.name, + "class": class_name, + "is_constructor": is_init, + "line": node.lineno, + } + ) + self.params.append(param) + + self.generic_visit(node) + visitor = ParameterVisitor() visitor.visit(tree) return visitor.params diff --git a/src/kgpipe_parameters/extraction/extractors/readme_doc.py b/src/kgpipe_parameters/extraction/extractors/readme_doc.py new file mode 100644 index 0000000..aca8392 --- /dev/null +++ b/src/kgpipe_parameters/extraction/extractors/readme_doc.py @@ -0,0 +1,320 @@ +""" +README / documentation parameter extraction. + +Extracts configuration parameters from README files, documentation pages, +and other unstructured markdown/text docs that describe tool usage. +""" + +import re +from typing import List, Optional, Set, Union + +from ..models import RawParameter, ExtractionResult, SourceType, ExtractionMethod +from ..base import RegexExtractor, LLMExtractor +from ..patterns import README_PATTERNS +from ..utils import normalize_parameter_name, parse_default_value + + +# Noise words that appear as flags/placeholders but are not real parameters +_NOISE_NAMES: Set[str] = { + "h", "help", "version", "v", "verbose", "quiet", "q", + "the", "a", "an", "is", "are", "was", "were", "be", + "to", "of", "in", "for", "on", "at", "by", "with", + "it", "its", "we", "our", "you", "your", + "e", "g", "i", "x", "s", +} + + +def _extract_code_blocks(text: str) -> List[str]: + """Return contents of fenced code blocks (``` … ```).""" + return re.findall(r"```[^\n]*\n(.*?)```", text, re.DOTALL) + + +class ReadmeDocExtractor(RegexExtractor): + """Extract parameters from README / documentation text (Markdown or plain text).""" + + def __init__(self, use_llm: bool = False, llm_client=None): + super().__init__(SourceType.README, README_PATTERNS) + self.use_llm = use_llm + if use_llm: + self.llm_extractor = LLMReadmeDocExtractor(llm_client) + + def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionResult: + """Extract parameters from README / documentation text.""" + if self.use_llm: + return self.llm_extractor.extract(source, tool_name) + + parameters: List[RawParameter] = [] + errors: List[str] = [] + seen_names: Set[str] = set() + + try: + # --- 1. Parameters described in markdown list items --- + # e.g. - `threshold`: The matching threshold (default: 0.5) + for match in README_PATTERNS["list_param"].finditer(source): + name_raw = match.group(1) + description = match.group(2).strip() + normalized = normalize_parameter_name(name_raw) + if normalized in _NOISE_NAMES or len(normalized) < 2: + continue + if normalized in seen_names: + continue + seen_names.add(normalized) + + default_val = self._find_default(description) + type_hint = self._find_type_hint(description) + + parameters.append(RawParameter( + name=normalized, + native_keys=[name_raw], + description=description, + type_hint=type_hint, + default_value=parse_default_value(default_val) if default_val else None, + required=False, + source=match.group(0).strip(), + provenance={"method": "readme_list_param"}, + )) + + # --- 2. Parameters in markdown tables --- + for match in README_PATTERNS["table_param"].finditer(source): + name_raw = match.group(1).strip() + col2 = match.group(2).strip() + col3 = match.group(3).strip() + normalized = normalize_parameter_name(name_raw) + if normalized in _NOISE_NAMES or len(normalized) < 2: + continue + if normalized in seen_names: + continue + seen_names.add(normalized) + + # Heuristic: second column is often type, third is description + type_hint = col2 if col2 and len(col2) < 30 else None + description = col3 or col2 + + parameters.append(RawParameter( + name=normalized, + native_keys=[name_raw], + description=description if description else None, + type_hint=type_hint, + default_value=None, + required=False, + source=match.group(0).strip(), + provenance={"method": "readme_table"}, + )) + + # --- 3. Flags from code blocks --- + code_blocks = _extract_code_blocks(source) + for block in code_blocks: + for match in README_PATTERNS["code_block_flag"].finditer(block): + flag = match.group(1) + value = match.group(2) + normalized = normalize_parameter_name(flag) + if normalized in _NOISE_NAMES or len(normalized) < 2: + continue + if normalized in seen_names: + continue + seen_names.add(normalized) + + parameters.append(RawParameter( + name=normalized, + native_keys=[flag], + description=None, + type_hint=None, + default_value=parse_default_value(value) if value else None, + required=False, + source=block[:200].strip(), + provenance={"method": "readme_code_block"}, + )) + + # JVM-style flags + for jvm_match in README_PATTERNS["jvm_flag"].finditer(block): + flag = jvm_match.group(1) + normalized = normalize_parameter_name(flag) + if normalized in seen_names: + continue + seen_names.add(normalized) + + parameters.append(RawParameter( + name=normalized, + native_keys=[flag], + description=f"JVM flag: {flag}", + type_hint=None, + default_value=None, + required=False, + source=block[:200].strip(), + provenance={"method": "readme_jvm_flag"}, + )) + + # --- 4. Inline flags referenced with backticks --- + for match in README_PATTERNS["inline_flag"].finditer(source): + flag = match.group(1) + normalized = normalize_parameter_name(flag) + if normalized in _NOISE_NAMES or len(normalized) < 2: + continue + if normalized in seen_names: + continue + seen_names.add(normalized) + + # Try to find a surrounding sentence as description + start = max(0, match.start() - 120) + end = min(len(source), match.end() + 120) + context = source[start:end].replace("\n", " ").strip() + + parameters.append(RawParameter( + name=normalized, + native_keys=[flag], + description=context, + type_hint=None, + default_value=None, + required=False, + source=context, + provenance={"method": "readme_inline_flag"}, + )) + + # --- 5. Environment variable references --- + for match in README_PATTERNS["env_reference"].finditer(source): + var_name = match.group(1) + var_value = match.group(2) if match.lastindex >= 2 else None + normalized = normalize_parameter_name(var_name) + if normalized in seen_names or len(normalized) < 2: + continue + seen_names.add(normalized) + + parameters.append(RawParameter( + name=normalized, + native_keys=[var_name], + description=f"Environment variable: {var_name}", + type_hint=None, + default_value=parse_default_value(var_value) if var_value else None, + required=False, + source=match.group(0).strip(), + provenance={"method": "readme_env_var"}, + )) + + # --- 6. Placeholder parameters from usage lines --- + for match in README_PATTERNS["placeholder"].finditer(source): + name_raw = match.group(1) + normalized = normalize_parameter_name(name_raw) + if normalized in _NOISE_NAMES or len(normalized) < 2: + continue + if normalized in seen_names: + continue + seen_names.add(normalized) + + # Grab surrounding line as context + line_start = source.rfind("\n", 0, match.start()) + 1 + line_end = source.find("\n", match.end()) + if line_end == -1: + line_end = len(source) + context_line = source[line_start:line_end].strip() + + parameters.append(RawParameter( + name=normalized, + native_keys=[f"<{name_raw}>"], + description=context_line, + type_hint=None, + default_value=None, + required=True, # placeholders are usually required + source=context_line, + provenance={"method": "readme_placeholder"}, + )) + + except Exception as e: + errors.append(f"Error extracting README parameters: {str(e)}") + + return ExtractionResult( + tool_name=tool_name or "unknown_readme", + source_type=SourceType.README, + extraction_method=ExtractionMethod.REGEX, + parameters=parameters, + errors=errors, + ) + + # ------------------------------------------------------------------ + # helpers + # ------------------------------------------------------------------ + + @staticmethod + def _find_default(text: str) -> Optional[str]: + """Try to extract a default value from a description string.""" + m = re.search(r"default[=:]\s*[`\"']?([^`\"'\]),\s]+)", text, re.IGNORECASE) + return m.group(1) if m else None + + @staticmethod + def _find_type_hint(text: str) -> Optional[str]: + """Try to infer a type hint from a description string.""" + for token in ("int", "integer", "float", "number", "bool", "boolean", "string", "str", "path", "file"): + if re.search(rf"\b{token}\b", text, re.IGNORECASE): + return token + return None + + +class LLMReadmeDocExtractor(LLMExtractor): + """LLM-based README / documentation parameter extraction.""" + + def __init__(self, llm_client=None): + super().__init__(SourceType.README, llm_client) + + def _create_prompt(self, source: str, tool_name: Optional[str] = None) -> str: + return f"""Extract all configuration parameters from the following README / documentation text. +Look for: +- Command-line flags and options mentioned in usage examples +- Environment variables +- Configuration keys or settings +- Input/output paths that can be parameterized +- Any tunable values (thresholds, limits, memory sizes, etc.) + +Documentation: +{source} + +Return a JSON object with a 'parameters' array. Each parameter should have: name, native_keys, description, type_hint, default_value, required.""" + + def extract(self, source: str, tool_name: Optional[str] = None) -> ExtractionResult: + """Extract parameters using LLM.""" + from pydantic import BaseModel + from typing import List as TypingList + + class ParameterSchema(BaseModel): + name: str + native_keys: TypingList[str] + description: Optional[str] = None + type_hint: Optional[str] = None + default_value: Optional[Union[str, int, float, bool]] = None + required: bool = False + + class ExtractionSchema(BaseModel): + parameters: TypingList[ParameterSchema] + + try: + prompt = self._create_prompt(source, tool_name) + response = self.llm_client.send_prompt(prompt, ExtractionSchema) + + parameters = [] + if "parameters" in response: + for param_data in response["parameters"]: + raw_param = RawParameter( + name=normalize_parameter_name(param_data["name"]), + native_keys=param_data.get("native_keys", []), + description=param_data.get("description"), + type_hint=param_data.get("type_hint"), + default_value=param_data.get("default_value"), + required=param_data.get("required", False), + source=source[:200], + provenance={"method": "llm"}, + ) + parameters.append(raw_param) + + return ExtractionResult( + tool_name=tool_name or "unknown_readme", + source_type=SourceType.README, + extraction_method=ExtractionMethod.LLM, + parameters=parameters, + ) + except Exception as e: + return ExtractionResult( + tool_name=tool_name or "unknown_readme", + source_type=SourceType.README, + extraction_method=ExtractionMethod.LLM, + parameters=[], + errors=[f"LLM extraction failed: {str(e)}"], + ) + diff --git a/src/kgpipe_parameters/extraction/models.py b/src/kgpipe_parameters/extraction/models.py index 393787b..f209fe0 100644 --- a/src/kgpipe_parameters/extraction/models.py +++ b/src/kgpipe_parameters/extraction/models.py @@ -14,6 +14,7 @@ class SourceType(str, Enum): PYTHON_LIB = "python_lib" HTTP_API = "http_api" DOCKER = "docker" + README = "readme" UNKNOWN = "unknown" diff --git a/src/kgpipe_parameters/extraction/param_miner.py b/src/kgpipe_parameters/extraction/param_miner.py index 1e00bd4..bb209d0 100644 --- a/src/kgpipe_parameters/extraction/param_miner.py +++ b/src/kgpipe_parameters/extraction/param_miner.py @@ -15,10 +15,12 @@ PythonLibExtractor, HTTPAPIExtractor, DockerExtractor, + ReadmeDocExtractor, LLMCLIExtractor, LLMPythonExtractor, LLMHTTPExtractor, LLMDockerExtractor, + LLMReadmeDocExtractor, ) # Re-export extractors for backwards compatibility @@ -28,10 +30,12 @@ "PythonLibExtractor", "HTTPAPIExtractor", "DockerExtractor", + "ReadmeDocExtractor", "LLMCLIExtractor", "LLMPythonExtractor", "LLMHTTPExtractor", "LLMDockerExtractor", + "LLMReadmeDocExtractor", ] @@ -54,6 +58,7 @@ def __init__(self, llm_client=None): SourceType.PYTHON_LIB: PythonLibExtractor(use_llm=False), SourceType.HTTP_API: HTTPAPIExtractor(use_llm=False), SourceType.DOCKER: DockerExtractor(use_llm=False), + SourceType.README: ReadmeDocExtractor(use_llm=False), } def extract_parameters( @@ -133,6 +138,7 @@ def extract_parameters( SourceType.PYTHON_LIB: LLMPythonExtractor(self.llm_client), SourceType.HTTP_API: LLMHTTPExtractor(self.llm_client), SourceType.DOCKER: LLMDockerExtractor(self.llm_client), + SourceType.README: LLMReadmeDocExtractor(self.llm_client), } extractor = llm_extractors.get(source_type) if extractor: @@ -166,6 +172,10 @@ def _detect_source_type(self, source: str) -> SourceType: if any(x in source for x in ["FROM ", "ENV ", "ARG ", "docker-compose", "services:"]): return SourceType.DOCKER + # Check for README / Markdown documentation + if any(x in source for x in ["# ", "## ", "```", "**", "[", "](", "---"]): + return SourceType.README + return SourceType.UNKNOWN def to_parameter_model(self, raw_param: RawParameter): diff --git a/src/kgpipe_parameters/extraction/patterns.py b/src/kgpipe_parameters/extraction/patterns.py index b6daf97..f1d985b 100644 --- a/src/kgpipe_parameters/extraction/patterns.py +++ b/src/kgpipe_parameters/extraction/patterns.py @@ -73,6 +73,31 @@ "port_mapping": re.compile(r"-p\s+(\d+):(\d+)"), } +# README / documentation patterns +README_PATTERNS = { + # Flags or options mentioned in code blocks or inline code: --param, -p + "inline_flag": re.compile(r"`(-{1,2}[a-zA-Z][a-zA-Z0-9_-]*)`"), + # Command-line invocations in code blocks: tool --param value + "code_block_flag": re.compile(r"(?:^|\s)(-{1,2}[a-zA-Z][a-zA-Z0-9_-]*)(?:\s+(\S+))?", re.MULTILINE), + # Environment variable references: $VAR, ${VAR}, ENV VAR, set VAR= + "env_reference": re.compile(r"(?:\$\{?|(?:set|export)\s+)([A-Z_][A-Z0-9_]*)(?:\}|=([^\s]+))?"), + # Config key-value in YAML/properties style: key: value or key = value + "config_kv": re.compile(r"^\s*([a-zA-Z_][a-zA-Z0-9_.]+)\s*[=:]\s*(.+)$", re.MULTILINE), + # JVM-style flags: -Xmx47000m, -XX:+UseG1GC + "jvm_flag": re.compile(r"(-X[a-z]+\d*[a-zA-Z]*|-XX:[+\-]?\w+(?:=\S+)?)"), + # Markdown table rows with parameter-like content: | param | type | description | + "table_param": re.compile(r"\|\s*`?([a-zA-Z_][a-zA-Z0-9_-]*)`?\s*\|([^|]*)\|([^|]*)\|"), + # Setting/configuration references: "set X to Y", "configure X as Y" + "setting_reference": re.compile( + r"(?:set|configure|specify|use)\s+[`\"']?([a-zA-Z_][a-zA-Z0-9_-]*)[`\"']?\s+(?:to|as|=)\s+[`\"']?([^\s,`\"']+)", + re.IGNORECASE, + ), + # Parameter descriptions in lists: - `param`: description or * param — description + "list_param": re.compile(r"^\s*[-*]\s+`([a-zA-Z_][a-zA-Z0-9_-]*)`[:\s]+(.+)$", re.MULTILINE), + # Placeholder patterns like , [param], {param} in usage lines + "placeholder": re.compile(r"<([a-zA-Z_][a-zA-Z0-9_]*)>"), +} + # Common patterns for all sources COMMON_PATTERNS = { # Numeric constraints: min=0, max=100 @@ -103,6 +128,7 @@ def get_patterns(source_type: str) -> Dict[str, re.Pattern]: "python": PYTHON_PATTERNS, "api": API_PATTERNS, "docker": DOCKER_PATTERNS, + "readme": README_PATTERNS, } return patterns_map.get(source_type.lower(), {}) diff --git a/src/kgpipe_parameters/extraction/utils.py b/src/kgpipe_parameters/extraction/utils.py index 9bb6876..1993bf2 100644 --- a/src/kgpipe_parameters/extraction/utils.py +++ b/src/kgpipe_parameters/extraction/utils.py @@ -78,17 +78,17 @@ def parse_default_value(value_str: Optional[str]) -> Optional[Union[str, int, fl value_str = value_str.strip().strip('"').strip("'") - # Try boolean - if value_str.lower() in ["true", "false", "yes", "no", "1", "0"]: - return value_str.lower() in ["true", "yes", "1"] - - # Try integer + # Try integer first (before boolean, so "0" and "1" stay numeric) try: if value_str.isdigit() or (value_str.startswith("-") and value_str[1:].isdigit()): return int(value_str) except ValueError: pass + # Try boolean + if value_str.lower() in ["true", "false", "yes", "no"]: + return value_str.lower() in ["true", "yes"] + # Try float try: return float(value_str) diff --git a/src/kgpipe_parameters/tests/conftest.py b/src/kgpipe_parameters/tests/conftest.py index 9c33cad..5c4004f 100644 --- a/src/kgpipe_parameters/tests/conftest.py +++ b/src/kgpipe_parameters/tests/conftest.py @@ -93,6 +93,20 @@ def docker_compose_content(): return path.read_text() +@pytest.fixture +def readme_tool_doc(): + """Fixture for a tool README with configuration parameters.""" + path = get_test_data_path("readme/tool_readme.md") + return path.read_text() + + +@pytest.fixture +def readme_minimal(): + """Fixture for a minimal README.""" + path = get_test_data_path("readme/minimal_readme.md") + return path.read_text() + + @pytest.fixture def mock_llm_client(): """Fixture for mocked LLM client.""" diff --git a/src/kgpipe_parameters/tests/test_chunk_filter.py b/src/kgpipe_parameters/tests/test_chunk_filter.py new file mode 100644 index 0000000..a8dc404 --- /dev/null +++ b/src/kgpipe_parameters/tests/test_chunk_filter.py @@ -0,0 +1,263 @@ +""" +Tests for keyword-based chunk scoring / filtering. +""" + +import pytest + +from kgpipe_parameters.extraction.chunk_filter import ( + score_chunk, + has_parameter_signals, + KEYWORD_SETS, + _detect_language, +) + + +# ── Language detection ────────────────────────────────────────────────── + +class TestLanguageDetection: + """Tests for _detect_language helper.""" + + def test_python_extension(self): + assert _detect_language("src/foo/bar.py") == "python" + + def test_java_extension(self): + assert _detect_language("src/Main.java") == "java" + + def test_properties_extension(self): + assert _detect_language("conf/server.properties") == "properties" + + def test_xml_extension(self): + assert _detect_language("config.xml") == "xml" + + def test_dockerfile(self): + assert _detect_language("Dockerfile") == "docker" + assert _detect_language("docker-compose.yml") == "docker" + + def test_readme(self): + assert _detect_language("README.md") == "readme" + assert _detect_language("INSTALL.txt") == "readme" + + def test_unknown_defaults_to_generic(self): + assert _detect_language("random.xyz") == "generic" + assert _detect_language(None) == "generic" + + +# ── Scoring ───────────────────────────────────────────────────────────── + +class TestScoreChunk: + """Tests for score_chunk.""" + + def test_empty_text(self): + score, matched = score_chunk("") + assert score == 0 + assert matched == [] + + def test_python_argparse(self): + code = ''' +import argparse +parser = argparse.ArgumentParser() +parser.add_argument("--threshold", type=float, default=0.5, help="Matching threshold") +''' + score, matched = score_chunk(code, file_path="cli.py") + assert score >= 3 # argparse, add_argument, default=, type=, help= + assert "argparse" in matched + assert "add_argument" in matched + + def test_python_dataclass(self): + code = ''' +from dataclasses import dataclass, field + +@dataclass +class Config: + threshold: float = 0.5 + batch_size: int = Field(default=32) +''' + score, matched = score_chunk(code, file_path="config.py") + assert score >= 2 + assert "@dataclass" in matched + assert "Field(" in matched + + def test_python_no_signals(self): + code = ''' +def compute_arabic_segmenter(text): + tokens = text.split() + return [t for t in tokens if len(t) > 2] +''' + score, matched = score_chunk(code, file_path="segmenter.py") + assert score < 2 # No real parameter signals + + def test_java_option_annotation(self): + code = ''' +public class RunPARIS { + @Option(name = "-n", usage = "number of iterations") + int numIterations = 10; + + @Option(name = "-t", usage = "threshold") + double threshold = 0.5; +} +''' + score, matched = score_chunk(code, file_path="RunPARIS.java") + assert score >= 1 # @Option is the signal; Java threshold is 1 + assert "@Option" in matched + # The file still passes the filter (Java auto-lowers threshold to 1) + assert has_parameter_signals(code, file_path="RunPARIS.java") is True + + def test_java_properties_access(self): + code = ''' +Properties props = new Properties(); +props.load(new FileInputStream("config.properties")); +String value = props.getProperty("matchThreshold"); +int maxIter = Integer.parseInt(props.getProperty("maxIterations")); +''' + score, matched = score_chunk(code, file_path="Config.java") + assert score >= 2 + assert "getProperty(" in matched + assert "Properties" in matched + + def test_java_no_signals(self): + code = ''' +public class ArabicTokenizer { + public List tokenize(String text) { + return Arrays.asList(text.split(" ")); + } +} +''' + score, matched = score_chunk(code, file_path="ArabicTokenizer.java") + assert score < 2 + + def test_properties_file(self): + content = ''' +# Server configuration +server.port=8080 +matching.threshold=0.5 +max.iterations=100 +''' + score, matched = score_chunk(content, file_path="server.properties") + assert score >= 1 # .properties files have low bar + + def test_xml_config(self): + content = ''' + + + + +''' + score, matched = score_chunk(content, file_path="config.xml") + assert score >= 2 + assert "= 3 + assert "ENV " in matched + assert "ARG " in matched + assert "EXPOSE " in matched + + def test_readme_with_params(self): + content = ''' +# My Tool + +## Usage + +```bash +mytool --threshold 0.5 --output result.txt +``` + +## Configuration + +- `threshold`: Matching threshold (default: 0.5) +- `max_iter`: Maximum iterations (default: 100) +''' + score, matched = score_chunk(content, file_path="README.md") + assert score >= 3 + + def test_readme_no_params(self): + content = ''' +# My Project + +This is a library for natural language processing. + +## License + +MIT License +''' + score, matched = score_chunk(content, file_path="README.md") + # Very few or no config signals + assert score <= 2 + + def test_explicit_language_override(self): + code = "parser.add_argument('--foo')" + score, matched = score_chunk(code, language="python") + assert "add_argument" in matched + + +# ── has_parameter_signals ─────────────────────────────────────────────── + +class TestHasParameterSignals: + """Tests for the boolean filter function.""" + + def test_python_with_signals(self): + code = 'parser = argparse.ArgumentParser()\nparser.add_argument("--x", default=5)' + assert has_parameter_signals(code, file_path="cli.py") is True + + def test_python_without_signals(self): + code = "x = 1 + 2\nprint(x)" + assert has_parameter_signals(code, file_path="math.py") is False + + def test_threshold_override(self): + code = "argparse" + # With default threshold=2 this would fail (only 1 keyword) + assert has_parameter_signals(code, file_path="x.py", threshold=2) is False + # With threshold=1 it passes + assert has_parameter_signals(code, file_path="x.py", threshold=1) is True + + def test_properties_low_bar(self): + content = "key=value" + # .properties files auto-lower threshold to 1 + assert has_parameter_signals(content, file_path="app.properties") is True + + def test_xml_low_bar(self): + content = '' + assert has_parameter_signals(content, file_path="config.xml") is True + + def test_java_config_class_passes(self): + code = ''' +public class AppConfig { + @Option(name = "-t") + double threshold = DEFAULT_THRESHOLD; +} +''' + assert has_parameter_signals(code, file_path="AppConfig.java") is True + + def test_java_non_config_class_fails(self): + code = ''' +public class Utils { + public static String trim(String s) { + return s.trim(); + } +} +''' + assert has_parameter_signals(code, file_path="Utils.java") is False + + +# ── Keyword set sanity ────────────────────────────────────────────────── + +class TestKeywordSets: + """Sanity checks on the keyword dictionaries.""" + + def test_all_sets_non_empty(self): + for name, kws in KEYWORD_SETS.items(): + assert len(kws) > 0, f"Keyword set '{name}' is empty" + + def test_no_empty_keywords(self): + for name, kws in KEYWORD_SETS.items(): + for kw in kws: + assert kw.strip() != "", f"Empty keyword in set '{name}'" + diff --git a/src/kgpipe_parameters/tests/test_clustering.py b/src/kgpipe_parameters/tests/test_clustering.py new file mode 100644 index 0000000..1839ad8 --- /dev/null +++ b/src/kgpipe_parameters/tests/test_clustering.py @@ -0,0 +1,324 @@ +""" +Tests for the parameter clustering module. +""" + +import json +import pytest +import numpy as np +from pathlib import Path +from unittest.mock import patch, MagicMock +from typing import List + +from kgpipe_parameters.clustering.models import ( + ParameterVector, + ParameterCluster, + ClusteringResult, +) +from kgpipe_parameters.clustering.similarity import ( + embed_parameters, + cosine_similarity_matrix, +) +from kgpipe_parameters.clustering.clusterer import ParameterClusterer + + +# ============================================================================ +# Fixtures +# ============================================================================ + + +def _make_param( + name: str, + tool: str, + description: str = "", + native_keys: List[str] | None = None, + type_hint: str | None = None, + default_value=None, +) -> ParameterVector: + return ParameterVector( + name=name, + tool_name=tool, + native_keys=native_keys or [], + description=description, + type_hint=type_hint, + default_value=default_value, + source_label=f"{tool}/source", + ) + + +@pytest.fixture +def sample_parameters() -> List[ParameterVector]: + """A small set of parameters from two fictitious tools.""" + return [ + # Tool A + _make_param("threshold", "tool_a", "Matching threshold value", ["--threshold", "-t"], "float", 0.5), + _make_param("max_iterations", "tool_a", "Maximum number of iterations", ["--max-iter"], "int", 100), + _make_param("output_dir", "tool_a", "Output directory path", ["--output", "-o"], "str"), + _make_param("batch_size", "tool_a", "Number of items per batch", ["--batch-size"], "int", 32), + # Tool B + _make_param("similarity_threshold", "tool_b", "Threshold for similarity matching", ["--sim-threshold"], "float", 0.7), + _make_param("iterations", "tool_b", "Number of iterations to run", ["--iterations", "-n"], "int", 50), + _make_param("output_path", "tool_b", "Path for output files", ["--output-path"], "str"), + _make_param("learning_rate", "tool_b", "Learning rate for optimizer", ["--lr"], "float", 0.001), + ] + + +@pytest.fixture +def mock_sentence_model(): + """A mock SentenceTransformer that returns deterministic embeddings.""" + model = MagicMock() + # Return embeddings designed so that similar parameters are closer. + # Each "encode" call gets a list of texts; we return a (N, 8) array + # seeded from the text hash so it is deterministic. + + def _encode(texts, batch_size=64, show_progress_bar=False): + rng = np.random.RandomState(42) + # Use a small embedding dim for testing speed + embs = [] + for t in texts: + seed = sum(ord(c) for c in t) % 2**31 + r = np.random.RandomState(seed) + embs.append(r.randn(8).astype(np.float32)) + return np.array(embs) + + model.encode = _encode + return model + + +# ============================================================================ +# ParameterVector tests +# ============================================================================ + + +class TestParameterVector: + def test_text_for_embedding_basic(self): + pv = _make_param("threshold", "t", "matching threshold", ["--threshold"]) + text = pv.text_for_embedding() + assert "threshold" in text + assert "matching threshold" in text + assert "--threshold" in text + + def test_text_for_embedding_minimal(self): + pv = _make_param("x", "t") + text = pv.text_for_embedding() + assert "x" in text + + +# ============================================================================ +# ParameterCluster tests +# ============================================================================ + + +class TestParameterCluster: + def test_size(self): + members = [_make_param("a", "t1"), _make_param("b", "t2")] + cluster = ParameterCluster(cluster_id=0, label="a", members=members, tools=["t1", "t2"]) + assert cluster.size() == 2 + + def test_is_cross_tool(self): + c1 = ParameterCluster(cluster_id=0, label="x", members=[], tools=["t1", "t2"]) + assert c1.is_cross_tool() + + c2 = ParameterCluster(cluster_id=1, label="x", members=[], tools=["t1"]) + assert not c2.is_cross_tool() + + +# ============================================================================ +# ClusteringResult tests +# ============================================================================ + + +class TestClusteringResult: + def test_cross_tool_clusters(self): + c1 = ParameterCluster(cluster_id=0, label="x", members=[], tools=["t1", "t2"]) + c2 = ParameterCluster(cluster_id=1, label="y", members=[], tools=["t1"]) + result = ClusteringResult(clusters=[c1, c2], n_clusters=2) + assert len(result.cross_tool_clusters()) == 1 + + def test_to_table_rows(self): + members = [_make_param("threshold", "t1"), _make_param("threshold", "t2")] + cluster = ParameterCluster(cluster_id=0, label="threshold", members=members, tools=["t1", "t2"]) + result = ClusteringResult(clusters=[cluster], n_clusters=1, n_parameters=2) + rows = result.to_table_rows() + assert len(rows) == 2 + assert rows[0]["cluster_label"] == "threshold" + assert rows[0]["tool"] == "t1" + assert rows[1]["tool"] == "t2" + + def test_to_table_rows_empty(self): + result = ClusteringResult() + assert result.to_table_rows() == [] + + +# ============================================================================ +# Similarity tests +# ============================================================================ + + +class TestSimilarity: + def test_embed_parameters(self, sample_parameters, mock_sentence_model): + embeddings = embed_parameters( + sample_parameters, model=mock_sentence_model + ) + assert embeddings.shape[0] == len(sample_parameters) + assert embeddings.shape[1] > 0 + # All embeddings should be stored back + for pv in sample_parameters: + assert pv.embedding is not None + assert len(pv.embedding) == embeddings.shape[1] + + def test_embed_parameters_empty(self, mock_sentence_model): + embeddings = embed_parameters([], model=mock_sentence_model) + assert embeddings.shape == (0, 0) + + def test_cosine_similarity_matrix_identity(self): + embs = np.eye(3, dtype=np.float32) + sim = cosine_similarity_matrix(embs) + np.testing.assert_allclose(sim, np.eye(3), atol=1e-5) + + def test_cosine_similarity_matrix_same_vector(self): + embs = np.ones((4, 5), dtype=np.float32) + sim = cosine_similarity_matrix(embs) + np.testing.assert_allclose(sim, np.ones((4, 4)), atol=1e-5) + + def test_cosine_similarity_matrix_empty(self): + embs = np.empty((0, 0)) + sim = cosine_similarity_matrix(embs) + assert sim.shape == (0, 0) + + +# ============================================================================ +# ParameterClusterer tests +# ============================================================================ + + +class TestParameterClusterer: + def test_cluster_basic(self, sample_parameters, mock_sentence_model): + """Clustering should produce at least one cluster.""" + clusterer = ParameterClusterer(distance_threshold=0.8) + clusterer._model = mock_sentence_model + + result = clusterer.cluster(sample_parameters) + assert result.n_parameters == len(sample_parameters) + assert result.n_clusters > 0 + # All parameters should be assigned to some cluster + total_members = sum(c.size() for c in result.clusters) + assert total_members == len(sample_parameters) + + def test_cluster_empty(self): + clusterer = ParameterClusterer() + result = clusterer.cluster([]) + assert result.n_parameters == 0 + assert result.n_clusters == 0 + + def test_cluster_single_param(self, mock_sentence_model): + clusterer = ParameterClusterer() + clusterer._model = mock_sentence_model + params = [_make_param("threshold", "tool_a", "test")] + result = clusterer.cluster(params) + assert result.n_parameters == 1 + assert result.n_clusters == 1 + + def test_load_parameters_from_json(self, tmp_path): + """Test loading parameters from a tool JSON output file.""" + data = { + "tool_name": "test_tool", + "parameters": [ + { + "name": "threshold", + "native_keys": ["--threshold"], + "description": "test", + "type_hint": "float", + "default_value": 0.5, + "required": False, + "_source": "cli", + }, + { + "name": "output", + "native_keys": ["--output"], + "description": "output path", + "_source": "cli", + }, + ], + } + json_file = tmp_path / "test_tool.json" + json_file.write_text(json.dumps(data)) + + params = ParameterClusterer.load_parameters_from_json(json_file) + assert len(params) == 2 + assert params[0].name == "threshold" + assert params[0].tool_name == "test_tool" + assert params[1].name == "output" + + def test_load_from_output_dir(self, tmp_path): + """Test loading from a directory with multiple tool files.""" + for tool_name in ["tool_a", "tool_b"]: + data = { + "tool_name": tool_name, + "parameters": [ + {"name": "param1", "native_keys": [], "_source": "cli"}, + ], + } + (tmp_path / f"{tool_name}.json").write_text(json.dumps(data)) + # Summary file should be skipped + (tmp_path / "_summary.json").write_text("{}") + + clusterer = ParameterClusterer() + params = clusterer.load_from_output_dir(tmp_path) + assert len(params) == 2 + tool_names = {p.tool_name for p in params} + assert tool_names == {"tool_a", "tool_b"} + + def test_save_result(self, tmp_path): + members = [_make_param("threshold", "t1")] + cluster = ParameterCluster(cluster_id=0, label="threshold", members=members, tools=["t1"]) + result = ClusteringResult(clusters=[cluster], n_clusters=1, n_parameters=1) + + out_path = tmp_path / "clusters.json" + ParameterClusterer.save_result(result, out_path) + assert out_path.exists() + + saved = json.loads(out_path.read_text()) + assert saved["n_clusters"] == 1 + assert len(saved["clusters"]) == 1 + + def test_save_table(self, tmp_path): + members = [ + _make_param("threshold", "t1", description="test"), + _make_param("threshold", "t2", description="test"), + ] + cluster = ParameterCluster(cluster_id=0, label="threshold", members=members, tools=["t1", "t2"]) + result = ClusteringResult(clusters=[cluster], n_clusters=1, n_parameters=2) + + csv_path = tmp_path / "table.csv" + ParameterClusterer.save_table(result, csv_path) + assert csv_path.exists() + + import csv + with open(csv_path) as f: + reader = csv.DictReader(f) + rows = list(reader) + assert len(rows) == 2 + assert rows[0]["parameter"] == "threshold" + + def test_cluster_from_output_dir(self, tmp_path, mock_sentence_model): + """Integration test: load → cluster from an output directory.""" + for tool_name, params in [ + ("tool_a", [ + {"name": "threshold", "native_keys": ["--threshold"], "description": "match threshold", "_source": "cli"}, + {"name": "output", "native_keys": ["--output"], "description": "output path", "_source": "cli"}, + ]), + ("tool_b", [ + {"name": "similarity_threshold", "native_keys": ["--sim-threshold"], "description": "threshold for similarity", "_source": "cli"}, + {"name": "output_dir", "native_keys": ["--output-dir"], "description": "directory for output", "_source": "cli"}, + ]), + ]: + data = {"tool_name": tool_name, "parameters": params} + (tmp_path / f"{tool_name}.json").write_text(json.dumps(data)) + + clusterer = ParameterClusterer(distance_threshold=0.8) + clusterer._model = mock_sentence_model + result = clusterer.cluster_from_output_dir(tmp_path) + + assert result.n_parameters == 4 + assert result.n_clusters > 0 + diff --git a/src/kgpipe_parameters/tests/test_data/readme/minimal_readme.md b/src/kgpipe_parameters/tests/test_data/readme/minimal_readme.md new file mode 100644 index 0000000..8f37aa4 --- /dev/null +++ b/src/kgpipe_parameters/tests/test_data/readme/minimal_readme.md @@ -0,0 +1,12 @@ +# SimpleTool + +A minimal tool. + +## Usage + +``` +simpletool +``` + +Set `workers` to control parallelism. + diff --git a/src/kgpipe_parameters/tests/test_data/readme/tool_readme.md b/src/kgpipe_parameters/tests/test_data/readme/tool_readme.md new file mode 100644 index 0000000..ef86be5 --- /dev/null +++ b/src/kgpipe_parameters/tests/test_data/readme/tool_readme.md @@ -0,0 +1,58 @@ +# EntityMatcher + +A tool for matching entities across knowledge graphs. + +## Installation + +```bash +pip install entity-matcher +``` + +## Usage + +```bash +entity-matcher --input data.nt --output results.tsv --threshold 0.8 --max-iter 10 +entity-matcher --format csv --verbose +``` + +## Configuration + +The following parameters can be set: + +- `threshold`: The matching threshold, a float between 0 and 1 (default: 0.5) +- `max_iter`: Maximum number of iterations (default: 10) +- `input`: Path to input knowledge base (required) +- `output`: Path to output results file (required) +- `format`: Output format, one of csv, tsv, json (default: tsv) +- `similarity_metric`: Similarity metric to use, e.g. jaccard, cosine (default: jaccard) + +## Advanced Configuration + +| Parameter | Type | Description | +|-----------|------|-------------| +| `batch_size` | int | Number of entities per batch | +| `num_threads` | int | Number of parallel threads | +| `cache_dir` | path | Directory for caching intermediate results | +| `log_level` | string | Logging level: DEBUG, INFO, WARNING, ERROR | + +## Environment Variables + +You can also configure via environment: + +```bash +export MATCHER_THRESHOLD=0.8 +export MATCHER_MAX_MEMORY=4096 +``` + +## Running with Java Backend + +For the Java backend, you may need to increase JVM memory: + +```bash +java -Xmx8192m -Xms2048m -jar entity-matcher.jar +``` + +## API + +See the [API documentation](docs/api.md) for details. + diff --git a/src/kgpipe_parameters/tests/test_paramters_extraction.py b/src/kgpipe_parameters/tests/test_paramters_extraction.py index 0571d65..e98966b 100644 --- a/src/kgpipe_parameters/tests/test_paramters_extraction.py +++ b/src/kgpipe_parameters/tests/test_paramters_extraction.py @@ -12,6 +12,7 @@ PythonLibExtractor, HTTPAPIExtractor, DockerExtractor, + ReadmeDocExtractor, RawParameter, ExtractionResult, SourceType, @@ -358,6 +359,90 @@ def test_docker_extractor_multiple_services(self, docker_compose_content): assert len(result.parameters) > 0 +# ============================================================================= +# README / Documentation Extractor Tests +# ============================================================================= + +class TestReadmeDocExtractor: + """Tests for README / documentation parameter extraction.""" + + def test_readme_extractor_list_params(self, readme_tool_doc): + """Test extraction of parameters from markdown list items.""" + extractor = ReadmeDocExtractor() + result = extractor.extract(readme_tool_doc, "entity_matcher") + + assert isinstance(result, ExtractionResult) + assert result.source_type == SourceType.README + assert result.extraction_method == ExtractionMethod.REGEX + assert len(result.parameters) > 0 + + param_names = [p.name for p in result.parameters] + assert "threshold" in param_names + assert "max_iter" in param_names + + def test_readme_extractor_table_params(self, readme_tool_doc): + """Test extraction of parameters from markdown tables.""" + extractor = ReadmeDocExtractor() + result = extractor.extract(readme_tool_doc, "entity_matcher") + + param_names = [p.name for p in result.parameters] + assert "batch_size" in param_names + assert "num_threads" in param_names + + def test_readme_extractor_env_vars(self, readme_tool_doc): + """Test extraction of environment variable references.""" + extractor = ReadmeDocExtractor() + result = extractor.extract(readme_tool_doc, "entity_matcher") + + param_names = [p.name for p in result.parameters] + assert "matcher_threshold" in param_names or "matcher_max_memory" in param_names + + def test_readme_extractor_placeholders(self, readme_tool_doc): + """Test extraction of placeholder parameters from usage lines.""" + extractor = ReadmeDocExtractor() + result = extractor.extract(readme_tool_doc, "entity_matcher") + + param_names = [p.name for p in result.parameters] + # , , from the Java usage line + assert "kb1" in param_names or "outputfolder" in param_names + + def test_readme_extractor_defaults(self, readme_tool_doc): + """Test that default values are extracted from descriptions.""" + extractor = ReadmeDocExtractor() + result = extractor.extract(readme_tool_doc, "entity_matcher") + + threshold_params = [p for p in result.parameters if p.name == "threshold"] + if threshold_params: + param = threshold_params[0] + assert param.default_value == 0.5 or param.default_value == "0.5" + + def test_readme_extractor_descriptions(self, readme_tool_doc): + """Test that descriptions are extracted.""" + extractor = ReadmeDocExtractor() + result = extractor.extract(readme_tool_doc, "entity_matcher") + + params_with_desc = [p for p in result.parameters if p.description] + assert len(params_with_desc) > 0 + + def test_readme_extractor_minimal(self, readme_minimal): + """Test extraction from a minimal README.""" + extractor = ReadmeDocExtractor() + result = extractor.extract(readme_minimal, "simple_tool") + + assert isinstance(result, ExtractionResult) + param_names = [p.name for p in result.parameters] + # Should find at least the and placeholders + assert "inputfile" in param_names or "outputfile" in param_names + + def test_readme_extractor_empty(self): + """Test handling of empty README.""" + extractor = ReadmeDocExtractor() + result = extractor.extract("", "test") + + assert isinstance(result, ExtractionResult) + assert len(result.parameters) == 0 + + # ============================================================================= # ParameterMiner Integration Tests # ============================================================================= @@ -393,6 +478,13 @@ def test_parameter_miner_auto_detect_docker(self, dockerfile_content): assert result.source_type == SourceType.DOCKER + def test_parameter_miner_auto_detect_readme(self, readme_tool_doc): + """Test auto-detection of README source.""" + miner = ParameterMiner() + result = miner.extract_parameters(readme_tool_doc, method=ExtractionMethod.AUTO) + + assert result.source_type == SourceType.README + def test_parameter_miner_file_path(self, test_data_dir): """Test extraction from file path.""" miner = ParameterMiner() From f5e86017aae6b883b801594f64c34c02d7d307f1 Mon Sep 17 00:00:00 2001 From: Marvin Date: Fri, 20 Feb 2026 16:24:15 +0100 Subject: [PATCH 07/96] viz of parameter extraction --- experiments/param-opti/Agent.md | 2 + .../param-opti/spec/implementation_state.md | 21 +- .../param-opti/src/param_opti/__main__.py | 17 ++ .../param-opti/src/param_opti/experiment.py | 21 ++ src/kgpipe_parameters/__init__.py | 4 + .../tests/test_visualization.py | 176 ++++++++++++ .../visualization/__init__.py | 6 + .../kgpipe_parameter_explorer.py | 258 +++++++++++++++++- 8 files changed, 503 insertions(+), 2 deletions(-) create mode 100644 src/kgpipe_parameters/tests/test_visualization.py create mode 100644 src/kgpipe_parameters/visualization/__init__.py diff --git a/experiments/param-opti/Agent.md b/experiments/param-opti/Agent.md index 0dd16a5..06c4e09 100644 --- a/experiments/param-opti/Agent.md +++ b/experiments/param-opti/Agent.md @@ -20,10 +20,12 @@ Tack issues under spec/fix_needed.md - Find similar parameters between the single tools implementing a cluster strategy - using sentence transformer embeddings - prompting llms with preselected terms +- Visualize the clusters # Success Criteria - simple tests for each extractors - a working experiment in experiments/param-opti - a table with configuration parameters +- A vizualization output I want you to check missing features and just implement the next feature required now. \ No newline at end of file diff --git a/experiments/param-opti/spec/implementation_state.md b/experiments/param-opti/spec/implementation_state.md index bbe865e..d837e66 100644 --- a/experiments/param-opti/spec/implementation_state.md +++ b/experiments/param-opti/spec/implementation_state.md @@ -97,8 +97,27 @@ and agglomerative clustering. 7. Output `_clusters.json` (full cluster details) and `_parameter_table.csv` (flat, one row per parameter with cluster assignment). +## Visualization + +Static plots generated from clustering results using matplotlib/seaborn. + +| Component | Status | Location | +|--------------------------|--------|---------------------------------------------------| +| ParameterVisualizer | ✅ | `visualization/kgpipe_parameter_explorer.py` | +| Cluster size bar chart | ✅ | `_viz_cluster_sizes.png` | +| Tool × cluster heatmap | ✅ | `_viz_tool_heatmap.png` | +| 2-D PCA embedding scatter| ✅ | `_viz_embedding_scatter.png` | +| CLI `--visualize` flag | ✅ | `__main__.py` | +| Experiment integration | ✅ | `experiment.py:visualize_clusters()` | +| Tests | ✅ | `tests/test_visualization.py` — 7 tests | + +**Plots:** +1. **Cluster sizes** — horizontal bar chart (top-30), cross-tool clusters highlighted. +2. **Tool × cluster heatmap** — parameter count per tool per cross-tool cluster. +3. **Embedding scatter** — PCA-2D projection of parameter embeddings, coloured by tool. + ## Still TODO -- [ ] Visualization (`visualization/kgpipe_parameter_explorer.py` is a stub) +- [x] Visualization (`visualization/kgpipe_parameter_explorer.py`) - [ ] Optimization (`optimization/` is empty) - [ ] Embedding-based RAG for LLM prompts (later, when keyword filter plateaus) diff --git a/experiments/param-opti/src/param_opti/__main__.py b/experiments/param-opti/src/param_opti/__main__.py index eb5c10e..893f0fe 100644 --- a/experiments/param-opti/src/param_opti/__main__.py +++ b/experiments/param-opti/src/param_opti/__main__.py @@ -71,6 +71,11 @@ def main(): default=0.55, help="Cosine distance threshold for clustering (default: 0.55, lower = tighter)" ) + parser.add_argument( + "--visualize", + action="store_true", + help="Generate visualization plots from clustering results" + ) args = parser.parse_args() @@ -135,6 +140,18 @@ def main(): print(f"\n Results saved to: {output_dir / '_clusters.json'}") print(f" Table saved to: {output_dir / '_parameter_table.csv'}") + # Visualization + if args.visualize: + print("\n" + "=" * 60) + print("Generating Visualizations") + print("=" * 60) + viz_paths = experiment.visualize_clusters() + if viz_paths: + for p in viz_paths: + print(f" Saved: {p}") + else: + print(" No visualizations generated (run with --cluster first?)") + return 0 diff --git a/experiments/param-opti/src/param_opti/experiment.py b/experiments/param-opti/src/param_opti/experiment.py index 34773fc..b3c18ea 100644 --- a/experiments/param-opti/src/param_opti/experiment.py +++ b/experiments/param-opti/src/param_opti/experiment.py @@ -709,6 +709,27 @@ def cluster_parameters( ) return result + def visualize_clusters(self) -> list[Path]: + """ + Generate visualization plots from existing clustering output. + + Reads ``_clusters.json`` from the output directory and produces + PNG plots in the same directory. Returns the list of generated + file paths. + """ + clusters_json = self.output_dir / "_clusters.json" + if not clusters_json.exists(): + logger.warning( + "No _clusters.json found in %s — run clustering first", + self.output_dir, + ) + return [] + + from kgpipe_parameters.visualization import ParameterVisualizer + + viz = ParameterVisualizer.from_clusters_json(clusters_json, self.output_dir) + return viz.generate_all() + def _generate_summary(self, results: Dict[str, ToolExtractionResult]) -> None: """Generate and save experiment summary.""" summary = { diff --git a/src/kgpipe_parameters/__init__.py b/src/kgpipe_parameters/__init__.py index 4d41245..1698efa 100644 --- a/src/kgpipe_parameters/__init__.py +++ b/src/kgpipe_parameters/__init__.py @@ -24,6 +24,8 @@ ClusteringResult, ) +from .visualization import ParameterVisualizer + __all__ = [ # Extraction "ParameterMiner", @@ -38,5 +40,7 @@ "ParameterVector", "ParameterCluster", "ClusteringResult", + # Visualization + "ParameterVisualizer", ] diff --git a/src/kgpipe_parameters/tests/test_visualization.py b/src/kgpipe_parameters/tests/test_visualization.py new file mode 100644 index 0000000..0b19589 --- /dev/null +++ b/src/kgpipe_parameters/tests/test_visualization.py @@ -0,0 +1,176 @@ +""" +Tests for the parameter visualization module. +""" + +import json +import pytest +import numpy as np +from pathlib import Path +from typing import List + +from kgpipe_parameters.clustering.models import ( + ParameterVector, + ParameterCluster, + ClusteringResult, +) +from kgpipe_parameters.visualization import ParameterVisualizer + + +# ============================================================================ +# Helpers +# ============================================================================ + + +def _make_param( + name: str, + tool: str, + description: str = "", + embedding: List[float] | None = None, +) -> ParameterVector: + return ParameterVector( + name=name, + tool_name=tool, + description=description, + native_keys=[f"--{name}"], + source_label=f"{tool}/source", + embedding=embedding, + ) + + +def _random_embedding( + dim: int = 16, rng: np.random.Generator | None = None +) -> List[float]: + rng = rng or np.random.default_rng(42) + vec = rng.standard_normal(dim).astype(np.float32) + vec /= np.linalg.norm(vec) + return vec.tolist() + + +@pytest.fixture +def sample_clustering_result() -> ClusteringResult: + """A small synthetic clustering result for visualization tests.""" + rng = np.random.default_rng(0) + + # Cluster 0: cross-tool (threshold, 3 params, 2 tools) + c0_members = [ + _make_param( + "threshold", "tool_a", "Matching threshold", _random_embedding(rng=rng) + ), + _make_param( + "threshold", "tool_b", "Score threshold", _random_embedding(rng=rng) + ), + _make_param( + "similarity_threshold", + "tool_a", + "Similarity cutoff", + _random_embedding(rng=rng), + ), + ] + c0 = ParameterCluster( + cluster_id=0, + label="threshold", + members=c0_members, + tools=["tool_a", "tool_b"], + ) + + # Cluster 1: cross-tool (output, 2 params, 2 tools) + c1_members = [ + _make_param( + "output_dir", "tool_a", "Output directory", _random_embedding(rng=rng) + ), + _make_param( + "output_path", "tool_b", "Output file path", _random_embedding(rng=rng) + ), + ] + c1 = ParameterCluster( + cluster_id=1, + label="output_dir", + members=c1_members, + tools=["tool_a", "tool_b"], + ) + + # Cluster 2: single-tool (verbose, 2 params) + c2_members = [ + _make_param( + "verbose", "tool_a", "Verbosity level", _random_embedding(rng=rng) + ), + _make_param("debug", "tool_a", "Debug mode", _random_embedding(rng=rng)), + ] + c2 = ParameterCluster( + cluster_id=2, + label="verbose", + members=c2_members, + tools=["tool_a"], + ) + + return ClusteringResult( + n_parameters=7, + n_clusters=3, + distance_threshold=0.55, + model_name="test-model", + clusters=[c0, c1, c2], + ) + + +# ============================================================================ +# Tests +# ============================================================================ + + +class TestParameterVisualizer: + """Tests for ParameterVisualizer.""" + + def test_generate_all_creates_files(self, sample_clustering_result, tmp_path): + viz = ParameterVisualizer(sample_clustering_result, tmp_path) + paths = viz.generate_all() + assert len(paths) == 3 + for p in paths: + assert p.exists() + assert p.suffix == ".png" + + def test_plot_cluster_sizes(self, sample_clustering_result, tmp_path): + viz = ParameterVisualizer(sample_clustering_result, tmp_path) + path = viz.plot_cluster_sizes() + assert path.exists() + assert path.name == "_viz_cluster_sizes.png" + + def test_plot_tool_cluster_heatmap(self, sample_clustering_result, tmp_path): + viz = ParameterVisualizer(sample_clustering_result, tmp_path) + path = viz.plot_tool_cluster_heatmap() + assert path.exists() + assert path.name == "_viz_tool_heatmap.png" + + def test_plot_embedding_scatter(self, sample_clustering_result, tmp_path): + viz = ParameterVisualizer(sample_clustering_result, tmp_path) + path = viz.plot_embedding_scatter() + assert path.exists() + assert path.name == "_viz_embedding_scatter.png" + + def test_empty_result_returns_empty(self, tmp_path): + empty = ClusteringResult() + viz = ParameterVisualizer(empty, tmp_path) + paths = viz.generate_all() + assert paths == [] + + def test_from_clusters_json(self, sample_clustering_result, tmp_path): + # Write a JSON file + json_path = tmp_path / "_clusters.json" + data = sample_clustering_result.model_dump() + # Strip centroids/embeddings like the real save does + for c in data.get("clusters", []): + c.pop("centroid", None) + with open(json_path, "w") as f: + json.dump(data, f, default=str) + + viz = ParameterVisualizer.from_clusters_json(json_path) + assert viz.result.n_clusters == 3 + + def test_scatter_too_few_points(self, tmp_path): + """Scatter plot gracefully handles < 3 embedded parameters.""" + m = _make_param("x", "t", embedding=_random_embedding()) + c = ParameterCluster(cluster_id=0, label="x", members=[m], tools=["t"]) + result = ClusteringResult(n_parameters=1, n_clusters=1, clusters=[c]) + viz = ParameterVisualizer(result, tmp_path) + path = viz.plot_embedding_scatter() + assert path.exists() + diff --git a/src/kgpipe_parameters/visualization/__init__.py b/src/kgpipe_parameters/visualization/__init__.py new file mode 100644 index 0000000..0b7baa9 --- /dev/null +++ b/src/kgpipe_parameters/visualization/__init__.py @@ -0,0 +1,6 @@ +"""Visualization module for parameter clustering results.""" + +from .kgpipe_parameter_explorer import ParameterVisualizer + +__all__ = ["ParameterVisualizer"] + diff --git a/src/kgpipe_parameters/visualization/kgpipe_parameter_explorer.py b/src/kgpipe_parameters/visualization/kgpipe_parameter_explorer.py index a59cc26..f03e086 100644 --- a/src/kgpipe_parameters/visualization/kgpipe_parameter_explorer.py +++ b/src/kgpipe_parameters/visualization/kgpipe_parameter_explorer.py @@ -1 +1,257 @@ -# Explorer for the extracted parameters \ No newline at end of file +""" +Visualization of parameter clustering results. + +Produces static plots (PNG) summarising how extracted parameters group +across tools: + - cluster size distribution + - tool × cluster heatmap (cross-tool clusters) + - 2-D embedding scatter (PCA, coloured by tool) +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Optional + +import numpy as np +import matplotlib + +matplotlib.use("Agg") # non-interactive backend +import matplotlib.pyplot as plt +import seaborn as sns + +from ..clustering.models import ClusteringResult + +logger = logging.getLogger(__name__) + +# Consistent style +sns.set_theme(style="whitegrid", font_scale=0.9) + + +class ParameterVisualizer: + """Generate static visualizations from a ``ClusteringResult``.""" + + def __init__(self, result: ClusteringResult, output_dir: Path): + self.result = result + self.output_dir = Path(output_dir) + self.output_dir.mkdir(parents=True, exist_ok=True) + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def generate_all(self) -> list[Path]: + """Run every visualization and return list of saved file paths.""" + paths: list[Path] = [] + if not self.result.clusters: + logger.warning("No clusters to visualize") + return paths + + paths.append(self.plot_cluster_sizes()) + paths.append(self.plot_tool_cluster_heatmap()) + paths.append(self.plot_embedding_scatter()) + logger.info( + "Generated %d visualization(s) in %s", len(paths), self.output_dir + ) + return paths + + # ------------------------------------------------------------------ + # Individual plots + # ------------------------------------------------------------------ + + def plot_cluster_sizes( + self, filename: str = "_viz_cluster_sizes.png" + ) -> Path: + """Horizontal bar chart of cluster sizes (top-30).""" + clusters = sorted(self.result.clusters, key=lambda c: -c.size())[:30] + labels = [ + f"[{c.cluster_id}] {c.label}" + (" ★" if c.is_cross_tool() else "") + for c in clusters + ] + sizes = [c.size() for c in clusters] + colors = [ + "#4c72b0" if c.is_cross_tool() else "#c0c0c0" for c in clusters + ] + + fig, ax = plt.subplots(figsize=(8, max(4, len(labels) * 0.35))) + ax.barh(range(len(labels)), sizes, color=colors) + ax.set_yticks(range(len(labels))) + ax.set_yticklabels(labels) + ax.invert_yaxis() + ax.set_xlabel("Number of parameters") + ax.set_title( + f"Cluster sizes (top {len(clusters)} of {self.result.n_clusters})" + ) + # Legend for cross-tool marker + from matplotlib.patches import Patch + + ax.legend( + handles=[ + Patch(facecolor="#4c72b0", label="Cross-tool"), + Patch(facecolor="#c0c0c0", label="Single tool"), + ], + loc="lower right", + ) + fig.tight_layout() + path = self.output_dir / filename + fig.savefig(path, dpi=150) + plt.close(fig) + logger.info("Saved cluster size chart to %s", path) + return path + + def plot_tool_cluster_heatmap( + self, filename: str = "_viz_tool_heatmap.png" + ) -> Path: + """Heatmap of tools × clusters (cross-tool clusters only).""" + import pandas as pd + + cross = self.result.cross_tool_clusters() + if not cross: + # Fall back to top-20 clusters if no cross-tool clusters + cross = sorted(self.result.clusters, key=lambda c: -c.size())[:20] + + all_tools = sorted( + {m.tool_name for c in cross for m in c.members} + ) + cluster_labels = [f"[{c.cluster_id}] {c.label}" for c in cross] + + matrix = np.zeros((len(all_tools), len(cross)), dtype=int) + for j, c in enumerate(cross): + for m in c.members: + i = all_tools.index(m.tool_name) + matrix[i, j] += 1 + + df = pd.DataFrame(matrix, index=all_tools, columns=cluster_labels) + + fig, ax = plt.subplots( + figsize=(max(6, len(cross) * 0.6), max(3, len(all_tools) * 0.5)) + ) + sns.heatmap( + df, + annot=True, + fmt="d", + cmap="YlOrRd", + linewidths=0.5, + ax=ax, + ) + ax.set_title("Parameters per tool × cluster (cross-tool clusters)") + ax.set_ylabel("Tool") + ax.set_xlabel("Cluster") + plt.xticks(rotation=45, ha="right") + fig.tight_layout() + path = self.output_dir / filename + fig.savefig(path, dpi=150) + plt.close(fig) + logger.info("Saved tool×cluster heatmap to %s", path) + return path + + def plot_embedding_scatter( + self, filename: str = "_viz_embedding_scatter.png" + ) -> Path: + """2-D PCA scatter of parameter embeddings, coloured by tool.""" + from sklearn.decomposition import PCA + + # Collect all members across clusters + all_members = [m for c in self.result.clusters for m in c.members] + cluster_for_member = [ + c.cluster_id for c in self.result.clusters for m in c.members + ] + + # Check if embeddings are present; if not, recompute them + has_embeddings = any(m.embedding is not None for m in all_members) + if not has_embeddings and all_members: + logger.info("Embeddings not in clustering result — recomputing") + from ..clustering.similarity import embed_parameters + + embed_parameters(all_members) + + # Collect embeddings and metadata + embeddings = [] + tools = [] + names = [] + cluster_ids = [] + for cid, m in zip(cluster_for_member, all_members): + if m.embedding is not None: + embeddings.append(m.embedding) + tools.append(m.tool_name) + names.append(m.name) + cluster_ids.append(cid) + + if len(embeddings) < 3: + # Not enough points for meaningful 2-D projection + logger.warning( + "Too few embedded parameters (%d) for scatter plot", + len(embeddings), + ) + fig, ax = plt.subplots() + ax.text( + 0.5, + 0.5, + "Too few parameters for scatter plot", + ha="center", + va="center", + transform=ax.transAxes, + ) + path = self.output_dir / filename + fig.savefig(path, dpi=150) + plt.close(fig) + return path + + X = np.array(embeddings, dtype=np.float32) + pca = PCA(n_components=2, random_state=42) + X_2d = pca.fit_transform(X) + + unique_tools = sorted(set(tools)) + palette = sns.color_palette("husl", len(unique_tools)) + tool_to_color = dict(zip(unique_tools, palette)) + + fig, ax = plt.subplots(figsize=(10, 7)) + for tool in unique_tools: + mask = [t == tool for t in tools] + pts = X_2d[mask] + ax.scatter( + pts[:, 0], + pts[:, 1], + label=tool, + color=tool_to_color[tool], + alpha=0.65, + s=30, + edgecolors="white", + linewidth=0.3, + ) + + ax.set_xlabel(f"PC1 ({pca.explained_variance_ratio_[0]:.1%} var)") + ax.set_ylabel(f"PC2 ({pca.explained_variance_ratio_[1]:.1%} var)") + ax.set_title( + f"Parameter embeddings — {self.result.n_parameters} params, " + f"{self.result.n_clusters} clusters" + ) + ax.legend(title="Tool", bbox_to_anchor=(1.02, 1), loc="upper left") + fig.tight_layout() + path = self.output_dir / filename + fig.savefig(path, dpi=150, bbox_inches="tight") + plt.close(fig) + logger.info("Saved embedding scatter to %s", path) + return path + + # ------------------------------------------------------------------ + # Alternate constructor from JSON file + # ------------------------------------------------------------------ + + @classmethod + def from_clusters_json( + cls, json_path: Path, output_dir: Optional[Path] = None + ) -> "ParameterVisualizer": + """ + Create a visualizer from a ``_clusters.json`` file. + + If *output_dir* is not given, plots are saved next to the JSON file. + """ + import json + + with open(json_path) as f: + data = json.load(f) + + result = ClusteringResult.model_validate(data) + return cls(result, output_dir or json_path.parent) From fb52172442c4665955fd1e76ac4d8bf3e1deb4cc Mon Sep 17 00:00:00 2001 From: Marvin Hofer Date: Fri, 27 Feb 2026 10:31:39 +0100 Subject: [PATCH 08/96] feature syskg: improving syskg interfaces --- .../moviekg/src/moviekg/pipelines/helpers.py | 2 +- scripts/docker-virtuoso.sh | 10 ++ src/kgpipe/common/config.py | 8 +- src/kgpipe/common/definitions.py | 96 +++++++++++++++- src/kgpipe/common/model/default_catalog.py | 13 +++ src/kgpipe/common/model/pipeline.py | 43 ++++++- src/kgpipe/common/model/task.py | 14 +-- src/kgpipe/common/models.py | 4 +- src/kgpipe/common/systemgraph.py | 67 +++++++++-- src/kgpipe/generation/loaders.py | 8 +- src/kgpipe/meta/subgraphs.py | 11 ++ src/kgpipe/test/test_meta.py | 25 +++++ src/kgpipe/test/test_meta_kg_query.py | 106 ++++++++++++++++++ src/kgpipe/test/test_owl_to_mermaid.py | 51 +++++++++ 14 files changed, 430 insertions(+), 28 deletions(-) create mode 100644 scripts/docker-virtuoso.sh create mode 100644 src/kgpipe/common/model/default_catalog.py create mode 100644 src/kgpipe/meta/subgraphs.py create mode 100644 src/kgpipe/test/test_meta.py create mode 100644 src/kgpipe/test/test_meta_kg_query.py create mode 100644 src/kgpipe/test/test_owl_to_mermaid.py diff --git a/experiments/moviekg/src/moviekg/pipelines/helpers.py b/experiments/moviekg/src/moviekg/pipelines/helpers.py index fab8ee3..cfc4e92 100644 --- a/experiments/moviekg/src/moviekg/pipelines/helpers.py +++ b/experiments/moviekg/src/moviekg/pipelines/helpers.py @@ -70,7 +70,7 @@ def run_helper( tmp_dir = stage_dir / "tmp" tmp_dir.mkdir(parents=True, exist_ok=True) - pipeline = build_from_conf(pipeline_conf, target_data, tmp_dir.as_posix()) + pipeline = build_from_conf(pipeline_name, pipeline_conf, target_data, tmp_dir.as_posix()) stage_dir.mkdir(parents=True, exist_ok=True) diff --git a/scripts/docker-virtuoso.sh b/scripts/docker-virtuoso.sh new file mode 100644 index 0000000..6ce81a0 --- /dev/null +++ b/scripts/docker-virtuoso.sh @@ -0,0 +1,10 @@ +docker run \ + --name kgpipe_virtdb \ + --interactive \ + --tty \ + --env DBA_PASSWORD=mysecret \ + --publish 1111:1111 \ + --publish 8890:8890 \ + openlink/virtuoso-opensource-7:latest + +# --volume `pwd`:/database \ diff --git a/src/kgpipe/common/config.py b/src/kgpipe/common/config.py index c26bebe..19b2f93 100644 --- a/src/kgpipe/common/config.py +++ b/src/kgpipe/common/config.py @@ -6,9 +6,11 @@ class KgPipeConfig(KGConfig): """ The configuration for kgpipe. """ - SYS_KG_URL: str = "memory://" - SYS_KG_USR: str = "" - SYS_KG_PSW: str = "" + SYS_KG_URL: str = "sparql://localhost:8890/sparql-auth" #"memory://" + SYS_KG_USR: str = "dba" + SYS_KG_PSW: str = "mysecret" + ONTOLOGY_PREFIX: str = "http://github.com/ScaDS/kgpipe/ontology/" + PIPEKG_PREFIX: str = "http://github.com/ScaDS/kgpipe/resource/" SOURCE_NAMESPACE: str = "http://kg.org/rdf/" diff --git a/src/kgpipe/common/definitions.py b/src/kgpipe/common/definitions.py index e503971..2f1bdde 100644 --- a/src/kgpipe/common/definitions.py +++ b/src/kgpipe/common/definitions.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from sys import implementation from pydantic import BaseModel from typing import Optional, List, Dict, Any # from kgcore.api.kg import KnowledgeGraph, KGProperty @@ -107,4 +108,97 @@ class PipelineResult(BaseModel): input: List[DataHandle] output: List[DataHandle] status: str - duration: float \ No newline at end of file + duration: float + +# new changes # + +from kgcore.api.kg import KGId + + +TaskEntityId = KGId +class TaskEntity(BaseModel): + name: str + hasSubtask: List[TaskEntityId] + +MethodEntityId = KGId +class MethodEntity(BaseModel): + name: str + realizesTask: List[TaskEntityId] + +ToolEntityId = KGId +class ToolEntity(BaseModel): + name: str + # supportsTasks: List[Task] + providesMethods: List[MethodEntityId] + +ParameterId = KGId +class ParameterEntity(BaseModel): + name: str + value: Any + type: str + description: Optional[str] = None + default_value: Optional[Any] = None + required: bool = False + allowed_values: Optional[List[Any]] = None + +ParameterBindingId = KGId +class ParameterBindingEntity(BaseModel): + value: Any + parameter: ParameterId + +ImplementationEntityId = KGId +class ImplementationEntity(BaseModel): + name: str + implementsMethod: List[MethodEntityId] + hasParameter: List[ParameterId] + usesTool: List[ToolEntityId] + + # interface: str # TODO: Interface + # hasParameter: Parameter + +TaskRunEntityId = KGId +class TaskRunEntity(BaseModel): + number: int + name: str + status: str + started_at: float + ended_at: float + input: List[DataHandle] + output: List[DataHandle] + executesTask: TaskEntityId + usesImplementation: ImplementationEntityId + hasParameterBinding: List[ParameterBindingId] + +# class PipelineDefinitionEntity(BaseModel): +# """ +# The definition of a pipeline +# """ +# placeholder: str +# #definesPipeline: Pipeline + +class PipelineRunEntity(BaseModel): + """ + The result of a pipeline execution + """ + name: str + status: str + started_at: float + ended_at: float + hasTaskRun: List[TaskRunEntity] + # usesPipelineDefinition: PipelineDefinition + # runsPipeline: Pipeline + +class MetricEntity(BaseModel): + pass + +class EvaluationRunEntity(BaseModel): + """ + The result of an evaluation execution + """ + name: str + status: str + started_at: float + ended_at: float + input: List[DataHandle] + output: List[DataHandle] + evaluatesEvaluation: EvaluationEntityId \ No newline at end of file diff --git a/src/kgpipe/common/model/default_catalog.py b/src/kgpipe/common/model/default_catalog.py new file mode 100644 index 0000000..0fde757 --- /dev/null +++ b/src/kgpipe/common/model/default_catalog.py @@ -0,0 +1,13 @@ + + +# TODO impl later for typed api +class TaskCategory():pass + +class EntityResolution(TaskCategory): pass +class EntityMatching(EntityResolution): pass +class Fusion(EntityResolution): pass +class InformationExtraction(TaskCategory): pass +class EntityLinking(InformationExtraction): pass +class RelationExtraction(InformationExtraction): pass +class RelationLinking(InformationExtraction): pass +class DataMapping(TaskCategory): pass \ No newline at end of file diff --git a/src/kgpipe/common/model/pipeline.py b/src/kgpipe/common/model/pipeline.py index 377cfc2..1832b6a 100644 --- a/src/kgpipe/common/model/pipeline.py +++ b/src/kgpipe/common/model/pipeline.py @@ -11,6 +11,7 @@ from uuid import uuid4 import logging import shutil +from kgcore.api.kg import KGId from rdflib import Graph from pydantic import BaseModel, field_validator from pydantic_core import core_schema @@ -19,6 +20,7 @@ from .task import KgTask, KgTaskReport # from .kg import KG from kgpipe.common.annotations import kg_class +from kgpipe.common.systemgraph import PipeKG class KgPipePlanStep(BaseModel): @@ -84,6 +86,7 @@ class KgPipe: tasks: List[KgTask] seed: Data data_dir: str = "" + name: str = "Unknown" data: List[Data] = field(default_factory=list) plan: KgPipePlan = field(default_factory=lambda: KgPipePlan( steps=[], @@ -200,7 +203,7 @@ def run(self, stable_files_override: bool = False) -> List[KgTaskReport]: self.previous_was_skipped = True - reports = [] + reports: List[KgTaskReport] = [] for task_spec in self.plan.steps: # Find the corresponding task task = next((t for t in self.tasks if t.name == task_spec.task), None) @@ -223,7 +226,45 @@ def run(self, stable_files_override: bool = False) -> List[KgTaskReport]: self.previous_was_skipped = False reports.append(report) + + from kgpipe.common.definitions import PipelineRunEntity, TaskRunEntity, ImplementationEntity, TaskEntity, ImplementationEntityId, TaskEntityId + from kgcore.api.kg import KGId + from kgpipe.common.config import config + from kgpipe.common.definitions import DataHandle + # TODO this is a workaround for now, taskrun should be built from the task itself + def build_pipeline_run_entity(reports: List[KgTaskReport]) -> PipelineRunEntity: + + task_runs: List[TaskRunEntity] = [] + for idx, report in enumerate(reports): + + + # def get_implementation_entity(report: KgTaskReport) -> ImplementationEntityId: + # return PipeKG.find_implementation_by_name(report.task_name).id + + task_runs.append(TaskRunEntity( + number=idx, + name=report.task_name, + status=report.status, + started_at=report.start_ts, + ended_at=report.start_ts + report.duration, + executesTask=TaskEntityId(config.PIPEKG_PREFIX+report.task_name), + usesImplementation=ImplementationEntityId(config.PIPEKG_PREFIX+report.task_name+"Impl"), + input=[DataHandle(uri=str(input_data.path), type=input_data.format) for input_data in report.inputs], + output=[DataHandle(uri=str(output_data.path), type=output_data.format) for output_data in report.outputs] + )) + + return PipelineRunEntity( + name=self.name, + status="success", + started_at=time.time(), + ended_at=time.time(), + hasTaskRun=task_runs + ) + + pipeline_run_entity = build_pipeline_run_entity(reports) + PipeKG.add_pipeline_run(pipeline_run_entity) + return reports def __str__(self) -> str: diff --git a/src/kgpipe/common/model/task.py b/src/kgpipe/common/model/task.py index c4c1b58..347a67b 100644 --- a/src/kgpipe/common/model/task.py +++ b/src/kgpipe/common/model/task.py @@ -7,7 +7,7 @@ from pydantic import BaseModel import time import shutil - +from kgpipe.common.model.default_catalog import TaskCategory from .configuration import Parameter, ConfigurationProfile from kgpipe.common.annotations import kg_class @@ -35,13 +35,11 @@ class TaskStatus(Enum): FAILED = "failed" SKIPPED = "skipped" -# TODO impl later for typed api -class TaskCategory(): - pass -# TODO impl later for typed api -class TaskCatalog(): - pass + +# # TODO impl later for typed api +# class TaskCatalog(): +# pass @kg_class() @dataclass @@ -52,7 +50,7 @@ class KgTask: output_spec: Mapping[str, Format] function: Callable[[Dict[str, Data], Dict[str, Data]], None] description: Optional[str] = None - category: List[str] = field(default_factory=list) + category: List[TaskCategory] = field(default_factory=list) config: Optional[ConfigurationProfile] = None def __post_init__(self): diff --git a/src/kgpipe/common/models.py b/src/kgpipe/common/models.py index 99b0484..b758f28 100644 --- a/src/kgpipe/common/models.py +++ b/src/kgpipe/common/models.py @@ -10,13 +10,13 @@ from .model.data import Data, DataFormat, DynamicFormat, DataSet, FormatRegistry from .model.task import KgTask, KgTaskReport -from .model.pipeline import KgPipe, KgPipePlan, KgPipePlanStep +from .model.pipeline import KgPipe, KgPipePlan, KgPipePlanStep, KgStageReport from .model.evaluation import Metric, EvaluationReport from .model.kg import KG from .model.task import TaskInput, TaskOutput __all__ = [ - "Data", "DataFormat", "DynamicFormat", "DataSet", "FormatRegistry", "KgTask", "KgTaskReport", "KgPipe", "KgPipePlan", "KgPipePlanStep", "Metric", "EvaluationReport", "KG", "TaskInput", "TaskOutput" + "Data", "DataFormat", "DynamicFormat", "DataSet", "FormatRegistry", "KgTask", "KgTaskReport", "KgPipe", "KgPipePlan", "KgPipePlanStep", "KgStageReport", "Metric", "EvaluationReport", "KG", "TaskInput", "TaskOutput" ] # TODO remove this for next release diff --git a/src/kgpipe/common/systemgraph.py b/src/kgpipe/common/systemgraph.py index 526ec23..a9b6923 100644 --- a/src/kgpipe/common/systemgraph.py +++ b/src/kgpipe/common/systemgraph.py @@ -11,12 +11,12 @@ from kgcore.backend.rdf.rdf_sparql import RDFSparqlBackend, SparqlAuth from kgcore.model.rdf.rdf_base import RDFBaseModel -from kgpipe.common.definitions import Task, TaskResult, Pipeline, PipelineResult +from kgpipe.common.definitions import Task, TaskResult, Pipeline, PipelineResult, PipelineRunEntity, ImplementationEntity from kgpipe.common.config import load_config from kgpipe.common.util import encode_string if TYPE_CHECKING: - from kgpipe.common.models import KgTask + from kgpipe.common.models import KgTask, KgTaskReport config = load_config() @@ -43,36 +43,52 @@ class PipeKG: + # cached_implementations: Dict[str, KGEntity] = {} + @staticmethod def add_task(task: "KgTask"): from kgpipe.common.models import KgTask # Import here to avoid circular import - types = [encode_string(c) for c in task.category] + types = [config.ONTOLOGY_PREFIX+encode_string(c) for c in task.category] properties = [] properties.append(KGProperty(key="description", value=task.description)) - task_entity = SYS_KG.create_entity(id=task.name, types=types+["Task"], properties=properties) + task_entity = SYS_KG.create_entity(id=config.PIPEKG_PREFIX+task.name+"Impl", types=types+[config.ONTOLOGY_PREFIX+"Implementation"], properties=properties) for input_name, input_format in task.input_spec.items(): - input_entity = SYS_KG.create_entity(id=task.name+"_"+input_name, types=["Data"], properties={ + input_entity = SYS_KG.create_entity(id=config.PIPEKG_PREFIX+task.name+"Impl_"+input_name, types=[config.ONTOLOGY_PREFIX+"Data"], properties={ "format": input_format, }) SYS_KG.create_relation(type="input", source=task_entity.id, target=input_entity.id) for output_name, output_format in task.output_spec.items(): - output_entity = SYS_KG.create_entity(id=task.name+"_"+output_name, types=["Data"], properties={ + output_entity = SYS_KG.create_entity(id=config.PIPEKG_PREFIX+task.name+"Impl_"+output_name, types=[config.ONTOLOGY_PREFIX+"Data"], properties={ "format": output_format, }) SYS_KG.create_relation(type="output", source=task_entity.id, target=output_entity.id) def list_tasks(self) -> List["KgTask"]: - return SYS_KG.list_entities(types=["Task"]) + return SYS_KG.list_entities(types=[config.ONTOLOGY_PREFIX+"Implementation"]) @staticmethod def add_task_result(task_result: TaskResult): - SYS_KG.create_entity(id=new_id(),types=["TaskResult"], properties={ + SYS_KG.create_entity(id=new_id(),types=[config.ONTOLOGY_PREFIX+"TaskRun"], properties={ + "task": task_result.task, "config": task_result.config, "input": task_result.input, "output": task_result.output, + "status": task_result.status, + "duration": task_result.duration, }) + @staticmethod + def add_task_run(task_run: "KgTaskReport"): + SYS_KG.create_entity(id=new_id(),types=[config.ONTOLOGY_PREFIX+"TaskReport"], properties={ + "task": task_run.task_name, + "input": [data.path for data in task_run.inputs], + "output": [data.path for data in task_run.outputs], + "status": task_run.status, + "duration": task_run.duration, + "error": task_run.error, + }) + @staticmethod def add_pipeline(pipeline: Pipeline): SYS_KG.create_entity(id=new_id(),types=["Pipeline"], properties={ @@ -90,6 +106,41 @@ def add_pipeline_result(pipeline_result: PipelineResult): "output": pipeline_result.output, }) + # @staticmethod + # def find_implementation_by_name(name: str) -> KGEntity: + # return SYS_KG.read_entity(id=config.PIPEKG_PREFIX+name, types=[config.ONTOLOGY_PREFIX+"Implementation"])[0] + + @staticmethod + def add_implementation(implementation: ImplementationEntity): + SYS_KG.create_entity(id=new_id(),types=[config.ONTOLOGY_PREFIX+"Implementation"], properties={ + "name": implementation.name, + "usesTool": implementation.usesTool, + "implementsMethod": implementation.implementsMethod, + "interface": implementation.interface, + + }) + + @staticmethod + def add_pipeline_run(pipeline_run: PipelineRunEntity): + pipeline_run_entity = SYS_KG.create_entity(id=new_id(),types=[config.ONTOLOGY_PREFIX+"PipelineRun"], properties={ + "name": pipeline_run.name, + "status": pipeline_run.status, + "started_at": pipeline_run.started_at, + "ended_at": pipeline_run.ended_at + }) + for idx, task_run in enumerate(pipeline_run.hasTaskRun): + task_run_entity = SYS_KG.create_entity(id=new_id(),types=[config.ONTOLOGY_PREFIX+"TaskRun"], properties={ + "number": idx, + "name": task_run.name, + "status": task_run.status, + "started_at": task_run.started_at, + "ended_at": task_run.ended_at, + }) + SYS_KG.create_relation(type="executesTask", source=task_run_entity.id, target=task_run.executesTask) + SYS_KG.create_relation(type="usesImplementation", source=task_run_entity.id, target=task_run.usesImplementation) + SYS_KG.create_relation(type=config.ONTOLOGY_PREFIX+"hasTaskRun", source=pipeline_run_entity.id, target=task_run_entity.id) + + # return pipeline_run_entity # def Track(_cls=None, *, with_timestamp: bool = False): diff --git a/src/kgpipe/generation/loaders.py b/src/kgpipe/generation/loaders.py index 8888fa4..953f033 100644 --- a/src/kgpipe/generation/loaders.py +++ b/src/kgpipe/generation/loaders.py @@ -70,14 +70,14 @@ def get_test_data(file_name: str) -> Path: return Path(__file__).parent / "test_data" / file_name -def ssp_pipeline(tasks: list[KgTask], target_data: Data, data_dir: str) -> KgPipe: - pipe = KgPipe(tasks, target_data, data_dir) +def ssp_pipeline(name: str, tasks: list[KgTask], target_data: Data, data_dir: str) -> KgPipe: + pipe = KgPipe(tasks, target_data, data_dir, name=name) return pipe -def build_from_conf(conf: PipelineConf, target_data: Data, data_dir: str) -> KgPipe: +def build_from_conf(name: str, conf: PipelineConf, target_data: Data, data_dir: str) -> KgPipe: tasks = [Registry.get_task(task_name) for task_name in conf.tasks] - pipe = ssp_pipeline(tasks, target_data, data_dir) + pipe = ssp_pipeline(name, tasks, target_data, data_dir) return pipe def build_from_yaml(yaml_path: Path): diff --git a/src/kgpipe/meta/subgraphs.py b/src/kgpipe/meta/subgraphs.py new file mode 100644 index 0000000..be11541 --- /dev/null +++ b/src/kgpipe/meta/subgraphs.py @@ -0,0 +1,11 @@ + + +# see meta-kg.owl.ttl for the ontology + +# eval subgraph + +# task subgraph + +# pipeline subgraph + +# execution subgraph diff --git a/src/kgpipe/test/test_meta.py b/src/kgpipe/test/test_meta.py new file mode 100644 index 0000000..ee535b8 --- /dev/null +++ b/src/kgpipe/test/test_meta.py @@ -0,0 +1,25 @@ +from kgcore.api import KG +from kgcore.decorators.event import event + +def test_meta(): + kg = KG(backend='memory', name='test') + kg.create_entity(["Task"], props={"name": "test", "description": "test"}) + + + @event("Task", "create") + def task_created(e): + print(f"Task created: {e.id}") + + @event("Task", "update") + def task_updated(e): + print(f"Task updated: {e.id}") + + @event("Task", "delete") + def task_deleted(e): + print(f"Task deleted: {e.id}") + + task_created("e") + + es = kg.find_entities() + for e in es: + print(e) \ No newline at end of file diff --git a/src/kgpipe/test/test_meta_kg_query.py b/src/kgpipe/test/test_meta_kg_query.py new file mode 100644 index 0000000..5f5a0d4 --- /dev/null +++ b/src/kgpipe/test/test_meta_kg_query.py @@ -0,0 +1,106 @@ +import importlib.util +from pathlib import Path + + +def _load_query_module(): + module_path = Path(__file__).resolve().parents[2] / "kgpipe_view" / "meta_kg_query.py" + spec = importlib.util.spec_from_file_location("meta_kg_query", module_path) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(module) + return module + + +def test_query_tasks_implementations_maps_primary_results(monkeypatch): + module = _load_query_module() + + calls = [] + + def _fake_run_select(_endpoint_url, query): + calls.append(query) + return [ + { + "task": {"value": "http://example.org/kgp#TaskA"}, + "method": {"value": "http://example.org/kgp#MethodA"}, + "implementation": {"value": "http://example.org/kgp#ImplA"}, + "tool": {"value": "http://example.org/kgp#ToolA"}, + "runtime": {"value": "python"}, + "implementationVersion": {"value": "1.0.0"}, + "commandTemplate": {"value": "python run.py"}, + } + ] + + monkeypatch.setattr(module, "_run_select", _fake_run_select) + + frame = module.query_tasks_implementations("http://localhost:8890/sparql") + + assert len(calls) == 1 + assert frame.shape == (1, 7) + assert frame.loc[0, "task"] == "http://example.org/kgp#TaskA" + assert frame.loc[0, "implementation_version"] == "1.0.0" + + +def test_query_tasks_implementations_falls_back_when_primary_is_empty(monkeypatch): + module = _load_query_module() + + calls = [] + + def _fake_run_select(_endpoint_url, query): + calls.append(query) + if len(calls) == 1: + return [] + return [{"implementation": {"value": "http://example.org/kgp#ImplB"}}] + + monkeypatch.setattr(module, "_run_select", _fake_run_select) + + frame = module.query_tasks_implementations("http://localhost:8890/sparql") + + assert len(calls) == 2 + assert frame.shape == (1, 7) + assert frame.loc[0, "implementation"] == "http://example.org/kgp#ImplB" + assert frame.loc[0, "task"] == "" + + +def test_query_task_hierarchy_maps_primary_results(monkeypatch): + module = _load_query_module() + + calls = [] + + def _fake_run_select(_endpoint_url, query): + calls.append(query) + return [ + { + "task": {"value": "http://example.org/kgp#NormalizeTask"}, + "parentTask": {"value": "http://example.org/kgp#TransformTask"}, + } + ] + + monkeypatch.setattr(module, "_run_select", _fake_run_select) + + frame = module.query_task_hierarchy("http://localhost:8890/sparql") + + assert len(calls) == 1 + assert frame.shape == (1, 2) + assert frame.loc[0, "task"] == "http://example.org/kgp#NormalizeTask" + assert frame.loc[0, "parent_task"] == "http://example.org/kgp#TransformTask" + + +def test_query_task_hierarchy_falls_back_when_primary_is_empty(monkeypatch): + module = _load_query_module() + + calls = [] + + def _fake_run_select(_endpoint_url, query): + calls.append(query) + if len(calls) == 1: + return [] + return [{"task": {"value": "http://example.org/kgp#TrainTask"}}] + + monkeypatch.setattr(module, "_run_select", _fake_run_select) + + frame = module.query_task_hierarchy("http://localhost:8890/sparql") + + assert len(calls) == 2 + assert frame.shape == (1, 2) + assert frame.loc[0, "task"] == "http://example.org/kgp#TrainTask" + assert frame.loc[0, "parent_task"] == "" diff --git a/src/kgpipe/test/test_owl_to_mermaid.py b/src/kgpipe/test/test_owl_to_mermaid.py new file mode 100644 index 0000000..88ff02e --- /dev/null +++ b/src/kgpipe/test/test_owl_to_mermaid.py @@ -0,0 +1,51 @@ +import importlib.util +from pathlib import Path + + +def _load_converter_module(): + module_path = ( + Path(__file__).resolve().parents[2] / "kgpipe_view" / "owl_to_mermaid.py" + ) + spec = importlib.util.spec_from_file_location("owl_to_mermaid", module_path) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(module) + return module + + +def test_get_available_layers_includes_core_layer(): + module = _load_converter_module() + ttl_path = Path(__file__).resolve().parents[2] / "kgpipe_view" / "kgpipe.owl.ttl" + + layers = module.get_available_layers(ttl_path) + + assert "CoreLayer" in layers + assert "PipelineLayer" in layers + + +def test_convert_owl_ttl_to_mermaid_filters_by_layer(): + module = _load_converter_module() + ttl_path = Path(__file__).resolve().parents[2] / "kgpipe_view" / "kgpipe.owl.ttl" + + mermaid = module.convert_owl_ttl_to_mermaid(ttl_path, layer_filter="CoreLayer") + + assert "class Task" in mermaid + assert "class Method" in mermaid + assert "class Pipeline" not in mermaid + assert "class PipelineStep" not in mermaid + assert 'Method "0..*" --> "0..*" Task : realizesTask' in mermaid + assert 'Pipeline "0..*" --> "0..*" PipelineStep : hasStep' not in mermaid + + +def test_convert_owl_ttl_to_mermaid_filters_by_multiple_layers(): + module = _load_converter_module() + ttl_path = Path(__file__).resolve().parents[2] / "kgpipe_view" / "kgpipe.owl.ttl" + + mermaid = module.convert_owl_ttl_to_mermaid( + ttl_path, layer_filter=["CoreLayer", "PipelineLayer"] + ) + + assert "class Task" in mermaid + assert "class Pipeline" in mermaid + assert 'Pipeline "0..*" --> "0..*" PipelineStep : hasStep' in mermaid + assert "class Artifact" not in mermaid From 507dbffc253a34a210556e7fe6aeaf7032e65167 Mon Sep 17 00:00:00 2001 From: Marvin Hofer Date: Fri, 27 Feb 2026 10:33:54 +0100 Subject: [PATCH 09/96] feat: owl-view: show owl.ttl in view as mermaid --- src/kgpipe_view/kgpipe.owl.ttl | 249 ++++++++++++++++++++++++++++++ src/kgpipe_view/kgpipe_view.py | 223 ++++++++++++++++---------- src/kgpipe_view/meta_kg_query.py | 245 +++++++++++++++++++++++++++++ src/kgpipe_view/owl_to_mermaid.py | 138 +++++++++++++++++ 4 files changed, 771 insertions(+), 84 deletions(-) create mode 100644 src/kgpipe_view/kgpipe.owl.ttl create mode 100644 src/kgpipe_view/meta_kg_query.py create mode 100644 src/kgpipe_view/owl_to_mermaid.py diff --git a/src/kgpipe_view/kgpipe.owl.ttl b/src/kgpipe_view/kgpipe.owl.ttl new file mode 100644 index 0000000..aea0dbb --- /dev/null +++ b/src/kgpipe_view/kgpipe.owl.ttl @@ -0,0 +1,249 @@ +@prefix : . +@prefix rdf: . +@prefix rdfs: . +@prefix owl: . +@prefix xsd: . + +:kgp a owl:Ontology . + +################################################################# +# Classes +################################################################# + +:Task a owl:Class, :CoreLayer . +:Method a owl:Class, :CoreLayer . +:Tool a owl:Class, :CoreLayer . +#:FrameworkTool a owl:Class ; rdfs:subClassOf :Tool . + +:Implementation a owl:Class, :CoreLayer . + +#:Interface a owl:Class, :CoreLayer . +#:CLIInterface a owl:Class ; rdfs:subClassOf :Interface . +#:RESTInterface a owl:Class ; rdfs:subClassOf :Interface . +#:LibraryAPIInterface a owl:Class ; rdfs:subClassOf :Interface . + +:Pipeline a owl:Class, :PipelineLayer . +:PipelineStep a owl:Class, :PipelineLayer . +:PipelineDefinition a owl:Class, :PipelineLayer . + +:TaskRun a owl:Class, :RunLayer . +:PipelineRun a owl:Class, :RunLayer . + +:Artifact a owl:Class, :DataLayer . +:ArtifactType a owl:Class, :DataLayer . +:Schema a owl:Class, :DataLayer . + +:Parameter a owl:Class, :ParameterLayer . +:ParameterBinding a owl:Class, :ParameterLayer . + +################################################################# +# Object Properties +################################################################# + +### Task decomposition +:hasSubtask a owl:ObjectProperty ; + rdfs:domain :Task ; + rdfs:range :Task . + +### Semantics: method / tool / implementation +:realizesTask a owl:ObjectProperty ; + rdfs:domain :Method ; + rdfs:range :Task . + +:providesMethod a owl:ObjectProperty ; + rdfs:domain :Tool ; + rdfs:range :Method . + +:supportsTask a owl:ObjectProperty ; + rdfs:domain :Tool ; + rdfs:range :Task . + +:usesTool a owl:ObjectProperty ; + rdfs:domain :Implementation ; + rdfs:range :Tool . + +:implementsMethod a owl:ObjectProperty ; + rdfs:domain :Implementation ; + rdfs:range :Method . + +:hasInterface a owl:ObjectProperty ; + rdfs:domain :Implementation ; + rdfs:range :Interface . + +### Pipeline structure +:hasStep a owl:ObjectProperty ; + rdfs:domain :Pipeline ; + rdfs:range :PipelineStep . + +:stepTask a owl:ObjectProperty ; + rdfs:domain :PipelineStep ; + rdfs:range :Task . + +:stepMethod a owl:ObjectProperty ; + rdfs:domain :PipelineStep ; + rdfs:range :Method . + +:nextStep a owl:ObjectProperty ; + rdfs:domain :PipelineStep ; + rdfs:range :PipelineStep . + +:definesPipeline a owl:ObjectProperty ; + rdfs:domain :PipelineDefinition ; + rdfs:range :Pipeline . + +:definedInTool a owl:ObjectProperty ; + rdfs:domain :PipelineDefinition ; + rdfs:range :Tool . + +:hasSourceArtifact a owl:ObjectProperty ; + rdfs:domain :PipelineDefinition ; + rdfs:range :Artifact . + +### Execution / runs +:executesTask a owl:ObjectProperty ; + rdfs:domain :TaskRun ; + rdfs:range :Task . + +:usesImplementation a owl:ObjectProperty ; + rdfs:domain :TaskRun ; + rdfs:range :Implementation . + +:runsPipeline a owl:ObjectProperty ; + rdfs:domain :PipelineRun ; + rdfs:range :Pipeline . + +:usesPipelineDefinition a owl:ObjectProperty ; + rdfs:domain :PipelineRun ; + rdfs:range :PipelineDefinition . + +:hasTaskRun a owl:ObjectProperty ; + rdfs:domain :PipelineRun ; + rdfs:range :TaskRun . + +### Data flow (runtime) +:hasInputArtifact a owl:ObjectProperty ; + rdfs:domain :TaskRun ; + rdfs:range :Artifact . + +:hasOutputArtifact a owl:ObjectProperty ; + rdfs:domain :TaskRun ; + rdfs:range :Artifact . + +### Data flow typing (design-time) +:expectsInputType a owl:ObjectProperty ; + rdfs:domain :Implementation ; + rdfs:range :ArtifactType . + +:producesOutputType a owl:ObjectProperty ; + rdfs:domain :Implementation ; + rdfs:range :ArtifactType . + +### Artifact typing / schema +:hasArtifactType a owl:ObjectProperty ; + rdfs:domain :Artifact ; + rdfs:range :ArtifactType . + +:conformsToSchema a owl:ObjectProperty ; + rdfs:domain :Artifact ; + rdfs:range :Schema . + +### Parameters +:hasParameter a owl:ObjectProperty ; + rdfs:domain :Implementation ; + rdfs:range :Parameter . + +:hasParameterBinding a owl:ObjectProperty ; + rdfs:domain :TaskRun ; + rdfs:range :ParameterBinding . + +:bindsParameter a owl:ObjectProperty ; + rdfs:domain :ParameterBinding ; + rdfs:range :Parameter . + +################################################################# +# Datatype Properties +################################################################# + +### Implementation +:commandTemplate a owl:DatatypeProperty ; + rdfs:domain :Implementation ; + rdfs:range xsd:string . + +:runtime a owl:DatatypeProperty ; + rdfs:domain :Implementation ; + rdfs:range xsd:string . + +:implementationVersion a owl:DatatypeProperty ; + rdfs:domain :Implementation ; + rdfs:range xsd:string . + +### Tool +:toolVersion a owl:DatatypeProperty ; + rdfs:domain :Tool ; + rdfs:range xsd:string . + +### Parameter +:paramName a owl:DatatypeProperty ; + rdfs:domain :Parameter ; + rdfs:range xsd:string . + +:paramDescription a owl:DatatypeProperty ; + rdfs:domain :Parameter ; + rdfs:range xsd:string . + +:paramDataType a owl:DatatypeProperty ; + rdfs:domain :Parameter ; + rdfs:range xsd:string . + +:defaultValue a owl:DatatypeProperty ; + rdfs:domain :Parameter ; + rdfs:range xsd:string . + +### ParameterBinding +:value a owl:DatatypeProperty ; + rdfs:domain :ParameterBinding ; + rdfs:range xsd:string . + +### TaskRun +:startedAt a owl:DatatypeProperty ; + rdfs:domain :TaskRun ; + rdfs:range xsd:dateTime . + +:endedAt a owl:DatatypeProperty ; + rdfs:domain :TaskRun ; + rdfs:range xsd:dateTime . + +:status a owl:DatatypeProperty ; + rdfs:domain :TaskRun ; + rdfs:range xsd:string . + +:exitCode a owl:DatatypeProperty ; + rdfs:domain :TaskRun ; + rdfs:range xsd:integer . + +:logPath a owl:DatatypeProperty ; + rdfs:domain :TaskRun ; + rdfs:range xsd:string . + +### PipelineRun +:pipelineStartedAt a owl:DatatypeProperty ; + rdfs:domain :PipelineRun ; + rdfs:range xsd:dateTime . + +:pipelineEndedAt a owl:DatatypeProperty ; + rdfs:domain :PipelineRun ; + rdfs:range xsd:dateTime . + +:pipelineStatus a owl:DatatypeProperty ; + rdfs:domain :PipelineRun ; + rdfs:range xsd:string . + +### Artifact +:location a owl:DatatypeProperty ; + rdfs:domain :Artifact ; + rdfs:range xsd:anyURI . + +### ArtifactType +:format a owl:DatatypeProperty ; + rdfs:domain :ArtifactType ; + rdfs:range xsd:string . \ No newline at end of file diff --git a/src/kgpipe_view/kgpipe_view.py b/src/kgpipe_view/kgpipe_view.py index 7a990c3..51e6a7c 100644 --- a/src/kgpipe_view/kgpipe_view.py +++ b/src/kgpipe_view/kgpipe_view.py @@ -1,85 +1,140 @@ -from turtle import back -from streamlit import table, title, text_input, button, write +from __future__ import annotations + +import json +from pathlib import Path + import streamlit as st -from kgpipe.common.registry import Registry -import kgpipe_tasks.tasks -import sqlite3 -import graphviz - -title("KGpipe View") -st.set_page_config(layout="wide") - -from streamlit_cytoscapejs import st_cytoscapejs - -elements = [ - {"data": {"id": "one", "label": "Node 1"}, "position": {"x": 0, "y": 0}}, - {"data": {"id": "two", "label": "Node 2"}, "position": {"x": 100, "y": 0}}, - {"data": {"source": "one", "target": "two", "label": "Edge from Node1 to Node2"}}, -] -stylesheet = [ - {"selector": "node", "style": {"width": 20, "height": 20, "shape": "rectangle"}}, - {"selector": "edge", "style": {"width": 10}}, -] - -clicked_elements = st_cytoscapejs(elements, stylesheet, width=1000, height=1000) - -if clicked_elements is not None: - st.write(clicked_elements) - -# # wide streamlit view -# wide_view = True - -# from kgpipe.common.systemgraph import backend - -# def sparql(query: str): -# qr = backend.query_sparql(query) -# bindings = qr["results"]["bindings"] -# results = [] -# for binding in bindings: -# keys = binding.keys() -# row = {} -# for key in keys: -# row[key] = binding[key]["value"] -# results.append(row) -# return results - -# # create sqlite3 database -# conn = sqlite3.connect("kgpipe_view.db") -# cursor = conn.cursor() -# cursor.execute("CREATE TABLE IF NOT EXISTS queries (id INTEGER PRIMARY KEY AUTOINCREMENT, query TEXT)") -# conn.commit() - -# def save_query(query: str): -# cursor.execute("INSERT INTO queries (query) VALUES (?)", (query,)) -# conn.commit() - -# def get_queries(): -# cursor.execute("SELECT * FROM queries") -# return cursor.fetchall() - -# queries = get_queries() -# # drop down menu for queries -# query_dropdown = st.selectbox("Queries", [q[1] for q in queries]) - -# # query field -# query = text_input("SELECT * { ?s ?p ?o . } LIMIT 10", value=query_dropdown) -# if button("Execute"): -# query_result = sparql(query) -# table(query_result) - -# # save query button -# if button("Save Query"): -# save_query(query) -# queries = get_queries() -# query_dropdown = st.selectbox("Queries", [q[1] for q in queries]) - - -# def graph_visualization(query_result: list): -# graph = graphviz.Digraph() -# for row in query_result: -# graph.edge(row["s"], row["o"]) -# return graph - -# # graph visualization -# graph = graph_visualization(query_result) -# st.graphviz_chart(graph) \ No newline at end of file +import streamlit.components.v1 as components + +from meta_kg_query import query_task_hierarchy, query_tasks_implementations, query_pipeline_hierarchy, query_evaluation_hierarchy, query_kg_data +from owl_to_mermaid import convert_and_write_mermaid, get_available_layers + + +def _render_mermaid(mermaid_text: str, height: int = 900) -> None: + """Render Mermaid source in Streamlit using Mermaid JS.""" + mermaid_json = json.dumps(mermaid_text) + html = f""" + +
+
+
+ + """ + components.html(html, height=height, scrolling=True) + + +st.set_page_config(page_title="KGpipe View", layout="wide") +st.title("KGpipe View") +st.caption("Explore the KGpipe meta knowledge graph rendered from Owl/Turtle.") + +base_dir = Path(__file__).resolve().parent +ttl_path = base_dir / "kgpipe.owl.ttl" +mermaid_path = base_dir / "kgpipe.owl.mmd" + +diagram_tab, tasks_tab, pipelines_tab, evaluations_tab = st.tabs(["Ontology Diagram", "Tasks", "Pipelines", "Evaluations"]) + +with diagram_tab: + try: + layer_options = get_available_layers(ttl_path) + selected_layers = st.multiselect( + "Layers", + options=layer_options, + default=layer_options, + key="layer-filter", + ) + mermaid_code = convert_and_write_mermaid( + ttl_path=ttl_path, + output_path=mermaid_path, + layer_filter=selected_layers, + ) + except Exception as exc: # pragma: no cover - UI fallback path + st.error(f"Failed to convert `{ttl_path.name}` to Mermaid: {exc}") + else: + st.success( + f"Generated Mermaid from `{ttl_path.name}` and saved `{mermaid_path.name}`." + ) + if selected_layers: + st.caption(f"Current layer filter: `{', '.join(selected_layers)}`") + else: + st.caption("Current layer filter: `none`") + _render_mermaid(mermaid_code) + with st.expander("Show Mermaid source"): + st.code(mermaid_code, language="mermaid") + +with tasks_tab: + endpoint_url = st.text_input( + "Meta KG SPARQL endpoint", + value="http://localhost:8890/sparql", + help="SPARQL endpoint for the live meta knowledge graph.", + ) + if st.button("Load task implementations", type="primary"): + try: + task_implementation_df = query_tasks_implementations(endpoint_url) + except Exception as exc: # pragma: no cover - network dependent path + st.error(f"Could not query `{endpoint_url}`: {exc}") + else: + if task_implementation_df.empty: + st.info("No task-implementation mappings returned by the endpoint.") + else: + st.dataframe(task_implementation_df, use_container_width=True) + + st.divider() + st.subheader("Task hierarchy") + st.caption("Shows subclass relations under `kgp:Task`, including standalone task nodes.") + + if st.button("Load task hierarchy"): + try: + task_hierarchy_df = query_task_hierarchy(endpoint_url) + except Exception as exc: # pragma: no cover - network dependent path + st.error(f"Could not query `{endpoint_url}`: {exc}") + else: + if task_hierarchy_df.empty: + st.info("No `kgp:Task` subclass hierarchy returned by the endpoint.") + else: + st.dataframe(task_hierarchy_df, use_container_width=True) + +with pipelines_tab: + st.subheader("Pipelines") + st.caption("Shows pipeline relations under `kgp:Pipeline`, including standalone pipeline nodes.") + + if st.button("Load pipeline hierarchy"): + try: + pipeline_hierarchy_df = query_pipeline_hierarchy(endpoint_url) + except Exception as exc: # pragma: no cover - network dependent path + st.error(f"Could not query `{endpoint_url}`: {exc}") + else: + if pipeline_hierarchy_df.empty: + st.info("No `kgp:Pipeline` subclass hierarchy returned by the endpoint.") + else: + st.dataframe(pipeline_hierarchy_df, use_container_width=True) + +with evaluations_tab: + st.subheader("Evaluations") + st.caption("Shows evaluation relations under `kgp:Evaluation`, including standalone evaluation nodes.") + + if st.button("Load evaluation hierarchy"): + try: + evaluation_hierarchy_df = query_kg_data(endpoint_url) #query_evaluation_hierarchy(endpoint_url) + except Exception as exc: # pragma: no cover - network dependent path + st.error(f"Could not query `{endpoint_url}`: {exc}") + else: + if evaluation_hierarchy_df.empty: + st.info("No `kgp:Evaluation` subclass hierarchy returned by the endpoint.") + else: + st.dataframe(evaluation_hierarchy_df, use_container_width=True) \ No newline at end of file diff --git a/src/kgpipe_view/meta_kg_query.py b/src/kgpipe_view/meta_kg_query.py new file mode 100644 index 0000000..5402fe3 --- /dev/null +++ b/src/kgpipe_view/meta_kg_query.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +from typing import Any + +import pandas as pd + + +PRIMARY_QUERY = """ +PREFIX kgp: + +SELECT ?task ?method ?implementation ?tool ?runtime ?implementationVersion ?commandTemplate +WHERE { + ?implementation a kgp:Implementation . + OPTIONAL { ?implementation kgp:implementsMethod ?method . } + OPTIONAL { ?implementation kgp:usesTool ?tool . } + OPTIONAL { ?implementation kgp:runtime ?runtime . } + OPTIONAL { ?implementation kgp:implementationVersion ?implementationVersion . } + OPTIONAL { ?implementation kgp:commandTemplate ?commandTemplate . } + OPTIONAL { ?method kgp:realizesTask ?task . } +} +ORDER BY ?task ?implementation +""" + + +TASK_HIERARCHY_PRIMARY_QUERY = """ +PREFIX kgp: +PREFIX rdfs: +PREFIX owl: + +SELECT DISTINCT ?task ?parentTask +WHERE { + { + ?task a kgp:Task . + } + UNION + { + ?task a owl:Class . + ?task rdfs:subClassOf+ kgp:Task . + FILTER(?task != kgp:Task) + } + UNION + { + ?method kgp:realizesTask ?task . + } + FILTER(isIRI(?task)) + OPTIONAL { + ?task rdfs:subClassOf ?parentTask . + ?parentTask rdfs:subClassOf* kgp:Task . + FILTER(?parentTask != owl:Thing) + } +} +ORDER BY ?task ?parentTask +""" + + +TASK_HIERARCHY_FALLBACK_QUERY = """ +PREFIX rdfs: + +SELECT DISTINCT ?task ?parentTask +WHERE { + { + ?task a ?taskType . + FILTER(STRENDS(STR(?taskType), "Task")) + } + UNION + { + ?task a ?classType . + FILTER(STRENDS(STR(?classType), "Class")) + ?task rdfs:subClassOf+ ?taskRoot . + FILTER(STRENDS(STR(?taskRoot), "Task")) + FILTER(?task != ?taskRoot) + } + UNION + { + ?method ?realizesTaskPredicate ?task . + FILTER(STRENDS(STR(?realizesTaskPredicate), "realizesTask")) + } + FILTER(isIRI(?task)) + OPTIONAL { + ?task rdfs:subClassOf ?parentTask . + FILTER(STRENDS(STR(?parentTask), "Task")) + } +} +ORDER BY ?task ?parentTask +""" + + +FALLBACK_QUERY = """ +SELECT ?task ?method ?implementation ?tool ?runtime ?implementationVersion ?commandTemplate +WHERE { + ?implementation a ?implementationType . + FILTER(STRENDS(STR(?implementationType), "Implementation")) + + OPTIONAL { + ?implementation ?implementsMethodPredicate ?method . + FILTER(STRENDS(STR(?implementsMethodPredicate), "implementsMethod")) + } + OPTIONAL { + ?method ?realizesTaskPredicate ?task . + FILTER(STRENDS(STR(?realizesTaskPredicate), "realizesTask")) + } + OPTIONAL { + ?implementation ?usesToolPredicate ?tool . + FILTER(STRENDS(STR(?usesToolPredicate), "usesTool")) + } + OPTIONAL { + ?implementation ?runtimePredicate ?runtime . + FILTER(STRENDS(STR(?runtimePredicate), "runtime")) + } + OPTIONAL { + ?implementation ?implementationVersionPredicate ?implementationVersion . + FILTER(STRENDS(STR(?implementationVersionPredicate), "implementationVersion")) + } + OPTIONAL { + ?implementation ?commandTemplatePredicate ?commandTemplate . + FILTER(STRENDS(STR(?commandTemplatePredicate), "commandTemplate")) + } +} +ORDER BY ?task ?implementation +""" + +PIPELINE_RUN_QUERY = """ +PREFIX kgp: + +SELECT DISTINCT ?pipelineRun +WHERE { + ?pipelineRun a kgp:PipelineRun . +} +""" + +KG_DATA_QUERY = """ +PREFIX kgp: + +SELECT DISTINCT ?kgData +WHERE { + VALUES ?format { + ".nt" + ".ttl" + ".rdf" + ".jsonld" + } + ?kgData a kgp:Data . + ?kgData ?format . +} +""" + + +def _run_select(endpoint_url: str, query: str) -> list[dict[str, Any]]: + from SPARQLWrapper import JSON, SPARQLWrapper + + client = SPARQLWrapper(endpoint_url) + client.setQuery(query) + client.setReturnFormat(JSON) + result = client.query().convert() + return result.get("results", {}).get("bindings", []) + + +def _cell(binding: dict[str, Any], key: str) -> str: + item = binding.get(key) + if not item: + return "" + return str(item.get("value", "")) + + +def _to_task_rows(bindings: list[dict[str, Any]]) -> list[dict[str, str]]: + rows: list[dict[str, str]] = [] + for binding in bindings: + rows.append( + { + "task": _cell(binding, "task"), + "method": _cell(binding, "method"), + "implementation": _cell(binding, "implementation"), + "tool": _cell(binding, "tool"), + "runtime": _cell(binding, "runtime"), + "implementation_version": _cell(binding, "implementationVersion"), + "command_template": _cell(binding, "commandTemplate"), + } + ) + return rows + + +def _to_task_hierarchy_rows(bindings: list[dict[str, Any]]) -> list[dict[str, str]]: + rows: list[dict[str, str]] = [] + for binding in bindings: + rows.append( + { + "task": _cell(binding, "task"), + "parent_task": _cell(binding, "parentTask"), + } + ) + return rows + +def _to_pipeline_run_rows(bindings: list[dict[str, Any]]) -> list[dict[str, str]]: + rows: list[dict[str, str]] = [] + for binding in bindings: + rows.append( + { + "pipeline_run": _cell(binding, "pipelineRun"), + } + ) + print(rows) + return rows + +def _to_kg_data_rows(bindings: list[dict[str, Any]]) -> list[dict[str, str]]: + rows: list[dict[str, str]] = [] + for binding in bindings: + rows.append( + { + "kg_data": _cell(binding, "kgData"), + } + ) + return rows + + +def query_tasks_implementations(endpoint_url: str) -> pd.DataFrame: + bindings = _run_select(endpoint_url, PRIMARY_QUERY) + if not bindings: + bindings = _run_select(endpoint_url, FALLBACK_QUERY) + rows = _to_task_rows(bindings) + return pd.DataFrame(rows) + + +def query_task_hierarchy(endpoint_url: str) -> pd.DataFrame: + bindings = _run_select(endpoint_url, TASK_HIERARCHY_PRIMARY_QUERY) + if not bindings: + bindings = _run_select(endpoint_url, TASK_HIERARCHY_FALLBACK_QUERY) + rows = _to_task_hierarchy_rows(bindings) + return pd.DataFrame(rows) + +def query_pipeline_hierarchy(endpoint_url: str) -> pd.DataFrame: + bindings = _run_select(endpoint_url, PIPELINE_RUN_QUERY) + rows = _to_pipeline_run_rows(bindings) + return pd.DataFrame(rows) + +def query_evaluation_hierarchy(endpoint_url: str) -> pd.DataFrame: + # TODO: Implement evaluation hierarchy query + # bindings = _run_select(endpoint_url, EVALUATION_HIERARCHY_QUERY) + # rows = _to_evaluation_hierarchy_rows(bindings) + # return pd.DataFrame(rows) + return pd.DataFrame([]) + +def query_kg_data(endpoint_url: str) -> pd.DataFrame: + bindings = _run_select(endpoint_url, KG_DATA_QUERY) + rows = _to_kg_data_rows(bindings) + return pd.DataFrame(rows) \ No newline at end of file diff --git a/src/kgpipe_view/owl_to_mermaid.py b/src/kgpipe_view/owl_to_mermaid.py new file mode 100644 index 0000000..af9f10b --- /dev/null +++ b/src/kgpipe_view/owl_to_mermaid.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +from collections import defaultdict +from pathlib import Path +from typing import Iterable, Optional + +from rdflib import Graph +from rdflib.namespace import OWL, RDF, RDFS + + +def _local_name(uri: object) -> str: + text = str(uri) + if "#" in text: + return text.rsplit("#", maxsplit=1)[-1] + if "/" in text: + return text.rsplit("/", maxsplit=1)[-1] + return text + + +def _load_graph(ttl_path: Path) -> Graph: + graph = Graph() + graph.parse(ttl_path, format="turtle") + return graph + + +def get_available_layers(ttl_path: Path) -> list[str]: + graph = _load_graph(ttl_path) + layer_names: set[str] = set() + for class_node in graph.subjects(RDF.type, OWL.Class): + for class_type in graph.objects(class_node, RDF.type): + if class_type != OWL.Class: + layer_names.add(_local_name(class_type)) + return sorted(layer_names) + + +def _normalize_layer_filter(layer_filter: Optional[str | Iterable[str]]) -> Optional[set[str]]: + if layer_filter is None: + return None + if isinstance(layer_filter, str): + return {layer_filter} + normalized = {layer for layer in layer_filter if layer} + return normalized or None + + +def _filtered_class_names( + graph: Graph, layer_filter: Optional[str | Iterable[str]] +) -> set[str]: + all_classes = {_local_name(node) for node in graph.subjects(RDF.type, OWL.Class)} + selected_layers = _normalize_layer_filter(layer_filter) + if not selected_layers: + return all_classes + + selected: set[str] = set() + for class_node in graph.subjects(RDF.type, OWL.Class): + class_types = {_local_name(node) for node in graph.objects(class_node, RDF.type)} + if class_types.intersection(selected_layers): + selected.add(_local_name(class_node)) + return selected + + +def convert_owl_ttl_to_mermaid( + ttl_path: Path, layer_filter: Optional[str | Iterable[str]] = None +) -> str: + graph = _load_graph(ttl_path) + selected_classes = _filtered_class_names(graph, layer_filter) + + classes = sorted(selected_classes) + object_property_nodes = sorted( + set(graph.subjects(RDF.type, OWL.ObjectProperty)), key=lambda node: _local_name(node) + ) + datatype_property_nodes = sorted( + set(graph.subjects(RDF.type, OWL.DatatypeProperty)), + key=lambda node: _local_name(node), + ) + + domain_map: dict[str, list[str]] = defaultdict(list) + range_map: dict[str, list[str]] = defaultdict(list) + for prop_node in object_property_nodes + datatype_property_nodes: + prop_name = _local_name(prop_node) + for domain in graph.objects(prop_node, RDFS.domain): + domain_map[prop_name].append(_local_name(domain)) + for value_range in graph.objects(prop_node, RDFS.range): + range_map[prop_name].append(_local_name(value_range)) + + lines: list[str] = ["classDiagram", "direction LR", ""] + + for class_name in classes: + lines.append(f"class {class_name}") + + subclass_lines: list[str] = [] + for child, _, parent in graph.triples((None, RDFS.subClassOf, None)): + child_name = _local_name(child) + parent_name = _local_name(parent) + if child_name not in selected_classes or parent_name not in selected_classes: + continue + subclass_lines.append(f"{parent_name} <|-- {child_name}") + if subclass_lines: + lines.extend(["", "%% Inheritance", *sorted(set(subclass_lines))]) + + relation_lines: list[str] = [] + for prop in sorted(_local_name(node) for node in object_property_nodes): + for domain in domain_map.get(prop, []): + for value_range in range_map.get(prop, []): + if domain not in selected_classes or value_range not in selected_classes: + continue + relation_lines.append( + f'{domain} "0..*" --> "0..*" {value_range} : {prop}' + ) + if relation_lines: + lines.extend(["", "%% Object properties", *sorted(set(relation_lines))]) + + datatype_map: dict[str, list[str]] = defaultdict(list) + for prop in sorted(_local_name(node) for node in datatype_property_nodes): + for domain in domain_map.get(prop, []): + if domain not in selected_classes: + continue + value_ranges = range_map.get(prop, ["string"]) + for value_range in value_ranges: + datatype_map[domain].append(f" +{value_range} {prop}") + + if datatype_map: + lines.extend(["", "%% Datatype properties"]) + for domain in sorted(datatype_map): + lines.append(f"class {domain} {{") + lines.extend(sorted(set(datatype_map[domain]))) + lines.append("}") + + return "\n".join(lines) + "\n" + + +def convert_and_write_mermaid( + ttl_path: Path, + output_path: Path, + layer_filter: Optional[str | Iterable[str]] = None, +) -> str: + mermaid = convert_owl_ttl_to_mermaid(ttl_path, layer_filter=layer_filter) + output_path.write_text(mermaid, encoding="utf-8") + return mermaid From 99a3fc244fdb8b7adff740c53d3aaa423b22d5b8 Mon Sep 17 00:00:00 2001 From: Marvin Date: Sun, 1 Mar 2026 22:22:28 +0100 Subject: [PATCH 10/96] docs: moved main -> index --- docs/{main.md => index.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/{main.md => index.md} (100%) diff --git a/docs/main.md b/docs/index.md similarity index 100% rename from docs/main.md rename to docs/index.md From 0d6af0d8ed66c02908a84c75b89b4044c7dfc271 Mon Sep 17 00:00:00 2001 From: Marvin Date: Mon, 2 Mar 2026 17:39:51 +0100 Subject: [PATCH 11/96] feat(syskg): added Metric and MetricRun; feat(params): added metric params (config) option --- src/kgpipe/cli/eval.py | 72 +++++++----- src/kgpipe/cli/main.py | 4 +- src/kgpipe/cli/show.py | 47 +++++++- src/kgpipe/common/definitions.py | 19 ++-- src/kgpipe/common/model/evaluation.py | 2 + src/kgpipe/common/model/pipeline.py | 3 +- src/kgpipe/common/registry.py | 7 ++ src/kgpipe/common/systemgraph.py | 26 ++++- src/kgpipe/evaluation/aspects/reference.py | 33 +++--- src/kgpipe/evaluation/aspects/semantic.py | 40 ++++--- src/kgpipe/evaluation/aspects/statistical.py | 2 + src/kgpipe/evaluation/base.py | 103 +++++++++--------- src/kgpipe/evaluation/evaluator.py | 36 ++++-- src/kgpipe/evaluation/util.py | 45 +++++++- .../evaluation/test_metric_config_template.py | 44 ++++++++ 15 files changed, 347 insertions(+), 136 deletions(-) create mode 100644 src/kgpipe/test/evaluation/test_metric_config_template.py diff --git a/src/kgpipe/cli/eval.py b/src/kgpipe/cli/eval.py index 9253bf8..7478ae3 100644 --- a/src/kgpipe/cli/eval.py +++ b/src/kgpipe/cli/eval.py @@ -7,6 +7,8 @@ import json import sys +import traceback +import os from pathlib import Path from typing import List, Optional @@ -48,7 +50,8 @@ def show_evaluation_results(evaluation_report): console.print(table) console.print("") - + else: + console.print("No metrics available") def save_evaluation_results(evaluation_report, output_file: str): """Save evaluation results to file.""" @@ -97,12 +100,18 @@ def save_evaluation_results(evaluation_report, output_file: str): default='json', help="Output format for results" ) +@click.option( + "--metric-config", + "-c", + type=click.Path(exists=True), + help="Path to metric config file" +) @click.option( # flag "--debug", ) @click.pass_context -def eval_cmd(ctx: click.Context, target: List[str], ground_truth: Optional[str], aspects: tuple, metrics: tuple, output: Optional[str], format: str, debug: Optional[str]): +def eval_cmd(ctx: click.Context, target: List[str], ground_truth: Optional[str], aspects: tuple, metrics: tuple, output: Optional[str], format: str, metric_config: Optional[str], debug: Optional[str]): """ Evaluate a knowledge graph against ground truth. @@ -123,34 +132,42 @@ def eval_cmd(ctx: click.Context, target: List[str], ground_truth: Optional[str], except ValueError: kg_format = DataFormat.JSON # Default to JSON + ontology_path = os.environ.get("ONTOLOGY_PATH", None) + if ontology_path is None: + raise ValueError("ONTOLOGY_PATH is not set") + from rdflib import Graph + ontology_graph = Graph() + ontology_graph.parse(ontology_path, format="turtle") + target_kg = KG( id=str(target_path), name=target_path.stem, path=target_path, - format=kg_format + format=kg_format, + ontology_graph=ontology_graph ) # Load ground truth if provided - reference_kg = None - if ground_truth: - ground_truth_path = Path(ground_truth) - console.print(f"[dim]Loading ground truth from:[/dim] {ground_truth_path}") + # reference_kg = None + # if ground_truth: + # ground_truth_path = Path(ground_truth) + # console.print(f"[dim]Loading ground truth from:[/dim] {ground_truth_path}") - ref_format_ext = ground_truth_path.suffix.lower().lstrip('.') - try: - ref_kg_format = DataFormat(ref_format_ext) - except ValueError: - ref_kg_format = DataFormat.JSON + # ref_format_ext = ground_truth_path.suffix.lower().lstrip('.') + # try: + # ref_kg_format = DataFormat(ref_format_ext) + # except ValueError: + # ref_kg_format = DataFormat.JSON - reference_kg = KG( - id=str(ground_truth_path), - name=ground_truth_path.stem, - path=ground_truth_path, - format=ref_kg_format - ) + # reference_kg = KG( + # id=str(ground_truth_path), + # name=ground_truth_path.stem, + # path=ground_truth_path, + # format=ref_kg_format + # ) # Set up evaluation configuration - config = EvaluationConfig() + config = EvaluationConfig(metric_config_path=metric_config) # Set aspects if specified if aspects: @@ -169,7 +186,7 @@ def eval_cmd(ctx: click.Context, target: List[str], ground_truth: Optional[str], # Run evaluation evaluator = Evaluator(config) - evaluation_report = evaluator.evaluate(target_kg) + evaluation_report = evaluator.evaluate(target_kg, config) # Display results console.print(f"[green]✓ Evaluation completed![/green]") @@ -181,15 +198,16 @@ def eval_cmd(ctx: click.Context, target: List[str], ground_truth: Optional[str], console.print(f"[dim]Results saved to:[/dim] {output}") except Exception as e: + print(traceback.format_exc()) console.print(f"[red]✗ Evaluation failed:[/red] {e}") if ctx.obj["verbose"]: console.print_exception() sys.exit(1) - if debug: - from kgpipe.meta.systemgraph import SYS_KG - # if has method asGraph, serialize it - if hasattr(SYS_KG, "asGraph"): - print(SYS_KG.asGraph().serialize(format="turtle")) - else: - print("SYS_KG does not have asGraph method") \ No newline at end of file + # if debug: + # from kgpipe.meta.systemgraph import SYS_KG + # # if has method asGraph, serialize it + # if hasattr(SYS_KG, "asGraph"): + # print(SYS_KG.asGraph().serialize(format="turtle")) + # else: + # print("SYS_KG does not have asGraph method") \ No newline at end of file diff --git a/src/kgpipe/cli/main.py b/src/kgpipe/cli/main.py index 0cdf0eb..0530d74 100644 --- a/src/kgpipe/cli/main.py +++ b/src/kgpipe/cli/main.py @@ -20,7 +20,7 @@ from .clean import clean_cmd from .task import task_cmd from .discover import discover_cmd - +from .rank import rank_cmd # Initialize Rich console for pretty output console = Console() @@ -81,7 +81,7 @@ def cli(ctx: click.Context, config: Optional[str], verbose: bool, quiet: bool): cli.add_command(clean_cmd) cli.add_command(task_cmd) cli.add_command(discover_cmd) - +cli.add_command(rank_cmd) if __name__ == "__main__": cli() \ No newline at end of file diff --git a/src/kgpipe/cli/show.py b/src/kgpipe/cli/show.py index 12a9b8f..a8f3b4e 100644 --- a/src/kgpipe/cli/show.py +++ b/src/kgpipe/cli/show.py @@ -16,6 +16,10 @@ from kgpipe.common.discovery import ( discover_entry_points, find_task_by_name, find_pipeline_by_name ) +from kgpipe.evaluation.aspects.reference import ReferenceConfig +from kgpipe.evaluation.aspects.semantic import SemanticConfig +from kgpipe.evaluation.aspects.statistical import StatisticalConfig +from kgpipe.evaluation.util import get_metric_config_template # Initialize Rich console for pretty output console = Console() @@ -145,8 +149,22 @@ def show_task_details(task_name: str): console.print(table) -@click.command() -@click.argument("item", type=str) +def show_metric_config_templates(): + """Show YAML templates for metric config models.""" + templates = [ + ("ReferenceConfig", get_metric_config_template(ReferenceConfig)), + ("StatisticalConfig", get_metric_config_template(StatisticalConfig)), + ("SemanticConfig", get_metric_config_template(SemanticConfig)), + ] + + for idx, (_, template) in enumerate(templates): + if idx > 0: + click.echo("---") + click.echo(template.rstrip()) + + +@click.group(name="show", invoke_without_command=True) +@click.argument("item", type=str, required=False) @click.option( "--type", "-t", @@ -154,12 +172,25 @@ def show_task_details(task_name: str): help="Type of item to show" ) @click.pass_context -def show_cmd(ctx: click.Context, item: str, type: Optional[str]): +def show_cmd(ctx: click.Context, item: Optional[str], type: Optional[str]): """ Show detailed information about an item. - ITEM: Name or path of the item to show details for + ITEM: Name or path of the item to show details for. """ + if ctx.invoked_subcommand: + return + + if not item: + console.print(ctx.get_help()) + return + + # Keep legacy `kgpipe show ` behavior while supporting + # `kgpipe show metric-config-templates`. + if item == "metric-config-templates": + show_metric_config_templates() + return + # Auto-detect type if not specified if not type: if item.endswith('.yaml') or item.endswith('.yml'): @@ -189,4 +220,10 @@ def show_cmd(ctx: click.Context, item: str, type: Optional[str]): elif type == "task": show_task_details(item) else: - console.print(f"[red]Unknown type:[/red] {type}") \ No newline at end of file + console.print(f"[red]Unknown type:[/red] {type}") + + +@show_cmd.command(name="metric-config-templates") +def show_metric_config_templates_cmd(): + """Show YAML templates for evaluation metric configs.""" + show_metric_config_templates() \ No newline at end of file diff --git a/src/kgpipe/common/definitions.py b/src/kgpipe/common/definitions.py index 2f1bdde..9db7aff 100644 --- a/src/kgpipe/common/definitions.py +++ b/src/kgpipe/common/definitions.py @@ -188,17 +188,20 @@ class PipelineRunEntity(BaseModel): # usesPipelineDefinition: PipelineDefinition # runsPipeline: Pipeline +MetricEntityId = KGId class MetricEntity(BaseModel): - pass - -class EvaluationRunEntity(BaseModel): - """ - The result of an evaluation execution - """ name: str + description: Optional[str] = None + type: str + # output: List[schema_format] + # hasParameter: List[ParameterId] + +MetricRunEntityId = KGId +class MetricRunEntity(BaseModel): status: str started_at: float ended_at: float + computedMetric: MetricEntityId input: List[DataHandle] - output: List[DataHandle] - evaluatesEvaluation: EvaluationEntityId \ No newline at end of file + value: float + details: str \ No newline at end of file diff --git a/src/kgpipe/common/model/evaluation.py b/src/kgpipe/common/model/evaluation.py index fc304b5..baefa87 100644 --- a/src/kgpipe/common/model/evaluation.py +++ b/src/kgpipe/common/model/evaluation.py @@ -17,6 +17,8 @@ from pydantic import BaseModel, field_validator from pydantic_core import core_schema +from kgpipe.common.model.kg import KG + class Metric(ABC): """Abstract base class for evaluation metrics.""" diff --git a/src/kgpipe/common/model/pipeline.py b/src/kgpipe/common/model/pipeline.py index 1832b6a..5ee69f8 100644 --- a/src/kgpipe/common/model/pipeline.py +++ b/src/kgpipe/common/model/pipeline.py @@ -251,7 +251,8 @@ def build_pipeline_run_entity(reports: List[KgTaskReport]) -> PipelineRunEntity: executesTask=TaskEntityId(config.PIPEKG_PREFIX+report.task_name), usesImplementation=ImplementationEntityId(config.PIPEKG_PREFIX+report.task_name+"Impl"), input=[DataHandle(uri=str(input_data.path), type=input_data.format) for input_data in report.inputs], - output=[DataHandle(uri=str(output_data.path), type=output_data.format) for output_data in report.outputs] + output=[DataHandle(uri=str(output_data.path), type=output_data.format) for output_data in report.outputs], + hasParameterBinding=[] )) return PipelineRunEntity( diff --git a/src/kgpipe/common/registry.py b/src/kgpipe/common/registry.py index eb7f030..0a8b41c 100644 --- a/src/kgpipe/common/registry.py +++ b/src/kgpipe/common/registry.py @@ -3,6 +3,7 @@ from typing import Any, Callable from kgpipe.common.models import KgTask, DataFormat from kgpipe.common.systemgraph import PipeKG +from kgpipe.common.definitions import MetricEntity # TODO add also to system graph @@ -27,6 +28,12 @@ def decorator(t): def metric(cls): def decorator(t): cls._registry[f"metric:{t.__name__.lower()}"] = t + obj = t() + name = getattr(obj, 'name', None) + description = getattr(obj, 'description', None) + type = getattr(obj, 'aspect', None) + metric = MetricEntity(name=name, description=description, type=type.value if type else None) + PipeKG.add_metric(metric) return t return decorator diff --git a/src/kgpipe/common/systemgraph.py b/src/kgpipe/common/systemgraph.py index a9b6923..d652e15 100644 --- a/src/kgpipe/common/systemgraph.py +++ b/src/kgpipe/common/systemgraph.py @@ -11,7 +11,7 @@ from kgcore.backend.rdf.rdf_sparql import RDFSparqlBackend, SparqlAuth from kgcore.model.rdf.rdf_base import RDFBaseModel -from kgpipe.common.definitions import Task, TaskResult, Pipeline, PipelineResult, PipelineRunEntity, ImplementationEntity +from kgpipe.common.definitions import Task, TaskResult, Pipeline, PipelineResult, PipelineRunEntity, ImplementationEntity, MetricEntity, MetricRunEntity from kgpipe.common.config import load_config from kgpipe.common.util import encode_string @@ -27,7 +27,7 @@ try: if scheme == "sparql": - print(f"Using SPARQL backend for system graph: {f"http://{rest}"}") + print(f"Using SPARQL backend for system graph: {f"http://{rest}"} with http://kg.org/systemgraph") backend = RDFSparqlBackend( endpoint=f"http://{rest}", update_endpoint=f"http://{rest}", @@ -106,6 +106,28 @@ def add_pipeline_result(pipeline_result: PipelineResult): "output": pipeline_result.output, }) + @staticmethod + def add_metric(metric: MetricEntity): + SYS_KG.create_entity(id=config.PIPEKG_PREFIX+encode_string(metric.name),types=[config.ONTOLOGY_PREFIX+"Metric"], properties={ + config.ONTOLOGY_PREFIX+"name": metric.name, + config.ONTOLOGY_PREFIX+"description": metric.description, + config.ONTOLOGY_PREFIX+"type": metric.type, + # "input": metric.input, + # "output": metric.output, + }) + + @staticmethod + def add_metric_run(metric_run: MetricRunEntity): + metric_run_entity = SYS_KG.create_entity(id=new_id(),types=[config.ONTOLOGY_PREFIX+"MetricRun"], properties={ + config.ONTOLOGY_PREFIX+"status": metric_run.status, + config.ONTOLOGY_PREFIX+"started_at": metric_run.started_at, + config.ONTOLOGY_PREFIX+"ended_at": metric_run.ended_at, + config.ONTOLOGY_PREFIX+"value": metric_run.value, + config.ONTOLOGY_PREFIX+"details": metric_run.details, + config.ONTOLOGY_PREFIX+"input": metric_run.input[0].uri, + }) + SYS_KG.create_relation(type=config.ONTOLOGY_PREFIX+"computedMetric", source=metric_run_entity.id, target=metric_run.computedMetric) + # @staticmethod # def find_implementation_by_name(name: str) -> KGEntity: # return SYS_KG.read_entity(id=config.PIPEKG_PREFIX+name, types=[config.ONTOLOGY_PREFIX+"Implementation"])[0] diff --git a/src/kgpipe/evaluation/aspects/reference.py b/src/kgpipe/evaluation/aspects/reference.py index cd6826d..58a6814 100644 --- a/src/kgpipe/evaluation/aspects/reference.py +++ b/src/kgpipe/evaluation/aspects/reference.py @@ -660,24 +660,24 @@ class ReferenceEvaluator(AspectEvaluator): def __init__(self): super().__init__(EvaluationAspect.REFERENCE) self.metrics = [ - # ER_EntityMatchMetric(), - # ER_RelationMatchMetric(), - # TE_ExpectedEntityLinkMetric(), - # TE_ExpectedRelationLinkMetric(), - # JsonEntityMatchingMetric(), - # JsonRelationMatchingMetric(), - # JsonEntityLinkingMetric(), - # SourceEntityCoverageMetric(), - # SourceEntityCoverageMetricSoft(), - # SourceEntityPrecisionMetric(), + ER_EntityMatchMetric(), + ER_RelationMatchMetric(), + TE_ExpectedEntityLinkMetric(), + TE_ExpectedRelationLinkMetric(), + JsonEntityMatchingMetric(), + JsonRelationMatchingMetric(), + JsonEntityLinkingMetric(), + SourceEntityCoverageMetric(), + SourceEntityCoverageMetricSoft(), + SourceEntityPrecisionMetric(), SourceTypedEntityCoverageMetric(), - # ReferenceTripleAlignmentMetric(), - # ReferenceTripleAlignmentMetricSoftE(), - # ReferenceTripleAlignmentMetricSoftEV(), - # ReferenceClassCoverageMetric() + ReferenceTripleAlignmentMetric(), + ReferenceTripleAlignmentMetricSoftE(), + ReferenceTripleAlignmentMetricSoftEV(), + ReferenceClassCoverageMetric() ] - def evaluate(self, kg: KG, config: ReferenceConfig, metrics: Optional[List[str]] = None, **kwargs) -> AspectResult: + def evaluate(self, kg: KG, config: Optional[ReferenceConfig] = None, metrics: Optional[List[str]] = None, **kwargs) -> AspectResult: """Evaluate reference-based properties of the KG.""" # if references is {}: # # Return empty result if no reference KG provided @@ -697,7 +697,7 @@ def evaluate(self, kg: KG, config: ReferenceConfig, metrics: Optional[List[str]] metrics_to_compute = self.metrics if metrics: metrics_to_compute = [m for m in self.metrics if m.name in metrics] - + # Compute each metric for metric in metrics_to_compute: try: @@ -705,6 +705,7 @@ def evaluate(self, kg: KG, config: ReferenceConfig, metrics: Optional[List[str]] result = metric.compute(kg, config, **kwargs) end_time = time.time() result.duration = end_time - start_time + result.input = str(kg.path) results.append(result) except Exception as e: print(f"[Error] computing metric {metric.name}: {e}") diff --git a/src/kgpipe/evaluation/aspects/semantic.py b/src/kgpipe/evaluation/aspects/semantic.py index f0a4dab..286575a 100644 --- a/src/kgpipe/evaluation/aspects/semantic.py +++ b/src/kgpipe/evaluation/aspects/semantic.py @@ -20,7 +20,11 @@ from kgcore.api.ontology import OntologyExtractor, OntologyUtil, Ontology from kgpipe.common.registry import Registry import time +from ..base import MetricConfig +class SemanticConfig(MetricConfig): + """Config for semantic metrics.""" + pass def enrich_type_information(graph: Graph, ontology: Ontology, type_property: URIRef = RDF.type) -> Graph: type_dict = {} @@ -55,7 +59,7 @@ def __init__(self): aspect=EvaluationAspect.SEMANTIC ) - def compute(self, kg: KG, **kwargs) -> MetricResult: + def compute(self, kg: KG, config: SemanticConfig, **kwargs) -> MetricResult: """Compute reasoning score.""" import tempfile @@ -118,7 +122,7 @@ def __init__(self): aspect=EvaluationAspect.SEMANTIC ) - def compute(self, kg: KG, **kwargs) -> MetricResult: + def compute(self, kg: KG, config: SemanticConfig, **kwargs) -> MetricResult: """Compute schema consistency score.""" try: # Simple implementation - check for basic RDF structure @@ -196,7 +200,7 @@ def __init__(self): aspect=EvaluationAspect.SEMANTIC ) - def compute(self, kg: KG, **kwargs) -> MetricResult: + def compute(self, kg: KG, config: SemanticConfig, **kwargs) -> MetricResult: """Compute namespace usage score.""" try: namespaces = set() @@ -260,7 +264,7 @@ def __init__(self): aspect=EvaluationAspect.SEMANTIC ) - def compute(self, kg: KG, **kwargs) -> MetricResult: + def compute(self, kg: KG, config: SemanticConfig, **kwargs) -> MetricResult: """Compute disjoint domain score.""" raw_graph: Graph = kg.get_graph() @@ -305,7 +309,7 @@ def __init__(self): aspect=EvaluationAspect.SEMANTIC ) - def compute(self, kg: KG, **kwargs) -> MetricResult: + def compute(self, kg: KG, config: SemanticConfig, **kwargs) -> MetricResult: """Compute incorrect relation direction score.""" raw_graph: Graph = kg.get_graph() ontology_graph: Graph = kg.get_ontology_graph() @@ -314,6 +318,7 @@ def compute(self, kg: KG, **kwargs) -> MetricResult: if len(ontology_graph) == 0: ontology_graph = graph + print(f"INFO: ontology_graph is empty, using graph instead") # TODO use ontology implementation from framework predicate_defs_sr = ontology_graph.query( @@ -403,7 +408,7 @@ def __init__(self): aspect=EvaluationAspect.SEMANTIC ) - def compute(self, kg: KG, **kwargs) -> MetricResult: + def compute(self, kg: KG, config: SemanticConfig, **kwargs) -> MetricResult: """Compute incorrect relation cardinality score.""" raw_graph: Graph = kg.get_graph() @@ -464,7 +469,7 @@ def __init__(self): aspect=EvaluationAspect.SEMANTIC ) - def compute(self, kg: KG, **kwargs) -> MetricResult: + def compute(self, kg: KG, config: SemanticConfig, **kwargs) -> MetricResult: raw_graph: Graph = kg.get_graph() ontology_graph: Graph = kg.get_ontology_graph() @@ -534,7 +539,7 @@ def __init__(self): aspect=EvaluationAspect.SEMANTIC ) - def compute(self, kg: KG, **kwargs) -> MetricResult: + def compute(self, kg: KG, config: SemanticConfig, **kwargs) -> MetricResult: """Compute incorrect relation domain score.""" raw_graph: Graph = kg.get_graph() @@ -602,7 +607,7 @@ def __init__(self): aspect=EvaluationAspect.SEMANTIC ) - def compute(self, kg: KG, **kwargs) -> MetricResult: + def compute(self, kg: KG, config: SemanticConfig, **kwargs) -> MetricResult: """Compute incorrect datatype score.""" raw_graph: Graph = kg.get_graph() @@ -675,7 +680,7 @@ def __init__(self): - def compute(self, kg: KG, **kwargs) -> MetricResult: + def compute(self, kg: KG, config: SemanticConfig, **kwargs) -> MetricResult: """Compute incorrect datatype format score.""" from kgpipe.evaluation.aspects.func.datatype_validator import validate_datatype @@ -750,7 +755,7 @@ def __init__(self): aspect=EvaluationAspect.SEMANTIC ) - def compute(self, kg: KG, **kwargs) -> MetricResult: + def compute(self, kg: KG, config: SemanticConfig, **kwargs) -> MetricResult: """Compute ontology class coverage score.""" raw_graph: Graph = kg.get_graph() @@ -788,7 +793,7 @@ def __init__(self): aspect=EvaluationAspect.SEMANTIC ) - def compute(self, kg: KG, **kwargs) -> MetricResult: + def compute(self, kg: KG, config: SemanticConfig, **kwargs) -> MetricResult: """Compute ontology relation coverage score.""" raw_graph: Graph = kg.get_graph() @@ -836,7 +841,7 @@ def __init__(self): aspect=EvaluationAspect.SEMANTIC ) - def compute(self, kg: KG, **kwargs) -> MetricResult: + def compute(self, kg: KG, config: SemanticConfig, **kwargs) -> MetricResult: """Compute ontology property coverage score.""" return MetricResult( name=self.name, @@ -856,7 +861,7 @@ def __init__(self): aspect=EvaluationAspect.SEMANTIC ) - def compute(self, kg: KG, **kwargs) -> MetricResult: + def compute(self, kg: KG, config: SemanticConfig, **kwargs) -> MetricResult: """Compute ontology namespace coverage score.""" # graph = kg.get_graph() @@ -895,8 +900,10 @@ def __init__(self): OntologyNamespaceCoverageMetric(), ] - def evaluate(self, kg: KG, metrics: Optional[List[str]] = None, **kwargs) -> AspectResult: + def evaluate(self, kg: KG, metrics: Optional[List[str]] = None, config: Optional[SemanticConfig] = None, **kwargs) -> AspectResult: """Evaluate semantic properties of the KG.""" + if config is None: + config = SemanticConfig(name="default") results = [] # Filter metrics if specified @@ -908,9 +915,10 @@ def evaluate(self, kg: KG, metrics: Optional[List[str]] = None, **kwargs) -> Asp for metric in metrics_to_compute: try: start_time = time.time() - result = metric.compute(kg, **kwargs) + result = metric.compute(kg, config, **kwargs) end_time = time.time() result.duration = end_time - start_time + result.input = str(kg.path) results.append(result) except Exception as e: # Create error result diff --git a/src/kgpipe/evaluation/aspects/statistical.py b/src/kgpipe/evaluation/aspects/statistical.py index 28cb350..1e5aac6 100644 --- a/src/kgpipe/evaluation/aspects/statistical.py +++ b/src/kgpipe/evaluation/aspects/statistical.py @@ -69,6 +69,7 @@ def compute(self, kg: KG, config: StatisticalConfig, **kwargs) -> MetricResult: ) except Exception as e: + print("this exception is raised") return MetricResult( name=self.name, value=0.0, @@ -440,6 +441,7 @@ def evaluate(self, kg: KG, metrics: Optional[List[str]] = None, config: Optional result = metric.compute(kg, config, **kwargs) end_time = time.time() result.duration = end_time - start_time + result.input = str(kg.path) results.append(result) except Exception as e: # Create error result diff --git a/src/kgpipe/evaluation/base.py b/src/kgpipe/evaluation/base.py index 9d1af8f..1a2fb60 100644 --- a/src/kgpipe/evaluation/base.py +++ b/src/kgpipe/evaluation/base.py @@ -9,13 +9,19 @@ from enum import Enum from typing import Any, Dict, List, Optional # from kgpipe.common.systemgraph import kg_class - +from kgpipe.common.systemgraph import PipeKG +import time +import json +import functools +import inspect # from kgpipe.common.util import create_insertable_nodes_and_edges, insert_kg_obj from pydantic import BaseModel from kgpipe.common.models import KG - - +from kgpipe.common.definitions import MetricEntity, MetricRunEntity, MetricEntityId, DataHandle +from kgpipe.common.config import config +from pathlib import Path +from kgpipe.common.util import encode_string class EvaluationAspect(Enum): """The three main aspects of KG evaluation.""" STATISTICAL = "statistical" @@ -34,11 +40,21 @@ class EvaluationConfig: output_format: str = "json" include_details: bool = True generate_report: bool = True + metric_config_path: Optional[Path] = None def __post_init__(self): if self.weights and not all(0.0 <= w <= 1.0 for w in self.weights.values()): raise ValueError("All weights must be between 0.0 and 1.0") + # def get_aspect_config(self, aspect: EvaluationAspect) -> MetricConfig: + # if aspect == EvaluationAspect.STATISTICAL: + # return StatisticalConfig(name="default") + # elif aspect == EvaluationAspect.SEMANTIC: + # return SemanticConfig(name="default") + # elif aspect == EvaluationAspect.REFERENCE: + # return ReferenceConfig(name="default") + # else: + # raise ValueError(f"No config available for aspect: {aspect}") @dataclass class AspectResult: @@ -56,21 +72,6 @@ def __str__(self) -> str: return f"{self.aspect.value}: {self.overall_score:.2f}" -class AspectEvaluator(ABC): - """Base class for aspect-specific evaluators.""" - - def __init__(self, aspect: EvaluationAspect): - self.aspect = aspect - - @abstractmethod - def evaluate(self, kg: KG, **kwargs) -> AspectResult: - """Evaluate the KG for this specific aspect.""" - pass - - @abstractmethod - def get_available_metrics(self) -> List[str]: - """Get list of available metrics for this aspect.""" - pass # @Track(with_timestamp=True) @@ -83,6 +84,7 @@ class MetricResult(BaseModel): details: Dict[str, Any] aspect: EvaluationAspect duration: float = 0.0 + input: str = "" # TODO def __post_init__(self): if not 0.0 <= self.normalized_score <= 1.0: @@ -91,12 +93,39 @@ def __post_init__(self): class MetricConfig(BaseModel): name: str -from kgpipe.common.systemgraph import SYS_KG +class AspectEvaluator(ABC): + """Base class for aspect-specific evaluators.""" + + def __init__(self, aspect: EvaluationAspect): + self.aspect = aspect + + @abstractmethod + def evaluate(self, kg: KG, config: Optional[MetricConfig], **kwargs) -> AspectResult: + """Evaluate the KG for this specific aspect.""" + pass + + @abstractmethod + def get_available_metrics(self) -> List[str]: + """Get list of available metrics for this aspect.""" + pass + -import time -import json -import functools -import inspect +def save_metric_run(metric: MetricResult): + + metric_run_entity = MetricRunEntity( + status="success", + started_at=time.time(), + ended_at=time.time(), + computedMetric=MetricEntityId(config.PIPEKG_PREFIX+encode_string(metric.name)), + input=[DataHandle(uri=metric.input, type="any/text")], + value=metric.value, + details=json.dumps(metric.details, default=str) + ) + PipeKG.add_metric_run(metric_run_entity) + + # # input=metric.input, + # # output=metric.output + # ) def track_metric_compute(func): @functools.wraps(func) @@ -112,36 +141,12 @@ def wrapper(self, *args, **kwargs): kg: KG = None config = None - - # Record the call entity (customize fields as you like) - call_entity = SYS_KG.create_entity(["Compute"],{ - "type": "MetricComputeCall", - "metric_class": type(self).__name__, - "method": func.__name__, - "input_kg_uri": kg.path.as_posix(), - "ts_start": time.time() - }) - try: result = func(self, *args, **kwargs) # <-- actually call it - result_id = insert_kg_obj(result) - SYS_KG.create_relation("metric_result",call_entity.id, result_id) - - # duration = time.perf_counter() - started - - # SYS_KG.up(call_entity, { - # "status": "ok", - # "duration_seconds": duration, - # "output_summary": _safe_summarize_result(result) - # }) + result.input = str(kg.path) + save_metric_run(result) return result except Exception as e: - # duration = time.perf_counter() - started - # SYS_KG.update_entity(call_entity, { - # "status": "error", - # "duration_seconds": duration, - # "error": repr(e) - # }) raise return wrapper diff --git a/src/kgpipe/evaluation/evaluator.py b/src/kgpipe/evaluation/evaluator.py index f8863a2..7ba622e 100644 --- a/src/kgpipe/evaluation/evaluator.py +++ b/src/kgpipe/evaluation/evaluator.py @@ -9,10 +9,25 @@ from pathlib import Path from ..common.models import KG, Data -from .base import EvaluationAspect, AspectResult, EvaluationConfig +from .base import EvaluationAspect, AspectResult, EvaluationConfig, AspectEvaluator from .metrics import MetricResult from .reports import EvaluationReport +from .aspects.statistical import StatisticalConfig +from .aspects.semantic import SemanticConfig +from .aspects.reference import ReferenceConfig +from .base import MetricConfig +from .util import read_metric_config_yaml +from typing import Type +def get_aspect_config_type(aspect: EvaluationAspect) -> Type[MetricConfig]: + if aspect == EvaluationAspect.STATISTICAL: + return StatisticalConfig + elif aspect == EvaluationAspect.SEMANTIC: + return SemanticConfig + elif aspect == EvaluationAspect.REFERENCE: + return ReferenceConfig + else: + raise ValueError(f"No config available for aspect: {aspect}") class Evaluator: """Main evaluator that orchestrates evaluation across all aspects.""" @@ -38,13 +53,13 @@ def _initialize_aspect_evaluators(self) -> Dict[EvaluationAspect, Any]: return evaluators - def evaluate(self, kg: KG, references: Dict[str, Data] = {}) -> EvaluationReport: + def evaluate(self, kg: KG, config: Optional[EvaluationConfig]) -> EvaluationReport: """Evaluate the KG across all configured aspects.""" if not kg.exists(): raise FileNotFoundError(f"KG file not found: {kg.path}") - if references is {}: - raise ValueError("References are required for reference-based evaluation") + # if references is {}: + # raise ValueError("References are required for reference-based evaluation") aspect_results = [] all_metrics = [] @@ -52,16 +67,19 @@ def evaluate(self, kg: KG, references: Dict[str, Data] = {}) -> EvaluationReport # Evaluate each aspect for aspect in self.config.aspects: if aspect in self.aspect_evaluators: - evaluator = self.aspect_evaluators[aspect] + evaluator: AspectEvaluator = self.aspect_evaluators[aspect] # Prepare kwargs for aspect evaluation kwargs = {} - if aspect == EvaluationAspect.REFERENCE: - kwargs['references'] = references if self.config.metrics: kwargs['metrics'] = self.config.metrics - + + config_type = get_aspect_config_type(aspect) + + # TODO if metric empty for aspect, use default config + kwargs['config'] = read_metric_config_yaml(self.config.metric_config_path, config_type) + try: aspect_result = evaluator.evaluate(kg, **kwargs) aspect_results.append(aspect_result) @@ -76,7 +94,7 @@ def evaluate(self, kg: KG, references: Dict[str, Data] = {}) -> EvaluationReport # Create evaluation report report = EvaluationReport( kg=kg, - references=references, + references={}, aspect_results=aspect_results, overall_score=overall_score, config=self.config diff --git a/src/kgpipe/evaluation/util.py b/src/kgpipe/evaluation/util.py index 6967de2..6e16825 100644 --- a/src/kgpipe/evaluation/util.py +++ b/src/kgpipe/evaluation/util.py @@ -1,12 +1,16 @@ from pathlib import Path -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Type import json import re +from enum import Enum + +import yaml from kgpipe.common import KG from kgpipe.common.models import Data, KgPipePlan, DataFormat from kgpipe.evaluation.aspects import reference from kgpipe.evaluation.aspects.reference import Reference +from kgpipe.evaluation.base import MetricConfig def resolve_relative_path(path: str, base_path: Path) -> Path: @@ -111,4 +115,43 @@ def get_plan(self) -> KgPipePlan: # TODO: use KgTask json_data = json.load(f) return KgPipePlan(**json_data) +def get_metric_config_template(metricConfig: MetricConfig) -> str: + """ + for a metricconfig which is a pydantic model, return a yaml that displays the model fields and their default values + """ + model = metricConfig if isinstance(metricConfig, type) else metricConfig.__class__ + fields = model.model_fields + + template: Dict[str, object] = {} + for field_name, field_info in fields.items(): + if field_info.is_required(): + value = None + elif field_info.default_factory is not None: + value = field_info.default_factory() + else: + value = field_info.default + + if isinstance(value, Path): + value = value.as_posix() + elif isinstance(value, Enum): + value = value.value + + template[field_name] = value + + return yaml.safe_dump(template, sort_keys=False) + +def read_metric_config_yaml(file: str, config_type: Type[MetricConfig]) -> MetricConfig: + """ + reads the config file and parses it into the given config type + """ + with open(file, "r") as f: + yaml_data = yaml.safe_load(f) + + if yaml_data is None: + yaml_data = {} + + if not isinstance(yaml_data, dict): + raise ValueError(f"Metric config YAML must be a mapping/object, got {type(yaml_data).__name__}") + return config_type.model_validate(yaml_data) + \ No newline at end of file diff --git a/src/kgpipe/test/evaluation/test_metric_config_template.py b/src/kgpipe/test/evaluation/test_metric_config_template.py new file mode 100644 index 0000000..7d971ef --- /dev/null +++ b/src/kgpipe/test/evaluation/test_metric_config_template.py @@ -0,0 +1,44 @@ +import yaml +from pathlib import Path + +from kgpipe.evaluation.aspects.reference import ReferenceConfig +from kgpipe.evaluation.util import get_metric_config_template, read_metric_config_yaml + + +def test_get_metric_config_template_for_reference_config(): + template_yaml = get_metric_config_template(ReferenceConfig) + template = yaml.safe_load(template_yaml) + + assert template["name"] is None + assert template["GT_MATCHES"] is None + assert template["GT_MATCHES_TARGET_DATASET"] is None + assert template["ENTITY_MATCH_THRESHOLD"] == 0.5 + assert template["RELATION_MATCH_THRESHOLD"] == 0.5 + assert template["VERIFIED_SOURCE_ENTITIES"] is None + assert template["REFERENCE_KG_PATH"] is None + assert template["EXPECTED_TEXT_LINKS"] is None + assert template["TE_LINK_THRESHOLD"] == 0.4 + assert template["SEED_KG_PATH"] is None + assert template["source_meta"] is None + assert template["dataset"] is None + assert template["JSON_EXPECTED_DIR"] is None + assert template["JSON_EXPECTED_RELATION_FILE"] is None + + +def test_metric_config_template_roundtrip_reference_config(tmp_path: Path): + template_yaml = get_metric_config_template(ReferenceConfig) + template = yaml.safe_load(template_yaml) + template["name"] = "reference-config-roundtrip" + template["GT_MATCHES"] = "/tmp/gt_matches.csv" + + config_path = tmp_path / "reference_config.yaml" + with open(config_path, "w") as f: + yaml.safe_dump(template, f, sort_keys=False) + + config = read_metric_config_yaml(config_path.as_posix(), ReferenceConfig) + + assert isinstance(config, ReferenceConfig) + assert config.name == "reference-config-roundtrip" + assert config.GT_MATCHES == Path("/tmp/gt_matches.csv") + assert config.ENTITY_MATCH_THRESHOLD == 0.5 + assert config.TE_LINK_THRESHOLD == 0.4 From a95e76b33c22e367d4e82dbafc5e959aa0cd7b84 Mon Sep 17 00:00:00 2001 From: Marvin Date: Tue, 3 Mar 2026 17:18:48 +0100 Subject: [PATCH 12/96] feat(llm): llm api core --- src/kgpipe_llm/common/apis/__init__.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/kgpipe_llm/common/apis/__init__.py diff --git a/src/kgpipe_llm/common/apis/__init__.py b/src/kgpipe_llm/common/apis/__init__.py new file mode 100644 index 0000000..6504700 --- /dev/null +++ b/src/kgpipe_llm/common/apis/__init__.py @@ -0,0 +1,20 @@ +"""API-specific completion backends.""" + +from .ollama_comp import ollama_call +from .openai_comp import ( + openai_call_with_json_out, + openai_call_with_tool, + pydantic_to_openai_tool, + schemadict_to_openai_tool, +) +from .openwebui_comp import openwebui_call_with_json_out, openwebui_call_with_tool + +__all__ = [ + "ollama_call", + "openai_call_with_json_out", + "openai_call_with_tool", + "openwebui_call_with_json_out", + "openwebui_call_with_tool", + "pydantic_to_openai_tool", + "schemadict_to_openai_tool", +] From 1407c5818f5bf7c4f8a14af623360d4a74d38f28 Mon Sep 17 00:00:00 2001 From: Marvin Date: Tue, 3 Mar 2026 17:20:02 +0100 Subject: [PATCH 13/96] exp(moviekg): new ranking --- .../src/moviekg/paper/test_ranksens.py | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 experiments/moviekg/src/moviekg/paper/test_ranksens.py diff --git a/experiments/moviekg/src/moviekg/paper/test_ranksens.py b/experiments/moviekg/src/moviekg/paper/test_ranksens.py new file mode 100644 index 0000000..e53f284 --- /dev/null +++ b/experiments/moviekg/src/moviekg/paper/test_ranksens.py @@ -0,0 +1,162 @@ +import re +import numpy as np +import pandas as pd +import matplotlib.pyplot as plt +from itertools import product + +# ========================= +# 1) Data +# ========================= +data = [ + ("T_C", 0.824, 0.367, 0.332), + ("R_A", 0.996, 0.993, 0.994), + ("TJR", 0.980, 0.980, 0.793), + ("RJT", 0.981, 0.967, 0.849), + ("TRJ", 0.980, 0.967, 0.808), + ("JRT", 0.982, 0.980, 0.838), + ("J_A", 0.938, 0.976, 0.988), + ("T_B", 0.893, 0.555, 0.580), + ("J_B", 0.968, 0.961, 0.788), + ("JTR", 0.981, 0.980, 0.806), + ("J_C", 0.993, 0.751, 0.851), + ("R_B", 0.993, 0.982, 0.962), + ("RTJ", 0.979, 0.967, 0.845), + ("R_C", 0.996, 0.984, 0.993), + ("T_A", 0.986, 0.526, 0.590), +] +df = pd.DataFrame(data, columns=["pipeline", "semantic", "correctness", "coverage"]) + +# ========================= +# 2) Define cohorts +# ========================= +# Single-source pipelines: "R_A", "J_B", "T_C", etc. +single_re = re.compile(r"^[RJT]_[A-Z]$") + +df["is_single"] = df["pipeline"].apply(lambda s: bool(single_re.match(s))) +df["source_type"] = df["pipeline"].apply(lambda s: s[0]) # 'R', 'J', 'T' + +single_df = df[df["is_single"]].copy() +multi_df = df[~df["is_single"]].copy() # e.g., "TJR", "RJT", ... + +# Cohort dict: RDF-only, JSON-only, TEXT-only, and Multi-source +cohorts = { + "RDF-only (R_*)": single_df[single_df["source_type"] == "R"].copy(), + "JSON-only (J_*)": single_df[single_df["source_type"] == "J"].copy(), + "Text-only (T_*)": single_df[single_df["source_type"] == "T"].copy(), + "Multi-source (no underscore)": multi_df.copy(), +} + +# ========================= +# 3) Weight grid on simplex +# ========================= +# Weights are (w_sem, w_cor, w_cov) with w_sum=1 and w_i>=0 +STEP = 0.05 # set to 0.1 for fewer points +vals = np.round(np.arange(0, 1 + 1e-9, STEP), 10) + +weights = [] +for w in product(vals, repeat=3): + if abs(sum(w) - 1.0) < 1e-9: + weights.append(w) +weights = np.array(weights) # (N, 3) +print(f"Weight grid: step={STEP}, N={len(weights)} points") + +# ========================= +# 4) Sensitivity computation +# ========================= +def sensitivity_summary(cohort_df: pd.DataFrame, weights: np.ndarray) -> pd.DataFrame: + """ + Returns per-pipeline: + - wins: how many weight points where it ranks #1 + - win_fraction + - avg_rank + - avg_score (mean across weights) + """ + if cohort_df.empty: + return pd.DataFrame() + + M = cohort_df[["semantic", "correctness", "coverage"]].to_numpy() # (m,3) + scores = weights @ M.T # (N,m) + + # winner counts + winner_idx = np.argmax(scores, axis=1) + winners = cohort_df["pipeline"].iloc[winner_idx].to_numpy() + win_counts = pd.Series(winners).value_counts().reindex(cohort_df["pipeline"]).fillna(0).astype(int) + + # rank matrix: rank 1 = best + order = scores.argsort(axis=1)[:, ::-1] + rank_matrix = np.empty_like(order) + for i in range(order.shape[0]): + rank_matrix[i, order[i]] = np.arange(1, M.shape[0] + 1) + + summary = pd.DataFrame({ + "wins": win_counts.values, + "win_fraction": (win_counts.values / len(weights)), + "avg_rank": rank_matrix.mean(axis=0), + "avg_score": scores.mean(axis=0), + }, index=cohort_df["pipeline"].values) + + summary = summary.sort_values(["win_fraction", "avg_rank"], ascending=[False, True]) + return summary + +all_summaries = {name: sensitivity_summary(cdf, weights) for name, cdf in cohorts.items()} + +# Print summaries +for name, summ in all_summaries.items(): + print("\n" + "=" * 80) + print(name) + if summ.empty: + print("(empty cohort)") + else: + print(summ) + +# ========================= +# 5) Plots (VLDB-friendly) +# ========================= +# A) Win-fraction bars for each cohort +# for name, summ in all_summaries.items(): +# if summ.empty: +# continue +# plt.figure(figsize=(9, 3.8)) +# plt.bar(summ.index, summ["win_fraction"].values) +# plt.xticks(rotation=45, ha="right") +# plt.ylabel("Win fraction (#1 over weight grid)") +# plt.title(f"{name} — winner sensitivity (step={STEP})") +# plt.tight_layout() +# plt.show() + +# B) Average-rank bars for each cohort +# for name, summ in all_summaries.items(): +# if summ.empty: +# continue +# plt.figure(figsize=(9, 3.8)) +# plt.bar(summ.index, summ["avg_rank"].values) +# plt.xticks(rotation=45, ha="right") +# plt.ylabel("Average rank (lower is better)") +# plt.title(f"{name} — average rank over weight grid") +# plt.tight_layout() +# plt.show() + +# ========================= +# 6) Optional: a compact “paper table” per cohort +# ========================= +paper_tables = {} +for name, summ in all_summaries.items(): + if summ.empty: + continue + paper_tables[name] = summ[["win_fraction", "avg_rank"]].copy() + +print("\n" + "=" * 80) +print("Compact paper tables (win_fraction, avg_rank):") +for name, t in paper_tables.items(): + print("\n---", name, "---") + print(t) + +# ========================= +# 7) Optional: export to CSV (uncomment if you want files) +# ========================= +# for name, summ in all_summaries.items(): +# if summ.empty: +# continue +# safe_name = re.sub(r"[^A-Za-z0-9]+", "_", name).strip("_") +# summ.to_csv(f"sensitivity_{safe_name}.csv") +# print("Wrote CSV files.") \ No newline at end of file From 117f8c3df1cf1b068635d5452c4f3e0bb7051fd3 Mon Sep 17 00:00:00 2001 From: Marvin Date: Tue, 3 Mar 2026 17:22:19 +0100 Subject: [PATCH 14/96] feat(llm): llm api core --- src/kgpipe_llm/common/api_utils.py | 386 ++----------------- src/kgpipe_llm/common/apis/ollama_comp.py | 67 ++++ src/kgpipe_llm/common/apis/openai_comp.py | 203 ++++++++++ src/kgpipe_llm/common/apis/openwebui_comp.py | 45 +++ src/kgpipe_llm/common/core.py | 254 ++++++------ 5 files changed, 454 insertions(+), 501 deletions(-) diff --git a/src/kgpipe_llm/common/api_utils.py b/src/kgpipe_llm/common/api_utils.py index 2299af9..61ec431 100644 --- a/src/kgpipe_llm/common/api_utils.py +++ b/src/kgpipe_llm/common/api_utils.py @@ -1,373 +1,37 @@ -# specific LLM API utils (ollama, openai, etc.) -import json -import requests -from typing import Tuple, List -from typing import Optional, Dict, Any, Type -from pydantic import BaseModel -from enum import Enum -from tiktoken import encoding_for_model -import os - -TIMEOUT = 900 # 10 minutes - -# def schemadict_to_openai_tool( -# schema_dict: Dict[str, Any], -# model_name: str, -# *, -# name: Optional[str] = None, -# description: Optional[str] = None, -# additional_properties: bool = False, -# ) -> Dict[str, Any]: -# """ -# Convert a schema dictionary to an OpenAI 'tools' entry (function calling). -# """ - -# schema = schema_dict - -# # We want the object schema under "parameters" -# # Keep $defs so nested models/refs work. -# params: Dict[str, Any] = { -# "type": "object", -# "properties": schema.get("properties", {}), -# "required": schema.get("required", []), -# "additionalProperties": additional_properties, -# } -# if "$defs" in schema: -# params["$defs"] = schema["$defs"] - -# tool = { -# "type": "function", -# "function": { -# "name": name or model_name, -# "description": description or (model_name.__doc__ or "").strip() or f"{model_name} schema", -# "parameters": params, -# }, -# } -# return tool - -# def pydantic_to_openai_tool( -# model: Type[BaseModel], -# *, -# name: Optional[str] = None, -# description: Optional[str] = None, -# additional_properties: bool = False, -# ) -> Dict[str, Any]: -# """ -# Convert a Pydantic v2 model to an OpenAI 'tools' entry (function calling). -# """ -# # Pydantic v2 emits draft-2020-12 JSON Schema. OpenAI accepts schemas -# # that look like draft-07/2019-09 object schemas, including $defs/$ref. -# #schema = model.model_json_schema(ref_template="#/$defs/{model}") -# schema = model.model_json_schema() -# return schemadict_to_openai_tool(schema, model.__name__, name=name, description=description, additional_properties=additional_properties) - -from typing import Any, Dict, Optional, Type -from pydantic import BaseModel - -def schemadict_to_openai_tool( - schema_dict: Dict[str, Any], - *, - name: str, - description: Optional[str] = None, - additional_properties: Optional[bool] = None, -) -> Dict[str, Any]: - """ - Convert a schema dictionary to an OpenAI 'tools' entry (function calling). - Pass the schema through unchanged (array/object/etc.), only tweaking root-level keys. - """ - # Copy so we don't mutate the caller's schema - params = dict(schema_dict) - - # Titles are optional noise for tool schemas; drop them at root. - params.pop("title", None) - - # Only inject additionalProperties if the root is an object. - if additional_properties is not None and params.get("type") == "object": - params["additionalProperties"] = additional_properties - - tool = { - "type": "function", - "function": { - "name": name, - "description": (description or schema_dict.get("description") or f"{name} parameters").strip(), - "parameters": params, - }, - } - return tool - - -def pydantic_to_openai_tool( - model: Type[BaseModel], - *, - name: Optional[str] = None, - description: Optional[str] = None, - additional_properties: Optional[bool] = None, -) -> Dict[str, Any]: - """ - Convert a Pydantic v2 model to an OpenAI 'tools' entry (function calling). - Works for object models, RootModel[list[...]], unions, literals, etc. - """ - schema = model.model_json_schema() - resolved_name = name or model.__name__ - resolved_description = ( - description - or (model.__doc__ or "").strip() - or schema.get("description") - or f"{resolved_name} parameters" - ) - return schemadict_to_openai_tool( - schema, - name=resolved_name, - description=resolved_description, - additional_properties=additional_properties, - ) - - -# def schemadict_to_openai_tool( -# schema_dict: Dict[str, Any], -# *, -# name: str, -# description: Optional[str] = None, -# additional_properties: Optional[bool] = None, -# ) -> Dict[str, Any]: -# """ -# Convert a schema dictionary to an OpenAI 'tools' entry (function calling). -# """ -# params: Dict[str, Any] = { -# "type": "object", -# "properties": schema_dict.get("properties", {}), -# "required": schema_dict.get("required", []), -# } -# # Only set this if the caller asked to, otherwise leave Pydantic's default intact. -# if additional_properties is not None: -# params["additionalProperties"] = additional_properties - -# # Keep nested refs/defs -# if "$defs" in schema_dict: -# params["$defs"] = schema_dict["$defs"] - -# tool = { -# "type": "function", -# "function": { -# "name": name, -# "description": (description or schema_dict.get("description") or f"{name} parameters").strip(), -# "parameters": params, -# }, -# } -# return tool - - -# def pydantic_to_openai_tool( -# model: Type[BaseModel], -# *, -# name: Optional[str] = None, -# description: Optional[str] = None, -# additional_properties: Optional[bool] = None, -# ) -> Dict[str, Any]: -# """ -# Convert a Pydantic v2 model to an OpenAI 'tools' entry (function calling). -# """ -# # Pydantic v2 emits draft-2020-12 JSON Schema (with $defs). That's fine for OpenAI tools. -# schema = model.model_json_schema() - -# resolved_name = name or model.__name__ -# # Prefer explicit description → model docstring → schema description → fallback -# resolved_description = ( -# description -# or (model.__doc__ or "").strip() -# or schema.get("description") -# or f"{resolved_name} parameters" -# ) - -# return schemadict_to_openai_tool( -# schema, -# name=resolved_name, -# description=resolved_description, -# additional_properties=additional_properties, -# ) +"""Compatibility facade for API-specific LLM helpers. -def openai_call_with_json_out( - *, - endpoint_url: str, - api_key: str, - model_name: str, - user_content: str, - system_prompt: str = "You are a careful JSON-LD KG engineering assistant.", - seed: str = "", - response_format: str = "json_object" -) -> Dict[str, Any]: +Provider implementations live under ``kgpipe_llm.common.apis``. +""" - payload = { - "model": model_name, - "messages": [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_content}, - ], +from __future__ import annotations - "temperature": 1 - } - - if response_format and response_format != "": - print(f"INFO: openai_call_with_json_out response_format json_object") - payload["response_format"] = { - "type": "json_object" - } - - if seed and seed != "": - payload["seed"] = seed - - headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {api_key}", - } - - resp = requests.post(endpoint_url, headers=headers, json=payload, timeout=TIMEOUT) - if resp.status_code != 200: - print(resp.content.decode("utf-8")) - resp.raise_for_status() - resp_data = resp.json() - - content = resp_data["choices"][0]["message"]["content"] - - try: - return json.loads(content) - except Exception as e: - print(f"Error parsing JSON: {e}") - return content - -def openai_call_with_tool( - *, - endpoint_url: str, - api_key: str, - model_name: str, - user_content: str, - pyd_model: Type[BaseModel] | Dict, - system_prompt: str = "You are a careful JSON-LD KG engineering assistant.", - seed: str = "" -) -> Tuple[dict, BaseModel]: - if isinstance(pyd_model, dict): - print(f"INFO: openai_call_with_tool CUSTOM JSON SCHEMA") - tool = schemadict_to_openai_tool(pyd_model, name="CustomJsonSchema", additional_properties=True) - else: - print(f"INFO: openai_call_with_tool Pydantic model {pyd_model.__name__}") - tool = pydantic_to_openai_tool(pyd_model, additional_properties=False) - - payload = { - "model": model_name, - "messages": [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_content}, - ], - "tools": [tool], - # Force the model to call our function so we get structured JSON back - "tool_choice": {"type": "function", "function": {"name": tool["function"]["name"]}}, - "temperature": 1 - } - - if seed and seed != "": - payload["seed"] = seed - - headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {api_key}", - } - - resp = requests.post(endpoint_url, headers=headers, json=payload, timeout=TIMEOUT) - if resp.status_code != 200: - print(resp.content.decode("utf-8")) - resp.raise_for_status() - data = resp.json() - - # Extract tool call arguments - choice = data["choices"][0] - tool_calls = choice["message"].get("tool_calls", []) - if not tool_calls: - raise ValueError("Model did not return a tool call; check tool_choice or prompt.") - - args_json_str = tool_calls[0]["function"]["arguments"] - args = json.loads(args_json_str) - - # Validate using Pydantic - if isinstance(pyd_model, dict): - validated = args - else: - validated = pyd_model.model_validate(args) - return args, validated - - -def ollama_call( - *, - endpoint_url: str, - api_key: str, - schema_class: Type[BaseModel] | Dict | str, - model_name: str, - user_content: str, - system_prompt: str = "You are a careful JSON-LD KG engineering assistant.", - seed: str = "" -) -> Dict[str, Any]: - payload = { - "model": model_name, - "prompt": user_content, - "stream": False, - } - - # If schema_class is a string, we want raw output - if isinstance(schema_class, str): - # For raw output, don't set format - pass - elif isinstance(schema_class, Dict): - payload["format"] = schema_class - else: - # For structured output, set the JSON schema format - payload["format"] = schema_class.model_json_schema() - - if system_prompt and system_prompt != "": - payload["system"] = system_prompt - - if seed and seed != "": - payload["seed"] = seed - - headers = { - "Content-Type": "application/json", - } +import os - if api_key and api_key != "": - headers["Authorization"] = f"Bearer {api_key}" - headers["X-API-Key"] = api_key +from tiktoken import encoding_for_model - try: - response = requests.post( - endpoint_url, - headers=headers, - json=payload, - timeout=300 - ) - - if response.status_code == 200: - raw_output = response.json()["response"] - if isinstance(schema_class, str): - return raw_output - elif isinstance(schema_class, Dict): - return json.loads(raw_output) - else: - parsed_output = json.loads(raw_output) - schema_class.model_validate(parsed_output) - return parsed_output - else: - print(f"Request failed: {response.status_code} - {response.text}") - return {} - - except Exception as e: - print(f"Error processing LLM response: {e}") - return {} +from .apis.ollama_comp import ollama_call +from .apis.openai_comp import ( + openai_call_with_json_out, + openai_call_with_tool, + pydantic_to_openai_tool, + schemadict_to_openai_tool, +) def get_token_count(text: str) -> int: - """ - Get the token count of a text string. - """ + """Return token count using the configured default GPT tokenizer.""" model_name = os.getenv("DEFAULT_LLM_MODEL_NAME", "gpt-5-mini") if not model_name.startswith("gpt"): model_name = "gpt-5-mini" encoding = encoding_for_model(model_name) - return len(encoding.encode(text)) \ No newline at end of file + return len(encoding.encode(text)) + + +__all__ = [ + "ollama_call", + "openai_call_with_json_out", + "openai_call_with_tool", + "pydantic_to_openai_tool", + "schemadict_to_openai_tool", + "get_token_count", +] \ No newline at end of file diff --git a/src/kgpipe_llm/common/apis/ollama_comp.py b/src/kgpipe_llm/common/apis/ollama_comp.py index e69de29..e4733ee 100644 --- a/src/kgpipe_llm/common/apis/ollama_comp.py +++ b/src/kgpipe_llm/common/apis/ollama_comp.py @@ -0,0 +1,67 @@ +"""Ollama-compatible completion helper.""" + +from __future__ import annotations + +import json +from typing import Any, Dict, Type + +import requests +from pydantic import BaseModel + +TIMEOUT = 300 + + +def ollama_call( + *, + endpoint_url: str, + api_key: str, + schema_class: Type[BaseModel] | Dict[str, Any] | str, + model_name: str, + user_content: str, + system_prompt: str = "You are a careful JSON-LD KG engineering assistant.", + seed: str = "", +) -> Dict[str, Any]: + payload: Dict[str, Any] = { + "model": model_name, + "prompt": user_content, + "stream": False, + } + + if isinstance(schema_class, str): + pass + elif isinstance(schema_class, dict): + payload["format"] = schema_class + else: + payload["format"] = schema_class.model_json_schema() + + if system_prompt: + payload["system"] = system_prompt + if seed: + payload["seed"] = seed + + headers = {"Content-Type": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + headers["X-API-Key"] = api_key + + try: + response = requests.post( + endpoint_url, + headers=headers, + json=payload, + timeout=TIMEOUT, + ) + if response.status_code != 200: + print(f"Request failed: {response.status_code} - {response.text}") + return {} + + raw_output = response.json()["response"] + if isinstance(schema_class, str): + return raw_output + parsed_output = json.loads(raw_output) + if not isinstance(schema_class, dict): + schema_class.model_validate(parsed_output) + return parsed_output + except Exception as exc: + print(f"Error processing LLM response: {exc}") + return {} diff --git a/src/kgpipe_llm/common/apis/openai_comp.py b/src/kgpipe_llm/common/apis/openai_comp.py index e69de29..d9bfb23 100644 --- a/src/kgpipe_llm/common/apis/openai_comp.py +++ b/src/kgpipe_llm/common/apis/openai_comp.py @@ -0,0 +1,203 @@ +"""OpenAI-compatible completion helpers with structured-output fallback.""" + +from __future__ import annotations + +import json +from typing import Any, Dict, Optional, Tuple, Type + +import requests +from pydantic import BaseModel + +TIMEOUT = 900 + + +def schemadict_to_openai_tool( + schema_dict: Dict[str, Any], + *, + name: str, + description: Optional[str] = None, + additional_properties: Optional[bool] = None, +) -> Dict[str, Any]: + """ + Convert a JSON schema dictionary into an OpenAI tool schema. + """ + params = dict(schema_dict) + params.pop("title", None) + if additional_properties is not None and params.get("type") == "object": + params["additionalProperties"] = additional_properties + return { + "type": "function", + "function": { + "name": name, + "description": (description or schema_dict.get("description") or f"{name} parameters").strip(), + "parameters": params, + }, + } + + +def pydantic_to_openai_tool( + model: Type[BaseModel], + *, + name: Optional[str] = None, + description: Optional[str] = None, + additional_properties: Optional[bool] = None, +) -> Dict[str, Any]: + """ + Convert a Pydantic model into an OpenAI tool schema. + """ + schema = model.model_json_schema() + resolved_name = name or model.__name__ + resolved_description = ( + description + or (model.__doc__ or "").strip() + or schema.get("description") + or f"{resolved_name} parameters" + ) + return schemadict_to_openai_tool( + schema, + name=resolved_name, + description=resolved_description, + additional_properties=additional_properties, + ) + + +def _build_headers(api_key: str) -> Dict[str, str]: + return { + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}", + } + + +def openai_call_with_json_out( + *, + endpoint_url: str, + api_key: str, + model_name: str, + user_content: str, + system_prompt: str = "You are a careful JSON-LD KG engineering assistant.", + seed: str = "", + response_format: str = "json_object", +) -> Dict[str, Any]: + payload: Dict[str, Any] = { + "model": model_name, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_content}, + ], + "temperature": 1, + } + + if response_format: + payload["response_format"] = {"type": "json_object"} + if seed: + payload["seed"] = seed + + response = requests.post( + endpoint_url, + headers=_build_headers(api_key), + json=payload, + timeout=TIMEOUT, + ) + if response.status_code != 200: + print(response.content.decode("utf-8")) + response.raise_for_status() + content = response.json()["choices"][0]["message"]["content"] + try: + return json.loads(content) + except Exception as exc: + print(f"Error parsing JSON: {exc}") + return content + + +def _validate_or_passthrough( + args: Dict[str, Any], pyd_model: Type[BaseModel] | Dict[str, Any] +) -> BaseModel | Dict[str, Any]: + if isinstance(pyd_model, dict): + return args + return pyd_model.model_validate(args) + + +def _fallback_structured_output( + *, + endpoint_url: str, + api_key: str, + model_name: str, + user_content: str, + system_prompt: str, + seed: str, + pyd_model: Type[BaseModel] | Dict[str, Any], +) -> Tuple[Dict[str, Any], BaseModel | Dict[str, Any]]: + fallback_args = openai_call_with_json_out( + endpoint_url=endpoint_url, + api_key=api_key, + model_name=model_name, + user_content=user_content, + system_prompt=system_prompt, + seed=seed, + response_format="json_object", + ) + if not isinstance(fallback_args, dict): + raise ValueError("Fallback response_format=json_object did not return JSON object.") + return fallback_args, _validate_or_passthrough(fallback_args, pyd_model) + + +def openai_call_with_tool( + *, + endpoint_url: str, + api_key: str, + model_name: str, + user_content: str, + pyd_model: Type[BaseModel] | Dict[str, Any], + system_prompt: str = "You are a careful JSON-LD KG engineering assistant.", + seed: str = "", +) -> Tuple[Dict[str, Any], BaseModel | Dict[str, Any]]: + if isinstance(pyd_model, dict): + tool = schemadict_to_openai_tool( + pyd_model, + name="CustomJsonSchema", + additional_properties=True, + ) + else: + tool = pydantic_to_openai_tool(pyd_model, additional_properties=False) + + payload: Dict[str, Any] = { + "model": model_name, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_content}, + ], + "tools": [tool], + "tool_choice": {"type": "function", "function": {"name": tool["function"]["name"]}}, + "temperature": 1, + } + if seed: + payload["seed"] = seed + + response = requests.post( + endpoint_url, + headers=_build_headers(api_key), + json=payload, + timeout=TIMEOUT, + ) + if response.status_code != 200: + print(response.content.decode("utf-8")) + response.raise_for_status() + data = response.json() + + try: + tool_calls = data["choices"][0]["message"].get("tool_calls", []) + if not tool_calls: + raise ValueError("No tool calls in response.") + args = json.loads(tool_calls[0]["function"]["arguments"]) + return args, _validate_or_passthrough(args, pyd_model) + except Exception as exc: + print(f"Tool-call structured parsing failed, using JSON fallback: {exc}") + return _fallback_structured_output( + endpoint_url=endpoint_url, + api_key=api_key, + model_name=model_name, + user_content=user_content, + system_prompt=system_prompt, + seed=seed, + pyd_model=pyd_model, + ) diff --git a/src/kgpipe_llm/common/apis/openwebui_comp.py b/src/kgpipe_llm/common/apis/openwebui_comp.py index e69de29..874948e 100644 --- a/src/kgpipe_llm/common/apis/openwebui_comp.py +++ b/src/kgpipe_llm/common/apis/openwebui_comp.py @@ -0,0 +1,45 @@ +"""OpenWebUI completion helpers. + +OpenWebUI often exposes OpenAI-compatible endpoints, so these wrappers delegate +to the OpenAI-compatible implementation while keeping a dedicated module. +""" + +from __future__ import annotations + +from typing import Any, Dict, Tuple, Type + +from pydantic import BaseModel + +from .openai_comp import ( + openai_call_with_json_out, + openai_call_with_tool, + pydantic_to_openai_tool, + schemadict_to_openai_tool, +) + + +def openwebui_call_with_json_out(**kwargs: Any) -> Dict[str, Any]: + """OpenWebUI wrapper for JSON-mode completions.""" + return openai_call_with_json_out(**kwargs) + + +def openwebui_call_with_tool( + *, + endpoint_url: str, + api_key: str, + model_name: str, + user_content: str, + pyd_model: Type[BaseModel] | Dict[str, Any], + system_prompt: str = "You are a careful JSON-LD KG engineering assistant.", + seed: str = "", +) -> Tuple[Dict[str, Any], BaseModel | Dict[str, Any]]: + """OpenWebUI wrapper for tool-calling structured output.""" + return openai_call_with_tool( + endpoint_url=endpoint_url, + api_key=api_key, + model_name=model_name, + user_content=user_content, + pyd_model=pyd_model, + system_prompt=system_prompt, + seed=seed, + ) diff --git a/src/kgpipe_llm/common/core.py b/src/kgpipe_llm/common/core.py index 90c8990..d1ee620 100644 --- a/src/kgpipe_llm/common/core.py +++ b/src/kgpipe_llm/common/core.py @@ -2,146 +2,117 @@ Core LLM client and base task functionality for data integration tasks. """ -import requests -import json -from typing import Optional, TypeVar, Generic, Dict -from pydantic import BaseModel -from typing import AnyStr import os -from .api_utils import openai_call_with_tool, openai_call_with_json_out, ollama_call, get_token_count - -# Type variable for Pydantic models -T = TypeVar('T', bound=BaseModel) +from typing import Any, Dict, Generic, Optional, TypeVar from dotenv import load_dotenv +from pydantic import BaseModel + +from .api_utils import get_token_count +from .apis.ollama_comp import ollama_call +from .apis.openai_comp import openai_call_with_json_out, openai_call_with_tool +from .apis.openwebui_comp import openwebui_call_with_json_out, openwebui_call_with_tool + load_dotenv() OPENAI_V1_COMPLETIONS_URL = "https://api.openai.com/v1/chat/completions" GPT_MODELS_EXTRA = ["o4-mini", "o1-mini", "o1-preview"] +OPENAI_LIKE_TYPES = {"openai", "openwebui"} + +T = TypeVar("T", bound=BaseModel) + + +def _infer_api_type(model_name: str, endpoint_url: str) -> str: + endpoint = (endpoint_url or "").lower() + if "localhost:11434" in endpoint or endpoint.endswith("/api/generate"): + return "ollama" + if "openwebui" in endpoint: + return "openwebui" + if model_name.startswith("gpt") or model_name in GPT_MODELS_EXTRA: + return "openai" + if endpoint.endswith("/v1/chat/completions"): + return "openai" + if endpoint.endswith("/api/chat/completions"): + return "openwebui" + return "ollama" + + +def _resolve_openai_like_endpoint(endpoint_url: str) -> str: + if endpoint_url and "chat/completions" in endpoint_url: + return endpoint_url + return OPENAI_V1_COMPLETIONS_URL + class LLMClient: - """Client for interacting with Ollama LLM API with structured output validation.""" - - def __init__(self, endpoint_url: str = "http://localhost:11434/api/generate", - model_name: str = "gemma3:27B", - token: str = "", - seed: str = ""): + """Client for interacting with LLM APIs with structured output validation.""" + + def __init__( + self, + endpoint_url: str = "http://localhost:11434/api/generate", + model_name: str = "gemma3:27B", + token: str = "", + seed: str = "", + api_type: Optional[str] = None, + ): self.endpoint_url = endpoint_url self.model_name = model_name self.token = token self.seed = seed - if model_name.startswith("gpt") or model_name in GPT_MODELS_EXTRA: - self.api_type = "openai" - else: - self.api_type = "ollama" - - - # def send_message(self, messages: list[dict], schema_class: type[T] | str, system_prompt: str = "") -> Optional[T] | str: - # """ - # Send a message to the LLM and validate the response against a Pydantic schema. - # """ - # payload = { - # "model": self.model_name, - # "messages": messages, - # "stream": False, - # } - - # if system_prompt and system_prompt != "": - # payload["system"] = system_prompt - - # if isinstance(schema_class, str): - # # For raw output, don't set format - # pass - # else: - # # For structured output, set the JSON schema format - # payload["format"] = schema_class.model_json_schema() - - # headers = { - # "Content-Type": "application/json", - # } - - # if self.token and self.token != "": - # headers["Authorization"] = f"Bearer {self.token}" - # headers["X-API-Key"] = self.token - - # try: - # response = requests.post( - # self.endpoint_url, - # headers=headers, - # json=payload, - # timeout=30 - # ) - # print(response.json()) - - # if response.status_code == 200: - # raw_output = response.json()["response"] - # if isinstance(schema_class, str): - # return raw_output - # else: - # parsed_output = json.loads(raw_output) - # result = schema_class.model_validate(parsed_output) - # return result - # else: - # print(f"Request failed: {response.status_code} - {response.text}") - # return None - - # except Exception as e: - # print(f"Error processing LLM response: {e}") - # return None - - - def send_prompt(self, prompt: str, schema_class: type[T] | str | Dict, system_prompt: str = "") -> Dict: + self.api_type = api_type or _infer_api_type(model_name, endpoint_url) + + def send_prompt( + self, + prompt: str, + schema_class: type[T] | str | Dict[str, Any], + system_prompt: str = "", + ) -> Any: """ Send a prompt to the LLM and validate the response against a Pydantic schema. - - Args: - prompt: The text prompt to send to the LLM - schema_class: The Pydantic model class to validate the response against, or str for raw output - - Returns: - Validated Pydantic model instance, raw string, or None if validation fails """ - print("INPUT_TOKEN_COUNT", get_token_count(prompt)) - if self.api_type == "openai": + if self.api_type in OPENAI_LIKE_TYPES: + endpoint = _resolve_openai_like_endpoint(self.endpoint_url) + json_call = ( + openwebui_call_with_json_out if self.api_type == "openwebui" else openai_call_with_json_out + ) + tool_call = openwebui_call_with_tool if self.api_type == "openwebui" else openai_call_with_tool + if isinstance(schema_class, str): - print(f"INFO: openai_call_with_json_out {type(schema_class)}") - return openai_call_with_json_out( - endpoint_url=OPENAI_V1_COMPLETIONS_URL, + print(f"INFO: {self.api_type}_call_with_json_out {type(schema_class)}") + return json_call( + endpoint_url=endpoint, api_key=self.token, model_name=self.model_name, user_content=prompt, system_prompt=system_prompt, seed=self.seed, - response_format=schema_class + response_format=schema_class, ) - else: - # special return type for openai - print(f"INFO: openai_call_with_tool {type(schema_class)}") - dict_val, model_val = openai_call_with_tool( - endpoint_url=OPENAI_V1_COMPLETIONS_URL, - api_key=self.token, - model_name=self.model_name, - user_content=prompt, - pyd_model=schema_class, - system_prompt=system_prompt, - seed=self.seed - ) - return dict_val - else: - print(f"INFO: ollama_call with {type(schema_class)}") - return ollama_call( - endpoint_url=self.endpoint_url, + print(f"INFO: {self.api_type}_call_with_tool {type(schema_class)}") + dict_val, _model_val = tool_call( + endpoint_url=endpoint, api_key=self.token, model_name=self.model_name, user_content=prompt, - schema_class=schema_class, + pyd_model=schema_class, system_prompt=system_prompt, - seed=self.seed + seed=self.seed, ) + return dict_val + + print(f"INFO: ollama_call with {type(schema_class)}") + return ollama_call( + endpoint_url=self.endpoint_url, + api_key=self.token, + model_name=self.model_name, + user_content=prompt, + schema_class=schema_class, + system_prompt=system_prompt, + seed=self.seed, + ) class BaseTask(Generic[T]): @@ -157,6 +128,7 @@ def execute(self, *args, **kwargs) -> Optional[T]: class LlmAPIConfig(BaseModel): """Configuration for an LLM API.""" + endpoint_url: str model_name: str ollama_token: Optional[str] @@ -164,36 +136,35 @@ class LlmAPIConfig(BaseModel): seed: str context_window: int - # def __init__(self, endpoint_url: str, model_name: str, ollama_token: str, openai_token: str): - # self.endpoint_url = endpoint_url - # self.model_name = model_name - # self.ollama_token = ollama_token - # self.openai_token = openai_token - def get_config_from_env() -> LlmAPIConfig: """Get the configuration for an LLM API from the environment.""" + opt_llm_endpoint_url = os.getenv("LLM_ENDPOINT_URL") + opt_llm_model_name = os.getenv("DEFAULT_LLM_MODEL_NAME", "gemma3:27B") + opt_ollama_token = os.getenv("OLLAMA_TOKEN") + opt_openai_token = os.getenv("OPENAI_TOKEN") + llm_seed = os.getenv("LLM_SEED", "") + opt_context_window = int(os.getenv("CONTEXT_WINDOW", 16384)) + + print( + "INFO: get_config_from_env", + opt_llm_endpoint_url, + opt_llm_model_name, + opt_ollama_token, + opt_openai_token, + llm_seed, + opt_context_window, + ) - OPT_LLM_ENDPOINT_URL = os.getenv("LLM_ENDPOINT_URL") - OPT_LLM_MODEL_NAME = os.getenv("DEFAULT_LLM_MODEL_NAME") - OPT_OLLAMA_TOKEN = os.getenv("OLLAMA_TOKEN") - OPT_OPENAI_TOKEN = os.getenv("OPENAI_TOKEN") - LLM_SEED = os.getenv("LLM_SEED", "") - OPT_CONTEXT_WINDOW = int(os.getenv("CONTEXT_WINDOW", 16384)) - - print(f"INFO: get_config_from_env {OPT_LLM_ENDPOINT_URL} {OPT_LLM_MODEL_NAME} {OPT_OLLAMA_TOKEN} {OPT_OPENAI_TOKEN} {LLM_SEED} {OPT_CONTEXT_WINDOW}") - - # TODO requires one token to be set - if OPT_LLM_ENDPOINT_URL is None or (OPT_LLM_MODEL_NAME is None and OPT_OLLAMA_TOKEN is None and OPT_OPENAI_TOKEN is None): - # raise ValueError("LLM_ENDPOINT_URL, LLM_MODEL_NAME, OLLAMA_TOKEN, and OPENAI_TOKEN must be set. Also, CONTEXT_WINDOW must be set.") - raise ValueError("LLM_ENDPOINT_URL, LLM_MODEL_NAME, OLLAMA_TOKEN, and OPENAI_TOKEN must be set. Also, CONTEXT_WINDOW must be set.") + if opt_llm_endpoint_url is None: + raise ValueError("LLM_ENDPOINT_URL must be set.") return LlmAPIConfig( - endpoint_url=OPT_LLM_ENDPOINT_URL, - model_name=OPT_LLM_MODEL_NAME, - ollama_token=OPT_OLLAMA_TOKEN, - openai_token=OPT_OPENAI_TOKEN, - seed=LLM_SEED, - context_window=OPT_CONTEXT_WINDOW, + endpoint_url=opt_llm_endpoint_url, + model_name=opt_llm_model_name, + ollama_token=opt_ollama_token, + openai_token=opt_openai_token, + seed=llm_seed, + context_window=opt_context_window, ) @@ -201,14 +172,17 @@ def get_client_from_env() -> LLMClient: """Get the client for an LLM API from the environment.""" config = get_config_from_env() print(f"INFO: get_client_from_env {config.model_name}") - if config.model_name.startswith("gpt") or config.model_name in GPT_MODELS_EXTRA: - api_type = "openai" - else: - api_type = "ollama" + api_type = _infer_api_type(config.model_name, config.endpoint_url) + token = config.ollama_token if api_type == "ollama" else config.openai_token print(f"INFO: get_client_from_env with {api_type}") return LLMClient( endpoint_url=config.endpoint_url, model_name=config.model_name, - token=config.ollama_token if api_type == "ollama" else config.openai_token, - seed=config.seed - ) \ No newline at end of file + token=token or "", + seed=config.seed, + api_type=api_type, + ) + + +# Backward compatibility for modules importing a shared default client. +default_client = LLMClient() From 35036d87487066f2d3d5e13e442a527b1eb72dcb Mon Sep 17 00:00:00 2001 From: Marvin Date: Tue, 3 Mar 2026 17:22:46 +0100 Subject: [PATCH 15/96] exp(moviekg): new ranking --- .../src/moviekg/paper/helpers/ranking.py | 60 +++++++++++++++++++ .../moviekg/src/moviekg/paper/test_figtab.py | 17 ++++++ 2 files changed, 77 insertions(+) diff --git a/experiments/moviekg/src/moviekg/paper/helpers/ranking.py b/experiments/moviekg/src/moviekg/paper/helpers/ranking.py index ba9102a..5d34668 100644 --- a/experiments/moviekg/src/moviekg/paper/helpers/ranking.py +++ b/experiments/moviekg/src/moviekg/paper/helpers/ranking.py @@ -99,6 +99,66 @@ def _rank_and_save2csv(weights: dict, outfile_stem: str, psmd: pipeline_stage_me out = df[["pipeline", "combined"]].sort_values(by="combined", ascending=False) out.to_csv(OUTPUT_ROOT / f"paper/{outfile_stem}.csv", sep="\t") +def _rank_and_save3csv(weights: dict, outfile_stem: str, psmd: pipeline_stage_metric_dict, round_digits: int = 3) -> pd.DataFrame: + + # psmd = normalize_metric(psmd, sta_fact_count.__name__, ["stage_3"], normalize_max_best) + psmd = normalize_metric(psmd, sta_denisity.__name__, ["stage_3"], normalize_max_best) + psmd = normalize_metric(psmd, sta_fact_count.__name__, ["stage_3"], normalize_max_best) + # sta_metric_names = [sta_denisity.__name__+"_norm", sta_fact_count.__name__+"_norm"] + # sta_agg = agg_metrics(psmd, sta_metric_names) + + sem_metric_names = [ + sem_disjoint_domain.__name__, sem_incorrect_relation_direction.__name__, + sem_incorrect_relation_range.__name__, sem_incorrect_relation_domain.__name__, + sem_incorrect_datatype.__name__, sem_incorrect_datatype_format.__name__] + sem_agg = agg_metrics(psmd, sem_metric_names) + + acc_metric_names = [ref_kg_p.__name__] + acc_agg = agg_metrics(psmd, acc_metric_names) + + cov_metric_names = [ref_source_entity_f1.__name__+"_avg"] + cov_agg = agg_metrics(psmd, cov_metric_names) + + # psmd = normalize_metric(psmd, sta_duration.__name__+"_sum", ["stage_3"], normalize_min_best) + # eff_metric_names = [sta_duration.__name__+"_sum_norm"] + # eff_agg = agg_metrics(psmd, eff_metric_names) + + import json + json.dump(psmd, open(OUTPUT_ROOT / f"paper/{outfile_stem}_psmd.json", "w"), indent=4) + + df_rows = [] + + for pipeline, value in sem_agg.items(): + df_rows.append( + { + "pipeline": pipeline, + "semantic": round(value, round_digits), + "correctness": round(acc_agg[pipeline], round_digits), + "coverage": round(cov_agg[pipeline], round_digits), + # "size": round(sta_agg[pipeline], round_digits), + # "efficiency": round(eff_agg[pipeline], round_digits) + } + ) + + + df = pd.DataFrame(df_rows) + + return df + + # cols = ["semantic", "correctness", "coverage"] + # # Ensure we only use known columns; fill missing weights with 0.0 + # w = pd.Series(weights).reindex(cols, fill_value=0.0) + + # # Compute combined score + # df = df[["pipeline"] + cols].copy() + # df["combined"] = (df[cols] * w).sum(axis=1).round(round_digits) + + # print(df.to_string()) + + # # Sort & save (keep default index=True to match original behavior) + # out = df[["pipeline", "combined"]].sort_values(by="combined", ascending=False) + # out.to_csv(OUTPUT_ROOT / f"paper/{outfile_stem}.csv", sep="\t") + # TODO cleanup # def _rank_and_save(weights: dict, outfile_stem: str, df: pd.DataFrame, round_digits: int = 3) -> None: # """ diff --git a/experiments/moviekg/src/moviekg/paper/test_figtab.py b/experiments/moviekg/src/moviekg/paper/test_figtab.py index 1600033..8b425a4 100644 --- a/experiments/moviekg/src/moviekg/paper/test_figtab.py +++ b/experiments/moviekg/src/moviekg/paper/test_figtab.py @@ -699,3 +699,20 @@ def test_full_ranking_table(): result = result.set_index("rank") result.to_csv(OUTPUT_ROOT / "paper/test_tab_7_full_ranking_table.csv", sep="\t") + +def test_new_ranking_table(): + """ + """ + from moviekg.paper.helpers.ranking import _rank_and_save3csv + df =_rank_and_save3csv(PRESETS["equal"], "test_rank_equal", psmd) + df["pipeline"] = df["pipeline"].map(PIPLEINE_NAME_MAP) + df.to_csv(OUTPUT_ROOT / "paper/test_tab_8_new_ranking_table.csv", sep="\t") + # metric_df = load_metrics_from_file(OUTPUT_ROOT / "all_metrics.csv") + # metric_df["pipeline"] = metric_df["pipeline"].map(map_pipeline_name_pretty) + # # metric_df = metric_df[metric_df["stage"] == "stage_3"] + # # metric_df = metric_df[metric_df["metric"].isin(TABLE_DISPLAY_NAMES.keys())] + # # metric_df = metric_df[metric_df["pipeline"] != "reference"] + # # metric_df = metric_df.reset_index(drop=True) + # # metric_df = metric_df.pivot(index="pipeline", columns="metric", values="normalized") + # # metric_df = metric_df.reset_index() + # metric_df.to_csv(OUTPUT_ROOT / "paper/test_tab_8_new_ranking_table.csv", sep="\t") \ No newline at end of file From 963f555833ea2a6bed07578b627763737e7ff976 Mon Sep 17 00:00:00 2001 From: Marvin Hofer Date: Tue, 27 Jan 2026 22:57:38 +0100 Subject: [PATCH 16/96] init explorer app --- experiments/explorer/README.md | 39 ++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 experiments/explorer/README.md diff --git a/experiments/explorer/README.md b/experiments/explorer/README.md new file mode 100644 index 0000000..fa17f59 --- /dev/null +++ b/experiments/explorer/README.md @@ -0,0 +1,39 @@ +# KGpipe Explorer + +A static web application for exploring the KGpipe framework's System Knowledge Graph (PipeKG) and pipeline execution results. The explorer provides an interactive interface to browse registered tasks, pipelines, metrics, and evaluation results without executing pipelines. + +## Overview + +The KGpipe Explorer is designed to visualize and navigate the meta knowledge graph that KGpipe maintains internally. This System KG tracks: + +- **Tasks**: Registered integration tasks with their specifications, input/output formats, and categories +- **Pipelines**: Pipeline definitions and their composition of tasks +- **Metrics**: Evaluation metrics and quality measurements +- **Execution Results**: Results from pipeline runs and their associated metadata + +## Purpose + +The explorer enables users to: + +- Discover available tasks and their capabilities +- Understand pipeline structures and task dependencies +- Review evaluation metrics and execution results +- Explore relationships between tasks, pipelines, and data formats +- Navigate the System KG structure through an intuitive interface + +## System Knowledge Graph + +The explorer operates on the PipeKG (Meta Knowledge Graph) that KGpipe maintains internally. For detailed information about the System KG structure, query capabilities, and SPARQL examples, see the [Meta KG documentation](../../docs/metakg.md). + +## Design Principles + +- **Static**: The explorer works with pre-generated System KG data and execution results. It does not execute pipelines or modify the framework state. +- **Read-only**: All exploration is read-only, ensuring no accidental modifications to pipeline definitions or execution results. +- **Interactive**: Provides an intuitive interface for navigating the complex relationships in the System KG. + +## Architecture + +The explorer consumes static RDF data from the System KG and presents it through a web-based interface, allowing users to query and visualize the knowledge graph structure without requiring direct SPARQL knowledge. + +## Backlog +- decide on framwork and src structure \ No newline at end of file From 97f17c8d8cf5ad95e0b710e2a299bf8d5f23cc7a Mon Sep 17 00:00:00 2001 From: Marvin Hofer Date: Fri, 27 Feb 2026 10:33:54 +0100 Subject: [PATCH 17/96] feat: owl-view: show owl.ttl in view as mermaid --- src/kgpipe_view/kgpipe.owl.ttl | 249 ++++++++++++++++++++++++++++++ src/kgpipe_view/kgpipe_view.py | 223 ++++++++++++++++---------- src/kgpipe_view/meta_kg_query.py | 245 +++++++++++++++++++++++++++++ src/kgpipe_view/owl_to_mermaid.py | 138 +++++++++++++++++ 4 files changed, 771 insertions(+), 84 deletions(-) create mode 100644 src/kgpipe_view/kgpipe.owl.ttl create mode 100644 src/kgpipe_view/meta_kg_query.py create mode 100644 src/kgpipe_view/owl_to_mermaid.py diff --git a/src/kgpipe_view/kgpipe.owl.ttl b/src/kgpipe_view/kgpipe.owl.ttl new file mode 100644 index 0000000..aea0dbb --- /dev/null +++ b/src/kgpipe_view/kgpipe.owl.ttl @@ -0,0 +1,249 @@ +@prefix : . +@prefix rdf: . +@prefix rdfs: . +@prefix owl: . +@prefix xsd: . + +:kgp a owl:Ontology . + +################################################################# +# Classes +################################################################# + +:Task a owl:Class, :CoreLayer . +:Method a owl:Class, :CoreLayer . +:Tool a owl:Class, :CoreLayer . +#:FrameworkTool a owl:Class ; rdfs:subClassOf :Tool . + +:Implementation a owl:Class, :CoreLayer . + +#:Interface a owl:Class, :CoreLayer . +#:CLIInterface a owl:Class ; rdfs:subClassOf :Interface . +#:RESTInterface a owl:Class ; rdfs:subClassOf :Interface . +#:LibraryAPIInterface a owl:Class ; rdfs:subClassOf :Interface . + +:Pipeline a owl:Class, :PipelineLayer . +:PipelineStep a owl:Class, :PipelineLayer . +:PipelineDefinition a owl:Class, :PipelineLayer . + +:TaskRun a owl:Class, :RunLayer . +:PipelineRun a owl:Class, :RunLayer . + +:Artifact a owl:Class, :DataLayer . +:ArtifactType a owl:Class, :DataLayer . +:Schema a owl:Class, :DataLayer . + +:Parameter a owl:Class, :ParameterLayer . +:ParameterBinding a owl:Class, :ParameterLayer . + +################################################################# +# Object Properties +################################################################# + +### Task decomposition +:hasSubtask a owl:ObjectProperty ; + rdfs:domain :Task ; + rdfs:range :Task . + +### Semantics: method / tool / implementation +:realizesTask a owl:ObjectProperty ; + rdfs:domain :Method ; + rdfs:range :Task . + +:providesMethod a owl:ObjectProperty ; + rdfs:domain :Tool ; + rdfs:range :Method . + +:supportsTask a owl:ObjectProperty ; + rdfs:domain :Tool ; + rdfs:range :Task . + +:usesTool a owl:ObjectProperty ; + rdfs:domain :Implementation ; + rdfs:range :Tool . + +:implementsMethod a owl:ObjectProperty ; + rdfs:domain :Implementation ; + rdfs:range :Method . + +:hasInterface a owl:ObjectProperty ; + rdfs:domain :Implementation ; + rdfs:range :Interface . + +### Pipeline structure +:hasStep a owl:ObjectProperty ; + rdfs:domain :Pipeline ; + rdfs:range :PipelineStep . + +:stepTask a owl:ObjectProperty ; + rdfs:domain :PipelineStep ; + rdfs:range :Task . + +:stepMethod a owl:ObjectProperty ; + rdfs:domain :PipelineStep ; + rdfs:range :Method . + +:nextStep a owl:ObjectProperty ; + rdfs:domain :PipelineStep ; + rdfs:range :PipelineStep . + +:definesPipeline a owl:ObjectProperty ; + rdfs:domain :PipelineDefinition ; + rdfs:range :Pipeline . + +:definedInTool a owl:ObjectProperty ; + rdfs:domain :PipelineDefinition ; + rdfs:range :Tool . + +:hasSourceArtifact a owl:ObjectProperty ; + rdfs:domain :PipelineDefinition ; + rdfs:range :Artifact . + +### Execution / runs +:executesTask a owl:ObjectProperty ; + rdfs:domain :TaskRun ; + rdfs:range :Task . + +:usesImplementation a owl:ObjectProperty ; + rdfs:domain :TaskRun ; + rdfs:range :Implementation . + +:runsPipeline a owl:ObjectProperty ; + rdfs:domain :PipelineRun ; + rdfs:range :Pipeline . + +:usesPipelineDefinition a owl:ObjectProperty ; + rdfs:domain :PipelineRun ; + rdfs:range :PipelineDefinition . + +:hasTaskRun a owl:ObjectProperty ; + rdfs:domain :PipelineRun ; + rdfs:range :TaskRun . + +### Data flow (runtime) +:hasInputArtifact a owl:ObjectProperty ; + rdfs:domain :TaskRun ; + rdfs:range :Artifact . + +:hasOutputArtifact a owl:ObjectProperty ; + rdfs:domain :TaskRun ; + rdfs:range :Artifact . + +### Data flow typing (design-time) +:expectsInputType a owl:ObjectProperty ; + rdfs:domain :Implementation ; + rdfs:range :ArtifactType . + +:producesOutputType a owl:ObjectProperty ; + rdfs:domain :Implementation ; + rdfs:range :ArtifactType . + +### Artifact typing / schema +:hasArtifactType a owl:ObjectProperty ; + rdfs:domain :Artifact ; + rdfs:range :ArtifactType . + +:conformsToSchema a owl:ObjectProperty ; + rdfs:domain :Artifact ; + rdfs:range :Schema . + +### Parameters +:hasParameter a owl:ObjectProperty ; + rdfs:domain :Implementation ; + rdfs:range :Parameter . + +:hasParameterBinding a owl:ObjectProperty ; + rdfs:domain :TaskRun ; + rdfs:range :ParameterBinding . + +:bindsParameter a owl:ObjectProperty ; + rdfs:domain :ParameterBinding ; + rdfs:range :Parameter . + +################################################################# +# Datatype Properties +################################################################# + +### Implementation +:commandTemplate a owl:DatatypeProperty ; + rdfs:domain :Implementation ; + rdfs:range xsd:string . + +:runtime a owl:DatatypeProperty ; + rdfs:domain :Implementation ; + rdfs:range xsd:string . + +:implementationVersion a owl:DatatypeProperty ; + rdfs:domain :Implementation ; + rdfs:range xsd:string . + +### Tool +:toolVersion a owl:DatatypeProperty ; + rdfs:domain :Tool ; + rdfs:range xsd:string . + +### Parameter +:paramName a owl:DatatypeProperty ; + rdfs:domain :Parameter ; + rdfs:range xsd:string . + +:paramDescription a owl:DatatypeProperty ; + rdfs:domain :Parameter ; + rdfs:range xsd:string . + +:paramDataType a owl:DatatypeProperty ; + rdfs:domain :Parameter ; + rdfs:range xsd:string . + +:defaultValue a owl:DatatypeProperty ; + rdfs:domain :Parameter ; + rdfs:range xsd:string . + +### ParameterBinding +:value a owl:DatatypeProperty ; + rdfs:domain :ParameterBinding ; + rdfs:range xsd:string . + +### TaskRun +:startedAt a owl:DatatypeProperty ; + rdfs:domain :TaskRun ; + rdfs:range xsd:dateTime . + +:endedAt a owl:DatatypeProperty ; + rdfs:domain :TaskRun ; + rdfs:range xsd:dateTime . + +:status a owl:DatatypeProperty ; + rdfs:domain :TaskRun ; + rdfs:range xsd:string . + +:exitCode a owl:DatatypeProperty ; + rdfs:domain :TaskRun ; + rdfs:range xsd:integer . + +:logPath a owl:DatatypeProperty ; + rdfs:domain :TaskRun ; + rdfs:range xsd:string . + +### PipelineRun +:pipelineStartedAt a owl:DatatypeProperty ; + rdfs:domain :PipelineRun ; + rdfs:range xsd:dateTime . + +:pipelineEndedAt a owl:DatatypeProperty ; + rdfs:domain :PipelineRun ; + rdfs:range xsd:dateTime . + +:pipelineStatus a owl:DatatypeProperty ; + rdfs:domain :PipelineRun ; + rdfs:range xsd:string . + +### Artifact +:location a owl:DatatypeProperty ; + rdfs:domain :Artifact ; + rdfs:range xsd:anyURI . + +### ArtifactType +:format a owl:DatatypeProperty ; + rdfs:domain :ArtifactType ; + rdfs:range xsd:string . \ No newline at end of file diff --git a/src/kgpipe_view/kgpipe_view.py b/src/kgpipe_view/kgpipe_view.py index 7a990c3..51e6a7c 100644 --- a/src/kgpipe_view/kgpipe_view.py +++ b/src/kgpipe_view/kgpipe_view.py @@ -1,85 +1,140 @@ -from turtle import back -from streamlit import table, title, text_input, button, write +from __future__ import annotations + +import json +from pathlib import Path + import streamlit as st -from kgpipe.common.registry import Registry -import kgpipe_tasks.tasks -import sqlite3 -import graphviz - -title("KGpipe View") -st.set_page_config(layout="wide") - -from streamlit_cytoscapejs import st_cytoscapejs - -elements = [ - {"data": {"id": "one", "label": "Node 1"}, "position": {"x": 0, "y": 0}}, - {"data": {"id": "two", "label": "Node 2"}, "position": {"x": 100, "y": 0}}, - {"data": {"source": "one", "target": "two", "label": "Edge from Node1 to Node2"}}, -] -stylesheet = [ - {"selector": "node", "style": {"width": 20, "height": 20, "shape": "rectangle"}}, - {"selector": "edge", "style": {"width": 10}}, -] - -clicked_elements = st_cytoscapejs(elements, stylesheet, width=1000, height=1000) - -if clicked_elements is not None: - st.write(clicked_elements) - -# # wide streamlit view -# wide_view = True - -# from kgpipe.common.systemgraph import backend - -# def sparql(query: str): -# qr = backend.query_sparql(query) -# bindings = qr["results"]["bindings"] -# results = [] -# for binding in bindings: -# keys = binding.keys() -# row = {} -# for key in keys: -# row[key] = binding[key]["value"] -# results.append(row) -# return results - -# # create sqlite3 database -# conn = sqlite3.connect("kgpipe_view.db") -# cursor = conn.cursor() -# cursor.execute("CREATE TABLE IF NOT EXISTS queries (id INTEGER PRIMARY KEY AUTOINCREMENT, query TEXT)") -# conn.commit() - -# def save_query(query: str): -# cursor.execute("INSERT INTO queries (query) VALUES (?)", (query,)) -# conn.commit() - -# def get_queries(): -# cursor.execute("SELECT * FROM queries") -# return cursor.fetchall() - -# queries = get_queries() -# # drop down menu for queries -# query_dropdown = st.selectbox("Queries", [q[1] for q in queries]) - -# # query field -# query = text_input("SELECT * { ?s ?p ?o . } LIMIT 10", value=query_dropdown) -# if button("Execute"): -# query_result = sparql(query) -# table(query_result) - -# # save query button -# if button("Save Query"): -# save_query(query) -# queries = get_queries() -# query_dropdown = st.selectbox("Queries", [q[1] for q in queries]) - - -# def graph_visualization(query_result: list): -# graph = graphviz.Digraph() -# for row in query_result: -# graph.edge(row["s"], row["o"]) -# return graph - -# # graph visualization -# graph = graph_visualization(query_result) -# st.graphviz_chart(graph) \ No newline at end of file +import streamlit.components.v1 as components + +from meta_kg_query import query_task_hierarchy, query_tasks_implementations, query_pipeline_hierarchy, query_evaluation_hierarchy, query_kg_data +from owl_to_mermaid import convert_and_write_mermaid, get_available_layers + + +def _render_mermaid(mermaid_text: str, height: int = 900) -> None: + """Render Mermaid source in Streamlit using Mermaid JS.""" + mermaid_json = json.dumps(mermaid_text) + html = f""" + +
+
+
+ + """ + components.html(html, height=height, scrolling=True) + + +st.set_page_config(page_title="KGpipe View", layout="wide") +st.title("KGpipe View") +st.caption("Explore the KGpipe meta knowledge graph rendered from Owl/Turtle.") + +base_dir = Path(__file__).resolve().parent +ttl_path = base_dir / "kgpipe.owl.ttl" +mermaid_path = base_dir / "kgpipe.owl.mmd" + +diagram_tab, tasks_tab, pipelines_tab, evaluations_tab = st.tabs(["Ontology Diagram", "Tasks", "Pipelines", "Evaluations"]) + +with diagram_tab: + try: + layer_options = get_available_layers(ttl_path) + selected_layers = st.multiselect( + "Layers", + options=layer_options, + default=layer_options, + key="layer-filter", + ) + mermaid_code = convert_and_write_mermaid( + ttl_path=ttl_path, + output_path=mermaid_path, + layer_filter=selected_layers, + ) + except Exception as exc: # pragma: no cover - UI fallback path + st.error(f"Failed to convert `{ttl_path.name}` to Mermaid: {exc}") + else: + st.success( + f"Generated Mermaid from `{ttl_path.name}` and saved `{mermaid_path.name}`." + ) + if selected_layers: + st.caption(f"Current layer filter: `{', '.join(selected_layers)}`") + else: + st.caption("Current layer filter: `none`") + _render_mermaid(mermaid_code) + with st.expander("Show Mermaid source"): + st.code(mermaid_code, language="mermaid") + +with tasks_tab: + endpoint_url = st.text_input( + "Meta KG SPARQL endpoint", + value="http://localhost:8890/sparql", + help="SPARQL endpoint for the live meta knowledge graph.", + ) + if st.button("Load task implementations", type="primary"): + try: + task_implementation_df = query_tasks_implementations(endpoint_url) + except Exception as exc: # pragma: no cover - network dependent path + st.error(f"Could not query `{endpoint_url}`: {exc}") + else: + if task_implementation_df.empty: + st.info("No task-implementation mappings returned by the endpoint.") + else: + st.dataframe(task_implementation_df, use_container_width=True) + + st.divider() + st.subheader("Task hierarchy") + st.caption("Shows subclass relations under `kgp:Task`, including standalone task nodes.") + + if st.button("Load task hierarchy"): + try: + task_hierarchy_df = query_task_hierarchy(endpoint_url) + except Exception as exc: # pragma: no cover - network dependent path + st.error(f"Could not query `{endpoint_url}`: {exc}") + else: + if task_hierarchy_df.empty: + st.info("No `kgp:Task` subclass hierarchy returned by the endpoint.") + else: + st.dataframe(task_hierarchy_df, use_container_width=True) + +with pipelines_tab: + st.subheader("Pipelines") + st.caption("Shows pipeline relations under `kgp:Pipeline`, including standalone pipeline nodes.") + + if st.button("Load pipeline hierarchy"): + try: + pipeline_hierarchy_df = query_pipeline_hierarchy(endpoint_url) + except Exception as exc: # pragma: no cover - network dependent path + st.error(f"Could not query `{endpoint_url}`: {exc}") + else: + if pipeline_hierarchy_df.empty: + st.info("No `kgp:Pipeline` subclass hierarchy returned by the endpoint.") + else: + st.dataframe(pipeline_hierarchy_df, use_container_width=True) + +with evaluations_tab: + st.subheader("Evaluations") + st.caption("Shows evaluation relations under `kgp:Evaluation`, including standalone evaluation nodes.") + + if st.button("Load evaluation hierarchy"): + try: + evaluation_hierarchy_df = query_kg_data(endpoint_url) #query_evaluation_hierarchy(endpoint_url) + except Exception as exc: # pragma: no cover - network dependent path + st.error(f"Could not query `{endpoint_url}`: {exc}") + else: + if evaluation_hierarchy_df.empty: + st.info("No `kgp:Evaluation` subclass hierarchy returned by the endpoint.") + else: + st.dataframe(evaluation_hierarchy_df, use_container_width=True) \ No newline at end of file diff --git a/src/kgpipe_view/meta_kg_query.py b/src/kgpipe_view/meta_kg_query.py new file mode 100644 index 0000000..5402fe3 --- /dev/null +++ b/src/kgpipe_view/meta_kg_query.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +from typing import Any + +import pandas as pd + + +PRIMARY_QUERY = """ +PREFIX kgp: + +SELECT ?task ?method ?implementation ?tool ?runtime ?implementationVersion ?commandTemplate +WHERE { + ?implementation a kgp:Implementation . + OPTIONAL { ?implementation kgp:implementsMethod ?method . } + OPTIONAL { ?implementation kgp:usesTool ?tool . } + OPTIONAL { ?implementation kgp:runtime ?runtime . } + OPTIONAL { ?implementation kgp:implementationVersion ?implementationVersion . } + OPTIONAL { ?implementation kgp:commandTemplate ?commandTemplate . } + OPTIONAL { ?method kgp:realizesTask ?task . } +} +ORDER BY ?task ?implementation +""" + + +TASK_HIERARCHY_PRIMARY_QUERY = """ +PREFIX kgp: +PREFIX rdfs: +PREFIX owl: + +SELECT DISTINCT ?task ?parentTask +WHERE { + { + ?task a kgp:Task . + } + UNION + { + ?task a owl:Class . + ?task rdfs:subClassOf+ kgp:Task . + FILTER(?task != kgp:Task) + } + UNION + { + ?method kgp:realizesTask ?task . + } + FILTER(isIRI(?task)) + OPTIONAL { + ?task rdfs:subClassOf ?parentTask . + ?parentTask rdfs:subClassOf* kgp:Task . + FILTER(?parentTask != owl:Thing) + } +} +ORDER BY ?task ?parentTask +""" + + +TASK_HIERARCHY_FALLBACK_QUERY = """ +PREFIX rdfs: + +SELECT DISTINCT ?task ?parentTask +WHERE { + { + ?task a ?taskType . + FILTER(STRENDS(STR(?taskType), "Task")) + } + UNION + { + ?task a ?classType . + FILTER(STRENDS(STR(?classType), "Class")) + ?task rdfs:subClassOf+ ?taskRoot . + FILTER(STRENDS(STR(?taskRoot), "Task")) + FILTER(?task != ?taskRoot) + } + UNION + { + ?method ?realizesTaskPredicate ?task . + FILTER(STRENDS(STR(?realizesTaskPredicate), "realizesTask")) + } + FILTER(isIRI(?task)) + OPTIONAL { + ?task rdfs:subClassOf ?parentTask . + FILTER(STRENDS(STR(?parentTask), "Task")) + } +} +ORDER BY ?task ?parentTask +""" + + +FALLBACK_QUERY = """ +SELECT ?task ?method ?implementation ?tool ?runtime ?implementationVersion ?commandTemplate +WHERE { + ?implementation a ?implementationType . + FILTER(STRENDS(STR(?implementationType), "Implementation")) + + OPTIONAL { + ?implementation ?implementsMethodPredicate ?method . + FILTER(STRENDS(STR(?implementsMethodPredicate), "implementsMethod")) + } + OPTIONAL { + ?method ?realizesTaskPredicate ?task . + FILTER(STRENDS(STR(?realizesTaskPredicate), "realizesTask")) + } + OPTIONAL { + ?implementation ?usesToolPredicate ?tool . + FILTER(STRENDS(STR(?usesToolPredicate), "usesTool")) + } + OPTIONAL { + ?implementation ?runtimePredicate ?runtime . + FILTER(STRENDS(STR(?runtimePredicate), "runtime")) + } + OPTIONAL { + ?implementation ?implementationVersionPredicate ?implementationVersion . + FILTER(STRENDS(STR(?implementationVersionPredicate), "implementationVersion")) + } + OPTIONAL { + ?implementation ?commandTemplatePredicate ?commandTemplate . + FILTER(STRENDS(STR(?commandTemplatePredicate), "commandTemplate")) + } +} +ORDER BY ?task ?implementation +""" + +PIPELINE_RUN_QUERY = """ +PREFIX kgp: + +SELECT DISTINCT ?pipelineRun +WHERE { + ?pipelineRun a kgp:PipelineRun . +} +""" + +KG_DATA_QUERY = """ +PREFIX kgp: + +SELECT DISTINCT ?kgData +WHERE { + VALUES ?format { + ".nt" + ".ttl" + ".rdf" + ".jsonld" + } + ?kgData a kgp:Data . + ?kgData ?format . +} +""" + + +def _run_select(endpoint_url: str, query: str) -> list[dict[str, Any]]: + from SPARQLWrapper import JSON, SPARQLWrapper + + client = SPARQLWrapper(endpoint_url) + client.setQuery(query) + client.setReturnFormat(JSON) + result = client.query().convert() + return result.get("results", {}).get("bindings", []) + + +def _cell(binding: dict[str, Any], key: str) -> str: + item = binding.get(key) + if not item: + return "" + return str(item.get("value", "")) + + +def _to_task_rows(bindings: list[dict[str, Any]]) -> list[dict[str, str]]: + rows: list[dict[str, str]] = [] + for binding in bindings: + rows.append( + { + "task": _cell(binding, "task"), + "method": _cell(binding, "method"), + "implementation": _cell(binding, "implementation"), + "tool": _cell(binding, "tool"), + "runtime": _cell(binding, "runtime"), + "implementation_version": _cell(binding, "implementationVersion"), + "command_template": _cell(binding, "commandTemplate"), + } + ) + return rows + + +def _to_task_hierarchy_rows(bindings: list[dict[str, Any]]) -> list[dict[str, str]]: + rows: list[dict[str, str]] = [] + for binding in bindings: + rows.append( + { + "task": _cell(binding, "task"), + "parent_task": _cell(binding, "parentTask"), + } + ) + return rows + +def _to_pipeline_run_rows(bindings: list[dict[str, Any]]) -> list[dict[str, str]]: + rows: list[dict[str, str]] = [] + for binding in bindings: + rows.append( + { + "pipeline_run": _cell(binding, "pipelineRun"), + } + ) + print(rows) + return rows + +def _to_kg_data_rows(bindings: list[dict[str, Any]]) -> list[dict[str, str]]: + rows: list[dict[str, str]] = [] + for binding in bindings: + rows.append( + { + "kg_data": _cell(binding, "kgData"), + } + ) + return rows + + +def query_tasks_implementations(endpoint_url: str) -> pd.DataFrame: + bindings = _run_select(endpoint_url, PRIMARY_QUERY) + if not bindings: + bindings = _run_select(endpoint_url, FALLBACK_QUERY) + rows = _to_task_rows(bindings) + return pd.DataFrame(rows) + + +def query_task_hierarchy(endpoint_url: str) -> pd.DataFrame: + bindings = _run_select(endpoint_url, TASK_HIERARCHY_PRIMARY_QUERY) + if not bindings: + bindings = _run_select(endpoint_url, TASK_HIERARCHY_FALLBACK_QUERY) + rows = _to_task_hierarchy_rows(bindings) + return pd.DataFrame(rows) + +def query_pipeline_hierarchy(endpoint_url: str) -> pd.DataFrame: + bindings = _run_select(endpoint_url, PIPELINE_RUN_QUERY) + rows = _to_pipeline_run_rows(bindings) + return pd.DataFrame(rows) + +def query_evaluation_hierarchy(endpoint_url: str) -> pd.DataFrame: + # TODO: Implement evaluation hierarchy query + # bindings = _run_select(endpoint_url, EVALUATION_HIERARCHY_QUERY) + # rows = _to_evaluation_hierarchy_rows(bindings) + # return pd.DataFrame(rows) + return pd.DataFrame([]) + +def query_kg_data(endpoint_url: str) -> pd.DataFrame: + bindings = _run_select(endpoint_url, KG_DATA_QUERY) + rows = _to_kg_data_rows(bindings) + return pd.DataFrame(rows) \ No newline at end of file diff --git a/src/kgpipe_view/owl_to_mermaid.py b/src/kgpipe_view/owl_to_mermaid.py new file mode 100644 index 0000000..af9f10b --- /dev/null +++ b/src/kgpipe_view/owl_to_mermaid.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +from collections import defaultdict +from pathlib import Path +from typing import Iterable, Optional + +from rdflib import Graph +from rdflib.namespace import OWL, RDF, RDFS + + +def _local_name(uri: object) -> str: + text = str(uri) + if "#" in text: + return text.rsplit("#", maxsplit=1)[-1] + if "/" in text: + return text.rsplit("/", maxsplit=1)[-1] + return text + + +def _load_graph(ttl_path: Path) -> Graph: + graph = Graph() + graph.parse(ttl_path, format="turtle") + return graph + + +def get_available_layers(ttl_path: Path) -> list[str]: + graph = _load_graph(ttl_path) + layer_names: set[str] = set() + for class_node in graph.subjects(RDF.type, OWL.Class): + for class_type in graph.objects(class_node, RDF.type): + if class_type != OWL.Class: + layer_names.add(_local_name(class_type)) + return sorted(layer_names) + + +def _normalize_layer_filter(layer_filter: Optional[str | Iterable[str]]) -> Optional[set[str]]: + if layer_filter is None: + return None + if isinstance(layer_filter, str): + return {layer_filter} + normalized = {layer for layer in layer_filter if layer} + return normalized or None + + +def _filtered_class_names( + graph: Graph, layer_filter: Optional[str | Iterable[str]] +) -> set[str]: + all_classes = {_local_name(node) for node in graph.subjects(RDF.type, OWL.Class)} + selected_layers = _normalize_layer_filter(layer_filter) + if not selected_layers: + return all_classes + + selected: set[str] = set() + for class_node in graph.subjects(RDF.type, OWL.Class): + class_types = {_local_name(node) for node in graph.objects(class_node, RDF.type)} + if class_types.intersection(selected_layers): + selected.add(_local_name(class_node)) + return selected + + +def convert_owl_ttl_to_mermaid( + ttl_path: Path, layer_filter: Optional[str | Iterable[str]] = None +) -> str: + graph = _load_graph(ttl_path) + selected_classes = _filtered_class_names(graph, layer_filter) + + classes = sorted(selected_classes) + object_property_nodes = sorted( + set(graph.subjects(RDF.type, OWL.ObjectProperty)), key=lambda node: _local_name(node) + ) + datatype_property_nodes = sorted( + set(graph.subjects(RDF.type, OWL.DatatypeProperty)), + key=lambda node: _local_name(node), + ) + + domain_map: dict[str, list[str]] = defaultdict(list) + range_map: dict[str, list[str]] = defaultdict(list) + for prop_node in object_property_nodes + datatype_property_nodes: + prop_name = _local_name(prop_node) + for domain in graph.objects(prop_node, RDFS.domain): + domain_map[prop_name].append(_local_name(domain)) + for value_range in graph.objects(prop_node, RDFS.range): + range_map[prop_name].append(_local_name(value_range)) + + lines: list[str] = ["classDiagram", "direction LR", ""] + + for class_name in classes: + lines.append(f"class {class_name}") + + subclass_lines: list[str] = [] + for child, _, parent in graph.triples((None, RDFS.subClassOf, None)): + child_name = _local_name(child) + parent_name = _local_name(parent) + if child_name not in selected_classes or parent_name not in selected_classes: + continue + subclass_lines.append(f"{parent_name} <|-- {child_name}") + if subclass_lines: + lines.extend(["", "%% Inheritance", *sorted(set(subclass_lines))]) + + relation_lines: list[str] = [] + for prop in sorted(_local_name(node) for node in object_property_nodes): + for domain in domain_map.get(prop, []): + for value_range in range_map.get(prop, []): + if domain not in selected_classes or value_range not in selected_classes: + continue + relation_lines.append( + f'{domain} "0..*" --> "0..*" {value_range} : {prop}' + ) + if relation_lines: + lines.extend(["", "%% Object properties", *sorted(set(relation_lines))]) + + datatype_map: dict[str, list[str]] = defaultdict(list) + for prop in sorted(_local_name(node) for node in datatype_property_nodes): + for domain in domain_map.get(prop, []): + if domain not in selected_classes: + continue + value_ranges = range_map.get(prop, ["string"]) + for value_range in value_ranges: + datatype_map[domain].append(f" +{value_range} {prop}") + + if datatype_map: + lines.extend(["", "%% Datatype properties"]) + for domain in sorted(datatype_map): + lines.append(f"class {domain} {{") + lines.extend(sorted(set(datatype_map[domain]))) + lines.append("}") + + return "\n".join(lines) + "\n" + + +def convert_and_write_mermaid( + ttl_path: Path, + output_path: Path, + layer_filter: Optional[str | Iterable[str]] = None, +) -> str: + mermaid = convert_owl_ttl_to_mermaid(ttl_path, layer_filter=layer_filter) + output_path.write_text(mermaid, encoding="utf-8") + return mermaid From 7b78ea86e7fd57760ce9ee6ab88bd30566c32bc3 Mon Sep 17 00:00:00 2001 From: Marvin Hofer Date: Mon, 9 Mar 2026 21:00:41 +0100 Subject: [PATCH 18/96] Squashed commit --- docs/explorer.md | 8 ++ src/kgpipe/cli/main.py | 4 +- src/kgpipe/common/definitions.py | 149 +++++++++++++++---------------- src/kgpipe/common/systemgraph.py | 86 +++++++++++------- src/kgpipe_view/kgpipe_view.py | 11 +++ 5 files changed, 148 insertions(+), 110 deletions(-) create mode 100644 docs/explorer.md diff --git a/docs/explorer.md b/docs/explorer.md new file mode 100644 index 0000000..dc1533f --- /dev/null +++ b/docs/explorer.md @@ -0,0 +1,8 @@ +# PipeKG Explorer + +A frontend to explorer defintions and experiments of the KGpipe framework. + +``` +uv run streamlit run src/kgpipe_view/kgpipe_view.py +``` + diff --git a/src/kgpipe/cli/main.py b/src/kgpipe/cli/main.py index 0530d74..fba6104 100644 --- a/src/kgpipe/cli/main.py +++ b/src/kgpipe/cli/main.py @@ -20,7 +20,7 @@ from .clean import clean_cmd from .task import task_cmd from .discover import discover_cmd -from .rank import rank_cmd +# from .rank import rank_cmd # Initialize Rich console for pretty output console = Console() @@ -81,7 +81,7 @@ def cli(ctx: click.Context, config: Optional[str], verbose: bool, quiet: bool): cli.add_command(clean_cmd) cli.add_command(task_cmd) cli.add_command(discover_cmd) -cli.add_command(rank_cmd) +# cli.add_command(rank_cmd) if __name__ == "__main__": cli() \ No newline at end of file diff --git a/src/kgpipe/common/definitions.py b/src/kgpipe/common/definitions.py index 9db7aff..e6f6213 100644 --- a/src/kgpipe/common/definitions.py +++ b/src/kgpipe/common/definitions.py @@ -2,12 +2,7 @@ from sys import implementation from pydantic import BaseModel from typing import Optional, List, Dict, Any -# from kgcore.api.kg import KnowledgeGraph, KGProperty -# from kgcore.backend.rdf import RDFLibBackend -# from kgcore.model.rdf import RDFBaseModel -# from kgcore.system import SystemRecorder, set_default_recorder, class_, event, pydantic_model - -# TODO add annotations to the classes here +from kgcore.api.kg import KGId # Types # @@ -35,86 +30,83 @@ class DataHandle(BaseModel): # Task # -# TODO describing entity vs entity with used values for the task -class TaskConfiguration(BaseModel): - key: str - value: Any +# # TODO describing entity vs entity with used values for the task +# class TaskConfiguration(BaseModel): +# key: str +# value: Any -class Task(BaseModel): - """ - A function that implements a task in a pipeline - - name: paris_rdf_matcher - type: entity_resolution - description: "PARIS java implementation to match two RDF files, producing CSV files..." - input: [any_rdf, any_rdf] - output: [any_csv] - """ - name: str - type: str - description: Optional[str] = None - input: List[schema_format] - output: List[schema_format] +# class Task(BaseModel): +# """ +# A function that implements a task in a pipeline -class TaskResult(BaseModel): - """ - The result of a task execution including configuration variables - """ - task: Task - config: Dict[str, Any] - input: List[DataHandle] - output: List[DataHandle] - status: str - duration: float +# name: paris_rdf_matcher +# type: entity_resolution +# description: "PARIS java implementation to match two RDF files, producing CSV files..." +# input: [any_rdf, any_rdf] +# output: [any_csv] +# """ +# name: str +# type: str +# description: Optional[str] = None +# input: List[schema_format] +# output: List[schema_format] -# Evaluation # +# class TaskResult(BaseModel): +# """ +# The result of a task execution including configuration variables +# """ +# task: Task +# config: Dict[str, Any] +# input: List[DataHandle] +# output: List[DataHandle] +# status: str +# duration: float -class Eval(BaseModel): - """ - A function that evaluates data produced by tasks - """ - name: str - type: str - description: Optional[str] = None - input: List[schema_format] +# # Evaluation # -class EvalResult(BaseModel):# - """ - Result of an evaluation function - """ - eval: Eval - config: Dict[str, Any] - input: List[DataHandle] - output: Dict[str, Any] - status: str - duration: float +# class Eval(BaseModel): +# """ +# A function that evaluates data produced by tasks +# """ +# name: str +# type: str +# description: Optional[str] = None +# input: List[schema_format] -# Pipeline # +# class EvalResult(BaseModel):# +# """ +# Result of an evaluation function +# """ +# eval: Eval +# config: Dict[str, Any] +# input: List[DataHandle] +# output: Dict[str, Any] +# status: str +# duration: float -class Pipeline(BaseModel): - """ - The plan of a pipeline - """ - tasks: List[Task] - input: List[schema_format] - output: List[schema_format] +# # Pipeline # -class PipelineResult(BaseModel): - """ - Result of a pipeline execution - """ - task_results: List[TaskResult] - eval_results: List[EvalResult] - input: List[DataHandle] - output: List[DataHandle] - status: str - duration: float +# class Pipeline(BaseModel): +# """ +# The plan of a pipeline +# """ +# tasks: List[Task] +# input: List[schema_format] +# output: List[schema_format] +# pokemon +# class PipelineResult(BaseModel): +# """ +# Result of a pipeline execution +# """ +# task_results: List[TaskResult] +# eval_results: List[EvalResult] +# input: List[DataHandle] +# output: List[DataHandle] +# status: str +# duration: float # new changes # -from kgcore.api.kg import KGId - - TaskEntityId = KGId class TaskEntity(BaseModel): name: str @@ -176,6 +168,13 @@ class TaskRunEntity(BaseModel): # placeholder: str # #definesPipeline: Pipeline +# TODO issue as the Graph has no ordering of the tasks +class PipelineEntity(BaseModel): + name: str + tasks: List[TaskEntityId] + input: List[DataHandle] + output: List[DataHandle] + class PipelineRunEntity(BaseModel): """ The result of a pipeline execution diff --git a/src/kgpipe/common/systemgraph.py b/src/kgpipe/common/systemgraph.py index d652e15..8c571b6 100644 --- a/src/kgpipe/common/systemgraph.py +++ b/src/kgpipe/common/systemgraph.py @@ -11,7 +11,9 @@ from kgcore.backend.rdf.rdf_sparql import RDFSparqlBackend, SparqlAuth from kgcore.model.rdf.rdf_base import RDFBaseModel -from kgpipe.common.definitions import Task, TaskResult, Pipeline, PipelineResult, PipelineRunEntity, ImplementationEntity, MetricEntity, MetricRunEntity +from kgpipe.common.definitions import ( + TaskEntity, TaskRunEntity, PipelineEntity, PipelineRunEntity, ImplementationEntity, MetricEntity, MetricRunEntity +) from kgpipe.common.config import load_config from kgpipe.common.util import encode_string @@ -27,11 +29,11 @@ try: if scheme == "sparql": - print(f"Using SPARQL backend for system graph: {f"http://{rest}"} with http://kg.org/systemgraph") + print(f"Using SPARQL backend for system graph: {f"http://{rest}"} with http://github.com/ScaDS/kgpipe/") backend = RDFSparqlBackend( endpoint=f"http://{rest}", update_endpoint=f"http://{rest}", - default_graph="http://kg.org/systemgraph", + default_graph="http://github.com/ScaDS/kgpipe/", auth=SparqlAuth(username=config.SYS_KG_USR, password=config.SYS_KG_PSW)) else: raise ValueError(f"Unsupported schema: {scheme}") @@ -42,6 +44,11 @@ SYS_KG: KnowledgeGraph = KnowledgeGraph(model=model, backend=backend) class PipeKG: + """ + PipeKG is the system graph for the KGpipe framework. + It is a Object Graph Mapper (OGM) for the KGpipe framework. + It is used to store the entities and relations of the KGpipe framework. + """ # cached_implementations: Dict[str, KGEntity] = {} @@ -66,45 +73,44 @@ def add_task(task: "KgTask"): def list_tasks(self) -> List["KgTask"]: return SYS_KG.list_entities(types=[config.ONTOLOGY_PREFIX+"Implementation"]) - @staticmethod - def add_task_result(task_result: TaskResult): - SYS_KG.create_entity(id=new_id(),types=[config.ONTOLOGY_PREFIX+"TaskRun"], properties={ - "task": task_result.task, - "config": task_result.config, - "input": task_result.input, - "output": task_result.output, - "status": task_result.status, - "duration": task_result.duration, - }) - + # @staticmethod + # def add_task_result(task_result: TaskResult): + # SYS_KG.create_entity(id=new_id(),types=[config.ONTOLOGY_PREFIX+"TaskRun"], properties={ + # "task": task_result.task, + # "config": task_result.config, + # "input": task_result.input, + # "output": task_result.output, + # "status": task_result.status, + # "duration": task_result.duration, + # }) - @staticmethod - def add_task_run(task_run: "KgTaskReport"): - SYS_KG.create_entity(id=new_id(),types=[config.ONTOLOGY_PREFIX+"TaskReport"], properties={ - "task": task_run.task_name, - "input": [data.path for data in task_run.inputs], - "output": [data.path for data in task_run.outputs], - "status": task_run.status, - "duration": task_run.duration, - "error": task_run.error, - }) + # @staticmethod + # def add_task_run(task_run: "KgTaskReport"): + # SYS_KG.create_entity(id=new_id(),types=[config.ONTOLOGY_PREFIX+"TaskReport"], properties={ + # "task": task_run.task_name, + # "input": [data.path for data in task_run.inputs], + # "output": [data.path for data in task_run.outputs], + # "status": task_run.status, + # "duration": task_run.duration, + # "error": task_run.error, + # }) @staticmethod - def add_pipeline(pipeline: Pipeline): + def add_pipeline(pipeline: PipelineEntity): SYS_KG.create_entity(id=new_id(),types=["Pipeline"], properties={ "tasks": pipeline.tasks, "input": pipeline.input, "output": pipeline.output, }) - @staticmethod - def add_pipeline_result(pipeline_result: PipelineResult): - SYS_KG.create_entity(id=new_id(),types=["PipelineResult"], properties={ - "task_results": pipeline_result.task_results, - "eval_results": pipeline_result.eval_results, - "input": pipeline_result.input, - "output": pipeline_result.output, - }) + # @staticmethod + # def add_pipeline_result(pipeline_result: PipelineResult): + # SYS_KG.create_entity(id=new_id(),types=["PipelineResult"], properties={ + # "task_results": pipeline_result.task_results, + # "eval_results": pipeline_result.eval_results, + # "input": pipeline_result.input, + # "output": pipeline_result.output, + # }) @staticmethod def add_metric(metric: MetricEntity): @@ -164,6 +170,20 @@ def add_pipeline_run(pipeline_run: PipelineRunEntity): # return pipeline_run_entity +class MapperUtil(): + """ + Intermediate class to map the core classes to the definitions to the system graph. + Will be replaced in the future + """ + + @staticmethod + def map_task(task: "KgTask") -> TaskEntity: + return TaskEntity( + name=task.name, + input=task.input, + output=task.output, + ) + # def Track(_cls=None, *, with_timestamp: bool = False): # """ diff --git a/src/kgpipe_view/kgpipe_view.py b/src/kgpipe_view/kgpipe_view.py index 51e6a7c..8b38ad5 100644 --- a/src/kgpipe_view/kgpipe_view.py +++ b/src/kgpipe_view/kgpipe_view.py @@ -5,6 +5,7 @@ import streamlit as st import streamlit.components.v1 as components +from streamlit_elements import elements, dashboard, mui from meta_kg_query import query_task_hierarchy, query_tasks_implementations, query_pipeline_hierarchy, query_evaluation_hierarchy, query_kg_data from owl_to_mermaid import convert_and_write_mermaid, get_available_layers @@ -124,6 +125,16 @@ def _render_mermaid(mermaid_text: str, height: int = 900) -> None: else: st.dataframe(pipeline_hierarchy_df, use_container_width=True) + with elements("dashboard"): + layout = [ + dashboard.Item("item1", 0, 0, 2, 2), + dashboard.Item("item2", 2, 0, 2, 2) + ] + + with dashboard.Grid(layout): + mui.Paper("Draggable Panel 1", key="item1") + mui.Paper("Draggable Panel 2", key="item2") + with evaluations_tab: st.subheader("Evaluations") st.caption("Shows evaluation relations under `kgp:Evaluation`, including standalone evaluation nodes.") From 8ac3c99c5ff13d84b1e0ffdb31243e8a774fafb7 Mon Sep 17 00:00:00 2001 From: Marvin Date: Thu, 12 Mar 2026 21:54:13 +0100 Subject: [PATCH 19/96] chore: small fixes and missing deps --- .gitignore | 2 + pyproject.toml | 3 + src/kgpipe/cli/config.py | 6 +- src/kgpipe/common/config.py | 6 +- src/kgpipe/common/systemgraph.py | 80 +++++++- src/kgpipe/evaluation/base.py | 2 +- src/kgpipe_parameters/README.md | 19 +- src/kgpipe_view/kgpipe_view.py | 318 ++++++++++++++++++++++++++++++- src/kgpipe_view/meta_kg_query.py | 64 +++++++ 9 files changed, 479 insertions(+), 21 deletions(-) diff --git a/.gitignore b/.gitignore index 10e2992..9804c77 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ out/ .env *.env **/.env +*.mmd +*.db uv.lock .metals/ diff --git a/pyproject.toml b/pyproject.toml index ca838be..8c4e40b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,9 @@ dependencies = [ "SPARQLWrapper>=2.0.0", "redis>=7.0.0", "kgcore @ git+https://github.com/Vehnem/kgcore.git", + "streamlit-elements>=0.1.0", + "uvicorn>=0.41.0", + "fastapi>=0.135.1", ] [project.optional-dependencies] diff --git a/src/kgpipe/cli/config.py b/src/kgpipe/cli/config.py index 23da51a..0a29eeb 100644 --- a/src/kgpipe/cli/config.py +++ b/src/kgpipe/cli/config.py @@ -17,6 +17,8 @@ from rich.console import Console from rich.table import Table +from kgcore.config import HOME_CONFIG_DIR + # Initialize Rich console for pretty output console = Console() @@ -35,9 +37,9 @@ def get_default_config(): def get_config_file(): """Get the path to the configuration file.""" - config_dir = Path.home() / ".kgpipe" + config_dir = HOME_CONFIG_DIR config_dir.mkdir(exist_ok=True) - return config_dir / "config.yaml" + return config_dir / "kgpipe.yaml" def load_config(): diff --git a/src/kgpipe/common/config.py b/src/kgpipe/common/config.py index 19b2f93..3bb4a42 100644 --- a/src/kgpipe/common/config.py +++ b/src/kgpipe/common/config.py @@ -6,9 +6,9 @@ class KgPipeConfig(KGConfig): """ The configuration for kgpipe. """ - SYS_KG_URL: str = "sparql://localhost:8890/sparql-auth" #"memory://" - SYS_KG_USR: str = "dba" - SYS_KG_PSW: str = "mysecret" + SYS_KG_URL: str = "memory://" + SYS_KG_USR: str = "" + SYS_KG_PSW: str = "" ONTOLOGY_PREFIX: str = "http://github.com/ScaDS/kgpipe/ontology/" PIPEKG_PREFIX: str = "http://github.com/ScaDS/kgpipe/resource/" diff --git a/src/kgpipe/common/systemgraph.py b/src/kgpipe/common/systemgraph.py index 8c571b6..e814f4b 100644 --- a/src/kgpipe/common/systemgraph.py +++ b/src/kgpipe/common/systemgraph.py @@ -1,4 +1,5 @@ import functools +import ast from uuid import uuid4 from typing import Any, List, TYPE_CHECKING from pydantic import BaseModel @@ -70,8 +71,83 @@ def add_task(task: "KgTask"): }) SYS_KG.create_relation(type="output", source=task_entity.id, target=output_entity.id) - def list_tasks(self) -> List["KgTask"]: - return SYS_KG.list_entities(types=[config.ONTOLOGY_PREFIX+"Implementation"]) + @staticmethod + def _prop_value(properties: List[KGProperty], *keys: str) -> Any: + """Find a property value by exact key or key suffix.""" + for prop in properties: + if prop.key in keys: + return prop.value + for prop in properties: + for key in keys: + if prop.key.endswith(key): + return prop.value + return None + + @staticmethod + def _to_list(value: Any) -> List[str]: + """Normalize KG property values to list[str].""" + if value is None: + return [] + if isinstance(value, list): + return [str(v) for v in value] + if isinstance(value, tuple): + return [str(v) for v in value] + if isinstance(value, str): + text = value.strip() + if not text: + return [] + # Stored literals may contain Python-list string repr. + if text.startswith("[") and text.endswith("]"): + try: + parsed = ast.literal_eval(text) + except (ValueError, SyntaxError): + return [text] + if isinstance(parsed, list): + return [str(v) for v in parsed] + return [text] + return [str(value)] + + def list_taskImplementations(self) -> List[ImplementationEntity]: + entities = SYS_KG.find_entities(types=[config.ONTOLOGY_PREFIX + "Implementation"]) + implementations: List[ImplementationEntity] = [] + + for entity in entities: + name_value = self._prop_value(entity.properties, "name", config.ONTOLOGY_PREFIX + "name") + if not name_value: + # Fallback: derive a readable name from implementation IRI. + name_value = str(entity.id).rstrip("/").split("/")[-1] + + implements_method_value = self._prop_value( + entity.properties, + "implementsMethod", + config.ONTOLOGY_PREFIX + "implementsMethod", + ) + uses_tool_value = self._prop_value( + entity.properties, + "usesTool", + config.ONTOLOGY_PREFIX + "usesTool", + ) + has_parameter_value = self._prop_value( + entity.properties, + "hasParameter", + config.ONTOLOGY_PREFIX + "hasParameter", + ) + + implementations.append( + ImplementationEntity( + name=str(name_value), + implementsMethod=self._to_list(implements_method_value), + hasParameter=self._to_list(has_parameter_value), + usesTool=self._to_list(uses_tool_value), + ) + ) + + return implementations + + def list_tasks(self) -> List[ImplementationEntity]: + """Backward-compatible alias used by existing UI code.""" + return self.list_taskImplementations() + # @staticmethod # def add_task_result(task_result: TaskResult): diff --git a/src/kgpipe/evaluation/base.py b/src/kgpipe/evaluation/base.py index 1a2fb60..e5c1675 100644 --- a/src/kgpipe/evaluation/base.py +++ b/src/kgpipe/evaluation/base.py @@ -168,7 +168,7 @@ def __init__(self, name: str, description: str, aspect: EvaluationAspect, metric self.metricConfig = metricConfig @abstractmethod - def compute(self, kg, **kwargs) -> MetricResult: + def compute(self, kg, **kwargs) -> MetricResult | List[MetricResult]: """Compute the metric value for the given KG.""" pass diff --git a/src/kgpipe_parameters/README.md b/src/kgpipe_parameters/README.md index 692f8ff..8619136 100644 --- a/src/kgpipe_parameters/README.md +++ b/src/kgpipe_parameters/README.md @@ -1,5 +1,7 @@ # KGpipe Parameters +Subpackage to analyze and optimize paramters for data integration tasks + 1. Extract/Find configuration Parameters for a Task T and its implementations I 2. Match and cluster configuration parameters 3. Find best configuration parameters @@ -7,9 +9,9 @@ ## TODOs -- [ ] Allow adding parameters to KgTask -- [ ] Describe in SysKg - +- [ ] Adding parameters to KgTask.run(file_input,file_output,parameters) +- [ ] Store extraction results in a structured way: provenance, assignment, descriptions +- [ ] Cluster paramters: same task (triple extract, cleaning, entity resolution) ## Parameter Mining @@ -17,9 +19,20 @@ Methods to find parameter or settings for codeing libraries, CLI, or remote APIs Inputs - api documentation +- Readmes +- command help output - code files Methods - regex - llm +## Clustering +... + +## Description +... + +## Optimization +... + diff --git a/src/kgpipe_view/kgpipe_view.py b/src/kgpipe_view/kgpipe_view.py index 8b38ad5..d3fc2db 100644 --- a/src/kgpipe_view/kgpipe_view.py +++ b/src/kgpipe_view/kgpipe_view.py @@ -2,14 +2,118 @@ import json from pathlib import Path +from uuid import uuid4 import streamlit as st import streamlit.components.v1 as components -from streamlit_elements import elements, dashboard, mui from meta_kg_query import query_task_hierarchy, query_tasks_implementations, query_pipeline_hierarchy, query_evaluation_hierarchy, query_kg_data from owl_to_mermaid import convert_and_write_mermaid, get_available_layers +from kgpipe.common.systemgraph import PipeKG + +def _entity_to_task_label(entity) -> str: + """Best-effort conversion from implementation-like object to task label.""" + name = getattr(entity, "name", "") or "" + return _task_label_to_base_name(str(name)) + + +def get_tasks() -> list: + """Load available task implementation entities from PipeKG.""" + return PipeKG().list_taskImplementations() + + +def _task_label_to_base_name(label: str) -> str: + base = str(label or "").strip() + if base.endswith("Impl"): + return base[:-4] + return base + + +@st.cache_data(show_spinner=False) +def _get_task_io_specs() -> dict[str, dict[str, set[str]]]: + return PipeKG().list_task_io_specs() + + +def _is_compatible( + previous_task_name: str, + next_task_name: str, + specs: dict[str, dict[str, set[str]]], +) -> bool: + previous_outputs = specs.get(_task_label_to_base_name(previous_task_name), {}).get("outputs", set()) + next_inputs = specs.get(_task_label_to_base_name(next_task_name), {}).get("inputs", set()) + + # Permissive fallback: if specs are missing, do not block composition. + if not previous_outputs or not next_inputs: + return True + return bool(previous_outputs.intersection(next_inputs)) + + +def _shared_formats( + source_task_name: str, + target_task_name: str, + specs: dict[str, dict[str, set[str]]], +) -> set[str]: + source_outputs = specs.get(_task_label_to_base_name(source_task_name), {}).get("outputs", set()) + target_inputs = specs.get(_task_label_to_base_name(target_task_name), {}).get("inputs", set()) + if not source_outputs or not target_inputs: + return {"*"} + return source_outputs.intersection(target_inputs) + + +def _edge_label(formats: set[str]) -> str: + if not formats: + return "" + if formats == {"*"}: + return "any" + return ", ".join(sorted(formats)) + + +def _task_io_summary(task_name: str, specs: dict[str, dict[str, set[str]]]) -> str: + task_spec = specs.get(_task_label_to_base_name(task_name), {}) + inputs = sorted(task_spec.get("inputs", set())) + outputs = sorted(task_spec.get("outputs", set())) + in_text = ", ".join(inputs) if inputs else "-" + out_text = ", ".join(outputs) if outputs else "-" + return f"in: {in_text} | out: {out_text}" + + +def _task_options_from_implementations(implementations: list) -> list[str]: + labels = {_entity_to_task_label(entity) for entity in implementations} + return sorted(label for label in labels if label) + + +def _pipeline_to_mermaid( + pipeline_nodes: list[dict[str, str]], + pipeline_edges: list[dict[str, object]], +) -> str: + if not pipeline_nodes: + return "flowchart LR\n empty[Empty pipeline]" + + def _safe_node_id(raw: str) -> str: + return "n_" + "".join(ch if ch.isalnum() else "_" for ch in raw) + + node_ids = {node["id"] for node in pipeline_nodes} + lines = ["flowchart LR"] + for node in pipeline_nodes: + node_id = _safe_node_id(node["id"]) + label = node["name"].replace(chr(34), chr(39)) + lines.append(f'{node_id}["{label}"]') + + for edge in pipeline_edges: + source = str(edge.get("from", "")) + target = str(edge.get("to", "")) + if source not in node_ids or target not in node_ids or source == target: + continue + src = _safe_node_id(source) + dst = _safe_node_id(target) + formats = set(edge.get("formats", [])) + label = _edge_label(formats) + if label: + lines.append(f'{src} -->|{label}| {dst}') + else: + lines.append(f"{src} --> {dst}") + return "\n".join(lines) def _render_mermaid(mermaid_text: str, height: int = 900) -> None: """Render Mermaid source in Streamlit using Mermaid JS.""" @@ -125,15 +229,209 @@ def _render_mermaid(mermaid_text: str, height: int = 900) -> None: else: st.dataframe(pipeline_hierarchy_df, use_container_width=True) - with elements("dashboard"): - layout = [ - dashboard.Item("item1", 0, 0, 2, 2), - dashboard.Item("item2", 2, 0, 2, 2) - ] - - with dashboard.Grid(layout): - mui.Paper("Draggable Panel 1", key="item1") - mui.Paper("Draggable Panel 2", key="item2") + st.divider() + st.subheader("Pipeline builder") + st.caption("Compose a DAG pipeline with reusable outputs and parallel branches.") + + if "pipeline_nodes" not in st.session_state: + legacy_steps = st.session_state.get("pipeline_steps", []) + migrated_nodes: list[dict[str, str]] = [] + if legacy_steps and isinstance(legacy_steps[0], str): + migrated_nodes = [ + {"id": f"task-{uuid4().hex[:8]}", "name": task_name} + for task_name in legacy_steps + ] + elif legacy_steps: + migrated_nodes = legacy_steps + st.session_state.pipeline_nodes = migrated_nodes + st.session_state.pipeline_edges = [] + for idx in range(len(migrated_nodes) - 1): + st.session_state.pipeline_edges.append( + { + "from": migrated_nodes[idx]["id"], + "to": migrated_nodes[idx + 1]["id"], + "formats": [], + } + ) + st.session_state.pop("pipeline_steps", None) + if "pipeline_edges" not in st.session_state: + st.session_state.pipeline_edges = [] + + implementations = get_tasks() + task_options = _task_options_from_implementations(implementations) + task_specs = _get_task_io_specs() + + pipeline_nodes = st.session_state.pipeline_nodes + pipeline_edges = st.session_state.pipeline_edges + + def _node_label(node: dict[str, str]) -> str: + return f'{node["name"]} ({node["id"][-6:]})' + + if not task_options: + st.info("No task implementations found in PipeKG yet.") + else: + task_option_labels = { + task_name: f"{task_name} [{_task_io_summary(task_name, task_specs)}]" + for task_name in task_options + } + selected_task_name = st.selectbox( + "Task to add", + options=task_options, + key="pipeline_builder_selected_task", + format_func=lambda task_name: task_option_labels.get(task_name, task_name), + ) + st.caption(f"Selected task spec: `{_task_io_summary(selected_task_name, task_specs)}`") + + selected_source_id = None + if pipeline_nodes: + source_options = [{"label": "No dependency (new branch/root)", "id": None}] + for node in pipeline_nodes: + formats = _shared_formats(node["name"], selected_task_name, task_specs) + if formats: + source_options.append( + { + "label": f'{_node_label(node)} [{_edge_label(formats)}]', + "id": node["id"], + } + ) + + source_label = st.selectbox( + "Connect new task from", + options=[opt["label"] for opt in source_options], + key="pipeline_builder_selected_source", + help="Pick an upstream task output to reuse, or create a root branch with no dependency.", + ) + source_lookup = {opt["label"]: opt["id"] for opt in source_options} + selected_source_id = source_lookup[source_label] + else: + st.caption("First task creates the first root in the pipeline.") + + add_col, remove_col, clear_col = st.columns(3) + with add_col: + if st.button("Add task node", key="pipeline_builder_add_task"): + new_id = f"task-{uuid4().hex[:8]}" + st.session_state.pipeline_nodes.append({"id": new_id, "name": selected_task_name}) + if selected_source_id is not None: + source_node = next( + (node for node in st.session_state.pipeline_nodes if node["id"] == selected_source_id), + None, + ) + formats = set() + if source_node is not None: + formats = _shared_formats(source_node["name"], selected_task_name, task_specs) + st.session_state.pipeline_edges.append( + { + "from": selected_source_id, + "to": new_id, + "formats": sorted(formats), + } + ) + st.rerun() + with remove_col: + if st.button("Remove last task node", key="pipeline_builder_remove_last"): + if st.session_state.pipeline_nodes: + removed = st.session_state.pipeline_nodes.pop() + removed_id = removed["id"] + st.session_state.pipeline_edges = [ + edge + for edge in st.session_state.pipeline_edges + if edge.get("from") != removed_id and edge.get("to") != removed_id + ] + st.rerun() + with clear_col: + if st.button("Clear pipeline", key="pipeline_builder_clear"): + st.session_state.pipeline_nodes = [] + st.session_state.pipeline_edges = [] + st.session_state.pop("pipeline_builder_selected_task", None) + st.session_state.pop("pipeline_builder_selected_source", None) + st.rerun() + + if len(st.session_state.pipeline_nodes) >= 2: + st.markdown("**Connect existing tasks**") + id_to_node = {node["id"]: node for node in st.session_state.pipeline_nodes} + source_node_id = st.selectbox( + "From task", + options=[node["id"] for node in st.session_state.pipeline_nodes], + format_func=lambda node_id: _node_label(id_to_node[node_id]), + key="pipeline_builder_connect_source", + ) + existing_pairs = { + (edge.get("from"), edge.get("to")) for edge in st.session_state.pipeline_edges + } + target_candidates: list[tuple[str, str]] = [] + for node in st.session_state.pipeline_nodes: + if node["id"] == source_node_id: + continue + if (source_node_id, node["id"]) in existing_pairs: + continue + source_node = id_to_node[source_node_id] + formats = _shared_formats(source_node["name"], node["name"], task_specs) + if formats: + target_candidates.append( + (node["id"], f'{_node_label(node)} [{_edge_label(formats)}]') + ) + + if target_candidates: + target_label = st.selectbox( + "To task", + options=[label for _, label in target_candidates], + key="pipeline_builder_connect_target", + ) + target_lookup = {label: node_id for node_id, label in target_candidates} + target_node_id = target_lookup[target_label] + if st.button("Add dependency edge", key="pipeline_builder_add_edge"): + source_node = id_to_node[source_node_id] + target_node = id_to_node[target_node_id] + formats = _shared_formats(source_node["name"], target_node["name"], task_specs) + st.session_state.pipeline_edges.append( + { + "from": source_node_id, + "to": target_node_id, + "formats": sorted(formats), + } + ) + st.rerun() + else: + st.caption("No additional compatible target task found for this source.") + + if pipeline_nodes: + st.caption("Current pipeline") + st.write(", ".join(_node_label(node) for node in pipeline_nodes)) + st.markdown("**Pipeline graph**") + _render_mermaid(_pipeline_to_mermaid(pipeline_nodes, pipeline_edges), height=300) + + incoming: dict[str, int] = {node["id"]: 0 for node in pipeline_nodes} + outgoing: dict[str, int] = {node["id"]: 0 for node in pipeline_nodes} + for edge in pipeline_edges: + source = edge.get("from") + target = edge.get("to") + if source in outgoing: + outgoing[source] += 1 + if target in incoming: + incoming[target] += 1 + + id_to_node = {node["id"]: node for node in pipeline_nodes} + roots = [id_to_node[node_id] for node_id, count in incoming.items() if count == 0] + leaves = [id_to_node[node_id] for node_id, count in outgoing.items() if count == 0] + + st.markdown("**Loose ends**") + roots_text = ", ".join(_node_label(node) for node in roots) if roots else "none" + leaves_text = ", ".join(_node_label(node) for node in leaves) if leaves else "none" + st.caption(f"Open inputs (roots): {roots_text}") + st.caption(f"Open outputs (leaves): {leaves_text}") + + st.code( + json.dumps( + { + "nodes": pipeline_nodes, + "edges": pipeline_edges, + }, + indent=2, + ), + language="json", + ) + else: + st.info("Your pipeline is empty. Add one or more task nodes to start building.") with evaluations_tab: st.subheader("Evaluations") diff --git a/src/kgpipe_view/meta_kg_query.py b/src/kgpipe_view/meta_kg_query.py index 5402fe3..3e3c398 100644 --- a/src/kgpipe_view/meta_kg_query.py +++ b/src/kgpipe_view/meta_kg_query.py @@ -144,6 +144,51 @@ } """ +TASK_IO_SPECS_QUERY = """ +PREFIX kgp: + +SELECT DISTINCT ?implementation ?ioType ?format +WHERE { + ?implementation a kgp:Implementation . + ?implementation ?ioPredicate ?dataNode . + ?dataNode ?formatPredicate ?format . + FILTER(isIRI(?implementation)) + FILTER(isIRI(?dataNode)) + FILTER( + STRENDS(STR(?ioPredicate), "input") + || STRENDS(STR(?ioPredicate), "output") + ) + FILTER(STRENDS(STR(?formatPredicate), "format")) + BIND( + IF(STRENDS(STR(?ioPredicate), "input"), "input", "output") + AS ?ioType + ) +} +ORDER BY ?implementation ?ioType ?format +""" + +TASK_IO_SPECS_FALLBACK_QUERY = """ +SELECT DISTINCT ?implementation ?ioType ?format +WHERE { + ?implementation a ?implementationType . + FILTER(STRENDS(STR(?implementationType), "Implementation")) + ?implementation ?ioPredicate ?dataNode . + ?dataNode ?formatPredicate ?format . + FILTER(isIRI(?implementation)) + FILTER(isIRI(?dataNode)) + FILTER( + STRENDS(STR(?ioPredicate), "input") + || STRENDS(STR(?ioPredicate), "output") + ) + FILTER(STRENDS(STR(?formatPredicate), "format")) + BIND( + IF(STRENDS(STR(?ioPredicate), "input"), "input", "output") + AS ?ioType + ) +} +ORDER BY ?implementation ?ioType ?format +""" + def _run_select(endpoint_url: str, query: str) -> list[dict[str, Any]]: from SPARQLWrapper import JSON, SPARQLWrapper @@ -211,6 +256,18 @@ def _to_kg_data_rows(bindings: list[dict[str, Any]]) -> list[dict[str, str]]: ) return rows +def _to_task_io_rows(bindings: list[dict[str, Any]]) -> list[dict[str, str]]: + rows: list[dict[str, str]] = [] + for binding in bindings: + rows.append( + { + "implementation": _cell(binding, "implementation"), + "io_type": _cell(binding, "ioType"), + "format": _cell(binding, "format"), + } + ) + return rows + def query_tasks_implementations(endpoint_url: str) -> pd.DataFrame: bindings = _run_select(endpoint_url, PRIMARY_QUERY) @@ -242,4 +299,11 @@ def query_evaluation_hierarchy(endpoint_url: str) -> pd.DataFrame: def query_kg_data(endpoint_url: str) -> pd.DataFrame: bindings = _run_select(endpoint_url, KG_DATA_QUERY) rows = _to_kg_data_rows(bindings) + return pd.DataFrame(rows) + +def query_task_io_specs(endpoint_url: str) -> pd.DataFrame: + bindings = _run_select(endpoint_url, TASK_IO_SPECS_QUERY) + if not bindings: + bindings = _run_select(endpoint_url, TASK_IO_SPECS_FALLBACK_QUERY) + rows = _to_task_io_rows(bindings) return pd.DataFrame(rows) \ No newline at end of file From bb59873426af7e97bd362653b2a482b99d643907 Mon Sep 17 00:00:00 2001 From: Marvin Date: Thu, 12 Mar 2026 21:57:28 +0100 Subject: [PATCH 20/96] chore: rm backlog.md, rm old mcp impls --- Backlog.md | 11 ------- rm/mcp_config.yaml | 10 ------- rm/mcp_server.py | 71 ---------------------------------------------- 3 files changed, 92 deletions(-) delete mode 100644 Backlog.md delete mode 100644 rm/mcp_config.yaml delete mode 100644 rm/mcp_server.py diff --git a/Backlog.md b/Backlog.md deleted file mode 100644 index f802e65..0000000 --- a/Backlog.md +++ /dev/null @@ -1,11 +0,0 @@ - - - -### Implement System KG with KG core - -Definitions -- Data Artifacts -- Configuration Options -- Tasks/Tools Function -- Pipelines -- Evaluation Function \ No newline at end of file diff --git a/rm/mcp_config.yaml b/rm/mcp_config.yaml deleted file mode 100644 index e4fc21c..0000000 --- a/rm/mcp_config.yaml +++ /dev/null @@ -1,10 +0,0 @@ -# MCP Configuration for Codex -# This file configures the MCP server for use with Codex (MCP client) - -[mcp_servers.kgbench-mcp-server] -command = "python" -args = ["/home/marvin/project/code/kgflex/src/kgbench/mcp_server.py"] -env = { "PYTHONPATH" = "/home/marvin/micromamba/envs/geneval/bin/python" } -cwd = "/home/marvin/project/code/kgflex/src/kgbench" -timeout = 30 -logLevel = "info" diff --git a/rm/mcp_server.py b/rm/mcp_server.py deleted file mode 100644 index 5ae11f5..0000000 --- a/rm/mcp_server.py +++ /dev/null @@ -1,71 +0,0 @@ -# pip install mcp -from mcp.server import FastMCP -import os - -# Create a dummy release library for demonstration -class DummyReleaseLib: - def create(self, dataset_id, version, channel, notes="", actor=None): - release_id = f"rel_{dataset_id}_{version}_{channel}_{hash(str(actor)) % 10000}" - print(f"Created release: {release_id} for dataset {dataset_id} v{version} ({channel})") - return release_id - - def publish(self, release_id, actor=None): - class PublishResult: - status = "published" - public_url = f"https://releases.example.com/{release_id}" - return PublishResult() - -your_release_lib = DummyReleaseLib() - -# Create MCP server using FastMCP -mcp = FastMCP("kgpipe-mcp-server") - -@mcp.tool() -def create_release(dataset_id: str, version: str, channel: str, notes: str = "") -> str: - """Create a new dataset release - - Args: - dataset_id: ID of the dataset - version: Version number - channel: Release channel (dev, rc, or prod) - notes: Release notes - """ - rid = your_release_lib.create(dataset_id, version, channel, notes, "user") - return f"Release created: {rid}" - -@mcp.tool() -def publish_release(release_id: str) -> str: - """Publish a release to make it public - - Args: - release_id: ID of the release to publish - """ - result = your_release_lib.publish(release_id, "user") - return f"Release published: {result.public_url}" - -@mcp.resource("policy://release") -def get_release_policy() -> str: - """Get the release policy document""" - return """# Release Policy - -## Overview -This document outlines the policy for creating and publishing dataset releases. - -## Release Channels -- **dev**: Development releases for testing -- **rc**: Release candidates for final testing -- **prod**: Production releases for general use - -## Process -1. Create a release using the create_release tool -2. Test the release thoroughly -3. Publish the release using the publish_release tool - -## Guidelines -- Always include meaningful release notes -- Test in dev channel before promoting to rc -- Only promote to prod after thorough testing -""" - -if __name__ == "__main__": - mcp.run() From c614eb54f9548c4712f1834849334a5ab41838ea Mon Sep 17 00:00:00 2001 From: Marvin Date: Sun, 15 Mar 2026 20:18:43 +0100 Subject: [PATCH 21/96] exp(moviekg): ranking changes --- .../moviekg/evaluation/test_sensitivity.py | 55 +++++++++++++++++++ .../src/moviekg/paper/helpers/getter.py | 30 +++++++++- .../src/moviekg/paper/helpers/ranking.py | 12 ++-- .../moviekg/src/moviekg/paper/test_figtab.py | 16 ++++-- 4 files changed, 99 insertions(+), 14 deletions(-) create mode 100644 experiments/moviekg/src/moviekg/evaluation/test_sensitivity.py diff --git a/experiments/moviekg/src/moviekg/evaluation/test_sensitivity.py b/experiments/moviekg/src/moviekg/evaluation/test_sensitivity.py new file mode 100644 index 0000000..83e1154 --- /dev/null +++ b/experiments/moviekg/src/moviekg/evaluation/test_sensitivity.py @@ -0,0 +1,55 @@ +from dataclasses import dataclass +from typing import List +from kgpipe.evaluation.aspects.reference import ReferenceEvaluator, ReferenceConfig + +@dataclass +class BinaryClassifier: + tp: int + fp: int + tn: int + fn: int + + def accuracy(self) -> float: + return (self.tp + self.tn) / (self.tp + self.tn + self.fp + self.fn) + + def precision(self) -> float: + return self.tp / (self.tp + self.fp) + +@dataclass +class ThresholdSensitivityResult: + pipeline_name: str + threshold: float + result: BinaryClassifier + + + +reference_evaluator = ReferenceEvaluator() + +def paris_threshold_sensitivity(pipeline_name: str, threshold: float) -> List[ThresholdSensitivityResult]: + ReferenceConfig( + ENTITY_MATCH_THRESHOLD=threshold + RELATION_MATCH_THRESHOLD=threshold + ) # TODO get config from dataset + # kg = KG(path=Path(f"data/moviekg/paris/{pipeline_name}.nt")) + # reference_kg = KG(path=Path("data/moviekg/paris/reference.nt")) + # result = reference_evaluator.evaluate(kg, reference_kg) + # return result + pass + +def jedai_threshold_sensitivity(pipeline_name: str, threshold: float) -> List[ThresholdSensitivityResult]: + pass + +def valentine_threshold_sensitivity(pipeline_name: str, threshold: float) -> List[ThresholdSensitivityResult]: + pass + +def corenlp_openie_threshold_sensitivity(pipeline_name: str, threshold: float) -> List[ThresholdSensitivityResult]: + pass + +def dbpedia_spotlight_threshold_sensitivity(pipeline_name: str, threshold: float) -> List[ThresholdSensitivityResult]: + pass + +def custom_relation_linking_threshold_sensitivity(pipeline_name: str, threshold: float) -> List[ThresholdSensitivityResult]: + pass + +def custom_entity_linking_threshold_sensitivity(pipeline_name: str, threshold: float) -> List[ThresholdSensitivityResult]: + pass \ No newline at end of file diff --git a/experiments/moviekg/src/moviekg/paper/helpers/getter.py b/experiments/moviekg/src/moviekg/paper/helpers/getter.py index 7c5bec0..7d07cb8 100644 --- a/experiments/moviekg/src/moviekg/paper/helpers/getter.py +++ b/experiments/moviekg/src/moviekg/paper/helpers/getter.py @@ -163,6 +163,24 @@ def ref_source_entity_r(df: pd.DataFrame): res[row.pipeline][row.stage] = recall return res +def ref_source_typed_entity_p(df: pd.DataFrame): + df = df[df["metric"] == "SourceTypedEntityCoverageMetric"] + res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) + for row in df.itertuples(): + details = json.loads(row.details) + precision = details["precision"] + res[row.pipeline][row.stage] = precision + return res + +def ref_source_typed_entity_r(df: pd.DataFrame): + df = df[df["metric"] == "SourceTypedEntityCoverageMetric"] + res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) + for row in df.itertuples(): + details = json.loads(row.details) + recall = details["recall"] + res[row.pipeline][row.stage] = recall + return res + def ref_entity_matching_f1(df: pd.DataFrame): df = df[df["metric"] == "ER_EntityMatchMetric"] @@ -351,6 +369,8 @@ def ref_json_entity_linking_r(df: pd.DataFrame): ref_source_entity_f1.__name__: "VSEC", ref_source_entity_p.__name__: "VSEC-P", ref_source_entity_r.__name__: "VSEC-R", + ref_source_typed_entity_p.__name__: "VSEC-P-TE", + ref_source_typed_entity_r.__name__: "VSEC-R-TE", ref_entity_matching_f1.__name__: "ER-EM", ref_entity_matching_p.__name__: "ER-EM-P", ref_entity_matching_r.__name__: "ER-EM-R", @@ -499,7 +519,13 @@ def apply_selected_updates(psmd: pipeline_stage_metric_dict) -> pipeline_stage_m agg_metric_over_stages(psmd, "ref_selected_task_metric", "_avg", agg_avg) agg_metric_over_stages(psmd, "sta_duration", "_sum", agg_sum) agg_metric_over_stages(psmd, "ref_source_entity_f1", "_avg", agg_avg) - agg_metric_over_stages(psmd, "ref_kg_f1", "_avg", agg_avg) + agg_metric_over_stages(psmd, "ref_source_entity_p", "_avg", agg_avg) + agg_metric_over_stages(psmd, "ref_source_entity_r", "_avg", agg_avg) + agg_metric_over_stages(psmd, "ref_source_typed_entity_p", "_avg", agg_avg) + agg_metric_over_stages(psmd, "ref_source_typed_entity_r", "_avg", agg_avg) + # agg_metric_over_stages(psmd, "ref_kg_f1", "_avg", agg_avg) + # agg_metric_over_stages(psmd, "ref_kg_p", "_avg", agg_avg) + # agg_metric_over_stages(psmd, "ref_kg_r", "_avg", agg_avg) return psmd def test_getter(): @@ -522,4 +548,4 @@ def test_getter(): print(pipeline) print(stage) print(json.dumps(metric_dict, indent=4)) - print("--------------------------------") \ No newline at end of file + print("--------------------------------") diff --git a/experiments/moviekg/src/moviekg/paper/helpers/ranking.py b/experiments/moviekg/src/moviekg/paper/helpers/ranking.py index 5d34668..e181e15 100644 --- a/experiments/moviekg/src/moviekg/paper/helpers/ranking.py +++ b/experiments/moviekg/src/moviekg/paper/helpers/ranking.py @@ -8,7 +8,7 @@ TABLE_DISPLAY_NAMES, normalize_metric, normalize_min_best, normalize_max_best, sta_fact_count, sta_denisity, sta_duration, #memory_peak is not considered - ref_kg_p, ref_source_entity_f1, + ref_kg_p, ref_kg_r, ref_source_entity_f1, ref_source_entity_r, ref_source_entity_p, sem_disjoint_domain, sem_incorrect_relation_direction, sem_incorrect_relation_range, sem_incorrect_relation_domain, sem_incorrect_datatype, sem_incorrect_datatype_format ) @@ -99,11 +99,11 @@ def _rank_and_save2csv(weights: dict, outfile_stem: str, psmd: pipeline_stage_me out = df[["pipeline", "combined"]].sort_values(by="combined", ascending=False) out.to_csv(OUTPUT_ROOT / f"paper/{outfile_stem}.csv", sep="\t") -def _rank_and_save3csv(weights: dict, outfile_stem: str, psmd: pipeline_stage_metric_dict, round_digits: int = 3) -> pd.DataFrame: +def _rank_and_save3csv(outfile_stem: str, psmd: pipeline_stage_metric_dict, round_digits: int = 3) -> pd.DataFrame: # psmd = normalize_metric(psmd, sta_fact_count.__name__, ["stage_3"], normalize_max_best) - psmd = normalize_metric(psmd, sta_denisity.__name__, ["stage_3"], normalize_max_best) - psmd = normalize_metric(psmd, sta_fact_count.__name__, ["stage_3"], normalize_max_best) + # psmd = normalize_metric(psmd, sta_denisity.__name__, ["stage_3"], normalize_max_best) + # psmd = normalize_metric(psmd, sta_fact_count.__name__, ["stage_3"], normalize_max_best) # sta_metric_names = [sta_denisity.__name__+"_norm", sta_fact_count.__name__+"_norm"] # sta_agg = agg_metrics(psmd, sta_metric_names) @@ -113,10 +113,10 @@ def _rank_and_save3csv(weights: dict, outfile_stem: str, psmd: pipeline_stage_me sem_incorrect_datatype.__name__, sem_incorrect_datatype_format.__name__] sem_agg = agg_metrics(psmd, sem_metric_names) - acc_metric_names = [ref_kg_p.__name__] + acc_metric_names = [ref_kg_p.__name__] # only final stage (3) acc_agg = agg_metrics(psmd, acc_metric_names) - cov_metric_names = [ref_source_entity_f1.__name__+"_avg"] + cov_metric_names = [ref_source_typed_entity_r.__name__+"_avg"] # avg of all stages cov_agg = agg_metrics(psmd, cov_metric_names) # psmd = normalize_metric(psmd, sta_duration.__name__+"_sum", ["stage_3"], normalize_min_best) diff --git a/experiments/moviekg/src/moviekg/paper/test_figtab.py b/experiments/moviekg/src/moviekg/paper/test_figtab.py index 8b425a4..bee9967 100644 --- a/experiments/moviekg/src/moviekg/paper/test_figtab.py +++ b/experiments/moviekg/src/moviekg/paper/test_figtab.py @@ -456,11 +456,11 @@ def test_table_6(): metric_df = load_metrics_from_file(OUTPUT_ROOT / "all_metrics.csv") metric_df["pipeline"] = metric_df["pipeline"].map(map_pipeline_name_pretty) from moviekg.paper.helpers.getter import ( - get_pipeline_stage_metric_dict, ref_kg_f1, ref_kg_p, ref_kg_r, ref_source_entity_f1, ref_source_entity_p, ref_source_entity_r + get_pipeline_stage_metric_dict, ref_kg_f1, ref_kg_p, ref_kg_r, ref_source_entity_f1, ref_source_entity_p, ref_source_entity_r, ref_source_typed_entity_r, ref_source_typed_entity_p ) metrics = [ - ref_kg_f1.__name__, ref_kg_p.__name__, ref_kg_r.__name__, ref_source_entity_f1.__name__, ref_source_entity_p.__name__, ref_source_entity_r.__name__ + ref_kg_f1.__name__, ref_kg_p.__name__, ref_kg_r.__name__, ref_source_entity_f1.__name__, ref_source_entity_p.__name__, ref_source_entity_r.__name__, ref_source_typed_entity_r.__name__, ref_source_typed_entity_p.__name__ ] psmd = get_pipeline_stage_metric_dict(metric_df, metrics) @@ -469,7 +469,7 @@ def test_table_6(): rows = [] - round_to = 2 + round_to = 3 for pipeline, stage_dict in psmd.items(): if pipeline in ["reference", "seed"]: @@ -478,18 +478,22 @@ def test_table_6(): kg_r = [0, 0, 0] se_p = [0, 0, 0] se_r = [0, 0, 0] - + ste_p = [0, 0, 0] + ste_r = [0, 0, 0] for stage, metric_dict in stage_dict.items(): kg_p[int(stage.split("_")[1]) - 1] = round(metric_dict.get(ref_kg_p.__name__, -1), round_to) kg_r[int(stage.split("_")[1]) - 1] = round(metric_dict.get(ref_kg_r.__name__, -1), round_to) se_p[int(stage.split("_")[1]) - 1] = round(metric_dict.get(ref_source_entity_p.__name__, -1), round_to) se_r[int(stage.split("_")[1]) - 1] = round(metric_dict.get(ref_source_entity_r.__name__, -1), round_to) + ste_p[int(stage.split("_")[1]) - 1] = round(metric_dict.get(ref_source_typed_entity_p.__name__, -1), round_to) + ste_r[int(stage.split("_")[1]) - 1] = round(metric_dict.get(ref_source_typed_entity_r.__name__, -1), round_to) rows.append({ "pipeline": pipeline, "kg_p@1": kg_p[0], "kg_r@1": kg_r[0], "kg_p@2": kg_p[1], "kg_r@2": kg_r[1], "kg_p@3": kg_p[2], "kg_r@3": kg_r[2], - "se_p@1": se_p[0], "se_r@1": se_r[0], "se_p@2": se_p[1], "se_r@2": se_r[1], "se_p@3": se_p[2], "se_r@3": se_r[2]}) + "se_p@1": se_p[0], "se_r@1": se_r[0], "se_p@2": se_p[1], "se_r@2": se_r[1], "se_p@3": se_p[2], "se_r@3": se_r[2], + "ste_p@1": ste_p[0], "ste_r@1": ste_r[0], "ste_p@2": ste_p[1], "ste_r@2": ste_r[1], "ste_p@3": ste_p[2], "ste_r@3": ste_r[2]}) df = pd.DataFrame(rows) output_path = OUTPUT_ROOT / "paper/test_tab_6_reference_alignment.csv" @@ -704,7 +708,7 @@ def test_new_ranking_table(): """ """ from moviekg.paper.helpers.ranking import _rank_and_save3csv - df =_rank_and_save3csv(PRESETS["equal"], "test_rank_equal", psmd) + df =_rank_and_save3csv("test_rank_new", psmd) df["pipeline"] = df["pipeline"].map(PIPLEINE_NAME_MAP) df.to_csv(OUTPUT_ROOT / "paper/test_tab_8_new_ranking_table.csv", sep="\t") # metric_df = load_metrics_from_file(OUTPUT_ROOT / "all_metrics.csv") From eb1c21fa3193a0daf1b59efa4e12248d23374488 Mon Sep 17 00:00:00 2001 From: Marvin Date: Sun, 15 Mar 2026 20:19:44 +0100 Subject: [PATCH 22/96] feat(syskg): added rdflib namespace --- src/kgpipe/common/definitions.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/kgpipe/common/definitions.py b/src/kgpipe/common/definitions.py index e6f6213..e0a9ef9 100644 --- a/src/kgpipe/common/definitions.py +++ b/src/kgpipe/common/definitions.py @@ -8,6 +8,29 @@ type schema_format = str +# Vocabulary # + +from rdflib.namespace import DefinedNamespace, Namespace + +class KGPIPE_NS(DefinedNamespace): + _fail = True + _NS = Namespace("http://github.com/ScaDS/kgpipe/") + Task = _NS["Task"] + TaskRun = _NS["TaskRun"] + Method = _NS["Method"] + Tool = _NS["Tool"] + Implementation = _NS["Implementation"] + Parameter = _NS["Parameter"] + ParameterBinding = _NS["ParameterBinding"] + Pipeline = _NS["Pipeline"] + PipelineRun = _NS["PipelineRun"] + Artifact = _NS["Artifact"] + ArtifactType = _NS["ArtifactType"] + Schema = _NS["Schema"] + Metric = _NS["Metric"] + MetricRun = _NS["MetricRun"] + + # Data # class DataHandle(BaseModel): From df2e75334b4e7d7c15b07464a3abc2043fc266a3 Mon Sep 17 00:00:00 2001 From: Marvin Date: Sun, 15 Mar 2026 20:20:33 +0100 Subject: [PATCH 23/96] feat(view): separated tabs for dev viewer --- src/kgpipe_view/__init__.py | 1 + src/kgpipe_view/diagram_tab.py | 42 +++ src/kgpipe_view/evaluations_tab.py | 24 ++ src/kgpipe_view/kgpipe_view.py | 439 ++--------------------------- src/kgpipe_view/pipelines_tab.py | 323 +++++++++++++++++++++ src/kgpipe_view/tasks_tab.py | 43 +++ src/kgpipe_view/ui_common.py | 34 +++ 7 files changed, 483 insertions(+), 423 deletions(-) create mode 100644 src/kgpipe_view/__init__.py create mode 100644 src/kgpipe_view/diagram_tab.py create mode 100644 src/kgpipe_view/evaluations_tab.py create mode 100644 src/kgpipe_view/pipelines_tab.py create mode 100644 src/kgpipe_view/tasks_tab.py create mode 100644 src/kgpipe_view/ui_common.py diff --git a/src/kgpipe_view/__init__.py b/src/kgpipe_view/__init__.py new file mode 100644 index 0000000..388a278 --- /dev/null +++ b/src/kgpipe_view/__init__.py @@ -0,0 +1 @@ +# Package marker for kgpipe_view modules. diff --git a/src/kgpipe_view/diagram_tab.py b/src/kgpipe_view/diagram_tab.py new file mode 100644 index 0000000..f1112e1 --- /dev/null +++ b/src/kgpipe_view/diagram_tab.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from pathlib import Path + +import streamlit as st + +try: + from kgpipe_view.owl_to_mermaid import convert_and_write_mermaid, get_available_layers + from kgpipe_view.ui_common import render_mermaid +except ModuleNotFoundError: + from owl_to_mermaid import convert_and_write_mermaid, get_available_layers + from ui_common import render_mermaid + + +def render_diagram_tab(ttl_path: Path, mermaid_path: Path) -> None: + try: + layer_options = get_available_layers(ttl_path) + selected_layers = st.multiselect( + "Layers", + options=layer_options, + default=layer_options, + key="layer-filter", + ) + mermaid_code = convert_and_write_mermaid( + ttl_path=ttl_path, + output_path=mermaid_path, + layer_filter=selected_layers, + ) + except Exception as exc: # pragma: no cover - UI fallback path + st.error(f"Failed to convert `{ttl_path.name}` to Mermaid: {exc}") + return + + st.success( + f"Generated Mermaid from `{ttl_path.name}` and saved `{mermaid_path.name}`." + ) + if selected_layers: + st.caption(f"Current layer filter: `{', '.join(selected_layers)}`") + else: + st.caption("Current layer filter: `none`") + render_mermaid(mermaid_code) + with st.expander("Show Mermaid source"): + st.code(mermaid_code, language="mermaid") diff --git a/src/kgpipe_view/evaluations_tab.py b/src/kgpipe_view/evaluations_tab.py new file mode 100644 index 0000000..593057a --- /dev/null +++ b/src/kgpipe_view/evaluations_tab.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import streamlit as st + +try: + from kgpipe_view.meta_kg_query import query_kg_data +except ModuleNotFoundError: + from meta_kg_query import query_kg_data + + +def render_evaluations_tab(endpoint_url: str) -> None: + st.subheader("Evaluations") + st.caption("Shows evaluation relations under `kgp:Evaluation`, including standalone evaluation nodes.") + + if st.button("Load evaluation hierarchy"): + try: + evaluation_hierarchy_df = query_kg_data(endpoint_url) + except Exception as exc: # pragma: no cover - network dependent path + st.error(f"Could not query `{endpoint_url}`: {exc}") + else: + if evaluation_hierarchy_df.empty: + st.info("No `kgp:Evaluation` subclass hierarchy returned by the endpoint.") + else: + st.dataframe(evaluation_hierarchy_df, use_container_width=True) diff --git a/src/kgpipe_view/kgpipe_view.py b/src/kgpipe_view/kgpipe_view.py index d3fc2db..b76ee3a 100644 --- a/src/kgpipe_view/kgpipe_view.py +++ b/src/kgpipe_view/kgpipe_view.py @@ -1,147 +1,22 @@ from __future__ import annotations -import json from pathlib import Path -from uuid import uuid4 import streamlit as st -import streamlit.components.v1 as components -from meta_kg_query import query_task_hierarchy, query_tasks_implementations, query_pipeline_hierarchy, query_evaluation_hierarchy, query_kg_data -from owl_to_mermaid import convert_and_write_mermaid, get_available_layers +try: + from kgpipe_view.diagram_tab import render_diagram_tab + from kgpipe_view.evaluations_tab import render_evaluations_tab + from kgpipe_view.pipelines_tab import render_pipelines_tab + from kgpipe_view.tasks_tab import render_tasks_tab +except ModuleNotFoundError: + # Support direct script execution via: streamlit run src/kgpipe_view/kgpipe_view.py + import importlib -from kgpipe.common.systemgraph import PipeKG - -def _entity_to_task_label(entity) -> str: - """Best-effort conversion from implementation-like object to task label.""" - name = getattr(entity, "name", "") or "" - return _task_label_to_base_name(str(name)) - - -def get_tasks() -> list: - """Load available task implementation entities from PipeKG.""" - return PipeKG().list_taskImplementations() - - -def _task_label_to_base_name(label: str) -> str: - base = str(label or "").strip() - if base.endswith("Impl"): - return base[:-4] - return base - - -@st.cache_data(show_spinner=False) -def _get_task_io_specs() -> dict[str, dict[str, set[str]]]: - return PipeKG().list_task_io_specs() - - -def _is_compatible( - previous_task_name: str, - next_task_name: str, - specs: dict[str, dict[str, set[str]]], -) -> bool: - previous_outputs = specs.get(_task_label_to_base_name(previous_task_name), {}).get("outputs", set()) - next_inputs = specs.get(_task_label_to_base_name(next_task_name), {}).get("inputs", set()) - - # Permissive fallback: if specs are missing, do not block composition. - if not previous_outputs or not next_inputs: - return True - return bool(previous_outputs.intersection(next_inputs)) - - -def _shared_formats( - source_task_name: str, - target_task_name: str, - specs: dict[str, dict[str, set[str]]], -) -> set[str]: - source_outputs = specs.get(_task_label_to_base_name(source_task_name), {}).get("outputs", set()) - target_inputs = specs.get(_task_label_to_base_name(target_task_name), {}).get("inputs", set()) - if not source_outputs or not target_inputs: - return {"*"} - return source_outputs.intersection(target_inputs) - - -def _edge_label(formats: set[str]) -> str: - if not formats: - return "" - if formats == {"*"}: - return "any" - return ", ".join(sorted(formats)) - - -def _task_io_summary(task_name: str, specs: dict[str, dict[str, set[str]]]) -> str: - task_spec = specs.get(_task_label_to_base_name(task_name), {}) - inputs = sorted(task_spec.get("inputs", set())) - outputs = sorted(task_spec.get("outputs", set())) - in_text = ", ".join(inputs) if inputs else "-" - out_text = ", ".join(outputs) if outputs else "-" - return f"in: {in_text} | out: {out_text}" - - -def _task_options_from_implementations(implementations: list) -> list[str]: - labels = {_entity_to_task_label(entity) for entity in implementations} - return sorted(label for label in labels if label) - - -def _pipeline_to_mermaid( - pipeline_nodes: list[dict[str, str]], - pipeline_edges: list[dict[str, object]], -) -> str: - if not pipeline_nodes: - return "flowchart LR\n empty[Empty pipeline]" - - def _safe_node_id(raw: str) -> str: - return "n_" + "".join(ch if ch.isalnum() else "_" for ch in raw) - - node_ids = {node["id"] for node in pipeline_nodes} - lines = ["flowchart LR"] - for node in pipeline_nodes: - node_id = _safe_node_id(node["id"]) - label = node["name"].replace(chr(34), chr(39)) - lines.append(f'{node_id}["{label}"]') - - for edge in pipeline_edges: - source = str(edge.get("from", "")) - target = str(edge.get("to", "")) - if source not in node_ids or target not in node_ids or source == target: - continue - src = _safe_node_id(source) - dst = _safe_node_id(target) - formats = set(edge.get("formats", [])) - label = _edge_label(formats) - if label: - lines.append(f'{src} -->|{label}| {dst}') - else: - lines.append(f"{src} --> {dst}") - return "\n".join(lines) - -def _render_mermaid(mermaid_text: str, height: int = 900) -> None: - """Render Mermaid source in Streamlit using Mermaid JS.""" - mermaid_json = json.dumps(mermaid_text) - html = f""" - -
-
-
- - """ - components.html(html, height=height, scrolling=True) + render_diagram_tab = importlib.import_module("diagram_tab").render_diagram_tab + render_evaluations_tab = importlib.import_module("evaluations_tab").render_evaluations_tab + render_pipelines_tab = importlib.import_module("pipelines_tab").render_pipelines_tab + render_tasks_tab = importlib.import_module("tasks_tab").render_tasks_tab st.set_page_config(page_title="KGpipe View", layout="wide") @@ -155,295 +30,13 @@ def _render_mermaid(mermaid_text: str, height: int = 900) -> None: diagram_tab, tasks_tab, pipelines_tab, evaluations_tab = st.tabs(["Ontology Diagram", "Tasks", "Pipelines", "Evaluations"]) with diagram_tab: - try: - layer_options = get_available_layers(ttl_path) - selected_layers = st.multiselect( - "Layers", - options=layer_options, - default=layer_options, - key="layer-filter", - ) - mermaid_code = convert_and_write_mermaid( - ttl_path=ttl_path, - output_path=mermaid_path, - layer_filter=selected_layers, - ) - except Exception as exc: # pragma: no cover - UI fallback path - st.error(f"Failed to convert `{ttl_path.name}` to Mermaid: {exc}") - else: - st.success( - f"Generated Mermaid from `{ttl_path.name}` and saved `{mermaid_path.name}`." - ) - if selected_layers: - st.caption(f"Current layer filter: `{', '.join(selected_layers)}`") - else: - st.caption("Current layer filter: `none`") - _render_mermaid(mermaid_code) - with st.expander("Show Mermaid source"): - st.code(mermaid_code, language="mermaid") + render_diagram_tab(ttl_path=ttl_path, mermaid_path=mermaid_path) with tasks_tab: - endpoint_url = st.text_input( - "Meta KG SPARQL endpoint", - value="http://localhost:8890/sparql", - help="SPARQL endpoint for the live meta knowledge graph.", - ) - if st.button("Load task implementations", type="primary"): - try: - task_implementation_df = query_tasks_implementations(endpoint_url) - except Exception as exc: # pragma: no cover - network dependent path - st.error(f"Could not query `{endpoint_url}`: {exc}") - else: - if task_implementation_df.empty: - st.info("No task-implementation mappings returned by the endpoint.") - else: - st.dataframe(task_implementation_df, use_container_width=True) - - st.divider() - st.subheader("Task hierarchy") - st.caption("Shows subclass relations under `kgp:Task`, including standalone task nodes.") - - if st.button("Load task hierarchy"): - try: - task_hierarchy_df = query_task_hierarchy(endpoint_url) - except Exception as exc: # pragma: no cover - network dependent path - st.error(f"Could not query `{endpoint_url}`: {exc}") - else: - if task_hierarchy_df.empty: - st.info("No `kgp:Task` subclass hierarchy returned by the endpoint.") - else: - st.dataframe(task_hierarchy_df, use_container_width=True) + endpoint_url = render_tasks_tab() with pipelines_tab: - st.subheader("Pipelines") - st.caption("Shows pipeline relations under `kgp:Pipeline`, including standalone pipeline nodes.") - - if st.button("Load pipeline hierarchy"): - try: - pipeline_hierarchy_df = query_pipeline_hierarchy(endpoint_url) - except Exception as exc: # pragma: no cover - network dependent path - st.error(f"Could not query `{endpoint_url}`: {exc}") - else: - if pipeline_hierarchy_df.empty: - st.info("No `kgp:Pipeline` subclass hierarchy returned by the endpoint.") - else: - st.dataframe(pipeline_hierarchy_df, use_container_width=True) - - st.divider() - st.subheader("Pipeline builder") - st.caption("Compose a DAG pipeline with reusable outputs and parallel branches.") - - if "pipeline_nodes" not in st.session_state: - legacy_steps = st.session_state.get("pipeline_steps", []) - migrated_nodes: list[dict[str, str]] = [] - if legacy_steps and isinstance(legacy_steps[0], str): - migrated_nodes = [ - {"id": f"task-{uuid4().hex[:8]}", "name": task_name} - for task_name in legacy_steps - ] - elif legacy_steps: - migrated_nodes = legacy_steps - st.session_state.pipeline_nodes = migrated_nodes - st.session_state.pipeline_edges = [] - for idx in range(len(migrated_nodes) - 1): - st.session_state.pipeline_edges.append( - { - "from": migrated_nodes[idx]["id"], - "to": migrated_nodes[idx + 1]["id"], - "formats": [], - } - ) - st.session_state.pop("pipeline_steps", None) - if "pipeline_edges" not in st.session_state: - st.session_state.pipeline_edges = [] - - implementations = get_tasks() - task_options = _task_options_from_implementations(implementations) - task_specs = _get_task_io_specs() - - pipeline_nodes = st.session_state.pipeline_nodes - pipeline_edges = st.session_state.pipeline_edges - - def _node_label(node: dict[str, str]) -> str: - return f'{node["name"]} ({node["id"][-6:]})' - - if not task_options: - st.info("No task implementations found in PipeKG yet.") - else: - task_option_labels = { - task_name: f"{task_name} [{_task_io_summary(task_name, task_specs)}]" - for task_name in task_options - } - selected_task_name = st.selectbox( - "Task to add", - options=task_options, - key="pipeline_builder_selected_task", - format_func=lambda task_name: task_option_labels.get(task_name, task_name), - ) - st.caption(f"Selected task spec: `{_task_io_summary(selected_task_name, task_specs)}`") - - selected_source_id = None - if pipeline_nodes: - source_options = [{"label": "No dependency (new branch/root)", "id": None}] - for node in pipeline_nodes: - formats = _shared_formats(node["name"], selected_task_name, task_specs) - if formats: - source_options.append( - { - "label": f'{_node_label(node)} [{_edge_label(formats)}]', - "id": node["id"], - } - ) - - source_label = st.selectbox( - "Connect new task from", - options=[opt["label"] for opt in source_options], - key="pipeline_builder_selected_source", - help="Pick an upstream task output to reuse, or create a root branch with no dependency.", - ) - source_lookup = {opt["label"]: opt["id"] for opt in source_options} - selected_source_id = source_lookup[source_label] - else: - st.caption("First task creates the first root in the pipeline.") - - add_col, remove_col, clear_col = st.columns(3) - with add_col: - if st.button("Add task node", key="pipeline_builder_add_task"): - new_id = f"task-{uuid4().hex[:8]}" - st.session_state.pipeline_nodes.append({"id": new_id, "name": selected_task_name}) - if selected_source_id is not None: - source_node = next( - (node for node in st.session_state.pipeline_nodes if node["id"] == selected_source_id), - None, - ) - formats = set() - if source_node is not None: - formats = _shared_formats(source_node["name"], selected_task_name, task_specs) - st.session_state.pipeline_edges.append( - { - "from": selected_source_id, - "to": new_id, - "formats": sorted(formats), - } - ) - st.rerun() - with remove_col: - if st.button("Remove last task node", key="pipeline_builder_remove_last"): - if st.session_state.pipeline_nodes: - removed = st.session_state.pipeline_nodes.pop() - removed_id = removed["id"] - st.session_state.pipeline_edges = [ - edge - for edge in st.session_state.pipeline_edges - if edge.get("from") != removed_id and edge.get("to") != removed_id - ] - st.rerun() - with clear_col: - if st.button("Clear pipeline", key="pipeline_builder_clear"): - st.session_state.pipeline_nodes = [] - st.session_state.pipeline_edges = [] - st.session_state.pop("pipeline_builder_selected_task", None) - st.session_state.pop("pipeline_builder_selected_source", None) - st.rerun() - - if len(st.session_state.pipeline_nodes) >= 2: - st.markdown("**Connect existing tasks**") - id_to_node = {node["id"]: node for node in st.session_state.pipeline_nodes} - source_node_id = st.selectbox( - "From task", - options=[node["id"] for node in st.session_state.pipeline_nodes], - format_func=lambda node_id: _node_label(id_to_node[node_id]), - key="pipeline_builder_connect_source", - ) - existing_pairs = { - (edge.get("from"), edge.get("to")) for edge in st.session_state.pipeline_edges - } - target_candidates: list[tuple[str, str]] = [] - for node in st.session_state.pipeline_nodes: - if node["id"] == source_node_id: - continue - if (source_node_id, node["id"]) in existing_pairs: - continue - source_node = id_to_node[source_node_id] - formats = _shared_formats(source_node["name"], node["name"], task_specs) - if formats: - target_candidates.append( - (node["id"], f'{_node_label(node)} [{_edge_label(formats)}]') - ) - - if target_candidates: - target_label = st.selectbox( - "To task", - options=[label for _, label in target_candidates], - key="pipeline_builder_connect_target", - ) - target_lookup = {label: node_id for node_id, label in target_candidates} - target_node_id = target_lookup[target_label] - if st.button("Add dependency edge", key="pipeline_builder_add_edge"): - source_node = id_to_node[source_node_id] - target_node = id_to_node[target_node_id] - formats = _shared_formats(source_node["name"], target_node["name"], task_specs) - st.session_state.pipeline_edges.append( - { - "from": source_node_id, - "to": target_node_id, - "formats": sorted(formats), - } - ) - st.rerun() - else: - st.caption("No additional compatible target task found for this source.") - - if pipeline_nodes: - st.caption("Current pipeline") - st.write(", ".join(_node_label(node) for node in pipeline_nodes)) - st.markdown("**Pipeline graph**") - _render_mermaid(_pipeline_to_mermaid(pipeline_nodes, pipeline_edges), height=300) - - incoming: dict[str, int] = {node["id"]: 0 for node in pipeline_nodes} - outgoing: dict[str, int] = {node["id"]: 0 for node in pipeline_nodes} - for edge in pipeline_edges: - source = edge.get("from") - target = edge.get("to") - if source in outgoing: - outgoing[source] += 1 - if target in incoming: - incoming[target] += 1 - - id_to_node = {node["id"]: node for node in pipeline_nodes} - roots = [id_to_node[node_id] for node_id, count in incoming.items() if count == 0] - leaves = [id_to_node[node_id] for node_id, count in outgoing.items() if count == 0] - - st.markdown("**Loose ends**") - roots_text = ", ".join(_node_label(node) for node in roots) if roots else "none" - leaves_text = ", ".join(_node_label(node) for node in leaves) if leaves else "none" - st.caption(f"Open inputs (roots): {roots_text}") - st.caption(f"Open outputs (leaves): {leaves_text}") - - st.code( - json.dumps( - { - "nodes": pipeline_nodes, - "edges": pipeline_edges, - }, - indent=2, - ), - language="json", - ) - else: - st.info("Your pipeline is empty. Add one or more task nodes to start building.") + render_pipelines_tab(endpoint_url=endpoint_url) with evaluations_tab: - st.subheader("Evaluations") - st.caption("Shows evaluation relations under `kgp:Evaluation`, including standalone evaluation nodes.") - - if st.button("Load evaluation hierarchy"): - try: - evaluation_hierarchy_df = query_kg_data(endpoint_url) #query_evaluation_hierarchy(endpoint_url) - except Exception as exc: # pragma: no cover - network dependent path - st.error(f"Could not query `{endpoint_url}`: {exc}") - else: - if evaluation_hierarchy_df.empty: - st.info("No `kgp:Evaluation` subclass hierarchy returned by the endpoint.") - else: - st.dataframe(evaluation_hierarchy_df, use_container_width=True) \ No newline at end of file + render_evaluations_tab(endpoint_url=endpoint_url) \ No newline at end of file diff --git a/src/kgpipe_view/pipelines_tab.py b/src/kgpipe_view/pipelines_tab.py new file mode 100644 index 0000000..78e92b1 --- /dev/null +++ b/src/kgpipe_view/pipelines_tab.py @@ -0,0 +1,323 @@ +from __future__ import annotations + +import json +from uuid import uuid4 + +import streamlit as st + +from kgpipe.common.systemgraph import PipeKG +try: + from kgpipe_view.meta_kg_query import query_pipeline_hierarchy + from kgpipe_view.ui_common import render_mermaid +except ModuleNotFoundError: + from meta_kg_query import query_pipeline_hierarchy + from ui_common import render_mermaid + + +def _entity_to_task_label(entity) -> str: + """Best-effort conversion from implementation-like object to task label.""" + name = getattr(entity, "name", "") or "" + return _task_label_to_base_name(str(name)) + + +def _task_label_to_base_name(label: str) -> str: + base = str(label or "").strip() + if base.endswith("Impl"): + return base[:-4] + return base + + +@st.cache_data(show_spinner=False) +def _get_task_io_specs() -> dict[str, dict[str, set[str]]]: + return PipeKG().list_task_io_specs() + + +def _shared_formats( + source_task_name: str, + target_task_name: str, + specs: dict[str, dict[str, set[str]]], +) -> set[str]: + source_outputs = specs.get(_task_label_to_base_name(source_task_name), {}).get("outputs", set()) + target_inputs = specs.get(_task_label_to_base_name(target_task_name), {}).get("inputs", set()) + if not source_outputs or not target_inputs: + return {"*"} + return source_outputs.intersection(target_inputs) + + +def _edge_label(formats: set[str]) -> str: + if not formats: + return "" + if formats == {"*"}: + return "any" + return ", ".join(sorted(formats)) + + +def _task_io_summary(task_name: str, specs: dict[str, dict[str, set[str]]]) -> str: + task_spec = specs.get(_task_label_to_base_name(task_name), {}) + inputs = sorted(task_spec.get("inputs", set())) + outputs = sorted(task_spec.get("outputs", set())) + in_text = ", ".join(inputs) if inputs else "-" + out_text = ", ".join(outputs) if outputs else "-" + return f"in: {in_text} | out: {out_text}" + + +def _task_options_from_implementations(implementations: list) -> list[str]: + labels = {_entity_to_task_label(entity) for entity in implementations} + return sorted(label for label in labels if label) + + +def _pipeline_to_mermaid( + pipeline_nodes: list[dict[str, str]], + pipeline_edges: list[dict[str, object]], +) -> str: + if not pipeline_nodes: + return "flowchart LR\n empty[Empty pipeline]" + + def _safe_node_id(raw: str) -> str: + return "n_" + "".join(ch if ch.isalnum() else "_" for ch in raw) + + node_ids = {node["id"] for node in pipeline_nodes} + lines = ["flowchart LR"] + for node in pipeline_nodes: + node_id = _safe_node_id(node["id"]) + label = node["name"].replace(chr(34), chr(39)) + lines.append(f'{node_id}["{label}"]') + + for edge in pipeline_edges: + source = str(edge.get("from", "")) + target = str(edge.get("to", "")) + if source not in node_ids or target not in node_ids or source == target: + continue + src = _safe_node_id(source) + dst = _safe_node_id(target) + formats = set(edge.get("formats", [])) + label = _edge_label(formats) + if label: + lines.append(f'{src} -->|{label}| {dst}') + else: + lines.append(f"{src} --> {dst}") + return "\n".join(lines) + + +def _get_tasks() -> list: + return PipeKG().list_taskImplementations() + + +def render_pipelines_tab(endpoint_url: str) -> None: + st.subheader("Pipelines") + st.caption("Shows pipeline relations under `kgp:Pipeline`, including standalone pipeline nodes.") + + if st.button("Load pipeline hierarchy"): + try: + pipeline_hierarchy_df = query_pipeline_hierarchy(endpoint_url) + except Exception as exc: # pragma: no cover - network dependent path + st.error(f"Could not query `{endpoint_url}`: {exc}") + else: + if pipeline_hierarchy_df.empty: + st.info("No `kgp:Pipeline` subclass hierarchy returned by the endpoint.") + else: + st.dataframe(pipeline_hierarchy_df, use_container_width=True) + + st.divider() + st.subheader("Pipeline builder") + st.caption("Compose a DAG pipeline with reusable outputs and parallel branches.") + + if "pipeline_nodes" not in st.session_state: + legacy_steps = st.session_state.get("pipeline_steps", []) + migrated_nodes: list[dict[str, str]] = [] + if legacy_steps and isinstance(legacy_steps[0], str): + migrated_nodes = [ + {"id": f"task-{uuid4().hex[:8]}", "name": task_name} + for task_name in legacy_steps + ] + elif legacy_steps: + migrated_nodes = legacy_steps + st.session_state.pipeline_nodes = migrated_nodes + st.session_state.pipeline_edges = [] + for idx in range(len(migrated_nodes) - 1): + st.session_state.pipeline_edges.append( + { + "from": migrated_nodes[idx]["id"], + "to": migrated_nodes[idx + 1]["id"], + "formats": [], + } + ) + st.session_state.pop("pipeline_steps", None) + if "pipeline_edges" not in st.session_state: + st.session_state.pipeline_edges = [] + + implementations = _get_tasks() + task_options = _task_options_from_implementations(implementations) + task_specs = _get_task_io_specs() + + pipeline_nodes = st.session_state.pipeline_nodes + pipeline_edges = st.session_state.pipeline_edges + + def _node_label(node: dict[str, str]) -> str: + return f'{node["name"]} ({node["id"][-6:]})' + + if not task_options: + st.info("No task implementations found in PipeKG yet.") + else: + task_option_labels = { + task_name: f"{task_name} [{_task_io_summary(task_name, task_specs)}]" + for task_name in task_options + } + selected_task_name = st.selectbox( + "Task to add", + options=task_options, + key="pipeline_builder_selected_task", + format_func=lambda task_name: task_option_labels.get(task_name, task_name), + ) + st.caption(f"Selected task spec: `{_task_io_summary(selected_task_name, task_specs)}`") + + selected_source_id = None + if pipeline_nodes: + source_options = [{"label": "No dependency (new branch/root)", "id": None}] + for node in pipeline_nodes: + formats = _shared_formats(node["name"], selected_task_name, task_specs) + if formats: + source_options.append( + { + "label": f'{_node_label(node)} [{_edge_label(formats)}]', + "id": node["id"], + } + ) + + source_label = st.selectbox( + "Connect new task from", + options=[opt["label"] for opt in source_options], + key="pipeline_builder_selected_source", + help="Pick an upstream task output to reuse, or create a root branch with no dependency.", + ) + source_lookup = {opt["label"]: opt["id"] for opt in source_options} + selected_source_id = source_lookup[source_label] + else: + st.caption("First task creates the first root in the pipeline.") + + add_col, remove_col, clear_col = st.columns(3) + with add_col: + if st.button("Add task node", key="pipeline_builder_add_task"): + new_id = f"task-{uuid4().hex[:8]}" + st.session_state.pipeline_nodes.append({"id": new_id, "name": selected_task_name}) + if selected_source_id is not None: + source_node = next( + (node for node in st.session_state.pipeline_nodes if node["id"] == selected_source_id), + None, + ) + formats = set() + if source_node is not None: + formats = _shared_formats(source_node["name"], selected_task_name, task_specs) + st.session_state.pipeline_edges.append( + { + "from": selected_source_id, + "to": new_id, + "formats": sorted(formats), + } + ) + st.rerun() + with remove_col: + if st.button("Remove last task node", key="pipeline_builder_remove_last"): + if st.session_state.pipeline_nodes: + removed = st.session_state.pipeline_nodes.pop() + removed_id = removed["id"] + st.session_state.pipeline_edges = [ + edge + for edge in st.session_state.pipeline_edges + if edge.get("from") != removed_id and edge.get("to") != removed_id + ] + st.rerun() + with clear_col: + if st.button("Clear pipeline", key="pipeline_builder_clear"): + st.session_state.pipeline_nodes = [] + st.session_state.pipeline_edges = [] + st.session_state.pop("pipeline_builder_selected_task", None) + st.session_state.pop("pipeline_builder_selected_source", None) + st.rerun() + + if len(st.session_state.pipeline_nodes) >= 2: + st.markdown("**Connect existing tasks**") + id_to_node = {node["id"]: node for node in st.session_state.pipeline_nodes} + source_node_id = st.selectbox( + "From task", + options=[node["id"] for node in st.session_state.pipeline_nodes], + format_func=lambda node_id: _node_label(id_to_node[node_id]), + key="pipeline_builder_connect_source", + ) + existing_pairs = { + (edge.get("from"), edge.get("to")) for edge in st.session_state.pipeline_edges + } + target_candidates: list[tuple[str, str]] = [] + for node in st.session_state.pipeline_nodes: + if node["id"] == source_node_id: + continue + if (source_node_id, node["id"]) in existing_pairs: + continue + source_node = id_to_node[source_node_id] + formats = _shared_formats(source_node["name"], node["name"], task_specs) + if formats: + target_candidates.append( + (node["id"], f'{_node_label(node)} [{_edge_label(formats)}]') + ) + + if target_candidates: + target_label = st.selectbox( + "To task", + options=[label for _, label in target_candidates], + key="pipeline_builder_connect_target", + ) + target_lookup = {label: node_id for node_id, label in target_candidates} + target_node_id = target_lookup[target_label] + if st.button("Add dependency edge", key="pipeline_builder_add_edge"): + source_node = id_to_node[source_node_id] + target_node = id_to_node[target_node_id] + formats = _shared_formats(source_node["name"], target_node["name"], task_specs) + st.session_state.pipeline_edges.append( + { + "from": source_node_id, + "to": target_node_id, + "formats": sorted(formats), + } + ) + st.rerun() + else: + st.caption("No additional compatible target task found for this source.") + + if pipeline_nodes: + st.caption("Current pipeline") + st.write(", ".join(_node_label(node) for node in pipeline_nodes)) + st.markdown("**Pipeline graph**") + render_mermaid(_pipeline_to_mermaid(pipeline_nodes, pipeline_edges), height=300) + + incoming: dict[str, int] = {node["id"]: 0 for node in pipeline_nodes} + outgoing: dict[str, int] = {node["id"]: 0 for node in pipeline_nodes} + for edge in pipeline_edges: + source = edge.get("from") + target = edge.get("to") + if source in outgoing: + outgoing[source] += 1 + if target in incoming: + incoming[target] += 1 + + id_to_node = {node["id"]: node for node in pipeline_nodes} + roots = [id_to_node[node_id] for node_id, count in incoming.items() if count == 0] + leaves = [id_to_node[node_id] for node_id, count in outgoing.items() if count == 0] + + st.markdown("**Loose ends**") + roots_text = ", ".join(_node_label(node) for node in roots) if roots else "none" + leaves_text = ", ".join(_node_label(node) for node in leaves) if leaves else "none" + st.caption(f"Open inputs (roots): {roots_text}") + st.caption(f"Open outputs (leaves): {leaves_text}") + + st.code( + json.dumps( + { + "nodes": pipeline_nodes, + "edges": pipeline_edges, + }, + indent=2, + ), + language="json", + ) + else: + st.info("Your pipeline is empty. Add one or more task nodes to start building.") diff --git a/src/kgpipe_view/tasks_tab.py b/src/kgpipe_view/tasks_tab.py new file mode 100644 index 0000000..b8525ce --- /dev/null +++ b/src/kgpipe_view/tasks_tab.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import streamlit as st + +try: + from kgpipe_view.meta_kg_query import query_task_hierarchy, query_tasks_implementations +except ModuleNotFoundError: + from meta_kg_query import query_task_hierarchy, query_tasks_implementations + + +def render_tasks_tab() -> str: + endpoint_url = st.text_input( + "Meta KG SPARQL endpoint", + value="http://localhost:8890/sparql", + help="SPARQL endpoint for the live meta knowledge graph.", + ) + if st.button("Load task implementations", type="primary"): + try: + task_implementation_df = query_tasks_implementations(endpoint_url) + except Exception as exc: # pragma: no cover - network dependent path + st.error(f"Could not query `{endpoint_url}`: {exc}") + else: + if task_implementation_df.empty: + st.info("No task-implementation mappings returned by the endpoint.") + else: + st.dataframe(task_implementation_df, use_container_width=True) + + st.divider() + st.subheader("Task hierarchy") + st.caption("Shows subclass relations under `kgp:Task`, including standalone task nodes.") + + if st.button("Load task hierarchy"): + try: + task_hierarchy_df = query_task_hierarchy(endpoint_url) + except Exception as exc: # pragma: no cover - network dependent path + st.error(f"Could not query `{endpoint_url}`: {exc}") + else: + if task_hierarchy_df.empty: + st.info("No `kgp:Task` subclass hierarchy returned by the endpoint.") + else: + st.dataframe(task_hierarchy_df, use_container_width=True) + + return endpoint_url diff --git a/src/kgpipe_view/ui_common.py b/src/kgpipe_view/ui_common.py new file mode 100644 index 0000000..e0fa70d --- /dev/null +++ b/src/kgpipe_view/ui_common.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import json + +import streamlit.components.v1 as components + + +def render_mermaid(mermaid_text: str, height: int = 900) -> None: + """Render Mermaid source in Streamlit using Mermaid JS.""" + mermaid_json = json.dumps(mermaid_text) + html = f""" + +
+
+
+ + """ + components.html(html, height=height, scrolling=True) From a0234fbdb4e8a53be9ef49618327efcb4bcf53c7 Mon Sep 17 00:00:00 2001 From: Marvin Date: Wed, 18 Mar 2026 23:28:16 +0100 Subject: [PATCH 24/96] feat(syskg): changes to task impl --- src/kgpipe/common/definitions.py | 7 ++++++- src/kgpipe/common/model/configuration.py | 9 +++++++++ src/kgpipe/common/model/task.py | 4 ++-- src/kgpipe/common/registry.py | 4 +++- src/kgpipe/common/systemgraph.py | 21 ++++++++++++++++++++- 5 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/kgpipe/common/definitions.py b/src/kgpipe/common/definitions.py index e0a9ef9..e14c4e8 100644 --- a/src/kgpipe/common/definitions.py +++ b/src/kgpipe/common/definitions.py @@ -1,9 +1,11 @@ from dataclasses import dataclass from sys import implementation from pydantic import BaseModel -from typing import Optional, List, Dict, Any +from typing import Mapping, Optional, List, Dict, Any from kgcore.api.kg import KGId +from kgpipe.common.model.data import DataFormat + # Types # type schema_format = str @@ -163,7 +165,10 @@ class ParameterBindingEntity(BaseModel): ImplementationEntityId = KGId class ImplementationEntity(BaseModel): + uri: Optional[str] = None name: str + input_spec: List[str] + output_spec: List[str] implementsMethod: List[MethodEntityId] hasParameter: List[ParameterId] usesTool: List[ToolEntityId] diff --git a/src/kgpipe/common/model/configuration.py b/src/kgpipe/common/model/configuration.py index 19c430a..f9f9b92 100644 --- a/src/kgpipe/common/model/configuration.py +++ b/src/kgpipe/common/model/configuration.py @@ -54,6 +54,14 @@ class ParameterBinding(BaseModel): parameter: Parameter value: str | int | float | bool # TODO extend to more types? +@kg_class() +class ConfigurationDefinition(BaseModel): + """ + Possible configurations of a task + """ + name: str + description: Optional[str] = None + parameters: List[Parameter] = field(default_factory=list) @kg_class() class ConfigurationProfile(BaseModel): @@ -61,5 +69,6 @@ class ConfigurationProfile(BaseModel): Configuration profile definition, not the actual values of the parameters in the pipeline execution """ name: str + definition: ConfigurationDefinition description: Optional[str] = None bindings: List[ParameterBinding] = field(default_factory=list) \ No newline at end of file diff --git a/src/kgpipe/common/model/task.py b/src/kgpipe/common/model/task.py index 347a67b..7d11cad 100644 --- a/src/kgpipe/common/model/task.py +++ b/src/kgpipe/common/model/task.py @@ -8,7 +8,7 @@ import time import shutil from kgpipe.common.model.default_catalog import TaskCategory -from .configuration import Parameter, ConfigurationProfile +from .configuration import Parameter, ConfigurationDefinition from kgpipe.common.annotations import kg_class type TaskName = str @@ -51,7 +51,7 @@ class KgTask: function: Callable[[Dict[str, Data], Dict[str, Data]], None] description: Optional[str] = None category: List[TaskCategory] = field(default_factory=list) - config: Optional[ConfigurationProfile] = None + config_spec: Optional[ConfigurationDefinition] = None def __post_init__(self): if not self.name: diff --git a/src/kgpipe/common/registry.py b/src/kgpipe/common/registry.py index 0a8b41c..5281372 100644 --- a/src/kgpipe/common/registry.py +++ b/src/kgpipe/common/registry.py @@ -4,6 +4,7 @@ from kgpipe.common.models import KgTask, DataFormat from kgpipe.common.systemgraph import PipeKG from kgpipe.common.definitions import MetricEntity +from kgpipe.common.model.configuration import ConfigurationDefinition # TODO add also to system graph @@ -43,7 +44,8 @@ def task( input_spec: dict[str, DataFormat], output_spec: dict[str, DataFormat], description: str | None = None, - category: list[str] = [] + category: list[str] = [], + config_spec: ConfigurationDefinition | None = None ) -> Callable[[Callable], KgTask]: def decorator(t): task = KgTask(t.__name__.lower(), input_spec, output_spec, t, description, category) diff --git a/src/kgpipe/common/systemgraph.py b/src/kgpipe/common/systemgraph.py index e814f4b..2e9e756 100644 --- a/src/kgpipe/common/systemgraph.py +++ b/src/kgpipe/common/systemgraph.py @@ -40,7 +40,7 @@ raise ValueError(f"Unsupported schema: {scheme}") except Exception as e: print(f"Error creating system graph: {e}") - print(f"Using RDFLib backend for system graph: {f"http://{rest}"}") + print(f"Using RDFLib memory backend for system graph") SYS_KG: KnowledgeGraph = KnowledgeGraph(model=model, backend=backend) @@ -133,9 +133,21 @@ def list_taskImplementations(self) -> List[ImplementationEntity]: config.ONTOLOGY_PREFIX + "hasParameter", ) + input_entities = SYS_KG.get_neighbors(entity.id, predicate="input") + output_entities = SYS_KG.get_neighbors(entity.id, predicate="output") + + def get_property_values(properties: list[KGProperty], key: str) -> list[str]: + return [prop.value for prop in properties if prop.key.endswith(key)] + + input_spec = [get_property_values(input_entity.properties, "format")[0] for input_entity in input_entities] + output_spec = [get_property_values(output_entity.properties, "format")[0] for output_entity in output_entities] + implementations.append( ImplementationEntity( + uri=str(entity.id), name=str(name_value), + input_spec=input_spec, + output_spec=output_spec, implementsMethod=self._to_list(implements_method_value), hasParameter=self._to_list(has_parameter_value), usesTool=self._to_list(uses_tool_value), @@ -246,6 +258,13 @@ def add_pipeline_run(pipeline_run: PipelineRunEntity): # return pipeline_run_entity + @staticmethod + def sparql_construct(query: str): + backend : RDFSparqlBackend = SYS_KG.backend + result = backend.query_sparql(query) + return result + + class MapperUtil(): """ Intermediate class to map the core classes to the definitions to the system graph. From 74fc48ed44a22617cd18554ee69e4ce04cdd7fa7 Mon Sep 17 00:00:00 2001 From: Marvin Date: Thu, 19 Mar 2026 17:41:55 +0100 Subject: [PATCH 25/96] docs: updates to parameters and eval --- docs/evaluation.md | 57 ++++++++++++++++++++++++++++++++++++++++++++++ docs/explorer.md | 8 ------- docs/parameters.md | 25 ++++++++++++++++++++ docs/view.md | 13 +++++++++++ 4 files changed, 95 insertions(+), 8 deletions(-) delete mode 100644 docs/explorer.md create mode 100644 docs/parameters.md create mode 100644 docs/view.md diff --git a/docs/evaluation.md b/docs/evaluation.md index 0650308..fc27bda 100644 --- a/docs/evaluation.md +++ b/docs/evaluation.md @@ -122,3 +122,60 @@ Reports can be serialized to JSON for storage and later analysis: ```python report.to_json("evaluation_results.json") ``` + +# Hierarchy + +``` +QualityEvaluationOntology + +QualityDimension + ├─ Accuracy + ├─ Coverage + ├─ Consistency + └─ Uniqueness + +Metric + ├─ BaseMetric + │ ├─ Precision + │ └─ Recall + ├─ CompositeMetric + │ └─ F1Score + └─ AggregatedMetric + ├─ MacroAverage + └─ MicroAverage + +QualityIssue + ├─ DuplicateEntities (false positives for EM) + ├─ DisjointDomainIssue + └─ MissingEntities (false positives for OM, or true positives for EM) + +EvaluationArtifact + ├─ ReferenceDataset + └─ QualityRulePattern +``` + +Example Instance: Entity Matching + +``` +ReferenceDataset + │ + ▼ +Precision / Recall + │ + ▼ +F1Score + │ + ▼ +Evaluation of Matching Quality + │ +False Negatives + │ + ▼ +DuplicateEntities + │ + ▼ +RedundancyIssue + │ + ▼ +Violates Uniqueness Dimension +``` \ No newline at end of file diff --git a/docs/explorer.md b/docs/explorer.md deleted file mode 100644 index dc1533f..0000000 --- a/docs/explorer.md +++ /dev/null @@ -1,8 +0,0 @@ -# PipeKG Explorer - -A frontend to explorer defintions and experiments of the KGpipe framework. - -``` -uv run streamlit run src/kgpipe_view/kgpipe_view.py -``` - diff --git a/docs/parameters.md b/docs/parameters.md new file mode 100644 index 0000000..150abab --- /dev/null +++ b/docs/parameters.md @@ -0,0 +1,25 @@ +# Parameters + +We can parameterize pipelines in the following way + +## Selection of task implementation + +pipeline Task layout +pipeline Task ... + +with subtasks +complete tasks + +## Selection of parameter in task implementation + +# Strategies + +Domain specific tasks + +Configuration options + + +## Backlog +- conf_examples.py +- kgpipe_parameters +- \ No newline at end of file diff --git a/docs/view.md b/docs/view.md new file mode 100644 index 0000000..d1404c4 --- /dev/null +++ b/docs/view.md @@ -0,0 +1,13 @@ +# View Package + +The view package is a simple streamlit app +to view and visualize the core components of the +framework during development + +``` +uv run streamlit run src/kgpipe_view/kgpipe_view.py +``` + +It is different from the KGpipe-Explorer as it only focuses +on viewing the internal structure and tabular versions of +the KGpipe Core classes for a connected PipeKG. From df95f4fcb1d070562295a8797840fe6ac1e3f5e2 Mon Sep 17 00:00:00 2001 From: Marvin Date: Thu, 19 Mar 2026 17:43:13 +0100 Subject: [PATCH 26/96] refactor: moved experiments/explorer to repo gtithub.com/vehnem/kgpipe-explorer --- experiments/explorer/README.md | 39 ---------------------------------- 1 file changed, 39 deletions(-) delete mode 100644 experiments/explorer/README.md diff --git a/experiments/explorer/README.md b/experiments/explorer/README.md deleted file mode 100644 index fa17f59..0000000 --- a/experiments/explorer/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# KGpipe Explorer - -A static web application for exploring the KGpipe framework's System Knowledge Graph (PipeKG) and pipeline execution results. The explorer provides an interactive interface to browse registered tasks, pipelines, metrics, and evaluation results without executing pipelines. - -## Overview - -The KGpipe Explorer is designed to visualize and navigate the meta knowledge graph that KGpipe maintains internally. This System KG tracks: - -- **Tasks**: Registered integration tasks with their specifications, input/output formats, and categories -- **Pipelines**: Pipeline definitions and their composition of tasks -- **Metrics**: Evaluation metrics and quality measurements -- **Execution Results**: Results from pipeline runs and their associated metadata - -## Purpose - -The explorer enables users to: - -- Discover available tasks and their capabilities -- Understand pipeline structures and task dependencies -- Review evaluation metrics and execution results -- Explore relationships between tasks, pipelines, and data formats -- Navigate the System KG structure through an intuitive interface - -## System Knowledge Graph - -The explorer operates on the PipeKG (Meta Knowledge Graph) that KGpipe maintains internally. For detailed information about the System KG structure, query capabilities, and SPARQL examples, see the [Meta KG documentation](../../docs/metakg.md). - -## Design Principles - -- **Static**: The explorer works with pre-generated System KG data and execution results. It does not execute pipelines or modify the framework state. -- **Read-only**: All exploration is read-only, ensuring no accidental modifications to pipeline definitions or execution results. -- **Interactive**: Provides an intuitive interface for navigating the complex relationships in the System KG. - -## Architecture - -The explorer consumes static RDF data from the System KG and presents it through a web-based interface, allowing users to query and visualize the knowledge graph structure without requiring direct SPARQL knowledge. - -## Backlog -- decide on framwork and src structure \ No newline at end of file From d8ccf8d7778c674ee86bfd6d6dd8509987d6b6a2 Mon Sep 17 00:00:00 2001 From: Marvin Date: Thu, 19 Mar 2026 17:45:13 +0100 Subject: [PATCH 27/96] feature(eval): some config improvements in reference eval --- .../evaluation/aspects/func/er_task_eval.py | 27 ++++++------------- src/kgpipe/evaluation/aspects/reference.py | 10 +++---- src/kgpipe/evaluation/cluster.py | 2 -- 3 files changed, 13 insertions(+), 26 deletions(-) diff --git a/src/kgpipe/evaluation/aspects/func/er_task_eval.py b/src/kgpipe/evaluation/aspects/func/er_task_eval.py index 5546164..37dafc1 100644 --- a/src/kgpipe/evaluation/aspects/func/er_task_eval.py +++ b/src/kgpipe/evaluation/aspects/func/er_task_eval.py @@ -167,9 +167,6 @@ def get_relation_matches(er_doc: ER_Document, threshold: float, match_cluster: O def get_matches_to_seed(er_doc: ER_Document, list_of_matches: list[MatchesRow], threshold): - print("list of matches", len(list_of_matches)) - print("threshold", threshold) - true_entity_match_cnt = 0 #tp false_entity_match_cnt = 0 #fp false_missing_entity_match_cnt = 0 #fn @@ -198,32 +195,21 @@ def get_matches_to_seed(er_doc: ER_Document, list_of_matches: list[MatchesRow], # get the seed and source ids seed_id = None source_id = None - if id1.startswith("http://kg.org/resource"): + if id1.startswith("http://kg.org/resource"): # TODO make configurable source_id = id2 seed_id = id1 if id2.startswith("http://kg.org/resource"): source_id = id1 seed_id = id2 - - checker = False - if seed_id == "http://kg.org/resource/b25598f9c0fce28a7700869fcb55d706": - checker = True - if seed_id is not None and source_id is not None: saw_seed_ids.add(seed_id) if is_match(source_id, seed_id, gt_cluster, False): true_entity_match_cnt += 1 - if checker: - print("true match", seed_id, source_id) else: false_entity_match_cnt += 1 - if checker: - print("false match", seed_id, source_id) else: # can not be checked, skip - if checker: - print("skip", seed_id, source_id) continue missing_seed_ids = gt_seed_ids - saw_seed_ids @@ -388,10 +374,10 @@ def evaluate_entity_matching(er_doc_path_or_kg: Path | KG, denom = 2 * tp + fp + fn f1_score = (2 * tp / denom) if denom > 0 else 0.0 - print("f1_score", f1_score) - print("tp", tp) - print("fp", fp) - print("fn", fn) + # print("f1_score", f1_score) + # print("tp", tp) + # print("fp", fp) + # print("fn", fn) return f1_score, f1_score, {"true_seed_match_cnt": tp, "false_seed_match_cnt": fp, "false_missing_seed_match_cnt": fn} @@ -407,6 +393,9 @@ def evaluate_relation_matching(er_doc_path: Path | KG, gt_match_path: Path, thre tp = match_counts.true_relation_match_cnt fp = match_counts.false_relation_match_cnt fn = match_counts.false_missing_relation_match_cnt + + if fn < 0: + fn = 0 f1_score = 2 * tp / (2 * tp + fp + fn) if tp > 0 else 0 diff --git a/src/kgpipe/evaluation/aspects/reference.py b/src/kgpipe/evaluation/aspects/reference.py index 58a6814..b27a934 100644 --- a/src/kgpipe/evaluation/aspects/reference.py +++ b/src/kgpipe/evaluation/aspects/reference.py @@ -112,12 +112,12 @@ def compute(self, kg: KG, config: ReferenceConfig, **kwargs) -> MetricResult: print(f"[CONFIG] Relation matching threshold: {config.RELATION_MATCH_THRESHOLD}") # TODO change to verfied entities level - dataset = config.dataset - if dataset is None: - raise ValueError("Dataset is not set") - gt_match_path = dataset.root / "split_match_entities.csv" + # dataset = config.dataset + # if dataset is None: + # raise ValueError("Dataset is not set") + # gt_match_path = dataset.root / "split_match_entities.csv" - value, normalized_score, details = evaluate_relation_matching(kg, gt_match_path, config.RELATION_MATCH_THRESHOLD) + value, normalized_score, details = evaluate_relation_matching(kg, config.GT_MATCHES, config.RELATION_MATCH_THRESHOLD) return MetricResult( name=self.name, diff --git a/src/kgpipe/evaluation/cluster.py b/src/kgpipe/evaluation/cluster.py index 013c68a..6d386a2 100644 --- a/src/kgpipe/evaluation/cluster.py +++ b/src/kgpipe/evaluation/cluster.py @@ -205,8 +205,6 @@ def is_match(uri1: str, uri2: str, match_cluster: Optional[MatchCluster] = None, Check if two URIs are in the same match cluster. """ checker = False - if uri2 == "http://kg.org/resource/b25598f9c0fce28a7700869fcb55d706": - checker = True if allow_match_on_suffix: suffix1 = uri1.split("/")[-1] From 0a22d9ac43c32c5a19e2b70b63b37aab63aae1ab Mon Sep 17 00:00:00 2001 From: Marvin Date: Thu, 19 Mar 2026 17:46:04 +0100 Subject: [PATCH 28/96] feat(params): working on task parameters --- src/kgpipe/common/model/configuration.py | 9 ++++++++- src/kgpipe/common/registry.py | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/kgpipe/common/model/configuration.py b/src/kgpipe/common/model/configuration.py index f9f9b92..6dcaace 100644 --- a/src/kgpipe/common/model/configuration.py +++ b/src/kgpipe/common/model/configuration.py @@ -71,4 +71,11 @@ class ConfigurationProfile(BaseModel): name: str definition: ConfigurationDefinition description: Optional[str] = None - bindings: List[ParameterBinding] = field(default_factory=list) \ No newline at end of file + bindings: List[ParameterBinding] = field(default_factory=list) + +class ConfigurationMapping(BaseModel): + """ + Mapping of a configuration profile to a task implementation + """ + for_task_spec: ConfigurationDefinition + to_global_spec: ConfigurationDefinition \ No newline at end of file diff --git a/src/kgpipe/common/registry.py b/src/kgpipe/common/registry.py index 5281372..49f8359 100644 --- a/src/kgpipe/common/registry.py +++ b/src/kgpipe/common/registry.py @@ -48,7 +48,7 @@ def task( config_spec: ConfigurationDefinition | None = None ) -> Callable[[Callable], KgTask]: def decorator(t): - task = KgTask(t.__name__.lower(), input_spec, output_spec, t, description, category) + task = KgTask(t.__name__.lower(), input_spec, output_spec, t, description, category, config_spec) cls._registry[f"task:{t.__name__.lower()}"] = task PipeKG.add_task(task) return task From 7ba89634a51f889171bb722453539c6cb3023d48 Mon Sep 17 00:00:00 2001 From: Marvin Date: Thu, 19 Mar 2026 17:47:36 +0100 Subject: [PATCH 29/96] chore: simple examples for task params --- .../src/kgpipe_examples/conf_examples.py | 159 ++++++++++++++++++ .../src/kgpipe_examples/task_examples.py | 16 ++ 2 files changed, 175 insertions(+) create mode 100644 experiments/examples/src/kgpipe_examples/conf_examples.py diff --git a/experiments/examples/src/kgpipe_examples/conf_examples.py b/experiments/examples/src/kgpipe_examples/conf_examples.py new file mode 100644 index 0000000..b40ab94 --- /dev/null +++ b/experiments/examples/src/kgpipe_examples/conf_examples.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +from collections import defaultdict, deque +from dataclasses import dataclass +from typing import Dict, List, Sequence, Tuple + + +@dataclass(frozen=True) +class DagNode: + name: str + inputs: Tuple[str, ...] = () + output: str | None = None + + +class Dag: + """Minimal DAG API with dependency validation and parallel batches.""" + + def __init__(self) -> None: + self._nodes: Dict[str, DagNode] = {} + self._parents: Dict[str, set[str]] = defaultdict(set) + self._children: Dict[str, set[str]] = defaultdict(set) + self._data_producers: Dict[str, str] = {} + + def task(self, name: str, *, needs: Sequence[str] = (), produces: str | None = None) -> Dag: + if name in self._nodes: + raise ValueError(f"Task '{name}' already exists") + + if produces and produces in self._data_producers: + producer = self._data_producers[produces] + raise ValueError(f"Data '{produces}' is already produced by '{producer}'") + + node = DagNode(name=name, inputs=tuple(needs), output=produces) + self._nodes[name] = node + if produces: + self._data_producers[produces] = name + return self + + def wire(self) -> Dag: + """Resolve data dependencies into task edges.""" + for node in self._nodes.values(): + for data_id in node.inputs: + parent = self._data_producers.get(data_id) + if parent is None: + raise ValueError( + f"Task '{node.name}' requires '{data_id}', but no upstream task produces it" + ) + self._parents[node.name].add(parent) + self._children[parent].add(node.name) + + self._assert_acyclic() + return self + + def execution_batches(self) -> List[List[str]]: + """ + Return topological levels. + Tasks in the same inner list can run in parallel. + """ + indegree = {name: len(self._parents[name]) for name in self._nodes} + frontier = deque(sorted([n for n, d in indegree.items() if d == 0])) + batches: List[List[str]] = [] + + while frontier: + level: List[str] = list(frontier) + frontier.clear() + batches.append(level) + + for task_name in level: + for child in sorted(self._children[task_name]): + indegree[child] -= 1 + if indegree[child] == 0: + frontier.append(child) + + total = sum(len(batch) for batch in batches) + if total != len(self._nodes): + raise ValueError("Graph contains a cycle") + return batches + + def edges(self) -> List[Tuple[str, str]]: + out: List[Tuple[str, str]] = [] + for parent, children in sorted(self._children.items()): + for child in sorted(children): + out.append((parent, child)) + return out + + def to_mermaid_mmd(self, direction: str = "LR") -> str: + """ + Export graph as Mermaid mmd text. + Call this after `wire()` so task dependencies are resolved. + """ + lines: List[str] = [f"flowchart {direction}"] + + for node_name, node in sorted(self._nodes.items()): + node_lines = [node.name] + if node.inputs: + node_lines.append(f"needs: {', '.join(node.inputs)}") + if node.output: + node_lines.append(f"produces: {node.output}") + label = "
".join(node_lines) + lines.append(f' {node_name}["{label}"]') + + for parent, child in self.edges(): + lines.append(f" {parent} --> {child}") + + return "\n".join(lines) + + def _assert_acyclic(self) -> None: + visited: set[str] = set() + in_stack: set[str] = set() + + def dfs(node_name: str) -> None: + visited.add(node_name) + in_stack.add(node_name) + for child_name in self._children[node_name]: + if child_name not in visited: + dfs(child_name) + elif child_name in in_stack: + raise ValueError(f"Cycle detected at '{child_name}'") + in_stack.remove(node_name) + + for name in self._nodes: + if name not in visited: + dfs(name) + + +def dag_example() -> Dag: + """ + Typical syntax: + - split: one output consumed by several branches + - join: one task requiring multiple inputs + - final output: last task produces the sink artifact + """ + dag = ( + Dag() + .task("load_users", produces="users") + .task("load_orders", produces="orders") + .task("clean_users", needs=("users",), produces="users_clean") + .task("clean_orders", needs=("orders",), produces="orders_clean") + .task("extract_features_a", needs=("users_clean","orders_clean"), produces="features_a") + .task("extract_features_b", needs=("users_clean",), produces="features_b") + .task( + "join_user_order_features", + needs=("features_a", "features_b", "orders_clean"), + produces="joined_features", + ) + .task("train_model", needs=("joined_features",), produces="model") + .task("evaluate_model", needs=("model",), produces="report") + .wire() + ) + return dag + + +if __name__ == "__main__": + dag = dag_example() + print("Edges:", dag.edges()) + print("Parallel batches:", dag.execution_batches()) + print("\nMermaid mmd:\n") + print(dag.to_mermaid_mmd()) + + diff --git a/experiments/examples/src/kgpipe_examples/task_examples.py b/experiments/examples/src/kgpipe_examples/task_examples.py index 82ba839..9a4fa7f 100644 --- a/experiments/examples/src/kgpipe_examples/task_examples.py +++ b/experiments/examples/src/kgpipe_examples/task_examples.py @@ -1,4 +1,5 @@ from kgpipe.common import TaskInput, TaskOutput +from kgpipe.common.model.configuration import ConfigurationProfile, ConfigurationDefinition, Parameter, ParameterType from kgpipe_examples.config import ExtendedFormats from kgpipe.common.registry import Registry @@ -28,3 +29,18 @@ def pipe_task_remote(inputs: TaskInput, outputs: TaskOutput): outputs["output"].path.touch() +@Registry.task( + input_spec={"input": ExtendedFormats.SPECIAL2}, + output_spec={"output": ExtendedFormats.SPECIAL_KG}, + config_spec=ConfigurationDefinition( + name="pipe_task_with_config_spec", + description="Configuration specification for the pipe_task_with_config task", + parameters=[ + Parameter(name="some_parameter", datatype=ParameterType.string, default_value="default", required=False) + ] + ) +) +def pipe_task_with_config(inputs: TaskInput, outputs: TaskOutput, config: ConfigurationProfile): + # print config + print(config) + outputs["output"].path.touch() \ No newline at end of file From bd7700c68c491f0ab6ff80d5039665bb585ffb9f Mon Sep 17 00:00:00 2001 From: Marvin Date: Thu, 19 Mar 2026 17:48:43 +0100 Subject: [PATCH 30/96] exp(moviekg): updates to moviekg evaluation --- experiments/moviekg/.gitignore | 1 + experiments/moviekg/eval.sh | 10 ++ .../moviekg/evaluation/test_sensitivity.py | 130 ++++++++++++++++-- .../src/moviekg/paper/helpers/getter.py | 5 +- .../moviekg/src/moviekg/paper/test_figtab.py | 62 ++++++++- 5 files changed, 192 insertions(+), 16 deletions(-) create mode 100644 experiments/moviekg/.gitignore create mode 100644 experiments/moviekg/eval.sh diff --git a/experiments/moviekg/.gitignore b/experiments/moviekg/.gitignore new file mode 100644 index 0000000..1e82fc7 --- /dev/null +++ b/experiments/moviekg/.gitignore @@ -0,0 +1 @@ +*.yaml diff --git a/experiments/moviekg/eval.sh b/experiments/moviekg/eval.sh new file mode 100644 index 0000000..ecaad6f --- /dev/null +++ b/experiments/moviekg/eval.sh @@ -0,0 +1,10 @@ +kgpipe eval -c metric_config.yaml \ + -m ReferenceTripleAlignmentMetricSoftEV \ + -m entity_count \ + -m incorrect_relation_direction \ + -m incorrect_relation_cardinality \ + -m incorrect_relation_range \ + -m incorrect_relation_domain \ + -m incorrect_datatype \ + -m incorrect_datatype_format \ + data/out/small/rdf_a/stage_3/result.nt diff --git a/experiments/moviekg/src/moviekg/evaluation/test_sensitivity.py b/experiments/moviekg/src/moviekg/evaluation/test_sensitivity.py index 83e1154..0c68210 100644 --- a/experiments/moviekg/src/moviekg/evaluation/test_sensitivity.py +++ b/experiments/moviekg/src/moviekg/evaluation/test_sensitivity.py @@ -1,7 +1,14 @@ from dataclasses import dataclass from typing import List -from kgpipe.evaluation.aspects.reference import ReferenceEvaluator, ReferenceConfig - +from kgpipe.common import KgPipe, Data, DataFormat, KG +from pathlib import Path +from kgpipe.common.models import KgPipePlan +from kgpipe.evaluation.aspects.reference import ( + ReferenceEvaluator, ReferenceConfig, + ER_EntityMatchMetric, ER_RelationMatchMetric, + TE_ExpectedEntityLinkMetric, TE_ExpectedRelationLinkMetric +) +import os @dataclass class BinaryClassifier: tp: int @@ -21,20 +28,117 @@ class ThresholdSensitivityResult: threshold: float result: BinaryClassifier +benchdata = Path("/home/marvin/phd/kgpipe/experiments/moviekg/data/datasets/film_10k/") +seed_path = benchdata / "split_0/kg/seed/data.nt" +rdf_path = benchdata / "split_1/sources/rdf/data.nt" +result_dir_path = Path(f"data/moviekg/threshold_sensitivity/") +# reference_evaluator = ReferenceEvaluator() -reference_evaluator = ReferenceEvaluator() +def run_paris_pipeline(pipeline_name: str, threshold: float) -> List[ThresholdSensitivityResult]: + from kgpipe_tasks.tasks import paris_entity_matching, paris_exchange -def paris_threshold_sensitivity(pipeline_name: str, threshold: float) -> List[ThresholdSensitivityResult]: - ReferenceConfig( - ENTITY_MATCH_THRESHOLD=threshold - RELATION_MATCH_THRESHOLD=threshold - ) # TODO get config from dataset - # kg = KG(path=Path(f"data/moviekg/paris/{pipeline_name}.nt")) - # reference_kg = KG(path=Path("data/moviekg/paris/reference.nt")) - # result = reference_evaluator.evaluate(kg, reference_kg) - # return result - pass + pipe_result_dir_path = result_dir_path / f"{pipeline_name}" + pipeline = KgPipe( + name="paris pipeline", + tasks=[paris_entity_matching, paris_exchange], + seed=Data(path=seed_path, format=DataFormat.RDF_NTRIPLES), + data_dir=pipe_result_dir_path / "tmp" + ) + plan = pipeline.build( + source=Data(path=rdf_path, format=DataFormat.RDF_NTRIPLES), + result=Data(path=pipe_result_dir_path / "result.json", format=DataFormat.ER_JSON) + ) + + os.makedirs(pipe_result_dir_path, exist_ok=True) + + with open(pipe_result_dir_path / "exec-plan.json", "w") as f: + f.write(plan.model_dump_json(indent=4)) + + pipeline.run() + +def paris_er_threshold_sensitivity(pipeline_name: str, threshold: float) -> List[ThresholdSensitivityResult]: + config = ReferenceConfig( + name="paris config", + ENTITY_MATCH_THRESHOLD=threshold, + RELATION_MATCH_THRESHOLD=threshold, + GT_MATCHES=benchdata / "split_1/sources/rdf/meta/verified_matches.csv", + GT_MATCHES_TARGET_DATASET="split_0/kg/seed" + ) + + plan = KgPipePlan.model_validate_json(open(result_dir_path / f"{pipeline_name}" / "exec-plan.json").read()) + + kg = KG(id="paris", name="paris", path=Path(f"data/moviekg/paris/{pipeline_name}.nt"), format=DataFormat.RDF_NTRIPLES, plan=plan) + + metric_result = ER_EntityMatchMetric().compute(kg, config=config) + # print(metric_result) + + return metric_result + +def paris_om_threshold_sensitivity(pipeline_name: str, threshold: float) -> List[ThresholdSensitivityResult]: + config = ReferenceConfig( + name="paris config", + ENTITY_MATCH_THRESHOLD=threshold, + RELATION_MATCH_THRESHOLD=threshold, + GT_MATCHES=benchdata / "split_1/sources/rdf/meta/verified_matches.csv", + GT_MATCHES_TARGET_DATASET="split_0/kg/seed" + ) + + plan = KgPipePlan.model_validate_json(open(result_dir_path / f"{pipeline_name}" / "exec-plan.json").read()) + + kg = KG(id="paris", name="paris", path=Path(f"data/moviekg/paris/{pipeline_name}.nt"), format=DataFormat.RDF_NTRIPLES, plan=plan) + + metric_result = ER_RelationMatchMetric().compute(kg, config=config) + # print(metric_result) + + return metric_result + +def test_paris(): + # run_paris_pipeline("paris", 0.99) + range_of_thresholds = [0.0, 0.001, 0.01, 0.05, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 0.95, 0.99, 0.999, 1.0] + + er_results = [] + for threshold in range_of_thresholds: + result = paris_er_threshold_sensitivity("paris", threshold) + er_results.append([threshold, result.normalized_score, result.details]) + + print() + print("ER Results:") + for r in er_results: + print(r[0], r[1], r[2]) + + om_results = [] + for threshold in range_of_thresholds: + result = paris_om_threshold_sensitivity("paris", threshold) + om_results.append([threshold, result.normalized_score, result.details]) + + print("OM Results:") + for r in om_results: + print(r[0], r[1], r[2]) + +# def paris_threshold_sensitivity(pipeline_name: str, threshold: float) -> List[ThresholdSensitivityResult]: +# result = run_paris_pipeline(pipeline_name, threshold) + +# pipeline.run( +# input=[Data(path=Path(f"data/moviekg/paris/{pipeline_name}.nt"), format=DataFormat.RDF_NTRIPLES)], +# output=[Data(path=Path(f"data/moviekg/paris/{pipeline_name}.paris_csv"), format=DataFormat.PARIS_CSV)] +# ) + +# config = ReferenceConfig( +# name="paris config", +# ENTITY_MATCH_THRESHOLD=threshold, +# RELATION_MATCH_THRESHOLD=threshold +# ) + + + + +# # TODO get config from dataset +# # kg = KG(path=Path(f"data/moviekg/paris/{pipeline_name}.nt")) +# # reference_kg = KG(path=Path("data/moviekg/paris/reference.nt")) +# # result = reference_evaluator.evaluate(kg, reference_kg) +# # return result +# pass def jedai_threshold_sensitivity(pipeline_name: str, threshold: float) -> List[ThresholdSensitivityResult]: pass diff --git a/experiments/moviekg/src/moviekg/paper/helpers/getter.py b/experiments/moviekg/src/moviekg/paper/helpers/getter.py index 7d07cb8..ab7fce4 100644 --- a/experiments/moviekg/src/moviekg/paper/helpers/getter.py +++ b/experiments/moviekg/src/moviekg/paper/helpers/getter.py @@ -168,7 +168,8 @@ def ref_source_typed_entity_p(df: pd.DataFrame): res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) for row in df.itertuples(): details = json.loads(row.details) - precision = details["precision"] + # print(details) + precision = details.get("fn", -1) res[row.pipeline][row.stage] = precision return res @@ -177,7 +178,7 @@ def ref_source_typed_entity_r(df: pd.DataFrame): res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) for row in df.itertuples(): details = json.loads(row.details) - recall = details["recall"] + recall = details.get("recall", -1) res[row.pipeline][row.stage] = recall return res diff --git a/experiments/moviekg/src/moviekg/paper/test_figtab.py b/experiments/moviekg/src/moviekg/paper/test_figtab.py index bee9967..f2dca06 100644 --- a/experiments/moviekg/src/moviekg/paper/test_figtab.py +++ b/experiments/moviekg/src/moviekg/paper/test_figtab.py @@ -719,4 +719,64 @@ def test_new_ranking_table(): # # metric_df = metric_df.reset_index(drop=True) # # metric_df = metric_df.pivot(index="pipeline", columns="metric", values="normalized") # # metric_df = metric_df.reset_index() - # metric_df.to_csv(OUTPUT_ROOT / "paper/test_tab_8_new_ranking_table.csv", sep="\t") \ No newline at end of file + # metric_df.to_csv(OUTPUT_ROOT / "paper/test_tab_8_new_ranking_table.csv", sep="\t") + + +def test_new_quality_table(): + + metric_df = load_metrics_from_file(OUTPUT_ROOT / "all_metrics.csv") + metric_df["pipeline"] = metric_df["pipeline"].map(map_pipeline_name_pretty) + from moviekg.paper.helpers.getter import ( + get_pipeline_stage_metric_dict, + sta_entity_count, sta_fact_count, sta_type_count, sta_relation_count, sta_shallow_entity_count, sta_denisity, sta_duration, + ref_kg_f1, ref_kg_p, ref_kg_r, + ref_source_entity_f1, ref_source_entity_p, ref_source_entity_r, + ref_source_typed_entity_r, ref_source_typed_entity_p, + sem_disjoint_domain, sem_incorrect_relation_direction, sem_incorrect_relation_cardinality, sem_incorrect_relation_range, sem_incorrect_relation_domain, sem_incorrect_datatype, sem_incorrect_datatype_format, + ) + + metrics = [ + sta_entity_count.__name__, sta_fact_count.__name__, sta_type_count.__name__, sta_relation_count.__name__, sta_shallow_entity_count.__name__, sta_denisity.__name__, sta_duration.__name__, + ref_kg_f1.__name__, ref_kg_p.__name__, + ref_kg_r.__name__, ref_source_entity_f1.__name__, + ref_source_entity_p.__name__, ref_source_entity_r.__name__, + ref_source_typed_entity_r.__name__, ref_source_typed_entity_p.__name__, + sem_disjoint_domain.__name__, sem_incorrect_relation_direction.__name__, sem_incorrect_relation_cardinality.__name__, sem_incorrect_relation_range.__name__, sem_incorrect_relation_domain.__name__, sem_incorrect_datatype.__name__, sem_incorrect_datatype_format.__name__, + ] + + psmd = get_pipeline_stage_metric_dict(metric_df, metrics) + # import json + # json.dump(psmd, open(OUTPUT_ROOT / "paper/test_tab_6_metrics.json", "w"), indent=4) + + rows = [] + + round_to = 3 + + for pipeline, stage_dict in psmd.items(): + if pipeline in ["reference", "seed"]: + continue + + for stage, metric_dict in stage_dict.items(): + ec = round(metric_dict.get(sta_entity_count.__name__, -1), round_to) + kg_p = round(metric_dict.get(ref_kg_p.__name__, -1), round_to) + kg_r = round(metric_dict.get(ref_kg_r.__name__, -1), round_to) + se_p = round(metric_dict.get(ref_source_entity_p.__name__, -1), round_to) + se_r= round(metric_dict.get(ref_source_entity_r.__name__, -1), round_to) + ste_p = round(metric_dict.get(ref_source_typed_entity_p.__name__, -1), round_to) + ste_r = round(metric_dict.get(ref_source_typed_entity_r.__name__, -1), round_to) + o_dt = round(metric_dict.get(sem_disjoint_domain.__name__, -1), round_to) + o_d = round(metric_dict.get(sem_incorrect_relation_domain.__name__, -1), round_to) + o_r = round(metric_dict.get(sem_incorrect_relation_range.__name__, -1), round_to) + o_rd = round(metric_dict.get(sem_incorrect_relation_direction.__name__, -1), round_to) + o_lt = round(metric_dict.get(sem_incorrect_datatype.__name__, -1), round_to) + o_lf = round(metric_dict.get(sem_incorrect_datatype_format.__name__, -1), round_to) + + rows.append({ + "pipeline": pipeline, "stage": stage, + "EC": ec, + "kg_p": kg_p, "kg_r": kg_r, "se_p": se_p, "se_r": se_r, "ste_p": ste_p, "ste_r": ste_r, + "O_DT": o_dt, "O_D": o_d, "O_R": o_r, "O_RD": o_rd, "O_LT": o_lt, "O_LF": o_lf + }) + + df = pd.DataFrame(rows) + df.to_csv(OUTPUT_ROOT / "paper/test_tab_9_new_quality_table.csv", sep="\t") \ No newline at end of file From 7f9846870bd8e543c3783e51b26df0cba5d0550c Mon Sep 17 00:00:00 2001 From: Marvin Date: Thu, 2 Apr 2026 18:48:03 +0200 Subject: [PATCH 31/96] chore: gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 9804c77..c985141 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ poetry.lock .idea/ target/ +# agents +.cursor/ + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] From 58899aba27ca1105b92373f507d602b9178c1a6d Mon Sep 17 00:00:00 2001 From: Marvin Date: Thu, 19 Mar 2026 18:10:19 +0100 Subject: [PATCH 32/96] exp(ontologies): experiments on infering or building target ontologies --- experiments/ontologies/scads-papers.owl.ttl | 221 +++++++++++ experiments/ontologies/scads-papers.ttl | 38 ++ experiments/ontologies/src/onto_chat.py | 404 ++++++++++++++++++++ experiments/ontologies/src/onto_diff.py | 0 4 files changed, 663 insertions(+) create mode 100644 experiments/ontologies/scads-papers.owl.ttl create mode 100644 experiments/ontologies/scads-papers.ttl create mode 100644 experiments/ontologies/src/onto_chat.py create mode 100644 experiments/ontologies/src/onto_diff.py diff --git a/experiments/ontologies/scads-papers.owl.ttl b/experiments/ontologies/scads-papers.owl.ttl new file mode 100644 index 0000000..87c4280 --- /dev/null +++ b/experiments/ontologies/scads-papers.owl.ttl @@ -0,0 +1,221 @@ +@prefix : . +@prefix owl: . +@prefix rdfs: . + +######## +# Classes +######## + +:ScientificPaper a owl:Class . + +:ContentUnit a owl:Class . +:RhetoricalUnit a owl:Class ; rdfs:subClassOf :ContentUnit . +:ScientificContribution a owl:Class ; rdfs:subClassOf :ContentUnit . + +:ResearchProblem a owl:Class ; rdfs:subClassOf :ScientificContribution . +:ResearchQuestion a owl:Class ; rdfs:subClassOf :ScientificContribution . +:Motivation a owl:Class ; rdfs:subClassOf :ScientificContribution . +:Goal a owl:Class ; rdfs:subClassOf :ScientificContribution . +:Hypothesis a owl:Class ; rdfs:subClassOf :ScientificContribution . +:Claim a owl:Class ; rdfs:subClassOf :ScientificContribution . +:Method a owl:Class ; rdfs:subClassOf :ScientificContribution . +:Material a owl:Class ; rdfs:subClassOf :ScientificContribution . +:Dataset a owl:Class ; rdfs:subClassOf :ScientificContribution . +:Experiment a owl:Class ; rdfs:subClassOf :ScientificContribution . +:Model a owl:Class ; rdfs:subClassOf :ScientificContribution . +:Observation a owl:Class ; rdfs:subClassOf :ScientificContribution . +:Result a owl:Class ; rdfs:subClassOf :ScientificContribution . +:Conclusion a owl:Class ; rdfs:subClassOf :ScientificContribution . +:Limitation a owl:Class ; rdfs:subClassOf :ScientificContribution . +:FutureWork a owl:Class ; rdfs:subClassOf :ScientificContribution . +:Evidence a owl:Class ; rdfs:subClassOf :ScientificContribution . +:RelatedWorkStatement a owl:Class ; rdfs:subClassOf :ScientificContribution . +:Concept a owl:Class . +:Variable a owl:Class . +:Metric a owl:Class . + +######## +# Paper -> content +######## + +:hasContentUnit a owl:ObjectProperty ; + rdfs:domain :ScientificPaper ; + rdfs:range :ContentUnit . + +:hasProblem a owl:ObjectProperty ; + rdfs:subPropertyOf :hasContentUnit ; + rdfs:domain :ScientificPaper ; + rdfs:range :ResearchProblem . + +:hasResearchQuestion a owl:ObjectProperty ; + rdfs:subPropertyOf :hasContentUnit ; + rdfs:domain :ScientificPaper ; + rdfs:range :ResearchQuestion . + +:hasMotivation a owl:ObjectProperty ; + rdfs:subPropertyOf :hasContentUnit ; + rdfs:domain :ScientificPaper ; + rdfs:range :Motivation . + +:hasGoal a owl:ObjectProperty ; + rdfs:subPropertyOf :hasContentUnit ; + rdfs:domain :ScientificPaper ; + rdfs:range :Goal . + +:hasHypothesis a owl:ObjectProperty ; + rdfs:subPropertyOf :hasContentUnit ; + rdfs:domain :ScientificPaper ; + rdfs:range :Hypothesis . + +:hasClaim a owl:ObjectProperty ; + rdfs:subPropertyOf :hasContentUnit ; + rdfs:domain :ScientificPaper ; + rdfs:range :Claim . + +:hasMethod a owl:ObjectProperty ; + rdfs:subPropertyOf :hasContentUnit ; + rdfs:domain :ScientificPaper ; + rdfs:range :Method . + +:hasMaterial a owl:ObjectProperty ; + rdfs:subPropertyOf :hasContentUnit ; + rdfs:domain :ScientificPaper ; + rdfs:range :Material . + +:hasDataset a owl:ObjectProperty ; + rdfs:subPropertyOf :hasContentUnit ; + rdfs:domain :ScientificPaper ; + rdfs:range :Dataset . + +:hasExperiment a owl:ObjectProperty ; + rdfs:subPropertyOf :hasContentUnit ; + rdfs:domain :ScientificPaper ; + rdfs:range :Experiment . + +:hasModel a owl:ObjectProperty ; + rdfs:subPropertyOf :hasContentUnit ; + rdfs:domain :ScientificPaper ; + rdfs:range :Model . + +:hasObservation a owl:ObjectProperty ; + rdfs:subPropertyOf :hasContentUnit ; + rdfs:domain :ScientificPaper ; + rdfs:range :Observation . + +:hasResult a owl:ObjectProperty ; + rdfs:subPropertyOf :hasContentUnit ; + rdfs:domain :ScientificPaper ; + rdfs:range :Result . + +:hasConclusion a owl:ObjectProperty ; + rdfs:subPropertyOf :hasContentUnit ; + rdfs:domain :ScientificPaper ; + rdfs:range :Conclusion . + +:hasLimitation a owl:ObjectProperty ; + rdfs:subPropertyOf :hasContentUnit ; + rdfs:domain :ScientificPaper ; + rdfs:range :Limitation . + +:hasFutureWork a owl:ObjectProperty ; + rdfs:subPropertyOf :hasContentUnit ; + rdfs:domain :ScientificPaper ; + rdfs:range :FutureWork . + +:hasRelatedWorkStatement a owl:ObjectProperty ; + rdfs:subPropertyOf :hasContentUnit ; + rdfs:domain :ScientificPaper ; + rdfs:range :RelatedWorkStatement . + +######## +# Internal semantics +######## + +:addressesProblem a owl:ObjectProperty ; + rdfs:domain :Method ; + rdfs:range :ResearchProblem . + +:investigatesQuestion a owl:ObjectProperty ; + rdfs:domain :Experiment ; + rdfs:range :ResearchQuestion . + +:testsHypothesis a owl:ObjectProperty ; + rdfs:domain :Experiment ; + rdfs:range :Hypothesis . + +:usesMethod a owl:ObjectProperty ; + rdfs:domain :Experiment ; + rdfs:range :Method . + +:usesMaterial a owl:ObjectProperty ; + rdfs:domain :Experiment ; + rdfs:range :Material . + +:usesDataset a owl:ObjectProperty ; + rdfs:domain :Experiment ; + rdfs:range :Dataset . + +:studiesConcept a owl:ObjectProperty ; + rdfs:domain :ScientificContribution ; + rdfs:range :Concept . + +:hasVariable a owl:ObjectProperty ; + rdfs:domain :Experiment ; + rdfs:range :Variable . + +:usesMetric a owl:ObjectProperty ; + rdfs:domain :Result ; + rdfs:range :Metric . + +:producesObservation a owl:ObjectProperty ; + rdfs:domain :Experiment ; + rdfs:range :Observation . + +:supportsClaim a owl:ObjectProperty ; + rdfs:domain :Evidence ; + rdfs:range :Claim . + +:reportsEvidence a owl:ObjectProperty ; + rdfs:domain :Result ; + rdfs:range :Evidence . + +:derivedFromObservation a owl:ObjectProperty ; + rdfs:domain :Result ; + rdfs:range :Observation . + +:supports a owl:ObjectProperty ; + rdfs:domain :ScientificContribution ; + rdfs:range :ScientificContribution . + +:contradicts a owl:ObjectProperty ; + rdfs:domain :ScientificContribution ; + rdfs:range :ScientificContribution . + +:extends a owl:ObjectProperty ; + rdfs:domain :ScientificContribution ; + rdfs:range :ScientificContribution . + +:motivates a owl:ObjectProperty ; + rdfs:domain :Motivation ; + rdfs:range :Goal . + +:answers a owl:ObjectProperty ; + rdfs:domain :Conclusion ; + rdfs:range :ResearchQuestion . + +:basedOn a owl:ObjectProperty ; + rdfs:domain :Conclusion ; + rdfs:range :Result . + +:hasLimitationOn a owl:ObjectProperty ; + rdfs:domain :Limitation ; + rdfs:range :Method . + +######## +# Optional rhetorical typing +######## + +:IntroductionUnit a owl:Class ; rdfs:subClassOf :RhetoricalUnit . +:MethodsUnit a owl:Class ; rdfs:subClassOf :RhetoricalUnit . +:ResultsUnit a owl:Class ; rdfs:subClassOf :RhetoricalUnit . +:DiscussionUnit a owl:Class ; rdfs:subClassOf :RhetoricalUnit . diff --git a/experiments/ontologies/scads-papers.ttl b/experiments/ontologies/scads-papers.ttl new file mode 100644 index 0000000..e18623c --- /dev/null +++ b/experiments/ontologies/scads-papers.ttl @@ -0,0 +1,38 @@ +@prefix : . + +:paper1 a :ScientificPaper ; + :hasProblem :problem1 ; + :hasGoal :goal1 ; + :hasMethod :method1 ; + :hasExperiment :exp1 ; + :hasObservation :obs1 ; + :hasResult :result1 ; + :hasClaim :claim1 ; + :hasConclusion :concl1 . + +:problem1 a :ResearchProblem . +:goal1 a :Goal . +:method1 a :Method ; + :addressesProblem :problem1 . + +:exp1 a :Experiment ; + :usesMethod :method1 ; + :testsHypothesis :hyp1 ; + :producesObservation :obs1 . + +:hyp1 a :Hypothesis . +:obs1 a :Observation . + +:result1 a :Result ; + :derivedFromObservation :obs1 . + +:evidence1 a :Evidence ; + :supportsClaim :claim1 . + +:result1 :reportsEvidence :evidence1 . + +:claim1 a :Claim ; + :supports :goal1 . + +:concl1 a :Conclusion ; + :basedOn :result1 . diff --git a/experiments/ontologies/src/onto_chat.py b/experiments/ontologies/src/onto_chat.py new file mode 100644 index 0000000..a4c5b58 --- /dev/null +++ b/experiments/ontologies/src/onto_chat.py @@ -0,0 +1,404 @@ +"""Streamlit ontology chat prototype. + +Run: + uv run streamlit run experiments/ontologies/src/onto_chat.py +""" + +from __future__ import annotations + +from dataclasses import dataclass +import importlib +import os +import re +from textwrap import dedent + +import streamlit as st +import streamlit.components.v1 as components +from rdflib import Graph, RDF, RDFS, URIRef +from rdflib.namespace import OWL + + +EXAMPLE_OWL = dedent( + """\ + @prefix ex: . + @prefix rdf: . + @prefix rdfs: . + @prefix owl: . + + ex:Person a owl:Class . + ex:Company a owl:Class . + ex:Project a owl:Class . + + ex:worksFor a owl:ObjectProperty ; + rdfs:domain ex:Person ; + rdfs:range ex:Company . + + ex:worksOn a owl:ObjectProperty ; + rdfs:domain ex:Person ; + rdfs:range ex:Project . + """ +) + +DEFAULT_OPENAI_MODEL = "gpt-4o-mini" +KNOWN_PREFIXES = { + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "owl": "http://www.w3.org/2002/07/owl#", + "xsd": "http://www.w3.org/2001/XMLSchema#", +} + + +@dataclass +class OntologySchema: + classes: list[str] + object_edges: list[tuple[str, str, str]] + datatype_edges: list[tuple[str, str, str]] + + +def short_name(uri: URIRef) -> str: + """Return a compact local name for URI nodes.""" + text = str(uri) + if "#" in text: + return text.rsplit("#", maxsplit=1)[-1] + if "/" in text: + return text.rstrip("/").rsplit("/", maxsplit=1)[-1] + return text + + +def parse_graph(raw_text: str, rdf_format: str) -> Graph: + """Parse ontology text into an RDF graph.""" + graph = Graph() + graph.parse(data=raw_text, format=rdf_format) + return graph + + +def extract_schema(graph: Graph) -> OntologySchema: + """Extract classes and property relations from graph.""" + classes: set[str] = set() + object_edges: list[tuple[str, str, str]] = [] + datatype_edges: list[tuple[str, str, str]] = [] + + for cls in graph.subjects(RDF.type, OWL.Class): + if isinstance(cls, URIRef): + classes.add(short_name(cls)) + for cls in graph.subjects(RDF.type, RDFS.Class): + if isinstance(cls, URIRef): + classes.add(short_name(cls)) + + for prop in graph.subjects(RDF.type, OWL.ObjectProperty): + if not isinstance(prop, URIRef): + continue + prop_name = short_name(prop) + domains = [d for d in graph.objects(prop, RDFS.domain) if isinstance(d, URIRef)] + ranges = [r for r in graph.objects(prop, RDFS.range) if isinstance(r, URIRef)] + for domain in domains or [URIRef("UnknownDomain")]: + for rng in ranges or [URIRef("UnknownRange")]: + src, dst = short_name(domain), short_name(rng) + classes.update([src, dst]) + object_edges.append((src, prop_name, dst)) + + for prop in graph.subjects(RDF.type, OWL.DatatypeProperty): + if not isinstance(prop, URIRef): + continue + prop_name = short_name(prop) + domains = [d for d in graph.objects(prop, RDFS.domain) if isinstance(d, URIRef)] + ranges = [r for r in graph.objects(prop, RDFS.range) if isinstance(r, URIRef)] + for domain in domains or [URIRef("UnknownDomain")]: + for rng in ranges or [URIRef("Literal")]: + src, dst = short_name(domain), short_name(rng) + classes.add(src) + datatype_edges.append((src, prop_name, dst)) + + return OntologySchema( + classes=sorted(classes), + object_edges=object_edges, + datatype_edges=datatype_edges, + ) + + +def to_mermaid(schema: OntologySchema) -> str: + """Serialize ontology schema as Mermaid classDiagram.""" + lines = ["classDiagram"] + for cls_name in schema.classes: + lines.append(f" class {cls_name}") + for src, rel, dst in schema.object_edges: + lines.append(f" {src} --> {dst} : {rel}") + for src, rel, dst in schema.datatype_edges: + lines.append(f" {src} : {rel} -> {dst}") + return "\n".join(lines) + + +def render_mermaid(mermaid_text: str) -> None: + """Render Mermaid diagram in Streamlit via embedded HTML.""" + escaped = ( + mermaid_text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + ) + html = f""" +
{escaped}
+ + + """ + components.html(html, height=500, scrolling=True) + + +def draft_llm_prompt(user_request: str, ontology_text: str, rdf_format: str) -> str: + """Build a prompt for a future LLM integration.""" + return dedent( + f"""\ + You are editing an OWL ontology. + + Task: + {user_request} + + Requirements: + - Return only ontology text in {rdf_format} format. + - Preserve existing prefixes when possible. + - Declare all prefixes you use (especially xsd when using xsd:* datatypes). + - Keep edits minimal and valid. + - Do not include markdown fences. + + Current ontology: + {ontology_text} + """ + ) + + +def strip_markdown_fences(text: str) -> str: + """Remove markdown code fences if model returns them.""" + cleaned = text.strip() + if cleaned.startswith("```") and cleaned.endswith("```"): + lines = cleaned.splitlines() + if len(lines) >= 2: + return "\n".join(lines[1:-1]).strip() + return cleaned + + +def extract_declared_prefixes(text: str) -> set[str]: + """Extract declared prefixes from Turtle/N3 text.""" + return set(re.findall(r"@prefix\s+([A-Za-z][\w\-]*)\s*:", text)) + + +def extract_used_prefixes(text: str) -> set[str]: + """Extract prefixed terms used in Turtle/N3 text.""" + matches = re.findall(r"(? tuple[str, list[str]]: + """Inject known prefix declarations when terms use undeclared prefixes.""" + declared = extract_declared_prefixes(text) + used = extract_used_prefixes(text) + missing = sorted((used - declared) & set(KNOWN_PREFIXES)) + if not missing: + return text, [] + + injections = [f"@prefix {p}: <{KNOWN_PREFIXES[p]}> ." for p in missing] + updated = "\n".join(injections) + "\n" + text.lstrip() + return updated, missing + + +def validate_and_normalize_ontology(raw_text: str, rdf_format: str) -> tuple[str, list[str]]: + """Normalize and validate returned ontology text.""" + normalized = raw_text.strip() + added_prefixes: list[str] = [] + if rdf_format in {"turtle", "n3"}: + normalized, added_prefixes = inject_missing_known_prefixes(normalized) + parse_graph(normalized, rdf_format) + return normalized, added_prefixes + + +def request_ontology_edit(prompt: str, model: str) -> str: + """Call OpenAI and return ontology text.""" + api_key = os.getenv("OPENAI_API_KEY") + if not api_key: + raise RuntimeError("OPENAI_API_KEY is not set.") + + try: + openai_module = importlib.import_module("openai") + openai_client = getattr(openai_module, "OpenAI") + except Exception as exc: # noqa: BLE001 + raise RuntimeError( + "The 'openai' package is required. Install it with: uv add openai" + ) from exc + + client = openai_client(api_key=api_key) + response = client.chat.completions.create( + model=model, + temperature=0, + messages=[ + { + "role": "system", + "content": ( + "You edit OWL ontologies. Return only ontology text in the requested " + "serialization format. Do not add markdown." + ), + }, + {"role": "user", "content": prompt}, + ], + ) + content = response.choices[0].message.content or "" + if not content.strip(): + raise RuntimeError("OpenAI returned an empty response.") + return strip_markdown_fences(content) + + +def request_ontology_syntax_fix( + ontology_text: str, + rdf_format: str, + parse_error: Exception, + model: str, +) -> str: + """Ask OpenAI for a syntax-only repair of ontology text.""" + prompt = dedent( + f"""\ + Fix the syntax of this ontology serialization. + + Requirements: + - Return only ontology text in {rdf_format}. + - Preserve meaning; only fix syntax/prefix issues. + - Ensure all used prefixes are declared. + - Do not include markdown fences. + + Parser error: + {parse_error} + + Ontology text: + {ontology_text} + """ + ) + return request_ontology_edit(prompt=prompt, model=model) + + +def init_state() -> None: + """Initialize app session state keys.""" + st.session_state.setdefault("ontology_text", EXAMPLE_OWL) + st.session_state.setdefault("rdf_format", "turtle") + st.session_state.setdefault("messages", []) + st.session_state.setdefault("last_llm_prompt", "") + st.session_state.setdefault("last_llm_response", "") + st.session_state.setdefault("last_normalized_response", "") + st.session_state.setdefault("openai_model", DEFAULT_OPENAI_MODEL) + + +def main() -> None: + st.set_page_config(page_title="Ontology Chat Draft", layout="wide") + st.title("Ontology Chat + Mermaid (Draft)") + st.caption("Prototype UI for OWL editing with chat-driven change requests.") + + init_state() + + left_col, right_col = st.columns([1, 1], gap="large") + + with left_col: + st.subheader("Ontology Text") + st.session_state.rdf_format = st.selectbox( + "RDF format", + options=["turtle", "xml", "nt", "n3"], + index=["turtle", "xml", "nt", "n3"].index(st.session_state.rdf_format), + ) + st.session_state.openai_model = st.text_input( + "OpenAI model", + value=st.session_state.openai_model, + help="Requires OPENAI_API_KEY in environment.", + ) + st.session_state.ontology_text = st.text_area( + "Edit ontology", + value=st.session_state.ontology_text, + height=340, + ) + + st.subheader("Chat") + for msg in st.session_state.messages: + with st.chat_message(msg["role"]): + st.markdown(msg["content"]) + + user_request = st.chat_input("Describe ontology change...") + if user_request: + st.session_state.messages.append({"role": "user", "content": user_request}) + prompt = draft_llm_prompt( + user_request=user_request, + ontology_text=st.session_state.ontology_text, + rdf_format=st.session_state.rdf_format, + ) + st.session_state.last_llm_prompt = prompt + try: + with st.spinner("Requesting ontology update from OpenAI..."): + model_name = st.session_state.openai_model.strip() or DEFAULT_OPENAI_MODEL + edited_ontology = request_ontology_edit( + prompt=prompt, + model=model_name, + ) + st.session_state.last_llm_response = edited_ontology + try: + normalized_ontology, added_prefixes = validate_and_normalize_ontology( + edited_ontology, st.session_state.rdf_format + ) + except Exception as parse_exc: # noqa: BLE001 + with st.spinner("Attempting syntax repair..."): + repaired = request_ontology_syntax_fix( + ontology_text=edited_ontology, + rdf_format=st.session_state.rdf_format, + parse_error=parse_exc, + model=model_name, + ) + st.session_state.last_llm_response = repaired + normalized_ontology, added_prefixes = validate_and_normalize_ontology( + repaired, st.session_state.rdf_format + ) + + st.session_state.ontology_text = normalized_ontology + st.session_state.last_normalized_response = normalized_ontology + prefix_note = "" + if added_prefixes: + prefix_note = f" Added missing prefixes: {', '.join(added_prefixes)}." + st.session_state.messages.append( + { + "role": "assistant", + "content": ( + "Applied OpenAI ontology update and refreshed Mermaid diagram." + f"{prefix_note}" + ), + } + ) + except Exception as exc: # noqa: BLE001 + st.session_state.messages.append( + { + "role": "assistant", + "content": ( + "OpenAI request failed after validation/repair attempts: " + f"{exc}" + ), + } + ) + st.rerun() + + with st.expander("Last drafted LLM prompt", expanded=False): + st.code(st.session_state.last_llm_prompt or "No prompt drafted yet.", language="text") + with st.expander("Last OpenAI response", expanded=False): + st.code(st.session_state.last_llm_response or "No model response yet.", language="text") + with st.expander("Last normalized ontology", expanded=False): + st.code( + st.session_state.last_normalized_response or "No normalized ontology yet.", + language="text", + ) + + with right_col: + st.subheader("Mermaid Render") + try: + graph = parse_graph(st.session_state.ontology_text, st.session_state.rdf_format) + schema = extract_schema(graph) + mermaid = to_mermaid(schema) + render_mermaid(mermaid) + with st.expander("Mermaid source", expanded=False): + st.code(mermaid, language="text") + except Exception as exc: # noqa: BLE001 + st.error(f"Could not parse ontology: {exc}") + st.info("Check RDF format and ontology syntax in the left panel.") + + +if __name__ == "__main__": + main() diff --git a/experiments/ontologies/src/onto_diff.py b/experiments/ontologies/src/onto_diff.py new file mode 100644 index 0000000..e69de29 From 3b3d408a3e0617de3b2d31a75a521d755b69d832 Mon Sep 17 00:00:00 2001 From: Marvin Date: Sun, 22 Mar 2026 21:54:12 +0100 Subject: [PATCH 33/96] stash --- src/kgpipe/common/annotations.py | 2 +- src/kgpipe/common/definitions.py | 83 +---------- src/kgpipe/common/models.py | 63 --------- src/kgpipe/common/registry.py | 60 ++++---- src/kgpipe/common/systemgraph.py | 230 +++++++++++++++++-------------- src/kgpipe_view/kgpipe.owl.ttl | 94 +++++++++---- 6 files changed, 223 insertions(+), 309 deletions(-) diff --git a/src/kgpipe/common/annotations.py b/src/kgpipe/common/annotations.py index ceb24b1..3462262 100644 --- a/src/kgpipe/common/annotations.py +++ b/src/kgpipe/common/annotations.py @@ -11,7 +11,7 @@ def kg_class(description: str = ""): as a KG entity (type/Class node) once at import time. """ def decorator(cls): - print("kg_class decorator called for class: ", cls.__name__) + # print("kg_class decorator called for class: ", cls.__name__) # add owl class props = [] if description: diff --git a/src/kgpipe/common/definitions.py b/src/kgpipe/common/definitions.py index e14c4e8..7d86878 100644 --- a/src/kgpipe/common/definitions.py +++ b/src/kgpipe/common/definitions.py @@ -32,8 +32,7 @@ class KGPIPE_NS(DefinedNamespace): Metric = _NS["Metric"] MetricRun = _NS["MetricRun"] - -# Data # +# Entities # class DataHandle(BaseModel): """ @@ -53,85 +52,6 @@ class DataHandle(BaseModel): hash: Optional[str] = None size: Optional[int] = None -# Task # - -# # TODO describing entity vs entity with used values for the task -# class TaskConfiguration(BaseModel): -# key: str -# value: Any - -# class Task(BaseModel): -# """ -# A function that implements a task in a pipeline - -# name: paris_rdf_matcher -# type: entity_resolution -# description: "PARIS java implementation to match two RDF files, producing CSV files..." -# input: [any_rdf, any_rdf] -# output: [any_csv] -# """ -# name: str -# type: str -# description: Optional[str] = None -# input: List[schema_format] -# output: List[schema_format] - -# class TaskResult(BaseModel): -# """ -# The result of a task execution including configuration variables -# """ -# task: Task -# config: Dict[str, Any] -# input: List[DataHandle] -# output: List[DataHandle] -# status: str -# duration: float - -# # Evaluation # - -# class Eval(BaseModel): -# """ -# A function that evaluates data produced by tasks -# """ -# name: str -# type: str -# description: Optional[str] = None -# input: List[schema_format] - -# class EvalResult(BaseModel):# -# """ -# Result of an evaluation function -# """ -# eval: Eval -# config: Dict[str, Any] -# input: List[DataHandle] -# output: Dict[str, Any] -# status: str -# duration: float - -# # Pipeline # - -# class Pipeline(BaseModel): -# """ -# The plan of a pipeline -# """ -# tasks: List[Task] -# input: List[schema_format] -# output: List[schema_format] -# pokemon -# class PipelineResult(BaseModel): -# """ -# Result of a pipeline execution -# """ -# task_results: List[TaskResult] -# eval_results: List[EvalResult] -# input: List[DataHandle] -# output: List[DataHandle] -# status: str -# duration: float - -# new changes # - TaskEntityId = KGId class TaskEntity(BaseModel): name: str @@ -189,6 +109,7 @@ class TaskRunEntity(BaseModel): usesImplementation: ImplementationEntityId hasParameterBinding: List[ParameterBindingId] +# Entity representing a task dag (not the implementation) # class PipelineDefinitionEntity(BaseModel): # """ # The definition of a pipeline diff --git a/src/kgpipe/common/models.py b/src/kgpipe/common/models.py index b758f28..06a56ec 100644 --- a/src/kgpipe/common/models.py +++ b/src/kgpipe/common/models.py @@ -18,66 +18,3 @@ __all__ = [ "Data", "DataFormat", "DynamicFormat", "DataSet", "FormatRegistry", "KgTask", "KgTaskReport", "KgPipe", "KgPipePlan", "KgPipePlanStep", "KgStageReport", "Metric", "EvaluationReport", "KG", "TaskInput", "TaskOutput" ] - -# TODO remove this for next release -# @dataclass -# class KG: -# """Represents a knowledge graph.""" -# id: str -# name: str -# path: Path -# format: Format -# triple_count: Optional[int] = None -# entity_count: Optional[int] = None -# description: Optional[str] = None -# metadata: Dict[str, Any] = field(default_factory=dict) -# graph: Optional[Graph] = None -# data_graph: Optional[Graph] = None -# ontology_graph: Optional[Graph] = None -# plan: Optional[KgPipePlan] = None - -# def __post_init__(self): -# if not self.id: -# self.id = str(uuid.uuid4()) -# if isinstance(self.path, str): -# self.path = Path(self.path) -# if not self.name: -# raise ValueError("KG name cannot be empty") - -# def get_graph(self) -> Graph: -# if self.graph is None: -# tmp = Graph().parse(self.path) -# graph = Graph() -# for s, p, o in tmp: -# if (str(p) != str(SKOS.altLabel)): -# graph.add((s, p, o)) -# self.graph = graph -# return self.graph - -# def get_data_graph(self) -> Graph: -# return Graph() - -# def get_ontology_graph(self) -> Graph: -# # TODO derive from graph -# if self.ontology_graph is None: -# self.ontology_graph = Graph() -# return self.ontology_graph - -# def set_ontology_graph(self, graph: Graph) -> None: -# print(f"Setting ontology graph with {len(graph)} triples") -# self.ontology_graph = graph - -# def exists(self) -> bool: -# """Check if the KG file exists.""" -# return self.path.exists() - -# def __str__(self) -> str: -# return f"KG({self.name}, {self.path}, {self.format.value})" - - - - - -# # Backward compatibility aliases -# Task = KgTask -# Pipeline = KgPipe \ No newline at end of file diff --git a/src/kgpipe/common/registry.py b/src/kgpipe/common/registry.py index 49f8359..d0243e3 100644 --- a/src/kgpipe/common/registry.py +++ b/src/kgpipe/common/registry.py @@ -1,6 +1,6 @@ # global Registry, entry-point discovery -from typing import Any, Callable +from typing import Any, Callable, List, Dict from kgpipe.common.models import KgTask, DataFormat from kgpipe.common.systemgraph import PipeKG from kgpipe.common.definitions import MetricEntity @@ -8,16 +8,15 @@ # TODO add also to system graph - - - class Registry: """ - Holds functions and python objects + Holds functions and python objects mappings KGpipe system graph """ _registry: dict[str, Any] = {} + # Generic # + @classmethod def register(cls, kind: str): def decorator(t): @@ -25,6 +24,25 @@ def decorator(t): return t return decorator + @classmethod + def get(cls, kind: str, name: str): + return cls._registry[f"{kind}:{name}"] + + @classmethod + def list(cls, kind: str): + """List all registered items of a specific kind.""" + items = [] + for key, value in cls._registry.items(): + if key.startswith(f"{kind}:"): + items.append(value) + return items + + @classmethod + def list_all(cls): + return cls._registry + + # Metric # + @classmethod def metric(cls): def decorator(t): @@ -38,13 +56,15 @@ def decorator(t): return t return decorator + # Task # + @classmethod def task( cls, - input_spec: dict[str, DataFormat], - output_spec: dict[str, DataFormat], + input_spec: Dict[str, DataFormat], + output_spec: Dict[str, DataFormat], description: str | None = None, - category: list[str] = [], + category: List[str] = [], config_spec: ConfigurationDefinition | None = None ) -> Callable[[Callable], KgTask]: def decorator(t): @@ -54,30 +74,6 @@ def decorator(t): return task return decorator - # @classmethod - # def pipeline(cls, tasks: list[KgTask], input: Data, output: Data): - # pipeline = KgPipe(tasks, input, output) - # cls._registry[f"pipeline:{pipeline.__name__.lower()}"] = pipeline - # PipeKG.add_pipeline(pipeline) - # return pipeline - - @classmethod - def get(cls, kind: str, name: str): - return cls._registry[f"{kind}:{name}"] - @classmethod def get_task(cls, name: str) -> KgTask: return cls._registry[f"task:{name}"] - - @classmethod - def list(cls, kind: str): - """List all registered items of a specific kind.""" - items = [] - for key, value in cls._registry.items(): - if key.startswith(f"{kind}:"): - items.append(value) - return items - - @classmethod - def list_all(cls): - return cls._registry \ No newline at end of file diff --git a/src/kgpipe/common/systemgraph.py b/src/kgpipe/common/systemgraph.py index 2e9e756..9c9f95a 100644 --- a/src/kgpipe/common/systemgraph.py +++ b/src/kgpipe/common/systemgraph.py @@ -13,7 +13,8 @@ from kgcore.model.rdf.rdf_base import RDFBaseModel from kgpipe.common.definitions import ( - TaskEntity, TaskRunEntity, PipelineEntity, PipelineRunEntity, ImplementationEntity, MetricEntity, MetricRunEntity + TaskEntity, TaskRunEntity, PipelineEntity, PipelineRunEntity, ImplementationEntity, MetricEntity, MetricRunEntity, + MethodEntity, ToolEntity, ) from kgpipe.common.config import load_config from kgpipe.common.util import encode_string @@ -51,7 +52,7 @@ class PipeKG: It is used to store the entities and relations of the KGpipe framework. """ - # cached_implementations: Dict[str, KGEntity] = {} + ### Core Layer Entities ### @staticmethod def add_task(task: "KgTask"): @@ -70,43 +71,8 @@ def add_task(task: "KgTask"): "format": output_format, }) SYS_KG.create_relation(type="output", source=task_entity.id, target=output_entity.id) - + @staticmethod - def _prop_value(properties: List[KGProperty], *keys: str) -> Any: - """Find a property value by exact key or key suffix.""" - for prop in properties: - if prop.key in keys: - return prop.value - for prop in properties: - for key in keys: - if prop.key.endswith(key): - return prop.value - return None - - @staticmethod - def _to_list(value: Any) -> List[str]: - """Normalize KG property values to list[str].""" - if value is None: - return [] - if isinstance(value, list): - return [str(v) for v in value] - if isinstance(value, tuple): - return [str(v) for v in value] - if isinstance(value, str): - text = value.strip() - if not text: - return [] - # Stored literals may contain Python-list string repr. - if text.startswith("[") and text.endswith("]"): - try: - parsed = ast.literal_eval(text) - except (ValueError, SyntaxError): - return [text] - if isinstance(parsed, list): - return [str(v) for v in parsed] - return [text] - return [str(value)] - def list_taskImplementations(self) -> List[ImplementationEntity]: entities = SYS_KG.find_entities(types=[config.ONTOLOGY_PREFIX + "Implementation"]) implementations: List[ImplementationEntity] = [] @@ -156,32 +122,44 @@ def get_property_values(properties: list[KGProperty], key: str) -> list[str]: return implementations - def list_tasks(self) -> List[ImplementationEntity]: - """Backward-compatible alias used by existing UI code.""" - return self.list_taskImplementations() + @staticmethod + def add_method(method: MethodEntity): pass + @staticmethod + def find_method(name: str) -> MethodEntity: pass - # @staticmethod - # def add_task_result(task_result: TaskResult): - # SYS_KG.create_entity(id=new_id(),types=[config.ONTOLOGY_PREFIX+"TaskRun"], properties={ - # "task": task_result.task, - # "config": task_result.config, - # "input": task_result.input, - # "output": task_result.output, - # "status": task_result.status, - # "duration": task_result.duration, - # }) + @staticmethod + def add_tool(tool: ToolEntity): pass + + @staticmethod + def find_tool(name: str) -> ToolEntity: pass + + @staticmethod + def find_implementation(): pass - # @staticmethod - # def add_task_run(task_run: "KgTaskReport"): - # SYS_KG.create_entity(id=new_id(),types=[config.ONTOLOGY_PREFIX+"TaskReport"], properties={ - # "task": task_run.task_name, - # "input": [data.path for data in task_run.inputs], - # "output": [data.path for data in task_run.outputs], - # "status": task_run.status, - # "duration": task_run.duration, - # "error": task_run.error, - # }) + @staticmethod + def add_implementation(implementation: ImplementationEntity): + SYS_KG.create_entity(id=new_id(),types=[config.ONTOLOGY_PREFIX+"Implementation"], properties={ + "name": implementation.name, + "usesTool": implementation.usesTool, + "implementsMethod": implementation.implementsMethod, + "interface": implementation.interface, + + }) + + @staticmethod + def find_implementation(name: str) -> KGEntity: + return SYS_KG.read_entity(id=config.PIPEKG_PREFIX+name, types=[config.ONTOLOGY_PREFIX+"Implementation"])[0] + + ### Data Layer Entities ### + def add_data_artifact(): pass + def add_data_artifact_type(): pass + def add_data_artifact_spec(): pass + def find_data_artifact(): pass + def find_data_artifact_type(): pass + def find_data_artifact_spec(): pass + + ### Pipeline Layer Entities ### @staticmethod def add_pipeline(pipeline: PipelineEntity): @@ -191,14 +169,13 @@ def add_pipeline(pipeline: PipelineEntity): "output": pipeline.output, }) - # @staticmethod - # def add_pipeline_result(pipeline_result: PipelineResult): - # SYS_KG.create_entity(id=new_id(),types=["PipelineResult"], properties={ - # "task_results": pipeline_result.task_results, - # "eval_results": pipeline_result.eval_results, - # "input": pipeline_result.input, - # "output": pipeline_result.output, - # }) + def find_pipeline(): pass + def add_pipeline_step(): pass + def find_pipeline_step(): pass + def add_pipeline_definition(): pass + def find_pipeline_definition(): pass + + ### Evaluation Layer Entities ### @staticmethod def add_metric(metric: MetricEntity): @@ -211,30 +188,28 @@ def add_metric(metric: MetricEntity): }) @staticmethod - def add_metric_run(metric_run: MetricRunEntity): - metric_run_entity = SYS_KG.create_entity(id=new_id(),types=[config.ONTOLOGY_PREFIX+"MetricRun"], properties={ - config.ONTOLOGY_PREFIX+"status": metric_run.status, - config.ONTOLOGY_PREFIX+"started_at": metric_run.started_at, - config.ONTOLOGY_PREFIX+"ended_at": metric_run.ended_at, - config.ONTOLOGY_PREFIX+"value": metric_run.value, - config.ONTOLOGY_PREFIX+"details": metric_run.details, - config.ONTOLOGY_PREFIX+"input": metric_run.input[0].uri, - }) - SYS_KG.create_relation(type=config.ONTOLOGY_PREFIX+"computedMetric", source=metric_run_entity.id, target=metric_run.computedMetric) + def find_metric(metric_name: str) -> MetricEntity: + pass - # @staticmethod - # def find_implementation_by_name(name: str) -> KGEntity: - # return SYS_KG.read_entity(id=config.PIPEKG_PREFIX+name, types=[config.ONTOLOGY_PREFIX+"Implementation"])[0] + ### Run Layer Entities ### + # def add_task_run(): pass + # def find_task_run(): pass + # def add_pipeline_run(): pass + # def find_pipeline_run(): pass + # def add_metric_run(): pass + # def find_metric_run(): pass @staticmethod - def add_implementation(implementation: ImplementationEntity): - SYS_KG.create_entity(id=new_id(),types=[config.ONTOLOGY_PREFIX+"Implementation"], properties={ - "name": implementation.name, - "usesTool": implementation.usesTool, - "implementsMethod": implementation.implementsMethod, - "interface": implementation.interface, - - }) + def add_task_run(task_run: TaskRunEntity): + # SYS_KG.create_entity(id=new_id(),types=[config.ONTOLOGY_PREFIX+"TaskReport"], properties={ + # "task": task_run.task_name, + # "input": [data.path for data in task_run.inputs], + # "output": [data.path for data in task_run.outputs], + # "status": task_run.status, + # "duration": task_run.duration, + # "error": task_run.error, + # }) + pass @staticmethod def add_pipeline_run(pipeline_run: PipelineRunEntity): @@ -258,27 +233,76 @@ def add_pipeline_run(pipeline_run: PipelineRunEntity): # return pipeline_run_entity + # @staticmethod + # def add_pipeline_result(pipeline_result: PipelineResult): + # SYS_KG.create_entity(id=new_id(),types=["PipelineResult"], properties={ + # "task_results": pipeline_result.task_results, + # "eval_results": pipeline_result.eval_results, + # "input": pipeline_result.input, + # "output": pipeline_result.output, + # }) + + @staticmethod + def add_metric_run(metric_run: MetricRunEntity): + metric_run_entity = SYS_KG.create_entity(id=new_id(),types=[config.ONTOLOGY_PREFIX+"MetricRun"], properties={ + config.ONTOLOGY_PREFIX+"status": metric_run.status, + config.ONTOLOGY_PREFIX+"started_at": metric_run.started_at, + config.ONTOLOGY_PREFIX+"ended_at": metric_run.ended_at, + config.ONTOLOGY_PREFIX+"value": metric_run.value, + config.ONTOLOGY_PREFIX+"details": metric_run.details, + config.ONTOLOGY_PREFIX+"input": metric_run.input[0].uri, + }) + SYS_KG.create_relation(type=config.ONTOLOGY_PREFIX+"computedMetric", source=metric_run_entity.id, target=metric_run.computedMetric) + + ### Parameter Layer Entities ### + # def add_parameter(): pass + # def find_parameter(): pass + # def add_parameter_binding(): pass + # def find_parameter_binding(): pass + + ### Utility Functions ### + @staticmethod def sparql_construct(query: str): backend : RDFSparqlBackend = SYS_KG.backend result = backend.query_sparql(query) return result - -class MapperUtil(): - """ - Intermediate class to map the core classes to the definitions to the system graph. - Will be replaced in the future - """ - @staticmethod - def map_task(task: "KgTask") -> TaskEntity: - return TaskEntity( - name=task.name, - input=task.input, - output=task.output, - ) + def _prop_value(properties: List[KGProperty], *keys: str) -> Any: + """Find a property value by exact key or key suffix.""" + for prop in properties: + if prop.key in keys: + return prop.value + for prop in properties: + for key in keys: + if prop.key.endswith(key): + return prop.value + return None + @staticmethod + def _to_list(value: Any) -> List[str]: + """Normalize KG property values to list[str].""" + if value is None: + return [] + if isinstance(value, list): + return [str(v) for v in value] + if isinstance(value, tuple): + return [str(v) for v in value] + if isinstance(value, str): + text = value.strip() + if not text: + return [] + # Stored literals may contain Python-list string repr. + if text.startswith("[") and text.endswith("]"): + try: + parsed = ast.literal_eval(text) + except (ValueError, SyntaxError): + return [text] + if isinstance(parsed, list): + return [str(v) for v in parsed] + return [text] + return [str(value)] # def Track(_cls=None, *, with_timestamp: bool = False): # """ diff --git a/src/kgpipe_view/kgpipe.owl.ttl b/src/kgpipe_view/kgpipe.owl.ttl index aea0dbb..a05c816 100644 --- a/src/kgpipe_view/kgpipe.owl.ttl +++ b/src/kgpipe_view/kgpipe.owl.ttl @@ -29,9 +29,10 @@ :TaskRun a owl:Class, :RunLayer . :PipelineRun a owl:Class, :RunLayer . -:Artifact a owl:Class, :DataLayer . -:ArtifactType a owl:Class, :DataLayer . -:Schema a owl:Class, :DataLayer . +:DataArtifact a owl:Class, :DataLayer . +:DataDataArtifactSpec a owl:Class, :DataLayer . +:DataDataArtifactType a owl:Class, :DataLayer . +#:Schema a owl:Class, :DataLayer . :Parameter a owl:Class, :ParameterLayer . :ParameterBinding a owl:Class, :ParameterLayer . @@ -95,9 +96,9 @@ rdfs:domain :PipelineDefinition ; rdfs:range :Tool . -:hasSourceArtifact a owl:ObjectProperty ; +:hasSourceDataArtifact a owl:ObjectProperty ; rdfs:domain :PipelineDefinition ; - rdfs:range :Artifact . + rdfs:range :DataArtifact . ### Execution / runs :executesTask a owl:ObjectProperty ; @@ -121,31 +122,35 @@ rdfs:range :TaskRun . ### Data flow (runtime) -:hasInputArtifact a owl:ObjectProperty ; +:hasInputDataArtifact a owl:ObjectProperty ; rdfs:domain :TaskRun ; - rdfs:range :Artifact . + rdfs:range :DataArtifact . -:hasOutputArtifact a owl:ObjectProperty ; +:hasOutputDataArtifact a owl:ObjectProperty ; rdfs:domain :TaskRun ; - rdfs:range :Artifact . + rdfs:range :DataArtifact . ### Data flow typing (design-time) -:expectsInputType a owl:ObjectProperty ; +:expectsInputSpec a owl:ObjectProperty ; rdfs:domain :Implementation ; - rdfs:range :ArtifactType . + rdfs:range :DataDataArtifactSpec . -:producesOutputType a owl:ObjectProperty ; +:producesOutputSpec a owl:ObjectProperty ; rdfs:domain :Implementation ; - rdfs:range :ArtifactType . + rdfs:range :DataDataArtifactSpec . -### Artifact typing / schema -:hasArtifactType a owl:ObjectProperty ; - rdfs:domain :Artifact ; - rdfs:range :ArtifactType . +:requiresType a owl:ObjectProperty ; + rdfs:domain :DataDataArtifactSpec ; + rdfs:range :DataDataArtifactType . -:conformsToSchema a owl:ObjectProperty ; - rdfs:domain :Artifact ; - rdfs:range :Schema . +### DataArtifact typing / schema +:hasDataDataArtifactType a owl:ObjectProperty ; + rdfs:domain :DataArtifact ; + rdfs:range :DataDataArtifactType . + +#:conformsToSchema a owl:ObjectProperty ; +# rdfs:domain :DataArtifact ; +# rdfs:range :Schema . ### Parameters :hasParameter a owl:ObjectProperty ; @@ -165,6 +170,10 @@ ################################################################# ### Implementation +:implementationName a owl:DatatypeProperty ; + rdfs:domain :Implementation ; + rdfs:range xsd:string . + :commandTemplate a owl:DatatypeProperty ; rdfs:domain :Implementation ; rdfs:range xsd:string . @@ -177,11 +186,47 @@ rdfs:domain :Implementation ; rdfs:range xsd:string . +### Method +:methodName a owl:DatatypeProperty ; + rdfs:domain :Method ; + rdfs:range xsd:string . + ### Tool :toolVersion a owl:DatatypeProperty ; rdfs:domain :Tool ; rdfs:range xsd:string . +:toolName a owl:DatatypeProperty ; + rdfs:domain :Tool ; + rdfs:range xsd:string . + +:toolPage a owl:DatatypeProperty ; + rdfs:domain :Tool ; + rdfs:range xsd:string . + +:toolName a owl:DatatypeProperty ; + rdfs:domain :Tool ; + rdfs:range xsd:string . + +### DataArtifact +:location a owl:DatatypeProperty ; + rdfs:domain :DataArtifact ; + rdfs:range xsd:anyURI . + +### DataDataArtifactSpec +:dataType a owl:DatatypeProperty ; + rdfs:domain :DataDataArtifactSpec ; + rdfs:range xsd:string . + +### DataDataArtifactType +:dataFormat a owl:DatatypeProperty ; + rdfs:domain :DataDataArtifactType ; + rdfs:range xsd:string . + +:dataSchema a owl:DatatypeProperty ; + rdfs:domain :DataDataArtifactType ; + rdfs:range xsd:string . + ### Parameter :paramName a owl:DatatypeProperty ; rdfs:domain :Parameter ; @@ -238,12 +283,3 @@ rdfs:domain :PipelineRun ; rdfs:range xsd:string . -### Artifact -:location a owl:DatatypeProperty ; - rdfs:domain :Artifact ; - rdfs:range xsd:anyURI . - -### ArtifactType -:format a owl:DatatypeProperty ; - rdfs:domain :ArtifactType ; - rdfs:range xsd:string . \ No newline at end of file From b06cad5000177052c3d7bc00c28d3b67bdd76703 Mon Sep 17 00:00:00 2001 From: Marvin Date: Thu, 26 Mar 2026 14:52:03 +0100 Subject: [PATCH 34/96] refactor(common): move graph/systemgraph into common.graph and reshape core models - Split graph-related code into src/kgpipe/common/graph/* (incl. systemgraph) - Drop legacy common/definitions.py + old common/systemgraph.py - Rework data formats/catalog and simplify model/data.py around BasicDataFormats/CustomDataFormats - Extend task/config models (e.g. KgTaskRun, config profile helpers) and export the new public surface via common/__init__.py --- src/kgpipe/common/__init__.py | 6 +- src/kgpipe/common/annotations.py | 88 ++++- src/kgpipe/common/definitions.py | 155 --------- src/kgpipe/common/graph/__init__.py | 0 src/kgpipe/common/graph/definitions.py | 286 +++++++++++++++++ src/kgpipe/common/graph/mapper.py | 213 +++++++++++++ src/kgpipe/common/graph/systemgraph.py | 352 ++++++++++++++++++++ src/kgpipe/common/model/__init__.py | 10 +- src/kgpipe/common/model/configuration.py | 37 ++- src/kgpipe/common/model/data.py | 245 ++------------ src/kgpipe/common/model/default_catalog.py | 210 +++++++++++- src/kgpipe/common/model/evaluation.py | 19 +- src/kgpipe/common/model/kg.py | 32 +- src/kgpipe/common/model/pipeline.py | 50 +-- src/kgpipe/common/model/task.py | 280 ++++++++++++---- src/kgpipe/common/models.py | 8 +- src/kgpipe/common/registry.py | 12 +- src/kgpipe/common/systemgraph.py | 354 --------------------- 18 files changed, 1450 insertions(+), 907 deletions(-) delete mode 100644 src/kgpipe/common/definitions.py create mode 100644 src/kgpipe/common/graph/__init__.py create mode 100644 src/kgpipe/common/graph/definitions.py create mode 100644 src/kgpipe/common/graph/mapper.py create mode 100644 src/kgpipe/common/graph/systemgraph.py delete mode 100644 src/kgpipe/common/systemgraph.py diff --git a/src/kgpipe/common/__init__.py b/src/kgpipe/common/__init__.py index 33cf414..b800851 100644 --- a/src/kgpipe/common/__init__.py +++ b/src/kgpipe/common/__init__.py @@ -25,8 +25,9 @@ def setup_logging(log_file='app.log', level=logging.DEBUG): # Call this once at the start of your application setup_logging() +from .annotations import trace_task_run from .models import ( - Data, DataFormat, KgTask, KgTaskReport, DynamicFormat, FormatRegistry, + Data, DataFormat, BasicDataFormats, CustomDataFormats, BasicTaskCategoryCatalog, KgTask, KgTaskReport, DataSet, KG, Metric, EvaluationReport, KgPipe, TaskInput, TaskOutput ) from .registry import Registry @@ -38,8 +39,9 @@ def setup_logging(log_file='app.log', level=logging.DEBUG): ) __all__ = [ - "Data", "DataFormat", "KgTask", "KgTaskReport", "DynamicFormat", "FormatRegistry", + "Data", "DataFormat", "BasicDataFormats", "CustomDataFormats", "BasicTaskCategoryCatalog", "KgTask", "KgTaskReport", "DataSet", "KG", "Stage", "Metric", "EvaluationReport", "KgPipe", "TaskInput", "TaskOutput", + "trace_task_run", "Registry", "get_docker_volume_bindings", "remap_data_path_for_container", "discover_entry_points", "get_registered_tasks", "get_registered_pipelines", diff --git a/src/kgpipe/common/annotations.py b/src/kgpipe/common/annotations.py index 3462262..0146571 100644 --- a/src/kgpipe/common/annotations.py +++ b/src/kgpipe/common/annotations.py @@ -1,5 +1,5 @@ from rdflib import OWL, RDFS -from kgpipe.common.systemgraph import SYS_KG +from kgpipe.common.graph.systemgraph import SYS_KG, PipeKG from kgcore.api import KGProperty from typing import get_origin, get_args, Union @@ -73,4 +73,88 @@ def decorator(cls): SYS_KG.create_relation(source=prop_et.id, target=class_et.id, type=str(RDFS.domain)) return cls - return decorator \ No newline at end of file + return decorator + + + +def trace_metric_run(): pass + + +def trace_task_run(obj): + """ + Mark a task (function or `KgTask`) so that its `.run()` persists a TaskRun in `PipeKG`. + + Works with either decorator order: + + ```python + @trace_task_run + @Registry.task(...) + def my_task(...): ... + + # or + @Registry.task(...) + @trace_task_run + def my_task(...): ... + ``` + """ + setattr(obj, "trace_task_run", True) + # TODO use logger print(f"trace_task_run decorator called for object: {obj.__name__}") + return obj + +def trace_pipeline_run(obj): + """ + Mark a pipeline (function or `KgPipeline`) so that its `.run()` persists a PipelineRun in `PipeKG`. + """ + setattr(obj, "trace_pipeline_run", True) + # TODO use logger print(f"trace_pipeline_run decorator called for object: {obj.__name__}") + return obj + + +# def Track(_cls=None, *, with_timestamp: bool = False): +# """ +# Use as: +# @Track +# @Track(with_timestamp=True) +# """ +# def decorator(cls): +# class Tracked(cls): # subclass the original class +# def __init__(self, *args: Any, **kwargs: Any): +# super().__init__(*args, **kwargs) + +# inst_id = f"{cls.__name__}:{uuid4().hex[:8]}" +# setattr(self, "_kg_id", inst_id) + +# if isinstance(self, BaseModel): +# props = self.model_dump() +# else: +# props = {k: v for k, v in vars(self).items() if not k.startswith("_")} + +# if with_timestamp: +# props["timestamp"] = datetime.now(timezone.utc).isoformat() + +# SYS_KG.create_entity([cls.__name__], id=inst_id, props=props) + +# Tracked.__name__ = cls.__name__ # optional cosmetics +# Tracked.__qualname__ = cls.__qualname__ +# Tracked.__doc__ = cls.__doc__ +# return Tracked + +# return decorator if _cls is None else decorator(_cls) + +# def kg_function(fn): +# @functools.wraps(fn) +# def wrapper(*args, **kwargs): +# result = fn(*args, **kwargs) +# call_id = f"{fn.__name__}:{uuid4().hex[:8]}" +# SYS_KG.create_entity( +# ["FunctionCall"], +# id=call_id, +# props={ +# "name": fn.__name__, +# # Be careful serializing args/kwargs; this is a toy example: +# "args": repr(args), +# "kwargs": repr(kwargs), +# }, +# ) +# return result +# return wrapper diff --git a/src/kgpipe/common/definitions.py b/src/kgpipe/common/definitions.py deleted file mode 100644 index 7d86878..0000000 --- a/src/kgpipe/common/definitions.py +++ /dev/null @@ -1,155 +0,0 @@ -from dataclasses import dataclass -from sys import implementation -from pydantic import BaseModel -from typing import Mapping, Optional, List, Dict, Any -from kgcore.api.kg import KGId - -from kgpipe.common.model.data import DataFormat - -# Types # - -type schema_format = str - -# Vocabulary # - -from rdflib.namespace import DefinedNamespace, Namespace - -class KGPIPE_NS(DefinedNamespace): - _fail = True - _NS = Namespace("http://github.com/ScaDS/kgpipe/") - Task = _NS["Task"] - TaskRun = _NS["TaskRun"] - Method = _NS["Method"] - Tool = _NS["Tool"] - Implementation = _NS["Implementation"] - Parameter = _NS["Parameter"] - ParameterBinding = _NS["ParameterBinding"] - Pipeline = _NS["Pipeline"] - PipelineRun = _NS["PipelineRun"] - Artifact = _NS["Artifact"] - ArtifactType = _NS["ArtifactType"] - Schema = _NS["Schema"] - Metric = _NS["Metric"] - MetricRun = _NS["MetricRun"] - -# Entities # - -class DataHandle(BaseModel): - """ - A handle to a data artifact - - uri: file://example.com/data.txt - type: any/text - timestamp: 2021-01-01 - version: 1.0.0 - hash: 1234567890 - size: 1000 - """ - uri: str - type: schema_format - timestamp: Optional[str] = None - version: Optional[str] = None - hash: Optional[str] = None - size: Optional[int] = None - -TaskEntityId = KGId -class TaskEntity(BaseModel): - name: str - hasSubtask: List[TaskEntityId] - -MethodEntityId = KGId -class MethodEntity(BaseModel): - name: str - realizesTask: List[TaskEntityId] - -ToolEntityId = KGId -class ToolEntity(BaseModel): - name: str - # supportsTasks: List[Task] - providesMethods: List[MethodEntityId] - -ParameterId = KGId -class ParameterEntity(BaseModel): - name: str - value: Any - type: str - description: Optional[str] = None - default_value: Optional[Any] = None - required: bool = False - allowed_values: Optional[List[Any]] = None - -ParameterBindingId = KGId -class ParameterBindingEntity(BaseModel): - value: Any - parameter: ParameterId - -ImplementationEntityId = KGId -class ImplementationEntity(BaseModel): - uri: Optional[str] = None - name: str - input_spec: List[str] - output_spec: List[str] - implementsMethod: List[MethodEntityId] - hasParameter: List[ParameterId] - usesTool: List[ToolEntityId] - - # interface: str # TODO: Interface - # hasParameter: Parameter - -TaskRunEntityId = KGId -class TaskRunEntity(BaseModel): - number: int - name: str - status: str - started_at: float - ended_at: float - input: List[DataHandle] - output: List[DataHandle] - executesTask: TaskEntityId - usesImplementation: ImplementationEntityId - hasParameterBinding: List[ParameterBindingId] - -# Entity representing a task dag (not the implementation) -# class PipelineDefinitionEntity(BaseModel): -# """ -# The definition of a pipeline -# """ -# placeholder: str -# #definesPipeline: Pipeline - -# TODO issue as the Graph has no ordering of the tasks -class PipelineEntity(BaseModel): - name: str - tasks: List[TaskEntityId] - input: List[DataHandle] - output: List[DataHandle] - -class PipelineRunEntity(BaseModel): - """ - The result of a pipeline execution - """ - name: str - status: str - started_at: float - ended_at: float - hasTaskRun: List[TaskRunEntity] - # usesPipelineDefinition: PipelineDefinition - # runsPipeline: Pipeline - -MetricEntityId = KGId -class MetricEntity(BaseModel): - name: str - description: Optional[str] = None - type: str - # output: List[schema_format] - # hasParameter: List[ParameterId] - -MetricRunEntityId = KGId -class MetricRunEntity(BaseModel): - status: str - started_at: float - ended_at: float - computedMetric: MetricEntityId - input: List[DataHandle] - value: float - details: str \ No newline at end of file diff --git a/src/kgpipe/common/graph/__init__.py b/src/kgpipe/common/graph/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/kgpipe/common/graph/definitions.py b/src/kgpipe/common/graph/definitions.py new file mode 100644 index 0000000..8889580 --- /dev/null +++ b/src/kgpipe/common/graph/definitions.py @@ -0,0 +1,286 @@ +from pydantic import BaseModel, ConfigDict +from typing import Optional, List, Any +from kgcore.api.kg import KGId + +# Types # + +type schema_format = str +type any_uri = str + +# Vocabulary # + +from rdflib.namespace import DefinedNamespace, Namespace + +class KGPIPE_NS(DefinedNamespace): + _fail = True + _NS = Namespace("http://github.com/ScaDS/kgpipe/") + + Task = _NS["Task"] + TaskRun = _NS["TaskRun"] + Method = _NS["Method"] + Tool = _NS["Tool"] + Implementation = _NS["Implementation"] + Parameter = _NS["Parameter"] + ParameterBinding = _NS["ParameterBinding"] + Pipeline = _NS["Pipeline"] + PipelineRun = _NS["PipelineRun"] + Artifact = _NS["Artifact"] + ArtifactType = _NS["ArtifactType"] + Schema = _NS["Schema"] + Metric = _NS["Metric"] + MetricRun = _NS["MetricRun"] + DataSpec = _NS["DataSpec"] + DataEntity = _NS["Data"] + DataType = _NS["DataType"] + ConfigSpec = _NS["ConfigSpec"] + ConfigBinding = _NS["ConfigBinding"] + + + status = _NS["status"] + started_at = _NS["started_at"] + ended_at = _NS["ended_at"] + schema = _NS["schema"] + format = _NS["format"] + name = _NS["name"] + partOfTask = _NS["partOfTask"] + hasSubtask = _NS["hasSubtask"] + description = _NS["description"] + + version = _NS["version"] + executesTask = _NS["executesTask"] + supportsTask = _NS["supportsTask"] + input = _NS["input"] + output = _NS["output"] + format = _NS["format"] + config_spec = _NS["config_spec"] + + timestamp = _NS["timestamp"] + version = _NS["version"] + hash = _NS["hash"] + size = _NS["size"] + location = _NS["location"] + data_type = _NS["data_type"] + + realisesTask = _NS["realisesTask"] + usesImplementation = _NS["usesImplementation"] + + homepage = _NS["homepage"] + implementsMethod = _NS["implementsMethod"] + usesTool = _NS["usesTool"] + hasParameter = _NS["hasParameter"] + + providesMethod = _NS["providesMethod"] + + key = _NS["key"] + alias_keys = _NS["alias_keys"] + datatype = _NS["datatype"] + required = _NS["required"] + default_value = _NS["default_value"] + allowed_values = _NS["allowed_values"] + minimum = _NS["minimum"] + maximum = _NS["maximum"] + unit = _NS["unit"] + value = _NS["value"] + binding = _NS["binding"] + + parameter = _NS["parameter"] + hasParameterBinding = _NS["hasParameterBinding"] + +# Entities # + +DataTypeEntityId = KGId +class DataTypeEntity(BaseModel): + model_config = ConfigDict(frozen=True) + ### object properties ### + format: str + data_schema: str + +DataEntityId = KGId +class DataEntity(BaseModel): + model_config = ConfigDict(frozen=True) + ### datatype properties ### + timestamp: Optional[str] = None + version: Optional[str] = None + hash: Optional[str] = None + size: Optional[int] = None + ### object properties ### + location: any_uri + data_type: DataTypeEntityId + +DataSpecEntityId = KGId +class DataSpecEntity(BaseModel): + model_config = ConfigDict(frozen=True) + uri: Optional[str] = None + ### datatype properties ### + name: str + ### object properties ### + data_type: DataTypeEntityId + +TaskEntityId = KGId +class TaskEntity(BaseModel): + model_config = ConfigDict(frozen=True) + name: str + description: Optional[str] = None + partOfTask: Optional[TaskEntityId] = None + +# TODO MethodEntityId = KGId +# TODO class MethodEntity(BaseModel): +# model_config = ConfigDict(frozen=True) +# name: str +# realizesTask: tuple[TaskEntityId, ...] + +ToolEntityId = KGId +class ToolEntity(BaseModel): + model_config = ConfigDict(frozen=True) + ### datatype properties ### + name: str + homepage: Optional[str] = None + ### object properties ### + # NOTE: these entities are used as `lru_cache` keys; must be hashable. + supportsTasks: tuple[TaskEntityId, ...] + # TODO providesMethods: tuple[MethodEntityId, ...] + +ParameterEntityId = KGId +class ParameterEntity(BaseModel): + model_config = ConfigDict(frozen=True) + uri: Optional[str] = None + ### datatype properties ### + key: str + # NOTE: these entities are used as `lru_cache` keys; must be hashable. + alias_keys: tuple[str, ...] + datatype: str + required: bool + default_value: str | int | float | bool + allowed_values: tuple[str | int | float | bool, ...] + # description: Optional[str] = None + # scope: Scope # (training/inference/io/resources) + # constraints + # minimum: Optional[float] = None + # maximum: Optional[float] = None + # unit: Optional[str] = None + +ParameterBindingEntityId = KGId +class ParameterBindingEntity(BaseModel): + value: Any + parameter: ParameterEntityId + +ConfigSpecEntityId = KGId +class ConfigSpecEntity(BaseModel): + model_config = ConfigDict(frozen=True) + uri: Optional[str] = None + ### datatype properties ### + name: str + ### object properties ### + # NOTE: these entities are used as `lru_cache` keys; must be hashable. + parameters: tuple[ParameterEntityId, ...] + +ConfigBindingEntityId = KGId +class ConfigBindingEntity(BaseModel): + name: Any + binding: tuple[ParameterBindingEntityId, ...] + +ImplementationEntityId = KGId +class ImplementationEntity(BaseModel): + model_config = ConfigDict(frozen=True) + uri: Optional[str] = None + ### datatype properties ### + name: str + version: str + ### object properties ### + input_spec: List[DataSpecEntityId] + output_spec: List[DataSpecEntityId] + realizesTask: List[TaskEntityId] + usesTool: List[ToolEntityId] + config_spec: Optional[ConfigSpecEntityId] = None + + # TODO implementsMethod: List[MethodEntityId] + # TODO interface: str + +TaskRunEntityId = KGId +class TaskRunEntity(BaseModel): + model_config = ConfigDict(frozen=True) + uri: Optional[str] = None + ### datatype properties ### + status: str + started_at: float + ended_at: float + ### object properties ### + input: List[DataEntityId] + output: List[DataEntityId] + # TODO executesTask: TaskEntityId + usesImplementation: ImplementationEntityId + hasConfigBinding: Optional[ConfigBindingEntityId] = None + +# Entity representing a task dag (not the implementation) +# class PipelineDefinitionEntity(BaseModel): +# """ +# The definition of a pipeline +# """ +# placeholder: str +# #definesPipeline: Pipeline + +PipelineStepEntityId = KGId +class PipelineStepEntity(BaseModel): + model_config = ConfigDict(frozen=True) + uri: Optional[str] = None + ### datatype properties ### + name: str + ### object properties ### + input: List[DataEntityId] + output: List[DataEntityId] + executesTask: TaskEntityId + +# TODO issue as the Graph has no ordering of the tasks +class PipelineEntity(BaseModel): + model_config = ConfigDict(frozen=True) + uri: Optional[str] = None + ### datatype properties ### + name: str + ### object properties ### + steps: List[PipelineStepEntityId] + firstStep: PipelineStepEntityId + lastStep: PipelineStepEntityId + input: List[DataEntityId] + output: List[DataEntityId] + +PipelineRunEntityId = KGId +class PipelineRunEntity(BaseModel): + """ + The result of a pipeline execution + """ + model_config = ConfigDict(frozen=True) + uri: Optional[str] = None + ### datatype properties ### + name: str + status: str + started_at: float + ended_at: float + ### object properties ### + hasTaskRun: List[TaskRunEntity] + # TODO usesPipelineDefinition: PipelineDefinition + # TODO runsPipeline: PipelineStepEntityId + +MetricEntityId = KGId +class MetricEntity(BaseModel): + model_config = ConfigDict(frozen=True) + ### datatype properties ### + name: str + description: Optional[str] = None + type: str # TODO should be an enum + ### object properties ### + # TODO output: List[schema_format] + # TODO hasParameter: List[ParameterId] + +MetricRunEntityId = KGId +class MetricRunEntity(BaseModel): + model_config = ConfigDict(frozen=True) + uri: Optional[str] = None + ### datatype properties ### + status: str + started_at: float + ended_at: float + value: float + details: str # TODO should be a dictionary + ### object properties ### + computedMetric: MetricEntityId + input: List[DataEntityId] diff --git a/src/kgpipe/common/graph/mapper.py b/src/kgpipe/common/graph/mapper.py new file mode 100644 index 0000000..037fd9e --- /dev/null +++ b/src/kgpipe/common/graph/mapper.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +from kgpipe.common.config import config +from kgpipe.common.graph.systemgraph import PipeKG +from kgpipe.common.model.default_catalog import TaskCategory +from kgpipe.common.util import encode_string + +from kgpipe.common.graph.definitions import ( + DataEntity, + DataEntityId, + DataSpecEntity, + DataSpecEntityId, + DataTypeEntity, + DataTypeEntityId, + ImplementationEntity, + ImplementationEntityId, + PipelineRunEntity, + PipelineRunEntityId, + TaskEntity, + TaskEntityId, + TaskRunEntity, + TaskRunEntityId, + MetricRunEntity, + MetricRunEntityId, + MetricEntity, + MetricEntityId, + ParameterEntity, + ParameterEntityId, + ParameterBindingEntity, + ParameterBindingEntityId, + ConfigSpecEntity, + ConfigSpecEntityId, + ConfigBindingEntity, + ConfigBindingEntityId, +) + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from kgpipe.common.model import ( + DataFormat, + KgData, + KgTask, + KgTaskRun, + KgPipelineRun, + KgMetricRun, + KgMetric, + ConfigurationDefinition, + Parameter, + ConfigurationProfile, + ParameterBinding, + ) + from kgpipe.evaluation.base import MetricResult + +def task_to_entity(task: "TaskCategory") -> TaskEntityId: + """Map runtime task definition to a Task entity.""" + name = task + partOfTask = None + if isinstance(task, TaskCategory): + name = task.name + if task.parent: + partOfTask = task_to_entity(task.parent) + task_entity = TaskEntity( + name=name, + partOfTask=partOfTask, + ) + return PipeKG.add_task(task_entity) + +def data_type_to_entity(data_type: DataFormat) -> DataTypeEntityId: + data_type_entity = DataTypeEntity( + format=data_type, + data_schema=data_type, + ) + return PipeKG.add_data_type(data_type_entity) + +def data_spec_to_entity(data_spec: tuple[str, DataFormat], implementation_name: str = "") -> DataSpecEntityId: + data_spec_entity = DataSpecEntity( + uri=config.PIPEKG_PREFIX + encode_string(implementation_name + "_" + data_spec[0]), + name=data_spec[0], + data_type=data_type_to_entity(data_spec[1]), + ) + return PipeKG.add_data_spec(data_spec_entity) + +def data_to_entity(data: "KgData") -> DataEntityId: + data_entity = DataEntity( + timestamp=None, # TODO + version=None, # TODO + hash=None, # TODO + size=None, # TODO + location=data.path.as_uri(), + data_type=data_type_to_entity(data.format), + ) + return PipeKG.add_data_entity(data_entity) + +def parameter_to_entity(parameter: "Parameter") -> ParameterEntityId: + parameter_entity = ParameterEntity( + key=parameter.name, + alias_keys=parameter.native_keys, + datatype=parameter.datatype, + required=parameter.required, + default_value=parameter.default_value, + allowed_values=parameter.allowed_values, + # minimum=parameter.minimum, + # maximum=parameter.maximum, + # unit=parameter.unit, + ) + return PipeKG.add_parameter(parameter_entity) + + +def config_spec_to_entity(config_spec: "ConfigurationDefinition", implementation_name: str = "") -> ConfigSpecEntityId: + if config_spec is None: + return None + parameter_entities = [parameter_to_entity(parameter) for parameter in config_spec.parameters] + config_spec_entity = ConfigSpecEntity( + name=config_spec.name, + parameters=parameter_entities, + ) + return PipeKG.add_config_spec(config_spec_entity) + +def implementation_to_entity(implementation: "KgTask") -> ImplementationEntityId: + + input_specs = [data_spec_to_entity(data_spec, implementation.name) for data_spec in implementation.input_spec.items()] + + output_specs = [data_spec_to_entity(data_spec, implementation.name) for data_spec in implementation.output_spec.items()] + + realizes_tasks = [task_to_entity(task) for task in implementation.category] + + config_spec = config_spec_to_entity(implementation.config_spec, implementation.name) + + implementation_entity = ImplementationEntity( + ### datatype properties ### + name=implementation.name, + version="1.0.0", # TODO: get version from implementation + ### object properties ### + input_spec=input_specs, + output_spec=output_specs, + realizesTask=realizes_tasks, + usesTool=[], # TODO add usesTool relations + config_spec=config_spec, + ) + return PipeKG.add_implementation(implementation_entity) + +def metric_to_entity(metric: "KgMetric") -> MetricEntityId: + metric_entity = MetricEntity( + name=metric.name, + description=metric.description, + type=metric.aspect.value, + ) + return PipeKG.add_metric(metric_entity) + + +def parameter_binding_to_entity(parameter_binding: "ParameterBinding") -> ParameterBindingEntityId: + parameter_binding_entity = ParameterBindingEntity( + value=parameter_binding.value, + parameter=parameter_to_entity(parameter_binding.parameter), + ) + return PipeKG.add_parameter_binding(parameter_binding_entity) + +def config_binding_to_entity(config_profile: "ConfigurationProfile") -> ConfigBindingEntityId: + config_binding_entity = ConfigBindingEntity( + name=config_profile.name, + binding=[parameter_binding_to_entity(binding) for binding in config_profile.bindings], + ) + return PipeKG.add_config_binding(config_binding_entity) + +def task_run_to_entity(task_run: "KgTaskRun") -> TaskRunEntityId: + + input=[data_to_entity(data) for data in task_run.inputs] + output=[data_to_entity(data) for data in task_run.outputs] + hasConfigBinding=None # TODO + usesImplementation=implementation_to_entity(task_run.task) + hasConfigBinding=config_binding_to_entity(task_run.config_profile) if task_run.config_profile else None + + print(f"hasConfigBinding: {hasConfigBinding}") + + task_run_entity = TaskRunEntity( + status=task_run.status, + started_at=task_run.start_ts, + ended_at=task_run.start_ts + task_run.duration, + input=input, + output=output, + usesImplementation=usesImplementation, + hasConfigBinding=hasConfigBinding, + ) + return PipeKG.add_task_run(task_run_entity) + +def pipeline_run_to_entity(pipeline_run: "KgPipelineRun") -> PipelineRunEntityId: + pipeline_run_entity = PipelineRunEntity( + name=pipeline_run.name, + status=pipeline_run.status, + started_at=pipeline_run.started_at, + ended_at=pipeline_run.ended_at, + ) + return PipeKG.add_pipeline_run(pipeline_run_entity) + +# TODO +# def metric_run_to_entity(metric_run: "MetricResult") -> MetricRunEntityId: +# import time +# import json +# computedMetric = metric_to_entity(metric_run.metric) +# # data_type = data_type_to_entity(DataFormat.ANY) +# input_entities = [KgData(path=metric_run.kg.path, format=DataFormat.ANY)] +# input = [data_to_entity(input_entity) for input_entity in input_entities] +# metric_run_entity = MetricRunEntity( +# status="success", +# started_at=time.time(), +# ended_at=time.time(), +# computedMetric=computedMetric, +# input=input, +# value=metric_run.value, +# details=json.dumps(metric_run.details, default=str) +# ) +# PipeKG.add_metric_run(metric_run_entity) \ No newline at end of file diff --git a/src/kgpipe/common/graph/systemgraph.py b/src/kgpipe/common/graph/systemgraph.py new file mode 100644 index 0000000..7af2736 --- /dev/null +++ b/src/kgpipe/common/graph/systemgraph.py @@ -0,0 +1,352 @@ +import functools +import ast +from uuid import uuid4 +from typing import Any, List, Optional, TYPE_CHECKING +from datetime import datetime, timezone +import hashlib +import json + +from kgcore.api import KnowledgeGraph, KGEntity, KGRelation, KGProperty, new_id +from kgcore.backend.rdf.rdf_rdflib import RDFLibBackend +from kgcore.backend.rdf.rdf_sparql import RDFSparqlBackend, SparqlAuth +from kgcore.model.rdf.rdf_base import RDFBaseModel + +from kgpipe.common.graph.definitions import ( + KGPIPE_NS, + ImplementationEntity, ImplementationEntityId, + TaskEntity, TaskEntityId, + ToolEntity, ToolEntityId, + DataEntity, DataEntityId, + DataSpecEntity, DataSpecEntityId, + DataTypeEntity, DataTypeEntityId, + MetricEntity, MetricEntityId, + MetricRunEntity, MetricRunEntityId, + TaskRunEntity, TaskRunEntityId, + ParameterEntity, ParameterEntityId, + ParameterBindingEntity, ParameterBindingEntityId, + ConfigSpecEntity, ConfigSpecEntityId, + ConfigBindingEntity, ConfigBindingEntityId, +) +from kgpipe.common.config import load_config +from kgpipe.common.util import encode_string + +if TYPE_CHECKING: + from kgpipe.common.models import KgTask, KgTaskReport + +config = load_config() +scheme, rest = config.SYS_KG_URL.split("://") + +backend = RDFLibBackend() +model = RDFBaseModel() + +try: + if scheme == "sparql": + print(f"Using SPARQL backend for system graph: {f"http://{rest}"} with http://github.com/ScaDS/kgpipe/") + backend = RDFSparqlBackend( + endpoint=f"http://{rest}", + update_endpoint=f"http://{rest}", + default_graph="http://github.com/ScaDS/kgpipe/", + auth=SparqlAuth(username=config.SYS_KG_USR, password=config.SYS_KG_PSW)) + else: + raise ValueError(f"Unsupported schema: {scheme}") +except Exception as e: + print(f"Error creating system graph: {e}") + print(f"Using RDFLib memory backend for system graph") + +SYS_KG: KnowledgeGraph = KnowledgeGraph(model=model, backend=backend) + +class PipeKG: + """ + PipeKG is the system graph for the KGpipe framework. + It is a Object Graph Mapper (OGM) for the KGpipe framework. + It is used to store the entities and relations of the KGpipe framework. + """ + + ### Core Layer Entities ### + + @staticmethod + @functools.lru_cache + def add_task(task: TaskEntity) -> TaskEntityId: + entity_id = config.PIPEKG_PREFIX + encode_string(task.name) + SYS_KG.create_entity( + id=entity_id, + types=[KGPIPE_NS.Task], + properties={ + KGPIPE_NS.name: task.name, + KGPIPE_NS.description: task.description + }, + ) + if task.partOfTask: + SYS_KG.create_relation(type=KGPIPE_NS.partOfTask, source=entity_id, target=task.partOfTask) + return TaskEntityId(entity_id) + + @staticmethod + @functools.lru_cache + def add_tool(tool: ToolEntity): + entity_id = config.PIPEKG_PREFIX + encode_string(tool.name) + SYS_KG.create_entity( + id=entity_id, + types=[KGPIPE_NS.Tool], + properties={ + KGPIPE_NS.name: tool.name, + KGPIPE_NS.homepage: tool.homepage, + }, + ) + for supports_task in tool.supportsTasks: + SYS_KG.create_relation(type=KGPIPE_NS.supportsTask, source=entity_id, target=supports_task) + return ToolEntityId(entity_id) + + @staticmethod + def add_implementation(implementation: ImplementationEntity): + entity_id = config.PIPEKG_PREFIX + encode_string(implementation.name) + SYS_KG.create_entity( + id=entity_id, + types=[KGPIPE_NS.Implementation], + properties={ + KGPIPE_NS.name: implementation.name, + KGPIPE_NS.version: implementation.version, + }, + ) + for input_spec in implementation.input_spec: + SYS_KG.create_relation(type=KGPIPE_NS.input, source=entity_id, target=input_spec) + for output_spec in implementation.output_spec: + SYS_KG.create_relation(type=KGPIPE_NS.output, source=entity_id, target=output_spec) + for realizes_task in implementation.realizesTask: + SYS_KG.create_relation(type=KGPIPE_NS.realisesTask, source=entity_id, target=realizes_task) + if implementation.config_spec: + SYS_KG.create_relation(type=KGPIPE_NS.config_spec, source=entity_id, target=implementation.config_spec) + return ImplementationEntityId(entity_id) + + @staticmethod + def find_implementation( + name: Optional[str] = None, + # version: Optional[str] = None, + # input_spec: Optional[List[str]] = None, + # output_spec: Optional[List[str]] = None, + # realizes_task: Optional[List[str]] = None, + # has_parameter: Optional[List[str]] = None, + ) -> List[ImplementationEntity]: + entities: List[KGEntity] = SYS_KG.find_entities( + types=[str(KGPIPE_NS.Implementation)], + ) + implementations = [ImplementationEntity( + uri=entity.id, + name=entity.get_property_value(str(KGPIPE_NS.name))[0], + version=entity.get_property_value(str(KGPIPE_NS.version))[0], + input_spec=[DataSpecEntityId(neighbor.id) for neighbor in SYS_KG.get_neighbors(entity.id, str(KGPIPE_NS.input))], + output_spec=[DataSpecEntityId(neighbor.id) for neighbor in SYS_KG.get_neighbors(entity.id, str(KGPIPE_NS.output))], + realizesTask=[TaskEntityId(neighbor.id) for neighbor in SYS_KG.get_neighbors(entity.id, str(KGPIPE_NS.realisesTask))], + # hasParameter=[ParameterEntityId(neighbor.id) for neighbor in entity.get_neighbors(KGPIPE_NS.hasParameter)], + usesTool=[ToolEntityId(neighbor.id) for neighbor in SYS_KG.get_neighbors(entity.id, str(KGPIPE_NS.usesTool))], + # config_spec=ConfigSpecEntityId(entity.get_property(KGPIPE_NS.config_spec)) if entity.get_property(KGPIPE_NS.config_spec) else None, + ) for entity in entities] + if name is not None: + implementations = [impl for impl in implementations if impl.name == name] + return implementations + + ### Data Layer Entities ### + + @staticmethod + @functools.lru_cache + def add_data_spec(data_spec: DataSpecEntity): + data_spec_entity = SYS_KG.create_entity( + id=data_spec.uri if data_spec.uri else new_id(), + types=[config.ONTOLOGY_PREFIX + "DataSpec"], + properties={ + config.ONTOLOGY_PREFIX + "name": data_spec.name, + }, + ) + SYS_KG.create_relation(type=KGPIPE_NS.data_type, source=data_spec_entity.id, target=data_spec.data_type) + return DataSpecEntityId(data_spec_entity.id) + + @staticmethod + @functools.lru_cache + def add_data_entity(data_entity: DataEntity): + entity_id = config.PIPEKG_PREFIX + new_id() + data_entity_entity = SYS_KG.create_entity( + id=entity_id, + types=[KGPIPE_NS.DataEntity], + properties={}, # TODO + # properties={ + # KGPIPE_NS.timestamp: data_entity.timestamp, + # KGPIPE_NS.version: data_entity.version, + # KGPIPE_NS.hash: data_entity.hash, + # KGPIPE_NS.size: data_entity.size, + # }, + ) + SYS_KG.create_relation(type=KGPIPE_NS.location, source=data_entity_entity.id, target=data_entity.location) + SYS_KG.create_relation(type=KGPIPE_NS.data_type, source=data_entity_entity.id, target=data_entity.data_type) + return DataEntityId(data_entity_entity.id) + + @staticmethod + @functools.lru_cache + def add_data_type(data_type: DataTypeEntity) -> DataTypeEntityId: + entity_id = config.PIPEKG_PREFIX + encode_string(data_type.format+"-"+data_type.data_schema) + SYS_KG.create_entity( + id=entity_id, + types=[KGPIPE_NS.DataType], + properties={ + KGPIPE_NS.format: data_type.format, + KGPIPE_NS.schema: data_type.data_schema, + }, + ) + return DataTypeEntityId(entity_id) + + ### Pipeline Layer Entities ### + + ### Evaluation Layer Entities ### + + def add_metric(metric: MetricEntity): + pass + + ### Run Layer Entities ### + + def add_task_run(task_run: TaskRunEntity): + entity_id = config.PIPEKG_PREFIX + new_id() + SYS_KG.create_entity( + id=entity_id, + types=[KGPIPE_NS.TaskRun], + properties={ + KGPIPE_NS.status: task_run.status, + KGPIPE_NS.started_at: task_run.started_at, + KGPIPE_NS.ended_at: task_run.ended_at, + }, + ) + for input in task_run.input: + SYS_KG.create_relation(type=KGPIPE_NS.input, source=entity_id, target=input) + for output in task_run.output: + SYS_KG.create_relation(type=KGPIPE_NS.output, source=entity_id, target=output) + SYS_KG.create_relation(type=KGPIPE_NS.usesImplementation, source=entity_id, target=task_run.usesImplementation) + return TaskRunEntityId(entity_id) + + def add_metric_run(metric_run: MetricRunEntity): + pass + + ### Configuration Layer Entities ### + + @staticmethod + @functools.lru_cache + def add_parameter(parameter: ParameterEntity): + + payload = json.dumps(parameter.model_dump(mode="json"), sort_keys=True, separators=(",", ":")) + stable_hash = hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16] # short suffix + entity_id = config.PIPEKG_PREFIX + encode_string(parameter.key) + "_" + stable_hash + SYS_KG.create_entity( + id=entity_id, + types=[KGPIPE_NS.Parameter], + properties={ + KGPIPE_NS.key: parameter.key, + KGPIPE_NS.alias_keys: parameter.alias_keys, + KGPIPE_NS.datatype: parameter.datatype, + KGPIPE_NS.required: parameter.required, + KGPIPE_NS.default_value: parameter.default_value, + KGPIPE_NS.allowed_values: parameter.allowed_values, + # KGPIPE_NS.minimum: parameter.minimum, + # KGPIPE_NS.maximum: parameter.maximum, + # KGPIPE_NS.unit: parameter.unit, + }, + ) + return ParameterEntityId(entity_id) + + def find_parameter(name: str): + pass + + @staticmethod + def add_parameter_binding(parameter_binding: ParameterBindingEntity): + payload = json.dumps(parameter_binding.model_dump(mode="json"), sort_keys=True, separators=(",", ":")) + stable_hash = hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16] # short suffix + entity_id = parameter_binding.parameter + "_" + stable_hash + SYS_KG.create_entity( + id=entity_id, + types=[KGPIPE_NS.ParameterBinding], + properties={ + KGPIPE_NS.value: parameter_binding.value, + }, + ) + SYS_KG.create_relation(type=KGPIPE_NS.parameter, source=entity_id, target=parameter_binding.parameter) + return ParameterBindingEntityId(entity_id) + + def find_parameter_binding(name: str): + pass + + @staticmethod + @functools.lru_cache + def add_config_spec(config_spec: ConfigSpecEntity): + entity_id = config.PIPEKG_PREFIX + encode_string(config_spec.name) + SYS_KG.create_entity( + id=entity_id, + types=[KGPIPE_NS.ConfigSpec], + properties={ + KGPIPE_NS.name: config_spec.name, + }, + ) + for parameter in config_spec.parameters: + SYS_KG.create_relation(type=KGPIPE_NS.hasParameter, source=entity_id, target=parameter) + return ConfigSpecEntityId(entity_id) + + + def find_config_spec(name: str): + pass + + @staticmethod + def add_config_binding(config_binding: ConfigBindingEntity): + entity_id = config.PIPEKG_PREFIX + encode_string(config_binding.name) + SYS_KG.create_entity( + id=entity_id, + types=[KGPIPE_NS.ConfigBinding], + properties={ + KGPIPE_NS.name: config_binding.name, + }, + ) + for binding in config_binding.binding: + SYS_KG.create_relation(type=KGPIPE_NS.hasParameterBinding, source=entity_id, target=binding) + return ConfigBindingEntityId(entity_id) + + def find_config_binding(name: str): + pass + + ### Utility Functions ### + + @staticmethod + def sparql_construct(query: str): + backend : RDFSparqlBackend = SYS_KG.backend + result = backend.query_sparql(query) + return result + + @staticmethod + def _prop_value(properties: List[KGProperty], *keys: str) -> Any: + """Find a property value by exact key or key suffix.""" + for prop in properties: + if prop.key in keys: + return prop.value + for prop in properties: + for key in keys: + if prop.key.endswith(key): + return prop.value + return None + + @staticmethod + def _to_list(value: Any) -> List[str]: + """Normalize KG property values to list[str].""" + if value is None: + return [] + if isinstance(value, list): + return [str(v) for v in value] + if isinstance(value, tuple): + return [str(v) for v in value] + if isinstance(value, str): + text = value.strip() + if not text: + return [] + # Stored literals may contain Python-list string repr. + if text.startswith("[") and text.endswith("]"): + try: + parsed = ast.literal_eval(text) + except (ValueError, SyntaxError): + return [text] + if isinstance(parsed, list): + return [str(v) for v in parsed] + return [text] + return [str(value)] + + diff --git a/src/kgpipe/common/model/__init__.py b/src/kgpipe/common/model/__init__.py index 4a7f388..8335da4 100644 --- a/src/kgpipe/common/model/__init__.py +++ b/src/kgpipe/common/model/__init__.py @@ -1,2 +1,10 @@ from .pipeline import KgPipe, KgPipePlan, KgPipePlanStep -from .task import TaskInput, TaskOutput \ No newline at end of file +from .task import TaskInput, TaskOutput, KgTask, KgTaskRun +from .evaluation import Metric, EvaluationReport +from .kg import KG +from .data import Data, DataFormat, DataSet, KgData +from .default_catalog import BasicDataFormats, CustomDataFormats, BasicTaskCategoryCatalog + +__all__ = [ + "KgPipe", "KgPipePlan", "KgPipePlanStep", "KgStageReport", "KgTask", "KgTaskRun", "Metric", "EvaluationReport", "KG", "TaskInput", "TaskOutput", "KgTaskRun", "Data", "DataSet", "BasicDataFormats", "CustomDataFormats", "BasicTaskCategoryCatalog", "KgData" +] \ No newline at end of file diff --git a/src/kgpipe/common/model/configuration.py b/src/kgpipe/common/model/configuration.py index 6dcaace..4ebacb9 100644 --- a/src/kgpipe/common/model/configuration.py +++ b/src/kgpipe/common/model/configuration.py @@ -19,7 +19,6 @@ class ParameterType(Enum): object = "object" -@kg_class() class Parameter(BaseModel): """ Configuration parameter definition, not the actual value of the parameter in the pipeline execution @@ -46,7 +45,6 @@ class Parameter(BaseModel): unit: Optional[str] = None -@kg_class() class ParameterBinding(BaseModel): """ Binding of a configuration parameter to a value in the pipeline execution @@ -54,25 +52,50 @@ class ParameterBinding(BaseModel): parameter: Parameter value: str | int | float | bool # TODO extend to more types? -@kg_class() + class ConfigurationDefinition(BaseModel): """ - Possible configurations of a task + Possible configurations specification of a task """ name: str description: Optional[str] = None parameters: List[Parameter] = field(default_factory=list) - -@kg_class() + + class ConfigurationProfile(BaseModel): """ - Configuration profile definition, not the actual values of the parameters in the pipeline execution + Configuration profile specification, the actual values of the parameters in the pipeline execution """ name: str definition: ConfigurationDefinition description: Optional[str] = None bindings: List[ParameterBinding] = field(default_factory=list) + def get_parameter(self, name: str) -> Parameter: + for parameter in self.definition.parameters: + if parameter.name == name: + return parameter + raise ValueError(f"Parameter {name} not found in configuration profile {self.name}") + + def get_parameter_binding(self, name: str) -> ParameterBinding: + for binding in self.bindings: + if binding.parameter.name == name: + return binding + raise ValueError(f"Parameter binding {name} not found in configuration profile {self.name}") + + def get_parameter_value(self, name: str) -> str | int | float | bool: + return self.get_parameter_binding(name).value + +class ConfigurationBuilder(): + def __init__(self, config_spec: ConfigurationDefinition): + self.config_spec = config_spec + self.config_profile = ConfigurationProfile(name=config_spec.name, definition=config_spec) + + def add_parameter(self, name: str, value: str | int | float | bool) -> None: + self.config_profile.bindings.append(ParameterBinding(parameter=self.get_parameter(name), value=value)) + + + class ConfigurationMapping(BaseModel): """ Mapping of a configuration profile to a task implementation diff --git a/src/kgpipe/common/model/data.py b/src/kgpipe/common/model/data.py index 0b66c0b..0e6eb49 100644 --- a/src/kgpipe/common/model/data.py +++ b/src/kgpipe/common/model/data.py @@ -1,228 +1,23 @@ from __future__ import annotations -import os -import time import uuid -from abc import ABC, abstractmethod from dataclasses import dataclass, field -from datetime import datetime from enum import Enum from pathlib import Path -from typing import Any, Callable, Dict, List, Mapping, Optional, Set, Tuple, Union, Type -import json -from uuid import uuid4 -import logging -import shutil -from rdflib import Graph +from typing import Any, Dict, Optional, Union from pydantic import BaseModel, field_validator -from pydantic_core import core_schema +from .default_catalog import BasicDataFormats, CustomDataFormats -# Format descriptions for built-in formats -FORMAT_DESCRIPTIONS = { - "ttl": "Turtle RDF format", - "nquads": "N-Quads RDF format", - "json": "JSON format", - "csv": "CSV format", - "parquet": "Parquet format", - "xml": "XML format", - "rdf": "RDF format", - "jsonld": "JSON-LD format", - "txt": "Text format", - "paris_csv": "Paris CSV format", - "openrefine_json": "OpenRefine JSON format", - "limes_xml": "LIMES XML format", - "spotlight_json": "DBpedia Spotlight JSON format", - "falcon_json": "FALCON JSON format", - "ie_json": "Information Extraction JSON format", - "valentine_json": "Valentine JSON format", - "corenlp_json": "CoreNLP JSON format", - "openie_json": "OpenIE JSON format", - "agreementmaker_rdf": "AgreementMaker RDF format", - "em_json": "Entity Matching JSON format", -} +# Backward-compatible alias used across the codebase. +DataFormat = BasicDataFormats -class DataFormat(Enum): - """Built-in data formats with enum benefits.""" - # Standard formats - RDF_TTL = "ttl" - RDF_NQUADS = "nq" - RDF_NTRIPLES = "nt" - JSON = "json" - CSV = "csv" - PARQUET = "parquet" - RDF_XML = "xml" - RDF = "rdf" - RDF_JSONLD = "jsonld" - TEXT = "txt" - XML = "xml" - ANY = "any" - - # Tool-specific formats - PARIS_CSV = "paris.csv" - OPENREFINE_JSON = "openrefine.json" - LIMES_XML = "limes.xml" - SPOTLIGHT_JSON = "spotlight.json" - FALCON_JSON = "falcon.json" - VALENTINE_JSON = "valentine.json" - CORENLP_JSON = "corenlp.json" - OPENIE_JSON = "openie.json" - AGREEMENTMAKER_RDF = "agreementmaker.rdf" - - # Exchange formats - ER_JSON = "er.json" # Entity Resolution JSON format - TE_JSON = "te.json" # Text Extraction JSON format - - # LLM Tasks - JSON_ONTO_MAPPING_JSON = "json_onto_mapping.json" - - @classmethod - def from_extension(cls, extension: str) -> DataFormat: - """Get a format by file extension. If fails print available formats and raise ValueError.""" - try: - return cls(extension) - except ValueError: - print(f"Available formats: {[f.value for f in cls]}") - raise ValueError(f"Invalid format: {extension}") - - - @property - def extension(self) -> str: - """Get the file extension for this format.""" - return self.value - - @property - def description(self) -> str: - """Get the description for this format.""" - return FORMAT_DESCRIPTIONS.get(self.value, self.value) - - @property - def is_tool_specific(self) -> bool: - """Check if this is a tool-specific format.""" - tool_specific_formats = { - "paris_csv", "openrefine_json", "limes_xml", "spotlight_json", - "falcon_json", "ie_json", "valentine_json", "corenlp_json", - "openie_json", "agreementmaker_rdf", "em_json" - } - return self.value in tool_specific_formats - - def __str__(self) -> str: - return f".{self.value}" - - def __repr__(self) -> str: - return f".{self.value}" - - -class DynamicFormat: - """Dynamic format for submodules to register custom formats.""" - - def __init__(self, name: str, extension: str, description: str, is_tool_specific: bool = False): - self.name = name - self.extension = extension - self.description = description - self.is_tool_specific = is_tool_specific - - @classmethod - def __get_pydantic_core_schema__(cls, source_type: Any, handler) -> Any: - """Provide Pydantic schema for this type.""" - return core_schema.union_schema([ - core_schema.is_instance_schema(cls), - core_schema.str_schema() - ]) - - @property - def value(self) -> str: - """Get the format value (same as name for compatibility).""" - return self.name - - def __eq__(self, other) -> bool: - """Compare formats by name.""" - if isinstance(other, DynamicFormat): - return self.name == other.name - elif isinstance(other, DataFormat): - return self.name == other.value - elif isinstance(other, str): - return self.name == other - return False - - def __hash__(self) -> int: - """Hash based on name.""" - return hash(self.name) - - def __str__(self) -> str: - return f"DynamicFormat({self.name})" - - def __repr__(self) -> str: - return f"DynamicFormat(name='{self.name}', extension='{self.extension}', description='{self.description}', is_tool_specific={self.is_tool_specific})" - - -class FormatRegistry: - """Registry for managing and discovering data formats.""" - - _dynamic_formats: Dict[str, DynamicFormat] = {} - - @classmethod - def register_format(cls, name: str, extension: str, description: str, is_tool_specific: bool = False) -> DynamicFormat: - """Register a new dynamic data format.""" - if name in cls._dynamic_formats: - return cls._dynamic_formats[name] - - format_obj = DynamicFormat(name, extension, description, is_tool_specific) - cls._dynamic_formats[name] = format_obj - return format_obj - - @classmethod - def get_format(cls, name: str) -> Optional[Union[DataFormat, DynamicFormat]]: - """Get a format by name, checking built-in formats first.""" - # Try built-in formats first - try: - return DataFormat(name) - except ValueError: - # Then check dynamic formats - return cls._dynamic_formats.get(name) - - @classmethod - def list_formats(cls, tool_specific_only: bool = False) -> List[Union[DataFormat, DynamicFormat]]: - """List all registered formats.""" - formats = list(DataFormat) + list(cls._dynamic_formats.values()) - if tool_specific_only: - formats = [f for f in formats if getattr(f, 'is_tool_specific', False)] - return formats - - @classmethod - def list_standard_formats(cls) -> List[Union[DataFormat, DynamicFormat]]: - """List all standard (non-tool-specific) formats.""" - formats = list(DataFormat) + list(cls._dynamic_formats.values()) - return [f for f in formats if not getattr(f, 'is_tool_specific', False)] - - @classmethod - def list_tool_specific_formats(cls) -> List[Union[DataFormat, DynamicFormat]]: - """List all tool-specific formats.""" - formats = list(DataFormat) + list(cls._dynamic_formats.values()) - return [f for f in formats if getattr(f, 'is_tool_specific', False)] - - @classmethod - def list_rdf_formats(cls) -> List[Union[DataFormat, DynamicFormat]]: - """List all RDF formats.""" - rdf_formats = [DataFormat.RDF_TTL, DataFormat.RDF_NQUADS, DataFormat.RDF, DataFormat.RDF_JSONLD] - dynamic_rdf = [f for f in cls._dynamic_formats.values() if 'rdf' in f.name.lower() or 'ttl' in f.name.lower()] - return rdf_formats + dynamic_rdf - - @classmethod - def list_text_formats(cls) -> List[Union[DataFormat, DynamicFormat]]: - """List all text formats.""" - text_formats = [DataFormat.JSON, DataFormat.CSV, DataFormat.XML, DataFormat.TEXT] - dynamic_text = [f for f in cls._dynamic_formats.values() if f.name.lower() in ['json', 'csv', 'xml', 'txt', 'yaml']] - return text_formats + dynamic_text - - @classmethod - def clear_dynamic_formats(cls) -> None: - """Clear all dynamically registered formats (useful for testing).""" - cls._dynamic_formats.clear() +# Type alias for any format +Format = Union[DataFormat, CustomDataFormats] -# Type alias for any format -Format = Union[DataFormat, DynamicFormat] +def _format_value(fmt: Format) -> str: + return str(fmt.value) class Data(BaseModel): """Represents a data file with a specific format.""" @@ -246,16 +41,16 @@ def __init__(self, *args, **data): @classmethod def validate_format(cls, v): """Convert string format to proper Format object.""" + if isinstance(v, (DataFormat, CustomDataFormats)): + return v + if isinstance(v, Enum) and isinstance(v.value, str): + # Allow user-defined enum values for strong typing/autocomplete. + return v if isinstance(v, str): # Try to convert string to DataFormat enum try: return DataFormat(v) except ValueError: - # If it's not a DataFormat, it might be a DynamicFormat - from .models import FormatRegistry - dynamic_format = FormatRegistry.get_format(v) - if dynamic_format: - return dynamic_format raise ValueError(f"Unknown format: {v}") return v @@ -266,23 +61,19 @@ def exists(self) -> bool: def to_dict(self) -> Dict[str, str]: return { "path": str(self.path), - "format": self.format.value + "format": _format_value(self.format) } def __str__(self) -> str: - return f"Data({self.path}, {self.format.value if isinstance(self.format, DynamicFormat) else self.format})" + return f"Data({self.path}, {_format_value(self.format)})" def __eq__(self, other): """Custom equality to handle format comparison.""" if not isinstance(other, Data): return False - return (self.path == other.path and - (hasattr(self.format, 'value') and hasattr(other.format, 'value') and - self.format.value == other.format.value)) - - - + return self.path == other.path and _format_value(self.format) == _format_value(other.format) +KgData = Data @dataclass class DataSet: @@ -307,4 +98,4 @@ def exists(self) -> bool: return self.path.exists() def __str__(self) -> str: - return f"DataSet({self.name}, {self.path}, {self.format.value})" + return f"DataSet({self.name}, {self.path}, {_format_value(self.format)})" diff --git a/src/kgpipe/common/model/default_catalog.py b/src/kgpipe/common/model/default_catalog.py index 0fde757..24fd368 100644 --- a/src/kgpipe/common/model/default_catalog.py +++ b/src/kgpipe/common/model/default_catalog.py @@ -1,13 +1,203 @@ +from __future__ import annotations +from dataclasses import dataclass +from enum import Enum +from typing import Dict, List, Optional -# TODO impl later for typed api -class TaskCategory():pass -class EntityResolution(TaskCategory): pass -class EntityMatching(EntityResolution): pass -class Fusion(EntityResolution): pass -class InformationExtraction(TaskCategory): pass -class EntityLinking(InformationExtraction): pass -class RelationExtraction(InformationExtraction): pass -class RelationLinking(InformationExtraction): pass -class DataMapping(TaskCategory): pass \ No newline at end of file +@dataclass(frozen=True) +class TaskCategory: + name: str + parent: Optional[TaskCategory] = None + description: str = "" + + + + + +class BasicTaskCategoryCatalog: + """ + Hierarchical catalog for task categories. + Supports default categories and custom category registration. + """ + entity_resolution = TaskCategory(name="EntityResolution") + entity_matching = TaskCategory(name="EntityMatching", parent=entity_resolution) + fusion = TaskCategory(name="Fusion", parent=entity_resolution) + information_extraction = TaskCategory(name="InformationExtraction") + entity_linking = TaskCategory(name="EntityLinking", parent=information_extraction) + relation_extraction = TaskCategory(name="RelationExtraction", parent=information_extraction) + relation_linking = TaskCategory(name="RelationLinking", parent=information_extraction) + data_mapping = TaskCategory(name="DataMapping") + blocking = TaskCategory(name="Blocking", parent=entity_resolution) + clustering = TaskCategory(name="Clustering", parent=entity_resolution) + + + # @dataclass(frozen=True) + # class TaskCategoryNode: + # name: str + # parent: Optional[str] = None + # description: str = "" + + # _nodes: Dict[str, TaskCategoryNode] = { + # "TaskCategory": TaskCategoryNode(name="TaskCategory", parent=None, description="Root category"), + # "EntityResolution": TaskCategoryNode(name="EntityResolution", parent="TaskCategory"), + # "Blocking": TaskCategoryNode(name="Blocking", parent="EntityResolution"), + # "EntityMatching": TaskCategoryNode(name="EntityMatching", parent="EntityResolution"), + # "Matching": TaskCategoryNode(name="Matching", parent="EntityResolution"), + # "Clustering": TaskCategoryNode(name="Clustering", parent="EntityResolution"), + # "Fusion": TaskCategoryNode(name="Fusion", parent="EntityResolution"), + # "InformationExtraction": TaskCategoryNode(name="InformationExtraction", parent="TaskCategory"), + # "EntityLinking": TaskCategoryNode(name="EntityLinking", parent="InformationExtraction"), + # "RelationExtraction": TaskCategoryNode(name="RelationExtraction", parent="InformationExtraction"), + # "RelationLinking": TaskCategoryNode(name="RelationLinking", parent="InformationExtraction"), + # "DataMapping": TaskCategoryNode(name="DataMapping", parent="TaskCategory"), + # } + + # @classmethod + # def has(cls, category: str) -> bool: + # return category in cls._nodes + + # @classmethod + # def register(cls, name: str, parent: str = "TaskCategory", description: str = "") -> None: + # if parent is not None and parent not in cls._nodes: + # raise ValueError(f"Unknown parent category: {parent}") + # cls._nodes[name] = TaskCategoryNode(name=name, parent=parent, description=description) + + # @classmethod + # def get_parent(cls, category: str) -> Optional[str]: + # node = cls._nodes.get(category) + # if node is None: + # raise ValueError(f"Unknown category: {category}") + # return node.parent + + # @classmethod + # def get_children(cls, category: str) -> List[str]: + # if category not in cls._nodes: + # raise ValueError(f"Unknown category: {category}") + # return sorted([node.name for node in cls._nodes.values() if node.parent == category]) + + # @classmethod + # def get_ancestors(cls, category: str) -> List[str]: + # if category not in cls._nodes: + # raise ValueError(f"Unknown category: {category}") + # ancestors: List[str] = [] + # cursor = cls._nodes[category].parent + # while cursor is not None: + # ancestors.append(cursor) + # cursor = cls._nodes[cursor].parent + # return ancestors + + # @classmethod + # def get_descendants(cls, category: str) -> List[str]: + # if category not in cls._nodes: + # raise ValueError(f"Unknown category: {category}") + # descendants: List[str] = [] + # queue = cls.get_children(category) + # while queue: + # current = queue.pop(0) + # descendants.append(current) + # queue.extend(cls.get_children(current)) + # return descendants + + # @classmethod + # def is_subtask_of(cls, category: str, parent: str) -> bool: + # if category not in cls._nodes or parent not in cls._nodes: + # return False + # return parent in cls.get_ancestors(category) + + # @classmethod + # def list_categories(cls) -> List[str]: + # return sorted(cls._nodes.keys()) + + +class BasicDataFormats(str, Enum): + """Framework-provided data formats with IDE autocomplete.""" + + # Standard formats + RDF_TTL = "ttl" + RDF_NQUADS = "nq" + RDF_NTRIPLES = "nt" + JSON = "json" + CSV = "csv" + PARQUET = "parquet" + RDF_XML = "xml" + RDF = "rdf" + RDF_JSONLD = "jsonld" + TEXT = "txt" + XML = "xml" + ANY = "any" + + # Tool-specific formats + PARIS_CSV = "paris.csv" + OPENREFINE_JSON = "openrefine.json" + LIMES_XML = "limes.xml" + SPOTLIGHT_JSON = "spotlight.json" + FALCON_JSON = "falcon.json" + VALENTINE_JSON = "valentine.json" + CORENLP_JSON = "corenlp.json" + OPENIE_JSON = "openie.json" + AGREEMENTMAKER_RDF = "agreementmaker.rdf" + + # Exchange formats + ER_JSON = "er.json" + TE_JSON = "te.json" + + # LLM task outputs + JSON_ONTO_MAPPING_JSON = "json_onto_mapping.json" + + @property + def extension(self) -> str: + return self.value + + @property + def description(self) -> str: + return BASIC_FORMAT_DESCRIPTIONS.get(self.value, self.value) + + @property + def is_tool_specific(self) -> bool: + return "." in self.value and self.value not in {"jsonld"} + + @classmethod + def from_extension(cls, extension: str) -> "BasicDataFormats": + try: + return cls(extension) + except ValueError as exc: + available = [f.value for f in cls] + raise ValueError(f"Invalid format: {extension}. Available formats: {available}") from exc + + +class CustomDataFormats(str, Enum): + """ + Base enum for user-defined formats. + Define project-specific formats by subclassing this enum. + """ + + @property + def extension(self) -> str: + return self.value + + +BASIC_FORMAT_DESCRIPTIONS: dict[str, str] = { + "ttl": "Turtle RDF format", + "nq": "N-Quads RDF format", + "json": "JSON format", + "csv": "CSV format", + "parquet": "Parquet format", + "xml": "XML format", + "rdf": "RDF format", + "jsonld": "JSON-LD format", + "txt": "Text format", + "paris.csv": "Paris CSV format", + "openrefine.json": "OpenRefine JSON format", + "limes.xml": "LIMES XML format", + "spotlight.json": "DBpedia Spotlight JSON format", + "falcon.json": "FALCON JSON format", + "valentine.json": "Valentine JSON format", + "corenlp.json": "CoreNLP JSON format", + "openie.json": "OpenIE JSON format", + "agreementmaker.rdf": "AgreementMaker RDF format", + "er.json": "Entity Resolution JSON format", + "te.json": "Text Extraction JSON format", + "json_onto_mapping.json": "JSON ontology mapping format", + "any": "Any format", +} \ No newline at end of file diff --git a/src/kgpipe/common/model/evaluation.py b/src/kgpipe/common/model/evaluation.py index baefa87..be5c6d7 100644 --- a/src/kgpipe/common/model/evaluation.py +++ b/src/kgpipe/common/model/evaluation.py @@ -1,28 +1,19 @@ from __future__ import annotations -import os -import time -import uuid from abc import ABC, abstractmethod from dataclasses import dataclass, field from datetime import datetime -from enum import Enum -from pathlib import Path -from typing import Any, Callable, Dict, List, Mapping, Optional, Set, Tuple, Union, Type -import json +from typing import Any, Dict from uuid import uuid4 -import logging -import shutil -from rdflib import Graph -from pydantic import BaseModel, field_validator -from pydantic_core import core_schema from kgpipe.common.model.kg import KG +# TODO move parts from kgpipe.evaluation.base to here + class Metric(ABC): """Abstract base class for evaluation metrics.""" - def __init__(self, name: str, description: Optional[str] = None): + def __init__(self, name: str, description: str | None = None): self.name = name self.description = description or name @@ -47,7 +38,7 @@ class EvaluationReport: def __post_init__(self): if not self.id: - self.id = str(uuid.uuid4()) + self.id = str(uuid4().hex) def add_metric(self, name: str, value: float) -> None: """Add a metric result to the report.""" diff --git a/src/kgpipe/common/model/kg.py b/src/kgpipe/common/model/kg.py index 92d5ab6..9e08c58 100644 --- a/src/kgpipe/common/model/kg.py +++ b/src/kgpipe/common/model/kg.py @@ -1,27 +1,14 @@ from __future__ import annotations -import os -import time import uuid -from abc import ABC, abstractmethod from dataclasses import dataclass, field -from datetime import datetime -from enum import Enum from pathlib import Path -from typing import Any, Callable, Dict, List, Mapping, Optional, Set, Tuple, Union, Type -import json -from uuid import uuid4 -import logging -import shutil -from rdflib import Graph -from pydantic import BaseModel, field_validator -from pydantic_core import core_schema +from typing import Any, Dict, List, Optional +from rdflib import Graph, SKOS, RDF from .data import Format from .pipeline import KgPipePlan -from rdflib import SKOS - # TODO check if this is still needed or if we can use the KG from kgcore and only use Data and DataSet @dataclass @@ -76,4 +63,17 @@ def exists(self) -> bool: return self.path.exists() def __str__(self) -> str: - return f"KG({self.name}, {self.path}, {self.format.value})" \ No newline at end of file + return f"KG({self.name}, {self.path}, {self.format.value})" + + +# TODO wip class for central KgPipe KG entity + +class KgKg: + """Represents a KG for the KgPipe framework.""" + # data: List[KgData] + # provenance: str + + @staticmethod + def load_from_plan(plan: KgPipePlan) -> KG: + pass + pass \ No newline at end of file diff --git a/src/kgpipe/common/model/pipeline.py b/src/kgpipe/common/model/pipeline.py index 5ee69f8..00495ef 100644 --- a/src/kgpipe/common/model/pipeline.py +++ b/src/kgpipe/common/model/pipeline.py @@ -20,7 +20,7 @@ from .task import KgTask, KgTaskReport # from .kg import KG from kgpipe.common.annotations import kg_class -from kgpipe.common.systemgraph import PipeKG +from kgpipe.common.graph.systemgraph import PipeKG class KgPipePlanStep(BaseModel): @@ -29,7 +29,7 @@ class KgPipePlanStep(BaseModel): input: List[Data] output: List[Data] -kg_class() +# kg_class() class KgPipePlan(BaseModel): """A KG pipeline plan.""" steps: List[KgPipePlanStep] @@ -41,7 +41,7 @@ class KgPipePlan(BaseModel): # return f"KgTaskReport({self.task_name}, {self.status}, {self.duration:.2f}s)" # TODO rename to KgPipeReport -@kg_class() +# @kg_class() class KgStageReport(BaseModel): """Report of a stage execution.""" stage_name: str @@ -51,6 +51,8 @@ class KgStageReport(BaseModel): status: str error: Optional[str] = None +KgPipelineRun = KgStageReport + # @dataclass # class Stage: # """Represents a stage in a pipeline, containing one or more tasks.""" @@ -78,7 +80,7 @@ class KgStageReport(BaseModel): # TODO rename to Pipeline -@kg_class() +# @kg_class() @dataclass class KgPipe: """A KG pipeline using a list of tasks.""" @@ -227,44 +229,8 @@ def run(self, stable_files_override: bool = False) -> List[KgTaskReport]: reports.append(report) - from kgpipe.common.definitions import PipelineRunEntity, TaskRunEntity, ImplementationEntity, TaskEntity, ImplementationEntityId, TaskEntityId - from kgcore.api.kg import KGId - from kgpipe.common.config import config - from kgpipe.common.definitions import DataHandle - - # TODO this is a workaround for now, taskrun should be built from the task itself - def build_pipeline_run_entity(reports: List[KgTaskReport]) -> PipelineRunEntity: - - task_runs: List[TaskRunEntity] = [] - for idx, report in enumerate(reports): - - - # def get_implementation_entity(report: KgTaskReport) -> ImplementationEntityId: - # return PipeKG.find_implementation_by_name(report.task_name).id - - task_runs.append(TaskRunEntity( - number=idx, - name=report.task_name, - status=report.status, - started_at=report.start_ts, - ended_at=report.start_ts + report.duration, - executesTask=TaskEntityId(config.PIPEKG_PREFIX+report.task_name), - usesImplementation=ImplementationEntityId(config.PIPEKG_PREFIX+report.task_name+"Impl"), - input=[DataHandle(uri=str(input_data.path), type=input_data.format) for input_data in report.inputs], - output=[DataHandle(uri=str(output_data.path), type=output_data.format) for output_data in report.outputs], - hasParameterBinding=[] - )) - - return PipelineRunEntity( - name=self.name, - status="success", - started_at=time.time(), - ended_at=time.time(), - hasTaskRun=task_runs - ) - - pipeline_run_entity = build_pipeline_run_entity(reports) - PipeKG.add_pipeline_run(pipeline_run_entity) + # pipeline_run_entity = reports_to_pipeline_run_entity(reports, self.name) + # PipeKG.add_pipeline_run(pipeline_run_entity) return reports diff --git a/src/kgpipe/common/model/task.py b/src/kgpipe/common/model/task.py index 7d11cad..e901aa9 100644 --- a/src/kgpipe/common/model/task.py +++ b/src/kgpipe/common/model/task.py @@ -7,25 +7,35 @@ from pydantic import BaseModel import time import shutil +from uuid import uuid4 +import inspect from kgpipe.common.model.default_catalog import TaskCategory -from .configuration import Parameter, ConfigurationDefinition -from kgpipe.common.annotations import kg_class +from .configuration import ( + Parameter, + ConfigurationDefinition, + ConfigurationProfile, + ParameterType, +) +from kgpipe.common.graph.systemgraph import PipeKG +from kgpipe.common.graph.mapper import task_run_to_entity type TaskName = str type TaskInput = Dict[TaskName, Data] type TaskOutput = Dict[TaskName, Data] -@kg_class() class KgTaskReport(BaseModel): """Report of a task execution.""" - task_name: str + task: "KgTask" inputs: List[Data] outputs: List[Data] start_ts: float duration: float status: str error: Optional[str] = None + config_profile: Optional[ConfigurationProfile] = None + +KgTaskRun = KgTaskReport class TaskStatus(Enum): """Status of a task in a pipeline.""" @@ -35,13 +45,10 @@ class TaskStatus(Enum): FAILED = "failed" SKIPPED = "skipped" - - # # TODO impl later for typed api # class TaskCatalog(): # pass -@kg_class() @dataclass class KgTask: """Represents a task that can be executed in a pipeline.""" @@ -52,6 +59,8 @@ class KgTask: description: Optional[str] = None category: List[TaskCategory] = field(default_factory=list) config_spec: Optional[ConfigurationDefinition] = None + tools: List[str] = field(default_factory=list) + trace_task_run: bool = False def __post_init__(self): if not self.name: @@ -63,70 +72,32 @@ def __post_init__(self): if not callable(self.function): raise ValueError("Function must be callable") - def run(self, inputs: List[Data], outputs: List[Data], stable_files_override: bool = False, configProfile: Optional[str] = None) -> KgTaskReport: + + # TODO if configProfile is not provided, use the default config profile derived from the config_spec + def run(self, inputs: List[Data], outputs: List[Data], stable_files_override: bool = False, configProfile: Optional[ConfigurationProfile] = None) -> KgTaskReport: """Execute the task with given inputs and outputs.""" start = time.time() + report: KgTaskReport try: named_inputs = self._match(inputs, self.input_spec) named_outputs = self._match(outputs, self.output_spec) - - # print(f"Running {self.name} with\n\t inputs: {[str(i.path) for i in named_inputs.values()]}\n\t outputs: {[str(o.path) for o in named_outputs.values()]}") print(f"Running {self.name} with\n\t inputs: {named_inputs}\n\t outputs: {named_outputs}") - - # Validate that all required inputs and outputs are present - if len(named_inputs) != len(self.input_spec): - missing = set(self.input_spec.keys()) - set(named_inputs.keys()) - available = {obj.format.value: obj for obj in inputs} - expected = {k: v.value for k, v in self.input_spec.items()} - raise ValueError( - f"Missing required inputs: {missing}. " - f"Expected: {expected}. " - f"Available: {[f'{obj.path} ({obj.format.value})' for obj in inputs]}" - ) - - if len(named_outputs) != len(self.output_spec): - missing = set(self.output_spec.keys()) - set(named_outputs.keys()) - available = {obj.format.value: obj for obj in outputs} - expected = {k: v.value for k, v in self.output_spec.items()} - raise ValueError( - f"Missing required outputs: {missing}. " - f"Expected: {expected}. " - f"Available: {[f'{obj.path} ({obj.format.value})' for obj in outputs]}" - ) - if stable_files_override: - for output in named_outputs.values(): - # delete the file or directory - if output.path.exists(): - if output.path.is_file(): - output.path.unlink() - elif output.path.is_dir(): - shutil.rmtree(output.path) - - # if all outputs exists skip the task - if all(output.path.exists() for output in named_outputs.values()): + self._validate_required_data(named_inputs, self.input_spec, "inputs", inputs) + self._validate_required_data(named_outputs, self.output_spec, "outputs", outputs) + self._prepare_outputs(named_outputs, stable_files_override) + + # TODO needs to check config profile changes, or maybe not + if self._should_skip(named_outputs): print(f"Skipping task {self.name} because all outputs exist") - # exit(1) - # TODO do not override old KgTaskReport - return KgTaskReport( - task_name=self.name, - inputs=list(named_inputs.values()), - outputs=list(named_outputs.values()), - start_ts=start, - duration=time.time() - start, - status="skipped", - ) + report = self._build_report(start, "skipped", list(named_inputs.values()), list(named_outputs.values()), config_profile=configProfile) + self._trace_task_run_to_pipekg(report) + return report - self.function(named_inputs, named_outputs) - - return KgTaskReport( - task_name=self.name, - inputs=list(named_inputs.values()), - outputs=list(named_outputs.values()), - start_ts=start, - duration=time.time() - start, - status="success", - ) + self._call_function(named_inputs, named_outputs, configProfile) + report = self._build_report(start, "success", list(named_inputs.values()), list(named_outputs.values()), config_profile=configProfile) + self._trace_task_run_to_pipekg(report) + return report except Exception as e: print(f"An error occurred while running the task '{self.name}'.") @@ -134,16 +105,185 @@ def run(self, inputs: List[Data], outputs: List[Data], stable_files_override: bo print(f"Exception message: {e}") import traceback traceback.print_exc() - return KgTaskReport( - task_name=self.name, - inputs=inputs, - outputs=outputs, - start_ts=start, - duration=time.time() - start, - status="failed", - error=str(e) + report = self._build_report(start, "failed", inputs, outputs, error=str(e), config_profile=configProfile) + self._trace_task_run_to_pipekg(report) + return report + + def _trace_task_run_to_pipekg(self, report: KgTaskReport) -> None: + # TODO print(f"Tracing task run to pipekg: {report}") + if not self.trace_task_run: + return + task_run_to_entity(report) + + def _call_function( + self, + named_inputs: Dict[str, Data], + named_outputs: Dict[str, Data], + config_profile: Optional[object], + ) -> None: + """ + Call the wrapped task function with or without config. + + Supported task signatures: + - fn(inputs, outputs) + - fn(inputs, outputs, config) + - fn(inputs, outputs, *, config=...) + - fn(inputs, outputs, **kwargs) (will receive config=... if provided) + """ + sig = inspect.signature(self.function) + params = sig.parameters + + accepts_var_kwargs = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()) + has_config_param = "config" in params + + if config_profile is None: + # If config is required positionally/without default, fail early with a clear error. + if has_config_param: + p = params["config"] + if p.default is inspect._empty and p.kind not in ( + inspect.Parameter.VAR_POSITIONAL, + inspect.Parameter.VAR_KEYWORD, + ): + raise TypeError( + f"{self.name} requires a 'config' argument but none was provided. " + f"Pass configProfile=... to KgTask.run(), or make 'config' optional." + ) + self.function(named_inputs, named_outputs) + return + + # config is provided: pass it only if the function can accept it + if has_config_param or accepts_var_kwargs: + # If the task declares a config spec, we require a structured ConfigurationProfile. + if self.config_spec is not None and not isinstance(config_profile, ConfigurationProfile): + raise TypeError( + f"{self.name} expects configProfile to be a ConfigurationProfile " + f"because it declares config_spec='{self.config_spec.name}', " + f"got {type(config_profile).__name__}." + ) + if isinstance(config_profile, ConfigurationProfile) and self.config_spec is not None: + self._validate_config(config_profile, self.config_spec) + self.function(named_inputs, named_outputs, config=config_profile) + return + + # Function cannot accept config: ignore it + self.function(named_inputs, named_outputs) + + def _validate_config(self, config_profile: ConfigurationProfile, config_spec: ConfigurationDefinition) -> None: + if config_profile.definition.name != config_spec.name: + raise ValueError( + f"Config profile definition '{config_profile.definition.name}' does not match " + f"task config spec '{config_spec.name}'." ) + spec_by_name: Dict[str, Parameter] = {p.name: p for p in config_spec.parameters} + spec_by_key: Dict[str, Parameter] = {} + for p in config_spec.parameters: + spec_by_key[p.name] = p + for nk in p.native_keys: + spec_by_key[nk] = p + + bound: Dict[str, object] = {} + for binding in config_profile.bindings: + raw_key = binding.parameter.name + if raw_key not in spec_by_key: + raise ValueError( + f"Unknown config parameter '{raw_key}' for spec '{config_spec.name}'. " + f"Known: {sorted(spec_by_name.keys())}" + ) + param = spec_by_key[raw_key] + value = binding.value + bound[param.name] = value + + if param.datatype == ParameterType.boolean and not isinstance(value, bool): + raise TypeError(f"Config parameter '{param.name}' expects boolean, got {type(value).__name__}") + if param.datatype == ParameterType.integer and not isinstance(value, int): + raise TypeError(f"Config parameter '{param.name}' expects integer, got {type(value).__name__}") + if param.datatype == ParameterType.number and not isinstance(value, (int, float)): + raise TypeError(f"Config parameter '{param.name}' expects number, got {type(value).__name__}") + if param.datatype == ParameterType.string and not isinstance(value, str): + raise TypeError(f"Config parameter '{param.name}' expects string, got {type(value).__name__}") + + if param.allowed_values and value not in param.allowed_values: + raise ValueError( + f"Config parameter '{param.name}' value {value!r} not in allowed_values {param.allowed_values!r}" + ) + + if param.minimum is not None: + if not isinstance(value, (int, float)): + raise TypeError(f"Config parameter '{param.name}' has minimum constraint but value is not numeric") + if value < param.minimum: + raise ValueError(f"Config parameter '{param.name}' value {value} < minimum {param.minimum}") + + if param.maximum is not None: + if not isinstance(value, (int, float)): + raise TypeError(f"Config parameter '{param.name}' has maximum constraint but value is not numeric") + if value > param.maximum: + raise ValueError(f"Config parameter '{param.name}' value {value} > maximum {param.maximum}") + + missing_required: List[str] = [] + for p in config_spec.parameters: + if not p.required: + continue + if p.name in bound: + continue + if getattr(p, "default_value", None) is None: + missing_required.append(p.name) + if missing_required: + raise ValueError(f"Missing required config parameters: {missing_required}") + + + def _build_report( + self, + start_ts: float, + status: str, + inputs: List[Data], + outputs: List[Data], + error: Optional[str] = None, + config_profile: Optional[ConfigurationProfile] = None, + ) -> KgTaskReport: + return KgTaskReport( + task=self, + inputs=inputs, + outputs=outputs, + start_ts=start_ts, + duration=time.time() - start_ts, + status=status, + error=error, + config_profile=config_profile, + ) + + def _validate_required_data( + self, + matched: Dict[str, Data], + spec: Mapping[str, Format], + label: str, + raw_items: List[Data], + ) -> None: + if len(matched) == len(spec): + return + + missing = set(spec.keys()) - set(matched.keys()) + expected = {k: v.value for k, v in spec.items()} + available = [f"{obj.path} ({obj.format.value})" for obj in raw_items] + raise ValueError( + f"Missing required {label}: {missing}. " + f"Expected: {expected}. " + f"Available: {available}" + ) + + def _prepare_outputs(self, outputs: Dict[str, Data], stable_files_override: bool) -> None: + if not stable_files_override: + return + for output in outputs.values(): + if output.path.exists(): + if output.path.is_file(): + output.path.unlink() + elif output.path.is_dir(): + shutil.rmtree(output.path) + + def _should_skip(self, outputs: Dict[str, Data]) -> bool: + return all(output.path.exists() for output in outputs.values()) + @staticmethod def _match(data: List[Data], spec: Mapping[str, Format]) -> Dict[str, Data]: """Match data objects to specification by format.""" diff --git a/src/kgpipe/common/models.py b/src/kgpipe/common/models.py index 06a56ec..d3f271a 100644 --- a/src/kgpipe/common/models.py +++ b/src/kgpipe/common/models.py @@ -8,13 +8,15 @@ from __future__ import annotations -from .model.data import Data, DataFormat, DynamicFormat, DataSet, FormatRegistry +from .model.data import Data, DataFormat, DataSet +from .model.default_catalog import BasicDataFormats, CustomDataFormats, BasicTaskCategoryCatalog from .model.task import KgTask, KgTaskReport from .model.pipeline import KgPipe, KgPipePlan, KgPipePlanStep, KgStageReport from .model.evaluation import Metric, EvaluationReport from .model.kg import KG -from .model.task import TaskInput, TaskOutput +from .model.task import TaskInput, TaskOutput, KgTask, KgTaskRun +# from .model.evaluation import KgMetric, KgMetricRun __all__ = [ - "Data", "DataFormat", "DynamicFormat", "DataSet", "FormatRegistry", "KgTask", "KgTaskReport", "KgPipe", "KgPipePlan", "KgPipePlanStep", "KgStageReport", "Metric", "EvaluationReport", "KG", "TaskInput", "TaskOutput" + "Data", "DataFormat", "BasicDataFormats", "CustomDataFormats", "BasicTaskCategoryCatalog", "DataSet", "KgTask", "KgTaskReport", "KgPipe", "KgPipePlan", "KgPipePlanStep", "KgStageReport", "Metric", "EvaluationReport", "KG", "TaskInput", "TaskOutput", "KgTaskRun" ] diff --git a/src/kgpipe/common/registry.py b/src/kgpipe/common/registry.py index d0243e3..f9d24e0 100644 --- a/src/kgpipe/common/registry.py +++ b/src/kgpipe/common/registry.py @@ -2,9 +2,10 @@ from typing import Any, Callable, List, Dict from kgpipe.common.models import KgTask, DataFormat -from kgpipe.common.systemgraph import PipeKG -from kgpipe.common.definitions import MetricEntity +# from kgpipe.common.graph.systemgraph import PipeKG +from kgpipe.common.graph.definitions import MetricEntity, TaskEntity from kgpipe.common.model.configuration import ConfigurationDefinition +from kgpipe.common.graph.mapper import implementation_to_entity # TODO add also to system graph @@ -52,7 +53,7 @@ def decorator(t): description = getattr(obj, 'description', None) type = getattr(obj, 'aspect', None) metric = MetricEntity(name=name, description=description, type=type.value if type else None) - PipeKG.add_metric(metric) + # TODO add to system graph return t return decorator @@ -69,8 +70,11 @@ def task( ) -> Callable[[Callable], KgTask]: def decorator(t): task = KgTask(t.__name__.lower(), input_spec, output_spec, t, description, category, config_spec) + if getattr(t, "_trace_task_run", False): + setattr(task, "trace_task_run", True) cls._registry[f"task:{t.__name__.lower()}"] = task - PipeKG.add_task(task) + # implementation_to_entity(task) + # PipeKG.add_implementation(implementation_to_entity(task)) return task return decorator diff --git a/src/kgpipe/common/systemgraph.py b/src/kgpipe/common/systemgraph.py deleted file mode 100644 index 9c9f95a..0000000 --- a/src/kgpipe/common/systemgraph.py +++ /dev/null @@ -1,354 +0,0 @@ -import functools -import ast -from uuid import uuid4 -from typing import Any, List, TYPE_CHECKING -from pydantic import BaseModel -from datetime import datetime, timezone - -# from kgcore.api import KG, BackendName - -from kgcore.api import KnowledgeGraph, KGEntity, KGRelation, KGProperty, new_id -from kgcore.backend.rdf.rdf_rdflib import RDFLibBackend -from kgcore.backend.rdf.rdf_sparql import RDFSparqlBackend, SparqlAuth -from kgcore.model.rdf.rdf_base import RDFBaseModel - -from kgpipe.common.definitions import ( - TaskEntity, TaskRunEntity, PipelineEntity, PipelineRunEntity, ImplementationEntity, MetricEntity, MetricRunEntity, - MethodEntity, ToolEntity, -) -from kgpipe.common.config import load_config -from kgpipe.common.util import encode_string - -if TYPE_CHECKING: - from kgpipe.common.models import KgTask, KgTaskReport - - -config = load_config() -scheme, rest = config.SYS_KG_URL.split("://") - -backend = RDFLibBackend() -model = RDFBaseModel() - -try: - if scheme == "sparql": - print(f"Using SPARQL backend for system graph: {f"http://{rest}"} with http://github.com/ScaDS/kgpipe/") - backend = RDFSparqlBackend( - endpoint=f"http://{rest}", - update_endpoint=f"http://{rest}", - default_graph="http://github.com/ScaDS/kgpipe/", - auth=SparqlAuth(username=config.SYS_KG_USR, password=config.SYS_KG_PSW)) - else: - raise ValueError(f"Unsupported schema: {scheme}") -except Exception as e: - print(f"Error creating system graph: {e}") - print(f"Using RDFLib memory backend for system graph") - -SYS_KG: KnowledgeGraph = KnowledgeGraph(model=model, backend=backend) - -class PipeKG: - """ - PipeKG is the system graph for the KGpipe framework. - It is a Object Graph Mapper (OGM) for the KGpipe framework. - It is used to store the entities and relations of the KGpipe framework. - """ - - ### Core Layer Entities ### - - @staticmethod - def add_task(task: "KgTask"): - from kgpipe.common.models import KgTask # Import here to avoid circular import - types = [config.ONTOLOGY_PREFIX+encode_string(c) for c in task.category] - properties = [] - properties.append(KGProperty(key="description", value=task.description)) - task_entity = SYS_KG.create_entity(id=config.PIPEKG_PREFIX+task.name+"Impl", types=types+[config.ONTOLOGY_PREFIX+"Implementation"], properties=properties) - for input_name, input_format in task.input_spec.items(): - input_entity = SYS_KG.create_entity(id=config.PIPEKG_PREFIX+task.name+"Impl_"+input_name, types=[config.ONTOLOGY_PREFIX+"Data"], properties={ - "format": input_format, - }) - SYS_KG.create_relation(type="input", source=task_entity.id, target=input_entity.id) - for output_name, output_format in task.output_spec.items(): - output_entity = SYS_KG.create_entity(id=config.PIPEKG_PREFIX+task.name+"Impl_"+output_name, types=[config.ONTOLOGY_PREFIX+"Data"], properties={ - "format": output_format, - }) - SYS_KG.create_relation(type="output", source=task_entity.id, target=output_entity.id) - - @staticmethod - def list_taskImplementations(self) -> List[ImplementationEntity]: - entities = SYS_KG.find_entities(types=[config.ONTOLOGY_PREFIX + "Implementation"]) - implementations: List[ImplementationEntity] = [] - - for entity in entities: - name_value = self._prop_value(entity.properties, "name", config.ONTOLOGY_PREFIX + "name") - if not name_value: - # Fallback: derive a readable name from implementation IRI. - name_value = str(entity.id).rstrip("/").split("/")[-1] - - implements_method_value = self._prop_value( - entity.properties, - "implementsMethod", - config.ONTOLOGY_PREFIX + "implementsMethod", - ) - uses_tool_value = self._prop_value( - entity.properties, - "usesTool", - config.ONTOLOGY_PREFIX + "usesTool", - ) - has_parameter_value = self._prop_value( - entity.properties, - "hasParameter", - config.ONTOLOGY_PREFIX + "hasParameter", - ) - - input_entities = SYS_KG.get_neighbors(entity.id, predicate="input") - output_entities = SYS_KG.get_neighbors(entity.id, predicate="output") - - def get_property_values(properties: list[KGProperty], key: str) -> list[str]: - return [prop.value for prop in properties if prop.key.endswith(key)] - - input_spec = [get_property_values(input_entity.properties, "format")[0] for input_entity in input_entities] - output_spec = [get_property_values(output_entity.properties, "format")[0] for output_entity in output_entities] - - implementations.append( - ImplementationEntity( - uri=str(entity.id), - name=str(name_value), - input_spec=input_spec, - output_spec=output_spec, - implementsMethod=self._to_list(implements_method_value), - hasParameter=self._to_list(has_parameter_value), - usesTool=self._to_list(uses_tool_value), - ) - ) - - return implementations - - @staticmethod - def add_method(method: MethodEntity): pass - - @staticmethod - def find_method(name: str) -> MethodEntity: pass - - @staticmethod - def add_tool(tool: ToolEntity): pass - - @staticmethod - def find_tool(name: str) -> ToolEntity: pass - - @staticmethod - def find_implementation(): pass - - @staticmethod - def add_implementation(implementation: ImplementationEntity): - SYS_KG.create_entity(id=new_id(),types=[config.ONTOLOGY_PREFIX+"Implementation"], properties={ - "name": implementation.name, - "usesTool": implementation.usesTool, - "implementsMethod": implementation.implementsMethod, - "interface": implementation.interface, - - }) - - @staticmethod - def find_implementation(name: str) -> KGEntity: - return SYS_KG.read_entity(id=config.PIPEKG_PREFIX+name, types=[config.ONTOLOGY_PREFIX+"Implementation"])[0] - - ### Data Layer Entities ### - def add_data_artifact(): pass - def add_data_artifact_type(): pass - def add_data_artifact_spec(): pass - def find_data_artifact(): pass - def find_data_artifact_type(): pass - def find_data_artifact_spec(): pass - - ### Pipeline Layer Entities ### - - @staticmethod - def add_pipeline(pipeline: PipelineEntity): - SYS_KG.create_entity(id=new_id(),types=["Pipeline"], properties={ - "tasks": pipeline.tasks, - "input": pipeline.input, - "output": pipeline.output, - }) - - def find_pipeline(): pass - def add_pipeline_step(): pass - def find_pipeline_step(): pass - def add_pipeline_definition(): pass - def find_pipeline_definition(): pass - - ### Evaluation Layer Entities ### - - @staticmethod - def add_metric(metric: MetricEntity): - SYS_KG.create_entity(id=config.PIPEKG_PREFIX+encode_string(metric.name),types=[config.ONTOLOGY_PREFIX+"Metric"], properties={ - config.ONTOLOGY_PREFIX+"name": metric.name, - config.ONTOLOGY_PREFIX+"description": metric.description, - config.ONTOLOGY_PREFIX+"type": metric.type, - # "input": metric.input, - # "output": metric.output, - }) - - @staticmethod - def find_metric(metric_name: str) -> MetricEntity: - pass - - ### Run Layer Entities ### - # def add_task_run(): pass - # def find_task_run(): pass - # def add_pipeline_run(): pass - # def find_pipeline_run(): pass - # def add_metric_run(): pass - # def find_metric_run(): pass - - @staticmethod - def add_task_run(task_run: TaskRunEntity): - # SYS_KG.create_entity(id=new_id(),types=[config.ONTOLOGY_PREFIX+"TaskReport"], properties={ - # "task": task_run.task_name, - # "input": [data.path for data in task_run.inputs], - # "output": [data.path for data in task_run.outputs], - # "status": task_run.status, - # "duration": task_run.duration, - # "error": task_run.error, - # }) - pass - - @staticmethod - def add_pipeline_run(pipeline_run: PipelineRunEntity): - pipeline_run_entity = SYS_KG.create_entity(id=new_id(),types=[config.ONTOLOGY_PREFIX+"PipelineRun"], properties={ - "name": pipeline_run.name, - "status": pipeline_run.status, - "started_at": pipeline_run.started_at, - "ended_at": pipeline_run.ended_at - }) - for idx, task_run in enumerate(pipeline_run.hasTaskRun): - task_run_entity = SYS_KG.create_entity(id=new_id(),types=[config.ONTOLOGY_PREFIX+"TaskRun"], properties={ - "number": idx, - "name": task_run.name, - "status": task_run.status, - "started_at": task_run.started_at, - "ended_at": task_run.ended_at, - }) - SYS_KG.create_relation(type="executesTask", source=task_run_entity.id, target=task_run.executesTask) - SYS_KG.create_relation(type="usesImplementation", source=task_run_entity.id, target=task_run.usesImplementation) - SYS_KG.create_relation(type=config.ONTOLOGY_PREFIX+"hasTaskRun", source=pipeline_run_entity.id, target=task_run_entity.id) - - # return pipeline_run_entity - - # @staticmethod - # def add_pipeline_result(pipeline_result: PipelineResult): - # SYS_KG.create_entity(id=new_id(),types=["PipelineResult"], properties={ - # "task_results": pipeline_result.task_results, - # "eval_results": pipeline_result.eval_results, - # "input": pipeline_result.input, - # "output": pipeline_result.output, - # }) - - @staticmethod - def add_metric_run(metric_run: MetricRunEntity): - metric_run_entity = SYS_KG.create_entity(id=new_id(),types=[config.ONTOLOGY_PREFIX+"MetricRun"], properties={ - config.ONTOLOGY_PREFIX+"status": metric_run.status, - config.ONTOLOGY_PREFIX+"started_at": metric_run.started_at, - config.ONTOLOGY_PREFIX+"ended_at": metric_run.ended_at, - config.ONTOLOGY_PREFIX+"value": metric_run.value, - config.ONTOLOGY_PREFIX+"details": metric_run.details, - config.ONTOLOGY_PREFIX+"input": metric_run.input[0].uri, - }) - SYS_KG.create_relation(type=config.ONTOLOGY_PREFIX+"computedMetric", source=metric_run_entity.id, target=metric_run.computedMetric) - - ### Parameter Layer Entities ### - # def add_parameter(): pass - # def find_parameter(): pass - # def add_parameter_binding(): pass - # def find_parameter_binding(): pass - - ### Utility Functions ### - - @staticmethod - def sparql_construct(query: str): - backend : RDFSparqlBackend = SYS_KG.backend - result = backend.query_sparql(query) - return result - - @staticmethod - def _prop_value(properties: List[KGProperty], *keys: str) -> Any: - """Find a property value by exact key or key suffix.""" - for prop in properties: - if prop.key in keys: - return prop.value - for prop in properties: - for key in keys: - if prop.key.endswith(key): - return prop.value - return None - - @staticmethod - def _to_list(value: Any) -> List[str]: - """Normalize KG property values to list[str].""" - if value is None: - return [] - if isinstance(value, list): - return [str(v) for v in value] - if isinstance(value, tuple): - return [str(v) for v in value] - if isinstance(value, str): - text = value.strip() - if not text: - return [] - # Stored literals may contain Python-list string repr. - if text.startswith("[") and text.endswith("]"): - try: - parsed = ast.literal_eval(text) - except (ValueError, SyntaxError): - return [text] - if isinstance(parsed, list): - return [str(v) for v in parsed] - return [text] - return [str(value)] - -# def Track(_cls=None, *, with_timestamp: bool = False): -# """ -# Use as: -# @Track -# @Track(with_timestamp=True) -# """ -# def decorator(cls): -# class Tracked(cls): # subclass the original class -# def __init__(self, *args: Any, **kwargs: Any): -# super().__init__(*args, **kwargs) - -# inst_id = f"{cls.__name__}:{uuid4().hex[:8]}" -# setattr(self, "_kg_id", inst_id) - -# if isinstance(self, BaseModel): -# props = self.model_dump() -# else: -# props = {k: v for k, v in vars(self).items() if not k.startswith("_")} - -# if with_timestamp: -# props["timestamp"] = datetime.now(timezone.utc).isoformat() - -# SYS_KG.create_entity([cls.__name__], id=inst_id, props=props) - -# Tracked.__name__ = cls.__name__ # optional cosmetics -# Tracked.__qualname__ = cls.__qualname__ -# Tracked.__doc__ = cls.__doc__ -# return Tracked - -# return decorator if _cls is None else decorator(_cls) - -# def kg_function(fn): -# @functools.wraps(fn) -# def wrapper(*args, **kwargs): -# result = fn(*args, **kwargs) -# call_id = f"{fn.__name__}:{uuid4().hex[:8]}" -# SYS_KG.create_entity( -# ["FunctionCall"], -# id=call_id, -# props={ -# "name": fn.__name__, -# # Be careful serializing args/kwargs; this is a toy example: -# "args": repr(args), -# "kwargs": repr(kwargs), -# }, -# ) -# return result -# return wrapper From 86a0e8182dc2d9fca55533d4d0365330ecc58704 Mon Sep 17 00:00:00 2001 From: Marvin Date: Tue, 31 Mar 2026 22:06:08 +0200 Subject: [PATCH 35/96] feat(eval): init eval api changes --- src/kgpipe/cli/list.py | 20 +- src/kgpipe/common/model/kg.py | 13 +- src/kgpipe/common/model/task.py | 28 ++- .../aspects/func/integration_eval.py | 30 ++- src/kgpipe/evaluation/aspects/reference.py | 8 + src/kgpipe/evaluation/aspects/statistical.py | 6 + src/kgpipe/evaluation/base.py | 21 +- src/kgpipe/test/common/test_graph.py | 41 ++++ src/kgpipe/test/common/test_model.py | 93 ++++++--- src/kgpipe/test/common/test_runtime_to_kg.py | 83 ++++++++ src/kgpipe/test/common/test_systemgraph.py | 189 ++++++++++++------ .../test/common/test_task_category_catalog.py | 33 +++ src/kgpipe/test/common/test_task_model.py | 136 +++++++++++++ 13 files changed, 575 insertions(+), 126 deletions(-) create mode 100644 src/kgpipe/test/common/test_graph.py create mode 100644 src/kgpipe/test/common/test_runtime_to_kg.py create mode 100644 src/kgpipe/test/common/test_task_category_catalog.py create mode 100644 src/kgpipe/test/common/test_task_model.py diff --git a/src/kgpipe/cli/list.py b/src/kgpipe/cli/list.py index df0ad90..daa3a6f 100644 --- a/src/kgpipe/cli/list.py +++ b/src/kgpipe/cli/list.py @@ -45,14 +45,18 @@ def show_registered_tasks(format: str = "table") -> None: tasks = get_registered_tasks() for task in tasks: - table.add_row( - task.name, - ", ".join(getattr(task, 'category', [])), - getattr(task, 'description', 'N/A'), - str(getattr(task, 'input_spec', 'N/A')), - str(getattr(task, 'output_spec', 'N/A')), - "/".join(function_location(task.function).split(".")[:-1]) - ) + try: + table.add_row( + task.name, + ", ".join(getattr(task, 'category', [])), + getattr(task, 'description', 'N/A'), + str(getattr(task, 'input_spec', 'N/A')), + str(getattr(task, 'output_spec', 'N/A')), + "/".join(function_location(task.function).split(".")[:-1]) + ) + except Exception as e: + print(f"Error adding task {task.name}: {e}") + continue if format == "table": console.print(table) diff --git a/src/kgpipe/common/model/kg.py b/src/kgpipe/common/model/kg.py index 9e08c58..42bdc18 100644 --- a/src/kgpipe/common/model/kg.py +++ b/src/kgpipe/common/model/kg.py @@ -68,12 +68,13 @@ def __str__(self) -> str: # TODO wip class for central KgPipe KG entity +@dataclass class KgKg: """Represents a KG for the KgPipe framework.""" - # data: List[KgData] + graph_data: KgData + ontology_data: KgData # provenance: str - - @staticmethod - def load_from_plan(plan: KgPipePlan) -> KG: - pass - pass \ No newline at end of file + # @staticmethod + # def load_from_plan(plan: KgPipePlan) -> KG: + # pass + # pass \ No newline at end of file diff --git a/src/kgpipe/common/model/task.py b/src/kgpipe/common/model/task.py index e901aa9..6f45559 100644 --- a/src/kgpipe/common/model/task.py +++ b/src/kgpipe/common/model/task.py @@ -4,7 +4,7 @@ # import field from dataclasses import dataclass, field from .data import Data, Format, DataFormat -from pydantic import BaseModel +from pydantic import BaseModel, Field, ConfigDict, model_validator import time import shutil from uuid import uuid4 @@ -26,7 +26,12 @@ class KgTaskReport(BaseModel): """Report of a task execution.""" - task: "KgTask" + model_config = ConfigDict(arbitrary_types_allowed=True) + + # Backwards-compatible identifier for persisted reports (`exec-report.json`). + # Historically we stored only the task name; newer runtime code may also attach the `KgTask`. + task_name: str + task: Optional["KgTask"] = Field(default=None, exclude=True) inputs: List[Data] outputs: List[Data] start_ts: float @@ -35,6 +40,24 @@ class KgTaskReport(BaseModel): error: Optional[str] = None config_profile: Optional[ConfigurationProfile] = None + @model_validator(mode="before") + @classmethod + def _coerce_task_fields(cls, data): + """ + Accept both legacy reports (with `task_name`) and new runtime reports (with `task`). + """ + if not isinstance(data, dict): + return data + + # If we have a task object but no explicit task_name, derive it. + if "task_name" not in data and "task" in data and data["task"] is not None: + task_obj = data["task"] + name = getattr(task_obj, "name", None) + if name is not None: + data["task_name"] = name + + return data + KgTaskRun = KgTaskReport class TaskStatus(Enum): @@ -243,6 +266,7 @@ def _build_report( ) -> KgTaskReport: return KgTaskReport( task=self, + task_name=self.name, inputs=inputs, outputs=outputs, start_ts=start_ts, diff --git a/src/kgpipe/evaluation/aspects/func/integration_eval.py b/src/kgpipe/evaluation/aspects/func/integration_eval.py index 72b6288..0e5392b 100644 --- a/src/kgpipe/evaluation/aspects/func/integration_eval.py +++ b/src/kgpipe/evaluation/aspects/func/integration_eval.py @@ -3,11 +3,11 @@ from pathlib import Path import pandas as pd from rdflib import RDFS, URIRef, Graph, RDF -from dataclasses import dataclass +from dataclasses import dataclass, field from kgpipe.util.embeddings.st_emb import get_model import numpy as np -from kgpipe.datasets.multipart_multisource import read_entities_csv - +from kgpipe.datasets.multipart_multisource import read_entities_csv, EntitiesRow +from typing import Any # model # entity dict @@ -41,6 +41,7 @@ class BinaryClassificationResult: fp: int tn: int fn: int + details: dict[str, Any] = field(default_factory=dict) def accuracy(self) -> float: return (self.tp + self.tn) / (self.tp + self.tn + self.fp + self.fn) @@ -63,6 +64,7 @@ def __dict__(self): "fp": self.fp, "tn": self.tn, "fn": self.fn, + "details": self.details, "accuracy": self.accuracy(), "precision": self.precision(), "recall": self.recall(), @@ -102,7 +104,7 @@ def load_entity_dict_from_csv(path: Path, delimiter: str = ",") -> dict: return entity_dict -def load_entity_dict(path: Path) -> dict: +def load_entity_dict(path: Path) -> dict[str, EntitiesRow]: """ """ if path.name.endswith(".json"): @@ -244,8 +246,9 @@ def evaluate_source_typed_entity_coverage(kg: KG, entity_dict_path: Path) -> Ent """ checks expected & integrated source typed entity overlap using label embeddings """ - model = get_model() - entity_dict = load_entity_dict(entity_dict_path) + model = get_model() # TODO this is not used here... + # TODO we need to substract the seed from the found entities... + entity_dict: dict[str, EntitiesRow] = load_entity_dict(entity_dict_path) expected_entity_label_type_pairs = [] @@ -270,15 +273,22 @@ def evaluate_source_typed_entity_coverage(kg: KG, entity_dict_path: Path) -> Ent found_eltp = set(found_entity_label_type_pairs) expected_eltp = set(expected_entity_label_type_pairs) - tp_set = found_eltp & expected_eltp - fp_set = found_eltp - expected_eltp - fn_set = expected_eltp - found_eltp + tp_set = found_eltp & expected_eltp # correct entity type pair + fp_set = found_eltp - expected_eltp # wrong entity type pair + fn_set = expected_eltp - found_eltp # missing entity type pair return BinaryClassificationResult( tp=len(tp_set), fp=len(fp_set), fn=len(fn_set), - tn=0 + tn=0, + details={ + "found_entity_label_type_pairs": found_entity_label_type_pairs, + "expected_entity_label_type_pairs": expected_entity_label_type_pairs, + "tp_set": len(tp_set), + "fp_set": len(fp_set), + "fn_set": len(fn_set) + } ) def evaluate_reference_triple_alignment(kg: KG, reference_kg: KG) -> TripleAlignmentResult: diff --git a/src/kgpipe/evaluation/aspects/reference.py b/src/kgpipe/evaluation/aspects/reference.py index b27a934..4b474b9 100644 --- a/src/kgpipe/evaluation/aspects/reference.py +++ b/src/kgpipe/evaluation/aspects/reference.py @@ -422,8 +422,14 @@ def compute(self, kg: KG, config: ReferenceConfig, **kwargs) -> MetricResult: result = evaluate_source_typed_entity_coverage(kg, verified_source_entities_path) + # log details to file + with open("source_typed_entity_coverage_details.json", "w") as f: + json.dump(result.__dict__(), f) + return MetricResult( name=self.name, + kg=kg, + metric=self, value=result.f1_score(), normalized_score=result.f1_score(), details=result.__dict__(), @@ -714,6 +720,8 @@ def evaluate(self, kg: KG, config: Optional[ReferenceConfig] = None, metrics: Op print(traceback.format_exc()) error_result = MetricResult( name=metric.name, + kg=kg, + metric=metric, value=0.0, normalized_score=0.0, details={"error": str(e)}, diff --git a/src/kgpipe/evaluation/aspects/statistical.py b/src/kgpipe/evaluation/aspects/statistical.py index 1e5aac6..c1bd9a0 100644 --- a/src/kgpipe/evaluation/aspects/statistical.py +++ b/src/kgpipe/evaluation/aspects/statistical.py @@ -71,6 +71,9 @@ def compute(self, kg: KG, config: StatisticalConfig, **kwargs) -> MetricResult: except Exception as e: print("this exception is raised") return MetricResult( + metric=self, + started_at=time.time(), + kg=kg, name=self.name, value=0.0, normalized_score=0.0, @@ -446,7 +449,10 @@ def evaluate(self, kg: KG, metrics: Optional[List[str]] = None, config: Optional except Exception as e: # Create error result error_result = MetricResult( + metric=metric, + kg=kg, name=metric.name, + started_at=time.time(), value=0.0, normalized_score=0.0, details={"error": str(e)}, diff --git a/src/kgpipe/evaluation/base.py b/src/kgpipe/evaluation/base.py index e5c1675..5a91d0b 100644 --- a/src/kgpipe/evaluation/base.py +++ b/src/kgpipe/evaluation/base.py @@ -9,7 +9,7 @@ from enum import Enum from typing import Any, Dict, List, Optional # from kgpipe.common.systemgraph import kg_class -from kgpipe.common.systemgraph import PipeKG +from kgpipe.common.graph.systemgraph import PipeKG import time import json import functools @@ -18,10 +18,11 @@ from pydantic import BaseModel from kgpipe.common.models import KG -from kgpipe.common.definitions import MetricEntity, MetricRunEntity, MetricEntityId, DataHandle +from kgpipe.common.graph.definitions import MetricRunEntity, MetricEntityId from kgpipe.common.config import config from pathlib import Path from kgpipe.common.util import encode_string + class EvaluationAspect(Enum): """The three main aspects of KG evaluation.""" STATISTICAL = "statistical" @@ -76,19 +77,19 @@ def __str__(self) -> str: # @Track(with_timestamp=True) # @kg_class(type="MetricResult", description="Result of computing a single metric.") -class MetricResult(BaseModel): +@dataclass +class MetricResult: """Result of computing a single metric.""" name: str + metric: "Metric" value: float normalized_score: float # 0.0-1.0 range - details: Dict[str, Any] aspect: EvaluationAspect + kg: KG + started_at: float = field(default_factory=time.time) + ended_at: float = field(default_factory=time.time) + details: Dict[str, Any] = field(default_factory=dict) duration: float = 0.0 - input: str = "" # TODO - - def __post_init__(self): - if not 0.0 <= self.normalized_score <= 1.0: - raise ValueError("Normalized score must be between 0.0 and 1.0") class MetricConfig(BaseModel): name: str @@ -117,7 +118,7 @@ def save_metric_run(metric: MetricResult): started_at=time.time(), ended_at=time.time(), computedMetric=MetricEntityId(config.PIPEKG_PREFIX+encode_string(metric.name)), - input=[DataHandle(uri=metric.input, type="any/text")], + input=[], # [Data(uri=metric.kg.path, type="any/text")], value=metric.value, details=json.dumps(metric.details, default=str) ) diff --git a/src/kgpipe/test/common/test_graph.py b/src/kgpipe/test/common/test_graph.py new file mode 100644 index 0000000..e989caf --- /dev/null +++ b/src/kgpipe/test/common/test_graph.py @@ -0,0 +1,41 @@ +from uuid import uuid4 + +from kgpipe.common.graph.definitions import ( + DataTypeEntity, + DataSpecEntity, + TaskEntity, + ImplementationEntity, +) +from kgpipe.common.graph.systemgraph import PipeKG + + +def _uid(prefix: str) -> str: + return f"{prefix}_{uuid4().hex[:8]}" + + +def test_add_implementation_and_find_implemenetation(): + task = TaskEntity(name=_uid("task"), description="test task") + task_id = PipeKG.add_task(task) + + data_type = DataTypeEntity(format="text/csv", data_schema=_uid("schema")) + data_type_id = PipeKG.add_data_type(data_type) + + in_spec_id = PipeKG.add_data_spec(DataSpecEntity(name=_uid("in_spec"), data_type=data_type_id)) + out_spec_id = PipeKG.add_data_spec(DataSpecEntity(name=_uid("out_spec"), data_type=data_type_id)) + + impl_name = _uid("impl") + impl = ImplementationEntity( + name=impl_name, + version="0.0.1", + input_spec=[in_spec_id], + output_spec=[out_spec_id], + realizesTask=[task_id], + usesTool=[], + ) + + PipeKG.add_implementation(impl) + found = PipeKG.find_implementation(impl_name) + + assert found is not None + assert found.name == impl_name + assert found.version == "0.0.1" diff --git a/src/kgpipe/test/common/test_model.py b/src/kgpipe/test/common/test_model.py index c7b73c0..d579f5b 100644 --- a/src/kgpipe/test/common/test_model.py +++ b/src/kgpipe/test/common/test_model.py @@ -1,29 +1,68 @@ -from kgpipe.common.models import KgPipePlan, KgPipePlanStep, Data, DataFormat -from pathlib import Path import json +from enum import Enum +from pathlib import Path + +import pytest + +from kgpipe.common.models import ( + BasicDataFormats, + CustomDataFormats, + Data, + DataFormat, + KgPipePlan, + KgPipePlanStep, +) + +class ProjectFormats(CustomDataFormats): + EMBEDDINGS_JSON = "embeddings.json" + + +class ForeignFormats(str, Enum): + MY_RAW = "my.raw" + + +def test_kg_pipe_plan_roundtrip(): + plan = KgPipePlan( + steps=[ + KgPipePlanStep( + task="paris_entity_matching", + input=[Data(path=Path("data.nt"), format=DataFormat.RDF_NTRIPLES)], + output=[Data(path=Path("data.paris_csv"), format=DataFormat.PARIS_CSV)], + ), + KgPipePlanStep( + task="paris_csv_to_matching_format", + input=[Data(path=Path("data.paris_csv"), format=DataFormat.PARIS_CSV)], + output=[Data(path=Path("data.em_json"), format=DataFormat.ER_JSON)], + ), + ], + seed=Data(path=Path("seed.nt"), format=DataFormat.RDF_NTRIPLES), + source=Data(path=Path("source.nt"), format=DataFormat.RDF_NTRIPLES), + result=Data(path=Path("result.nt"), format=DataFormat.RDF_NTRIPLES), + ) + + plan_json = plan.model_dump_json() + plan_back = KgPipePlan(**json.loads(plan_json)) + + assert plan == plan_back + + +def test_data_accepts_basic_data_formats(): + data = Data(path=Path("a.nt"), format=BasicDataFormats.RDF_NTRIPLES) + assert data.format == BasicDataFormats.RDF_NTRIPLES + assert data.to_dict()["format"] == "nt" + + +def test_data_accepts_custom_data_formats(): + data = Data(path=Path("embed.json"), format=ProjectFormats.EMBEDDINGS_JSON) + assert data.format == ProjectFormats.EMBEDDINGS_JSON + assert data.to_dict()["format"] == "embeddings.json" + + +def test_data_rejects_foreign_string_enum_not_based_on_custom_catalog(): + with pytest.raises(ValueError): + Data(path=Path("x.raw"), format=ForeignFormats.MY_RAW) + -def test_kg_pipe_plan(): - plan = KgPipePlan( - steps=[ - KgPipePlanStep( - task="paris_entity_matching", - input=[Data(path=Path("data.nt"), format=DataFormat.RDF_NTRIPLES)], - output=[Data(path=Path("data.paris_csv"), format=DataFormat.PARIS_CSV)] - ), - KgPipePlanStep( - task="paris_csv_to_matching_format", - input=[Data(path=Path("data.paris_csv"), format=DataFormat.PARIS_CSV)], - output=[Data(path=Path("data.em_json"), format=DataFormat.ER_JSON)] - ), - ], - seed=Data(path=Path("seed.nt"), format=DataFormat.RDF_NTRIPLES), - source=Data(path=Path("source.nt"), format=DataFormat.RDF_NTRIPLES), - result=Data(path=Path("result.nt"), format=DataFormat.RDF_NTRIPLES), - ) - - plan_json = plan.model_dump_json() - print(plan_json) - - plan_back = KgPipePlan(**json.loads(plan_json)) - - assert plan == plan_back \ No newline at end of file +def test_data_rejects_unknown_string_format(): + with pytest.raises(ValueError, match="Unknown format: does-not-exist"): + Data(path=Path("x.any"), format="does-not-exist") \ No newline at end of file diff --git a/src/kgpipe/test/common/test_runtime_to_kg.py b/src/kgpipe/test/common/test_runtime_to_kg.py new file mode 100644 index 0000000..a716642 --- /dev/null +++ b/src/kgpipe/test/common/test_runtime_to_kg.py @@ -0,0 +1,83 @@ +from pathlib import Path + +from kgpipe.common.config import config +from kgpipe.common.models import Data, DataFormat, KgTaskReport +from kgpipe.common.model.task import KgTask +from kgpipe.common.runtime_to_kg import ( + data_to_handle, + reports_to_pipeline_run_entity, + task_to_task_entity, + task_report_to_task_run_entity, +) + + +def _make_report(name: str, start_ts: float, duration: float, status: str = "success") -> KgTaskReport: + return KgTaskReport( + task_name=name, + inputs=[Data(path=Path(f"{name}.in.nt"), format=DataFormat.RDF_NTRIPLES)], + outputs=[Data(path=Path(f"{name}.out.nt"), format=DataFormat.RDF_NTRIPLES)], + start_ts=start_ts, + duration=duration, + status=status, + ) + + +def test_data_to_handle_maps_path_and_format(): + data = Data(path=Path("test.nt"), format=DataFormat.RDF_NTRIPLES) + handle = data_to_handle(data) + assert handle.uri == "test.nt" + assert handle.type == DataFormat.RDF_NTRIPLES + + +def test_task_to_task_entity_maps_name_and_defaults(): + task = KgTask( + name="normalize", + input_spec={"in": DataFormat.RDF_NTRIPLES}, + output_spec={"out": DataFormat.RDF_NTRIPLES}, + function=lambda _i, _o: None, + ) + entity = task_to_task_entity(task) + assert entity.name == "normalize" + assert entity.hasSubtask == [] + + +def test_task_report_to_task_run_entity_maps_core_fields(): + report = _make_report("normalize", start_ts=10.0, duration=2.5) + entity = task_report_to_task_run_entity(report, index=3) + + assert entity.number == 3 + assert entity.name == "normalize" + assert entity.status == "success" + assert entity.started_at == 10.0 + assert entity.ended_at == 12.5 + assert str(entity.executesTask) == f"{config.PIPEKG_PREFIX}normalize" + assert str(entity.usesImplementation) == f"{config.PIPEKG_PREFIX}normalizeImpl" + assert len(entity.input) == 1 + assert len(entity.output) == 1 + + +def test_reports_to_pipeline_run_entity_aggregates_times_and_runs(): + reports = [ + _make_report("step_a", start_ts=100.0, duration=10.0), + _make_report("step_b", start_ts=80.0, duration=5.0), + ] + + pipeline_entity = reports_to_pipeline_run_entity(reports, pipeline_name="demo_pipe") + + assert pipeline_entity.name == "demo_pipe" + assert pipeline_entity.status == "success" + assert pipeline_entity.started_at == 80.0 + assert pipeline_entity.ended_at == 110.0 + assert len(pipeline_entity.hasTaskRun) == 2 + assert pipeline_entity.hasTaskRun[0].number == 0 + assert pipeline_entity.hasTaskRun[1].number == 1 + + +def test_reports_to_pipeline_run_entity_handles_empty_reports(): + pipeline_entity = reports_to_pipeline_run_entity([], pipeline_name="empty_pipe") + + assert pipeline_entity.name == "empty_pipe" + assert pipeline_entity.status == "success" + assert pipeline_entity.started_at == 0.0 + assert pipeline_entity.ended_at == 0.0 + assert pipeline_entity.hasTaskRun == [] diff --git a/src/kgpipe/test/common/test_systemgraph.py b/src/kgpipe/test/common/test_systemgraph.py index 870bd75..2be56c7 100644 --- a/src/kgpipe/test/common/test_systemgraph.py +++ b/src/kgpipe/test/common/test_systemgraph.py @@ -1,72 +1,135 @@ -from kgpipe.common.systemgraph import kg_class, kg_function, SYS_KG, add_task, add_task_result, add_pipeline, add_pipeline_result -from kgpipe.common.definitions import Task, Eval, Pipeline, TaskResult, DataHandle, PipelineResult -import sys -from kgcore.backend.rdf.rdf_rdflib import RDFLibBackend - -task1 = Task( - name="test_task", - type="test_type", - description="test_description", - input=["test_input"], - output=["test_output"] -) -task2 = Task( - name="test_task2", - type="test_type2", - description="test_description2", - input=["test_input2"], - output=["test_output2"] -) -task_result1 = TaskResult( - task=task1, - config={"test_config": "test_config"}, - input=[DataHandle(uri="test_input", type="test_input_type")], - output=[DataHandle(uri="test_output", type="test_output_type")], - status="test_status", - duration=10.0 -) -task_result2 = TaskResult( - task=task2, - config={"test_config2": "test_config2"}, - input=[DataHandle(uri="test_input2", type="test_input_type2")], - output=[DataHandle(uri="test_output2", type="test_output_type2")], - status="test_status2", - duration=20.0 -) -pipeline = Pipeline( - tasks=[task1, task2], - input=["test_input"], - output=["test_output"] -) -pipeline_result = PipelineResult( - task_results=[task_result1, task_result2], - eval_results=[], - input=[DataHandle(uri="test_input", type="test_input_type")], - output=[DataHandle(uri="test_output", type="test_output_type")], - status="test_status", - duration=30.0 +from uuid import uuid4 + +from kgpipe.common.definitions import ( + DataHandle, + ImplementationEntity, + MethodEntity, + MetricEntity, + PipelineEntity, + TaskRunEntity, + ToolEntity, ) +from kgpipe.common.systemgraph import PipeKG + + +def _uid(prefix: str) -> str: + return f"{prefix}_{uuid4().hex[:8]}" + + +def test_core_layer_method_tool_and_implementation(): + method_name = _uid("method") + tool_name = _uid("tool") + impl_name = _uid("impl") + + method = MethodEntity(name=method_name, realizesTask=["task:a"]) + tool = ToolEntity(name=tool_name, providesMethods=["method:a"]) + implementation = ImplementationEntity( + name=impl_name, + input_spec=["text/csv"], + output_spec=["application/json"], + implementsMethod=["method:a"], + hasParameter=["param:a"], + usesTool=["tool:a"], + ) + + PipeKG.add_method(method) + PipeKG.add_tool(tool) + PipeKG.add_implementation(implementation) + + found_method = PipeKG.find_method(method_name) + found_tool = PipeKG.find_tool(tool_name) + found_implementation = PipeKG.find_implementation(impl_name) + + assert found_method is not None + assert found_method.name == method_name + assert "task:a" in found_method.realizesTask + + assert found_tool is not None + assert found_tool.name == tool_name + assert "method:a" in found_tool.providesMethods + + assert found_implementation is not None + assert found_implementation.name == impl_name + assert found_implementation.input_spec == ["text/csv"] + assert found_implementation.output_spec == ["application/json"] + + +def test_data_layer_artifact_type_and_spec(): + artifact_uri = f"file:///{_uid('artifact')}.csv" + artifact_type = _uid("artifact_type") + spec_name = _uid("spec") + specification = '{"type":"object","properties":{"name":{"type":"string"}}}' + data = DataHandle( + uri=artifact_uri, + type="text/csv", + version="1.0.0", + hash="abc123", + size=42, + ) + + PipeKG.add_data_artifact(data) + PipeKG.add_data_artifact_type(artifact_type) + PipeKG.add_data_artifact_spec(spec_name, specification) + + found_data = PipeKG.find_data_artifact(artifact_uri) + found_type = PipeKG.find_data_artifact_type(artifact_type) + found_spec = PipeKG.find_data_artifact_spec(spec_name) + + assert found_data is not None + assert found_data.uri == artifact_uri + assert found_data.type == "text/csv" + assert found_data.version == "1.0.0" + assert found_type == artifact_type + assert found_spec == specification + + +def test_pipeline_layer_pipeline_step_and_definition(): + pipeline_name = _uid("pipeline") + step_task = "task:clean" + definition_name = _uid("pipeline_def") + pipeline_id = f"pipeline:{pipeline_name}" + + pipeline = PipelineEntity(name=pipeline_name, tasks=[step_task], input=[], output=[]) + PipeKG.add_pipeline(pipeline) + PipeKG.add_pipeline_step(pipeline_name=pipeline_name, step_number=1, task_id=step_task) + PipeKG.add_pipeline_definition(name=definition_name, pipeline_id=pipeline_id) + + found_pipeline = PipeKG.find_pipeline(pipeline_name) + found_step = PipeKG.find_pipeline_step(pipeline_name, 1) + found_definition = PipeKG.find_pipeline_definition(definition_name) -model: RDFLibBackend = SYS_KG.backend + assert found_pipeline is not None + assert found_pipeline.name == pipeline_name + assert step_task in found_pipeline.tasks + assert found_step is not None + assert found_definition is not None -def test_task_entity(): - add_task(task1) - add_task(task2) - # print(model.get_rdflibgraph().serialize(format="turtle")) +def test_metrics_layer_add_and_find_metric(): + metric_name = _uid("metric") + metric = MetricEntity(name=metric_name, description="Accuracy metric", type="score") + PipeKG.add_metric(metric) -def test_task_result_entity(): - add_task_result(task_result1) - add_task_result(task_result2) + found_metric = PipeKG.find_metric(metric_name) - # print(model.get_rdflibgraph().serialize(format="turtle")) + assert found_metric is not None + assert found_metric.name == metric_name + assert found_metric.description == "Accuracy metric" + assert found_metric.type == "score" -def test_pipeline_entity(): - add_pipeline(pipeline) - # print(model.get_rdflibgraph().serialize(format="turtle")) +def test_run_layer_add_task_run(): + task_run = TaskRunEntity( + number=1, + name=_uid("task_run"), + status="success", + started_at=1.0, + ended_at=2.0, + input=[DataHandle(uri="file:///in.csv", type="text/csv")], + output=[DataHandle(uri="file:///out.csv", type="text/csv")], + executesTask="task:clean", + usesImplementation="impl:clean_v1", + hasParameterBinding=[], + ) -def test_pipeline_result_entity(): - add_pipeline_result(pipeline_result) - - print(model.get_rdflibgraph().serialize(format="turtle")) \ No newline at end of file + PipeKG.add_task_run(task_run) \ No newline at end of file diff --git a/src/kgpipe/test/common/test_task_category_catalog.py b/src/kgpipe/test/common/test_task_category_catalog.py new file mode 100644 index 0000000..605ac0f --- /dev/null +++ b/src/kgpipe/test/common/test_task_category_catalog.py @@ -0,0 +1,33 @@ +from kgpipe.common.models import TaskCategoryCatalog + + +def test_entity_resolution_children_include_expected_subtasks(): + children = TaskCategoryCatalog.get_children("EntityResolution") + assert "Blocking" in children + assert "Matching" in children + assert "EntityMatching" in children + assert "Clustering" in children + + +def test_subtask_relationships_for_entity_resolution(): + assert TaskCategoryCatalog.is_subtask_of("Blocking", "EntityResolution") + assert TaskCategoryCatalog.is_subtask_of("Matching", "EntityResolution") + assert TaskCategoryCatalog.is_subtask_of("Clustering", "EntityResolution") + assert not TaskCategoryCatalog.is_subtask_of("EntityResolution", "Blocking") + + +def test_ancestors_and_descendants_are_resolved(): + ancestors = TaskCategoryCatalog.get_ancestors("EntityMatching") + descendants = TaskCategoryCatalog.get_descendants("EntityResolution") + + assert ancestors[0] == "EntityResolution" + assert "TaskCategory" in ancestors + assert "Blocking" in descendants + assert "Clustering" in descendants + + +def test_register_custom_category_under_existing_parent(): + TaskCategoryCatalog.register("CandidateGeneration", parent="EntityResolution") + assert TaskCategoryCatalog.has("CandidateGeneration") + assert TaskCategoryCatalog.get_parent("CandidateGeneration") == "EntityResolution" + assert TaskCategoryCatalog.is_subtask_of("CandidateGeneration", "EntityResolution") diff --git a/src/kgpipe/test/common/test_task_model.py b/src/kgpipe/test/common/test_task_model.py new file mode 100644 index 0000000..cbe0e19 --- /dev/null +++ b/src/kgpipe/test/common/test_task_model.py @@ -0,0 +1,136 @@ +from pathlib import Path + +from kgpipe.common.models import Data, DataFormat, KgTask + + +def _write_output_task(inputs: dict[str, Data], outputs: dict[str, Data]) -> None: + _ = inputs["in"] + out_path = outputs["out"].path + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text("generated") + + +def test_kgtask_run_success(tmp_path: Path): + in_file = tmp_path / "input.nt" + out_file = tmp_path / "output.nt" + in_file.write_text("seed") + + task = KgTask( + name="copy_like", + input_spec={"in": DataFormat.RDF_NTRIPLES}, + output_spec={"out": DataFormat.RDF_NTRIPLES}, + function=_write_output_task, + ) + + report = task.run( + inputs=[Data(path=in_file, format=DataFormat.RDF_NTRIPLES)], + outputs=[Data(path=out_file, format=DataFormat.RDF_NTRIPLES)], + ) + + assert report.status == "success" + assert out_file.exists() + assert report.task_name == "copy_like" + assert len(report.inputs) == 1 + assert len(report.outputs) == 1 + + +def test_kgtask_run_failed_when_function_raises(tmp_path: Path): + def failing_task(_: dict[str, Data], __: dict[str, Data]) -> None: + raise RuntimeError("boom") + + in_file = tmp_path / "input.nt" + out_file = tmp_path / "output.nt" + in_file.write_text("seed") + + task = KgTask( + name="fails", + input_spec={"in": DataFormat.RDF_NTRIPLES}, + output_spec={"out": DataFormat.RDF_NTRIPLES}, + function=failing_task, + ) + + report = task.run( + inputs=[Data(path=in_file, format=DataFormat.RDF_NTRIPLES)], + outputs=[Data(path=out_file, format=DataFormat.RDF_NTRIPLES)], + ) + + assert report.status == "failed" + assert report.error is not None + assert "boom" in report.error + + +def test_kgtask_run_skips_when_outputs_exist(tmp_path: Path): + called = {"count": 0} + + def should_not_run(_: dict[str, Data], __: dict[str, Data]) -> None: + called["count"] += 1 + + in_file = tmp_path / "input.nt" + out_file = tmp_path / "output.nt" + in_file.write_text("seed") + out_file.write_text("already-here") + + task = KgTask( + name="skip_if_present", + input_spec={"in": DataFormat.RDF_NTRIPLES}, + output_spec={"out": DataFormat.RDF_NTRIPLES}, + function=should_not_run, + ) + + report = task.run( + inputs=[Data(path=in_file, format=DataFormat.RDF_NTRIPLES)], + outputs=[Data(path=out_file, format=DataFormat.RDF_NTRIPLES)], + ) + + assert report.status == "skipped" + assert called["count"] == 0 + + +def test_kgtask_stable_files_override_forces_run(tmp_path: Path): + called = {"count": 0} + out_file = tmp_path / "output.nt" + + def rewrite_output(_: dict[str, Data], outputs: dict[str, Data]) -> None: + called["count"] += 1 + outputs["out"].path.write_text("fresh") + + in_file = tmp_path / "input.nt" + in_file.write_text("seed") + out_file.write_text("stale") + + task = KgTask( + name="override_output", + input_spec={"in": DataFormat.RDF_NTRIPLES}, + output_spec={"out": DataFormat.RDF_NTRIPLES}, + function=rewrite_output, + ) + + report = task.run( + inputs=[Data(path=in_file, format=DataFormat.RDF_NTRIPLES)], + outputs=[Data(path=out_file, format=DataFormat.RDF_NTRIPLES)], + stable_files_override=True, + ) + + assert report.status == "success" + assert called["count"] == 1 + assert out_file.read_text() == "fresh" + + +def test_kgtask_run_fails_for_missing_required_input(tmp_path: Path): + out_file = tmp_path / "output.nt" + + task = KgTask( + name="needs_input", + input_spec={"in": DataFormat.RDF_NTRIPLES}, + output_spec={"out": DataFormat.RDF_NTRIPLES}, + function=_write_output_task, + ) + + report = task.run( + inputs=[], + outputs=[Data(path=out_file, format=DataFormat.RDF_NTRIPLES)], + ) + + assert report.status == "failed" + assert report.error is not None + assert "Missing required inputs" in report.error From 37f67e49d7979d933a77c47c41a527608b7c1629 Mon Sep 17 00:00:00 2001 From: Marvin Date: Thu, 2 Apr 2026 19:03:55 +0200 Subject: [PATCH 36/96] exp(examples): keep up examples with new api --- .../examples/src/kgpipe_examples/config.py | 23 +- .../src/kgpipe_examples/eval_examples.py | 88 ++++++++ .../src/kgpipe_examples/pipe_examples.py | 1 + .../src/kgpipe_examples/task_examples.py | 25 ++- .../src/kgpipe_examples/test_examples.py | 208 +++++++++++++++++- 5 files changed, 319 insertions(+), 26 deletions(-) create mode 100644 experiments/examples/src/kgpipe_examples/eval_examples.py diff --git a/experiments/examples/src/kgpipe_examples/config.py b/experiments/examples/src/kgpipe_examples/config.py index c9cc32e..77768ae 100644 --- a/experiments/examples/src/kgpipe_examples/config.py +++ b/experiments/examples/src/kgpipe_examples/config.py @@ -1,19 +1,8 @@ -from enum import Enum -from kgpipe.common.model.data import DynamicFormat, FormatRegistry +from kgpipe.common.model.default_catalog import CustomDataFormats -class ExtendedFormats(Enum): - SPECIAL_IN = DynamicFormat(name="special_in", extension=".special_in", description="Special input format") - SPECIAL1 = DynamicFormat(name="special1", extension=".special1", description="Special format 1") - SPECIAL2 = DynamicFormat(name="special2", extension=".special2", description="Special format 2") - SPECIAL_KG = DynamicFormat(name="special_kg", extension=".special_kg", description="Special output format for knowledge graph") -FORMAT_REGISTRY = FormatRegistry() - -FORMAT_REGISTRY.register_format( - ExtendedFormats.SPECIAL_IN.value.name, ExtendedFormats.SPECIAL_IN.value.extension, ExtendedFormats.SPECIAL_IN.value.description) -FORMAT_REGISTRY.register_format( - ExtendedFormats.SPECIAL1.value.name, ExtendedFormats.SPECIAL1.value.extension, ExtendedFormats.SPECIAL1.value.description) -FORMAT_REGISTRY.register_format( - ExtendedFormats.SPECIAL2.value.name, ExtendedFormats.SPECIAL2.value.extension, ExtendedFormats.SPECIAL2.value.description) -FORMAT_REGISTRY.register_format( - ExtendedFormats.SPECIAL_KG.value.name, ExtendedFormats.SPECIAL_KG.value.extension, ExtendedFormats.SPECIAL_KG.value.description) \ No newline at end of file +class ExtendedFormats(CustomDataFormats): + SPECIAL_IN = "special_in" + SPECIAL1 = "special1" + SPECIAL2 = "special2" + SPECIAL_KG = "special_kg" \ No newline at end of file diff --git a/experiments/examples/src/kgpipe_examples/eval_examples.py b/experiments/examples/src/kgpipe_examples/eval_examples.py new file mode 100644 index 0000000..ae5434f --- /dev/null +++ b/experiments/examples/src/kgpipe_examples/eval_examples.py @@ -0,0 +1,88 @@ +from kgpipe.evaluation.aspects.statistical import ( + StatisticalEvaluator, + StatisticalConfig, + EntityCountMetric +) +from kgpipe.evaluation.aspects.semantic import ( + SemanticEvaluator, + SemanticConfig, + DisjointDomainMetric, + IncorrectRelationDirectionMetric, + IncorrectRelationRangeMetric, + IncorrectRelationDomainMetric, + IncorrectDatatypeMetric, + IncorrectDatatypeFormatMetric, +) +from kgpipe.evaluation.aspects.reference import ( + ReferenceEvaluator, + ReferenceConfig, + SourceTypedEntityCoverageMetric, + ReferenceTripleAlignmentMetric, + ReferenceTripleAlignmentMetricSoftE, + ReferenceTripleAlignmentMetricSoftEV, +) +from kgpipe.common.model.kg import KG +from kgpipe.common.model.default_catalog import BasicDataFormats +from typing import List +from pathlib import Path + +from kgpipe.common.graph import mapper + +TEST_NTRIPLES = """ + . + "itemA" . + "The Hobbit, or There and Back Again" . + . + "9780261102217" . + + . + "itemB" . + "Pride & Prejudice" . + . + "9780199535569" . + + . + "itemC" . + "1984" . + . + "9780452284234" . +""" + +def eval_example(tmp_path: Path): + """Example: Evaluate a KG against a ground truth.""" + + tmp_path = tmp_path / "my_kg.nt" + tmp_path.write_text(TEST_NTRIPLES) + + kg = KG( + id="my_kg", + name="My Knowledge Graph", + path=tmp_path, + format=BasicDataFormats.RDF_NTRIPLES + ) + + statistical_config = StatisticalConfig(name="default") + # semantic_config = SemanticConfig(name="default") + # reference_config = ReferenceConfig( + # name="default" + # REFERENCE_KG_PATH=...) + + statistical_evaluator = StatisticalEvaluator() + # semantic_evaluator = SemanticEvaluator() + # reference_evaluator = ReferenceEvaluator() + + statistical_metrics: List[str] = [EntityCountMetric().name] + # semantic_metrics: List[str] = [DisjointDomainMetric().name, IncorrectRelationDirectionMetric().name, IncorrectRelationRangeMetric().name, IncorrectRelationDomainMetric().name, IncorrectDatatypeMetric().name, IncorrectDatatypeFormatMetric().name] + # reference_metrics: List[str] = [SourceTypedEntityCoverageMetric().name, ReferenceTripleAlignmentMetric().name, ReferenceTripleAlignmentMetricSoftE().name, ReferenceTripleAlignmentMetricSoftEV().name] + + statistical_results = statistical_evaluator.evaluate( + kg, metrics=statistical_metrics, config=statistical_config) + # semantic_results = semantic_evaluator.evaluate( + # kg, metrics=semantic_metrics, config=semantic_config) + # reference_results = reference_evaluator.evaluate( + # kg, metrics=reference_metrics, config=reference_config) + + for metric in statistical_results.metrics: + mapper.metric_run_to_entity(metric) + + return statistical_results #, semantic_results, reference_results \ No newline at end of file diff --git a/experiments/examples/src/kgpipe_examples/pipe_examples.py b/experiments/examples/src/kgpipe_examples/pipe_examples.py index 0a44e3d..79289ea 100644 --- a/experiments/examples/src/kgpipe_examples/pipe_examples.py +++ b/experiments/examples/src/kgpipe_examples/pipe_examples.py @@ -19,6 +19,7 @@ def pipe_example(): tmp_data_dir = tempfile.mkdtemp() input_data = Data(path=os.path.join(tmp_data_dir, "input.special_in"), format=ExtendedFormats.SPECIAL_IN) output_data = Data(path=os.path.join(tmp_data_dir, "output.special_kg"), format=ExtendedFormats.SPECIAL_KG) + input_data.path.touch() tasks = [pipe_task_python, pipe_task_docker, pipe_task_remote] diff --git a/experiments/examples/src/kgpipe_examples/task_examples.py b/experiments/examples/src/kgpipe_examples/task_examples.py index 9a4fa7f..89345d0 100644 --- a/experiments/examples/src/kgpipe_examples/task_examples.py +++ b/experiments/examples/src/kgpipe_examples/task_examples.py @@ -1,17 +1,26 @@ from kgpipe.common import TaskInput, TaskOutput +from kgpipe.common import trace_task_run from kgpipe.common.model.configuration import ConfigurationProfile, ConfigurationDefinition, Parameter, ParameterType from kgpipe_examples.config import ExtendedFormats +from kgpipe.common.model.default_catalog import BasicTaskCategoryCatalog from kgpipe.common.registry import Registry +@trace_task_run @Registry.task( input_spec={"input": ExtendedFormats.SPECIAL_IN}, - output_spec={"output": ExtendedFormats.SPECIAL1} + output_spec={"output": ExtendedFormats.SPECIAL1}, + category=[BasicTaskCategoryCatalog.entity_resolution], + description="A task that processes a special input and produces a special output" ) def pipe_task_python(inputs: TaskInput, outputs: TaskOutput): # touch output file outputs["output"].path.touch() +# def converts_pdfs: pass +# def extracts_text + +@trace_task_run @Registry.task( input_spec={"input": ExtendedFormats.SPECIAL1}, output_spec={"output": ExtendedFormats.SPECIAL2} @@ -20,6 +29,7 @@ def pipe_task_docker(inputs: TaskInput, outputs: TaskOutput): # touch output file outputs["output"].path.touch() +@trace_task_run @Registry.task( input_spec={"input": ExtendedFormats.SPECIAL2}, output_spec={"output": ExtendedFormats.SPECIAL_KG} @@ -29,14 +39,23 @@ def pipe_task_remote(inputs: TaskInput, outputs: TaskOutput): outputs["output"].path.touch() +@trace_task_run @Registry.task( - input_spec={"input": ExtendedFormats.SPECIAL2}, + input_spec={"input": ExtendedFormats.SPECIAL1}, output_spec={"output": ExtendedFormats.SPECIAL_KG}, + category=[BasicTaskCategoryCatalog.entity_resolution], config_spec=ConfigurationDefinition( name="pipe_task_with_config_spec", description="Configuration specification for the pipe_task_with_config task", parameters=[ - Parameter(name="some_parameter", datatype=ParameterType.string, default_value="default", required=False) + Parameter( + name="some_parameter", + native_keys=["some_parameter"], + datatype=ParameterType.string, + default_value="default", + required=False, + allowed_values=[] + ) ] ) ) diff --git a/experiments/examples/src/kgpipe_examples/test_examples.py b/experiments/examples/src/kgpipe_examples/test_examples.py index 08d63f7..c937774 100644 --- a/experiments/examples/src/kgpipe_examples/test_examples.py +++ b/experiments/examples/src/kgpipe_examples/test_examples.py @@ -1,17 +1,213 @@ +from pathlib import Path +from kgpipe.common import Data -def test_python_task_defintion(): + +def test_python_task_execution(tmp_path: Path): + from kgpipe_examples.config import ExtendedFormats from kgpipe_examples.task_examples import pipe_task_python - assert pipe_task_python.name == "pipe_task_python" -def test_docker_task_defintion(): + in_file = tmp_path / "input.special_in" + out_file = tmp_path / "output.special1" + in_file.touch() + + report = pipe_task_python.run( + inputs=[Data(path=in_file, format=ExtendedFormats.SPECIAL_IN)], + outputs=[Data(path=out_file, format=ExtendedFormats.SPECIAL1)], + ) + + assert pipe_task_python.name == "pipe_task_python" + assert report.status == "success" + assert out_file.exists() + + +def test_docker_task_execution(tmp_path: Path): + from kgpipe_examples.config import ExtendedFormats from kgpipe_examples.task_examples import pipe_task_docker + + in_file = tmp_path / "input.special1" + out_file = tmp_path / "output.special2" + in_file.touch() + + report = pipe_task_docker.run( + inputs=[Data(path=in_file, format=ExtendedFormats.SPECIAL1)], + outputs=[Data(path=out_file, format=ExtendedFormats.SPECIAL2)], + ) + assert pipe_task_docker.name == "pipe_task_docker" + assert report.status == "success" + assert out_file.exists() -def test_remote_task_defintion(): + +def test_remote_task_execution(tmp_path: Path): + from kgpipe_examples.config import ExtendedFormats from kgpipe_examples.task_examples import pipe_task_remote + + in_file = tmp_path / "input.special2" + out_file = tmp_path / "output.special_kg" + in_file.touch() + + report = pipe_task_remote.run( + inputs=[Data(path=in_file, format=ExtendedFormats.SPECIAL2)], + outputs=[Data(path=out_file, format=ExtendedFormats.SPECIAL_KG)], + ) + assert pipe_task_remote.name == "pipe_task_remote" + assert report.status == "success" + assert out_file.exists() + + +def test_config_spec_execution(tmp_path: Path): + from kgpipe_examples.config import ExtendedFormats + from kgpipe_examples.task_examples import pipe_task_with_config + from kgpipe.common.model.configuration import ( + ConfigurationProfile, + ParameterBinding, + Parameter, + ParameterType, + ) + + in_file = tmp_path / "input.special1" + out_file = tmp_path / "output.special_kg" + in_file.touch() + + report = pipe_task_with_config.run( + inputs=[Data(path=in_file, format=ExtendedFormats.SPECIAL1)], + outputs=[Data(path=out_file, format=ExtendedFormats.SPECIAL_KG)], + configProfile=ConfigurationProfile( + name="pipe_task_with_config_profile", + definition=pipe_task_with_config.config_spec, + bindings=[ + ParameterBinding( + parameter=Parameter( + name="some_parameter", + native_keys=["some_parameter"], + datatype=ParameterType.string, + default_value="default", + required=False, + allowed_values=[], + ), + value="some", + ) + ], + ), + ) + + assert pipe_task_with_config.name == "pipe_task_with_config" + assert report.status == "success" + assert out_file.exists() + +def test_config_profile_missing_fails(tmp_path: Path): + from kgpipe_examples.config import ExtendedFormats + from kgpipe_examples.task_examples import pipe_task_with_config + + in_file = tmp_path / "input.special1" + out_file = tmp_path / "output.special_kg" + in_file.touch() + + report = pipe_task_with_config.run( + inputs=[Data(path=in_file, format=ExtendedFormats.SPECIAL1)], + outputs=[Data(path=out_file, format=ExtendedFormats.SPECIAL_KG)], + # configProfile intentionally omitted + ) -def test_pipeline_defintion(): + assert report.status == "failed" + assert report.error is not None + assert "requires a 'config' argument" in report.error + + +def test_config_profile_wrong_type_fails(tmp_path: Path): + from kgpipe_examples.config import ExtendedFormats + from kgpipe_examples.task_examples import pipe_task_with_config + + in_file = tmp_path / "input.special1" + out_file = tmp_path / "output.special_kg" + in_file.touch() + + report = pipe_task_with_config.run( + inputs=[Data(path=in_file, format=ExtendedFormats.SPECIAL1)], + outputs=[Data(path=out_file, format=ExtendedFormats.SPECIAL_KG)], + configProfile="not-a-profile", + ) + + assert report.status == "failed" + assert report.error is not None + assert "expects configProfile to be a ConfigurationProfile" in report.error + + +def test_config_profile_spec_mismatch_fails(tmp_path: Path): + from kgpipe_examples.config import ExtendedFormats + from kgpipe_examples.task_examples import pipe_task_with_config + from kgpipe.common.model.configuration import ( + ConfigurationProfile, + ConfigurationDefinition, + ) + + in_file = tmp_path / "input.special1" + out_file = tmp_path / "output.special_kg" + in_file.touch() + + report = pipe_task_with_config.run( + inputs=[Data(path=in_file, format=ExtendedFormats.SPECIAL1)], + outputs=[Data(path=out_file, format=ExtendedFormats.SPECIAL_KG)], + configProfile=ConfigurationProfile( + name="mismatching_profile", + definition=ConfigurationDefinition(name="different_spec_name"), + bindings=[], + ), + ) + + assert report.status == "failed" + assert report.error is not None + assert "does not match task config spec" in report.error + + +def test_config_profile_unknown_parameter_fails(tmp_path: Path): + from kgpipe_examples.config import ExtendedFormats + from kgpipe_examples.task_examples import pipe_task_with_config + from kgpipe.common.model.configuration import ( + ConfigurationProfile, + ParameterBinding, + Parameter, + ParameterType, + ) + + in_file = tmp_path / "input.special1" + out_file = tmp_path / "output.special_kg" + in_file.touch() + + report = pipe_task_with_config.run( + inputs=[Data(path=in_file, format=ExtendedFormats.SPECIAL1)], + outputs=[Data(path=out_file, format=ExtendedFormats.SPECIAL_KG)], + configProfile=ConfigurationProfile( + name="pipe_task_with_config_profile_unknown_param", + definition=pipe_task_with_config.config_spec, + bindings=[ + ParameterBinding( + parameter=Parameter( + name="other_parameter", + native_keys=["other_parameter"], + datatype=ParameterType.string, + default_value="default", + required=False, + allowed_values=[], + ), + value="some", + ) + ], + ), + ) + + assert report.status == "failed" + assert report.error is not None + assert "Unknown config parameter" in report.error + +def test_pipeline_definition_executes(): from kgpipe_examples.pipe_examples import pipe_example - \ No newline at end of file + + # Main objective: execute the pipeline example end-to-end without errors. + pipe_example() + +def test_evaluation_example(tmp_path: Path): + from kgpipe_examples.eval_examples import eval_example + eval_example(tmp_path) \ No newline at end of file From fa406d37019e48138ac59b2db08867fb31ed6ba6 Mon Sep 17 00:00:00 2001 From: Marvin Date: Thu, 2 Apr 2026 19:04:17 +0200 Subject: [PATCH 37/96] exp(moviekg): added dup rate and entity precision to table --- .../moviekg/src/moviekg/evaluation/helpers.py | 15 +++-- .../src/moviekg/paper/helpers/getter.py | 23 ++++--- .../src/moviekg/paper/helpers/ranking.py | 62 +------------------ .../moviekg/src/moviekg/paper/test_figtab.py | 21 +++---- .../moviekg/src/moviekg/pipelines/helpers.py | 2 +- 5 files changed, 35 insertions(+), 88 deletions(-) diff --git a/experiments/moviekg/src/moviekg/evaluation/helpers.py b/experiments/moviekg/src/moviekg/evaluation/helpers.py index 03ce1d0..4b80b58 100644 --- a/experiments/moviekg/src/moviekg/evaluation/helpers.py +++ b/experiments/moviekg/src/moviekg/evaluation/helpers.py @@ -10,7 +10,7 @@ from kgpipe.evaluation.aspects import reference, semantic, statistical from kgpipe.evaluation.aspects.reference import ReferenceConfig from kgpipe.evaluation.base import MetricResult -from kgcore.model.ontology import OntologyUtil +from kgcore.api.ontology import OntologyUtil from moviekg.datasets.pipe_out import StageOut from moviekg.config import dataset @@ -145,6 +145,8 @@ def add_duration_metrics(stage: StageOut) -> MetricResult: try: duration = stage.report.duration return MetricResult( + metric=None, + kg=KG(id=f"result_{stage.stage_name}", name=f"result_{stage.stage_name}", path=stage.resultKG, format=DataFormat.RDF_NTRIPLES,plan=stage.plan), aspect=EvaluationAspect.STATISTICAL, name="duration", value=duration, @@ -156,6 +158,8 @@ def add_duration_metrics(stage: StageOut) -> MetricResult: except Exception as e: return MetricResult( + metric=None, + kg=KG(id=f"result_{stage.stage_name}", name=f"result_{stage.stage_name}", path=stage.resultKG, format=DataFormat.RDF_NTRIPLES,plan=stage.plan), aspect=EvaluationAspect.STATISTICAL, name="duration", value=0, @@ -178,12 +182,13 @@ def evaluate_stage(stage: StageOut, is_ssp: bool) -> List[MetricResult]: ref_eval = reference.ReferenceEvaluator() sem_eval = semantic.SemanticEvaluator() - stats_aspect_result = stat_eval.evaluate(result_kg) - ref_aspect_result = ref_eval.evaluate(result_kg, config=get_reference_config(stage, is_ssp)) - sem_aspect_result = sem_eval.evaluate(result_kg) + # stats_aspect_result = stat_eval.evaluate(result_kg) + ref_aspect_result = ref_eval.evaluate(result_kg, config=get_reference_config(stage, is_ssp), metrics=["SourceTypedEntityCoverageMetric"]) + # sem_aspect_result = sem_eval.evaluate(result_kg) metrics = [] - metrics = stats_aspect_result.metrics + ref_aspect_result.metrics + sem_aspect_result.metrics + # metrics = stats_aspect_result.metrics + ref_aspect_result.metrics + sem_aspect_result.metrics + metrics = ref_aspect_result.metrics # metrics = sem_aspect_result.metrics metrics.append(add_duration_metrics(stage)) # metrics = ref_aspect_result.metrics diff --git a/experiments/moviekg/src/moviekg/paper/helpers/getter.py b/experiments/moviekg/src/moviekg/paper/helpers/getter.py index ab7fce4..c5889a4 100644 --- a/experiments/moviekg/src/moviekg/paper/helpers/getter.py +++ b/experiments/moviekg/src/moviekg/paper/helpers/getter.py @@ -163,13 +163,23 @@ def ref_source_entity_r(df: pd.DataFrame): res[row.pipeline][row.stage] = recall return res +def ref_source_typed_entity_fn(df: pd.DataFrame): + df = df[df["metric"] == "SourceTypedEntityCoverageMetric"] + res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) + for row in df.itertuples(): + details = json.loads(row.details) + # print(details) + fn = details.get("fn", -1) + res[row.pipeline][row.stage] = fn + return res + def ref_source_typed_entity_p(df: pd.DataFrame): df = df[df["metric"] == "SourceTypedEntityCoverageMetric"] res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) for row in df.itertuples(): details = json.loads(row.details) # print(details) - precision = details.get("fn", -1) + precision = details.get("precision", -1) res[row.pipeline][row.stage] = precision return res @@ -372,6 +382,7 @@ def ref_json_entity_linking_r(df: pd.DataFrame): ref_source_entity_r.__name__: "VSEC-R", ref_source_typed_entity_p.__name__: "VSEC-P-TE", ref_source_typed_entity_r.__name__: "VSEC-R-TE", + ref_source_typed_entity_fn.__name__: "VSEC-FN-TE", ref_entity_matching_f1.__name__: "ER-EM", ref_entity_matching_p.__name__: "ER-EM-P", ref_entity_matching_r.__name__: "ER-EM-R", @@ -520,13 +531,7 @@ def apply_selected_updates(psmd: pipeline_stage_metric_dict) -> pipeline_stage_m agg_metric_over_stages(psmd, "ref_selected_task_metric", "_avg", agg_avg) agg_metric_over_stages(psmd, "sta_duration", "_sum", agg_sum) agg_metric_over_stages(psmd, "ref_source_entity_f1", "_avg", agg_avg) - agg_metric_over_stages(psmd, "ref_source_entity_p", "_avg", agg_avg) - agg_metric_over_stages(psmd, "ref_source_entity_r", "_avg", agg_avg) - agg_metric_over_stages(psmd, "ref_source_typed_entity_p", "_avg", agg_avg) - agg_metric_over_stages(psmd, "ref_source_typed_entity_r", "_avg", agg_avg) - # agg_metric_over_stages(psmd, "ref_kg_f1", "_avg", agg_avg) - # agg_metric_over_stages(psmd, "ref_kg_p", "_avg", agg_avg) - # agg_metric_over_stages(psmd, "ref_kg_r", "_avg", agg_avg) + agg_metric_over_stages(psmd, "ref_kg_f1", "_avg", agg_avg) return psmd def test_getter(): @@ -549,4 +554,4 @@ def test_getter(): print(pipeline) print(stage) print(json.dumps(metric_dict, indent=4)) - print("--------------------------------") + print("--------------------------------") \ No newline at end of file diff --git a/experiments/moviekg/src/moviekg/paper/helpers/ranking.py b/experiments/moviekg/src/moviekg/paper/helpers/ranking.py index e181e15..ba9102a 100644 --- a/experiments/moviekg/src/moviekg/paper/helpers/ranking.py +++ b/experiments/moviekg/src/moviekg/paper/helpers/ranking.py @@ -8,7 +8,7 @@ TABLE_DISPLAY_NAMES, normalize_metric, normalize_min_best, normalize_max_best, sta_fact_count, sta_denisity, sta_duration, #memory_peak is not considered - ref_kg_p, ref_kg_r, ref_source_entity_f1, ref_source_entity_r, ref_source_entity_p, + ref_kg_p, ref_source_entity_f1, sem_disjoint_domain, sem_incorrect_relation_direction, sem_incorrect_relation_range, sem_incorrect_relation_domain, sem_incorrect_datatype, sem_incorrect_datatype_format ) @@ -99,66 +99,6 @@ def _rank_and_save2csv(weights: dict, outfile_stem: str, psmd: pipeline_stage_me out = df[["pipeline", "combined"]].sort_values(by="combined", ascending=False) out.to_csv(OUTPUT_ROOT / f"paper/{outfile_stem}.csv", sep="\t") -def _rank_and_save3csv(outfile_stem: str, psmd: pipeline_stage_metric_dict, round_digits: int = 3) -> pd.DataFrame: - - # psmd = normalize_metric(psmd, sta_fact_count.__name__, ["stage_3"], normalize_max_best) - # psmd = normalize_metric(psmd, sta_denisity.__name__, ["stage_3"], normalize_max_best) - # psmd = normalize_metric(psmd, sta_fact_count.__name__, ["stage_3"], normalize_max_best) - # sta_metric_names = [sta_denisity.__name__+"_norm", sta_fact_count.__name__+"_norm"] - # sta_agg = agg_metrics(psmd, sta_metric_names) - - sem_metric_names = [ - sem_disjoint_domain.__name__, sem_incorrect_relation_direction.__name__, - sem_incorrect_relation_range.__name__, sem_incorrect_relation_domain.__name__, - sem_incorrect_datatype.__name__, sem_incorrect_datatype_format.__name__] - sem_agg = agg_metrics(psmd, sem_metric_names) - - acc_metric_names = [ref_kg_p.__name__] # only final stage (3) - acc_agg = agg_metrics(psmd, acc_metric_names) - - cov_metric_names = [ref_source_typed_entity_r.__name__+"_avg"] # avg of all stages - cov_agg = agg_metrics(psmd, cov_metric_names) - - # psmd = normalize_metric(psmd, sta_duration.__name__+"_sum", ["stage_3"], normalize_min_best) - # eff_metric_names = [sta_duration.__name__+"_sum_norm"] - # eff_agg = agg_metrics(psmd, eff_metric_names) - - import json - json.dump(psmd, open(OUTPUT_ROOT / f"paper/{outfile_stem}_psmd.json", "w"), indent=4) - - df_rows = [] - - for pipeline, value in sem_agg.items(): - df_rows.append( - { - "pipeline": pipeline, - "semantic": round(value, round_digits), - "correctness": round(acc_agg[pipeline], round_digits), - "coverage": round(cov_agg[pipeline], round_digits), - # "size": round(sta_agg[pipeline], round_digits), - # "efficiency": round(eff_agg[pipeline], round_digits) - } - ) - - - df = pd.DataFrame(df_rows) - - return df - - # cols = ["semantic", "correctness", "coverage"] - # # Ensure we only use known columns; fill missing weights with 0.0 - # w = pd.Series(weights).reindex(cols, fill_value=0.0) - - # # Compute combined score - # df = df[["pipeline"] + cols].copy() - # df["combined"] = (df[cols] * w).sum(axis=1).round(round_digits) - - # print(df.to_string()) - - # # Sort & save (keep default index=True to match original behavior) - # out = df[["pipeline", "combined"]].sort_values(by="combined", ascending=False) - # out.to_csv(OUTPUT_ROOT / f"paper/{outfile_stem}.csv", sep="\t") - # TODO cleanup # def _rank_and_save(weights: dict, outfile_stem: str, df: pd.DataFrame, round_digits: int = 3) -> None: # """ diff --git a/experiments/moviekg/src/moviekg/paper/test_figtab.py b/experiments/moviekg/src/moviekg/paper/test_figtab.py index f2dca06..6eb685c 100644 --- a/experiments/moviekg/src/moviekg/paper/test_figtab.py +++ b/experiments/moviekg/src/moviekg/paper/test_figtab.py @@ -456,11 +456,11 @@ def test_table_6(): metric_df = load_metrics_from_file(OUTPUT_ROOT / "all_metrics.csv") metric_df["pipeline"] = metric_df["pipeline"].map(map_pipeline_name_pretty) from moviekg.paper.helpers.getter import ( - get_pipeline_stage_metric_dict, ref_kg_f1, ref_kg_p, ref_kg_r, ref_source_entity_f1, ref_source_entity_p, ref_source_entity_r, ref_source_typed_entity_r, ref_source_typed_entity_p + get_pipeline_stage_metric_dict, ref_kg_f1, ref_kg_p, ref_kg_r, ref_source_entity_f1, ref_source_entity_p, ref_source_entity_r ) metrics = [ - ref_kg_f1.__name__, ref_kg_p.__name__, ref_kg_r.__name__, ref_source_entity_f1.__name__, ref_source_entity_p.__name__, ref_source_entity_r.__name__, ref_source_typed_entity_r.__name__, ref_source_typed_entity_p.__name__ + ref_kg_f1.__name__, ref_kg_p.__name__, ref_kg_r.__name__, ref_source_entity_f1.__name__, ref_source_entity_p.__name__, ref_source_entity_r.__name__ ] psmd = get_pipeline_stage_metric_dict(metric_df, metrics) @@ -469,7 +469,7 @@ def test_table_6(): rows = [] - round_to = 3 + round_to = 2 for pipeline, stage_dict in psmd.items(): if pipeline in ["reference", "seed"]: @@ -478,22 +478,18 @@ def test_table_6(): kg_r = [0, 0, 0] se_p = [0, 0, 0] se_r = [0, 0, 0] - ste_p = [0, 0, 0] - ste_r = [0, 0, 0] + for stage, metric_dict in stage_dict.items(): kg_p[int(stage.split("_")[1]) - 1] = round(metric_dict.get(ref_kg_p.__name__, -1), round_to) kg_r[int(stage.split("_")[1]) - 1] = round(metric_dict.get(ref_kg_r.__name__, -1), round_to) se_p[int(stage.split("_")[1]) - 1] = round(metric_dict.get(ref_source_entity_p.__name__, -1), round_to) se_r[int(stage.split("_")[1]) - 1] = round(metric_dict.get(ref_source_entity_r.__name__, -1), round_to) - ste_p[int(stage.split("_")[1]) - 1] = round(metric_dict.get(ref_source_typed_entity_p.__name__, -1), round_to) - ste_r[int(stage.split("_")[1]) - 1] = round(metric_dict.get(ref_source_typed_entity_r.__name__, -1), round_to) rows.append({ "pipeline": pipeline, "kg_p@1": kg_p[0], "kg_r@1": kg_r[0], "kg_p@2": kg_p[1], "kg_r@2": kg_r[1], "kg_p@3": kg_p[2], "kg_r@3": kg_r[2], - "se_p@1": se_p[0], "se_r@1": se_r[0], "se_p@2": se_p[1], "se_r@2": se_r[1], "se_p@3": se_p[2], "se_r@3": se_r[2], - "ste_p@1": ste_p[0], "ste_r@1": ste_r[0], "ste_p@2": ste_p[1], "ste_r@2": ste_r[1], "ste_p@3": ste_p[2], "ste_r@3": ste_r[2]}) + "se_p@1": se_p[0], "se_r@1": se_r[0], "se_p@2": se_p[1], "se_r@2": se_r[1], "se_p@3": se_p[2], "se_r@3": se_r[2]}) df = pd.DataFrame(rows) output_path = OUTPUT_ROOT / "paper/test_tab_6_reference_alignment.csv" @@ -731,7 +727,7 @@ def test_new_quality_table(): sta_entity_count, sta_fact_count, sta_type_count, sta_relation_count, sta_shallow_entity_count, sta_denisity, sta_duration, ref_kg_f1, ref_kg_p, ref_kg_r, ref_source_entity_f1, ref_source_entity_p, ref_source_entity_r, - ref_source_typed_entity_r, ref_source_typed_entity_p, + ref_source_typed_entity_r, ref_source_typed_entity_p, ref_source_typed_entity_fn, sem_disjoint_domain, sem_incorrect_relation_direction, sem_incorrect_relation_cardinality, sem_incorrect_relation_range, sem_incorrect_relation_domain, sem_incorrect_datatype, sem_incorrect_datatype_format, ) @@ -740,7 +736,7 @@ def test_new_quality_table(): ref_kg_f1.__name__, ref_kg_p.__name__, ref_kg_r.__name__, ref_source_entity_f1.__name__, ref_source_entity_p.__name__, ref_source_entity_r.__name__, - ref_source_typed_entity_r.__name__, ref_source_typed_entity_p.__name__, + ref_source_typed_entity_r.__name__, ref_source_typed_entity_p.__name__, ref_source_typed_entity_fn.__name__, sem_disjoint_domain.__name__, sem_incorrect_relation_direction.__name__, sem_incorrect_relation_cardinality.__name__, sem_incorrect_relation_range.__name__, sem_incorrect_relation_domain.__name__, sem_incorrect_datatype.__name__, sem_incorrect_datatype_format.__name__, ] @@ -764,6 +760,7 @@ def test_new_quality_table(): se_r= round(metric_dict.get(ref_source_entity_r.__name__, -1), round_to) ste_p = round(metric_dict.get(ref_source_typed_entity_p.__name__, -1), round_to) ste_r = round(metric_dict.get(ref_source_typed_entity_r.__name__, -1), round_to) + ste_fn = round(metric_dict.get(ref_source_typed_entity_fn.__name__, -1), round_to) o_dt = round(metric_dict.get(sem_disjoint_domain.__name__, -1), round_to) o_d = round(metric_dict.get(sem_incorrect_relation_domain.__name__, -1), round_to) o_r = round(metric_dict.get(sem_incorrect_relation_range.__name__, -1), round_to) @@ -774,7 +771,7 @@ def test_new_quality_table(): rows.append({ "pipeline": pipeline, "stage": stage, "EC": ec, - "kg_p": kg_p, "kg_r": kg_r, "se_p": se_p, "se_r": se_r, "ste_p": ste_p, "ste_r": ste_r, + "kg_p": kg_p, "kg_r": kg_r, "se_p": se_p, "se_r": se_r, "ste_p": ste_p, "ste_r": ste_r, "ste_fn": ste_fn, "O_DT": o_dt, "O_D": o_d, "O_R": o_r, "O_RD": o_rd, "O_LT": o_lt, "O_LF": o_lf }) diff --git a/experiments/moviekg/src/moviekg/pipelines/helpers.py b/experiments/moviekg/src/moviekg/pipelines/helpers.py index cfc4e92..fab8ee3 100644 --- a/experiments/moviekg/src/moviekg/pipelines/helpers.py +++ b/experiments/moviekg/src/moviekg/pipelines/helpers.py @@ -70,7 +70,7 @@ def run_helper( tmp_dir = stage_dir / "tmp" tmp_dir.mkdir(parents=True, exist_ok=True) - pipeline = build_from_conf(pipeline_name, pipeline_conf, target_data, tmp_dir.as_posix()) + pipeline = build_from_conf(pipeline_conf, target_data, tmp_dir.as_posix()) stage_dir.mkdir(parents=True, exist_ok=True) From 442ef68f7fd23e9dc4b6463cc72602f002f42ac2 Mon Sep 17 00:00:00 2001 From: Marvin Date: Thu, 2 Apr 2026 18:01:07 +0200 Subject: [PATCH 38/96] feat(eval): init refactor --- src/kgpipe_eval/__init__.py | 2 + src/kgpipe_eval/api.py | 41 ++++++ src/kgpipe_eval/metrics/__init__.py | 108 ++++++++++++++ src/kgpipe_eval/metrics/alignment_utils.py | 18 +++ src/kgpipe_eval/metrics/annotation_utils.py | 9 ++ src/kgpipe_eval/metrics/duplicates.py | 19 +++ src/kgpipe_eval/metrics/entailment_utils.py | 0 src/kgpipe_eval/metrics/kg_utils.py | 135 ++++++++++++++++++ src/kgpipe_eval/metrics/reference_entities.py | 5 + src/kgpipe_eval/metrics/reference_triples.py | 0 src/kgpipe_eval/metrics/statistics.py | 47 ++++++ 11 files changed, 384 insertions(+) create mode 100644 src/kgpipe_eval/__init__.py create mode 100644 src/kgpipe_eval/api.py create mode 100644 src/kgpipe_eval/metrics/__init__.py create mode 100644 src/kgpipe_eval/metrics/alignment_utils.py create mode 100644 src/kgpipe_eval/metrics/annotation_utils.py create mode 100644 src/kgpipe_eval/metrics/duplicates.py create mode 100644 src/kgpipe_eval/metrics/entailment_utils.py create mode 100644 src/kgpipe_eval/metrics/kg_utils.py create mode 100644 src/kgpipe_eval/metrics/reference_entities.py create mode 100644 src/kgpipe_eval/metrics/reference_triples.py create mode 100644 src/kgpipe_eval/metrics/statistics.py diff --git a/src/kgpipe_eval/__init__.py b/src/kgpipe_eval/__init__.py new file mode 100644 index 0000000..04e3f84 --- /dev/null +++ b/src/kgpipe_eval/__init__.py @@ -0,0 +1,2 @@ +# Refactor of kgpipe.evaluation to be a standalone package + diff --git a/src/kgpipe_eval/api.py b/src/kgpipe_eval/api.py new file mode 100644 index 0000000..8b37e9e --- /dev/null +++ b/src/kgpipe_eval/api.py @@ -0,0 +1,41 @@ +from dataclasses import dataclass +from functools import lru_cache +from abc import ABC, abstractmethod +from typing import Callable +from kgpipe.common.model.kg import KgKg + + +# MetricConfig (rich, typed, input) +# ↓ +# computation +# ↓ +# MetricResult +# ├── measurements (results) +# └── metadata (flattened config + context) + +@dataclass(frozen=True) +class MetricConfig: + pass + +@dataclass(frozen=True) +class Measurement: + name: str + value: int | float | str | bool + unit: str | None = None + +@dataclass(frozen=True) +class MetricResult: + metric: "Metric" + measurements: list[Measurement] + summary: str | None = None + # TODO metadata/properties: dict[str, int | float | str | bool] = field(default_factory=dict) + +@dataclass(frozen=True) +class Metric: + key: str + description: str + compute: Callable[[KgKg, MetricConfig], MetricResult] + + +# --- + diff --git a/src/kgpipe_eval/metrics/__init__.py b/src/kgpipe_eval/metrics/__init__.py new file mode 100644 index 0000000..a1ccd07 --- /dev/null +++ b/src/kgpipe_eval/metrics/__init__.py @@ -0,0 +1,108 @@ + + +# @dataclass(frozen=True) +# class BinaryClassificationStats: +# tp: int +# fp: int +# tn: int +# fn: int + +# def recall(self) -> float: +# d = self.tp + self.fn +# return self.tp / d if d else 0.0 + +# def precision(self) -> float: +# d = self.tp + self.fp +# return self.tp / d if d else 0.0 + +# def f1(self) -> float: +# p = self.precision() +# r = self.recall() +# return 2 * p * r / (p + r) if (p + r) else 0.0 + +# def accuracy(self) -> float: +# d = self.tp + self.fp + self.tn + self.fn +# return (self.tp + self.tn) / d if d else 0.0 + +# def reference_binary_classification(kg: KgKg, config: MetricConfig) -> MetricResult: +# stats = BinaryClassificationStats(tp=10, fp=5, tn=15, fn=3) +# return MetricResult( +# metric_key="reference_binary_classification", +# summary="Reference comparison computed", +# measurements=[ +# Measurement("tp", stats.tp), +# Measurement("fp", stats.fp), +# Measurement("tn", stats.tn), +# Measurement("fn", stats.fn), +# Measurement("precision", stats.precision(), "ratio"), +# Measurement("recall", stats.recall(), "ratio"), +# Measurement("f1", stats.f1(), "ratio"), +# Measurement("accuracy", stats.accuracy(), "ratio"), +# ], +# ) + +# def graph_size(kg: KgKg, config: MetricConfig) -> MetricResult: +# size = 1532 +# return MetricResult( +# metric_key="graph_size", +# measurements=[ +# Measurement("triple_count", size, "triples") +# ], +# summary=f"Graph contains {size} triples", +# ) + +# def entity_duplication_rate(kg: KgKg, config: MetricConfig) -> MetricResult: +# duplicates = 7 +# total = 100 +# rate = duplicates / total if total else 0.0 +# return MetricResult( +# metric_key="entity_duplication_rate", +# measurements=[ +# Measurement("duplication_rate", rate, "ratio"), +# Measurement("duplicate_entities", duplicates, "entities"), +# Measurement("total_entities", total, "entities"), +# ], +# summary=f"Entity duplication rate: {rate:.2%}", +# ) +# --- + +# class BinaryClassifier(): +# tp: int +# fp: int +# tn: int +# fn: int + +# def recall(self) -> float: +# return self.tp / (self.tp + self.fn) + +# def precision(self) -> float: +# return self.tp / (self.tp + self.fp) + +# def f1(self) -> float: +# return 2 * self.precision() * self.recall() / (self.precision() + self.recall()) + +# def accuracy(self) -> float: +# return (self.tp + self.tn) / (self.tp + self.tn + self.fp + self.fn) + +# @lru_cache +# def compute_binary_classifier(kg: KgKg) -> BinaryClassifier: +# return BinaryClassifier(tp=10, fp=5, tn=15, fn=3) + + +# # Option 1 the metrics are recall, precision, f1, accuracy +# def reference_recall(kg: KgKg, reference: KgKg) -> KgMetricResult: +# binary_classifier = compute_binary_classifier(kg, reference) +# return KgMetricResult(summary=f"Reference recall: {binary_classifier.recall()}") + +# def reference_precision(kg: KgKg, reference: KgKg) -> KgMetricResult: +# binary_classifier = compute_binary_classifier(kg, reference) +# return KgMetricResult(summary=f"Reference precision: {binary_classifier.precision()}") + +# def reference_f1(kg: KgKg, reference: KgKg) -> KgMetricResult: +# binary_classifier = compute_binary_classifier(kg, reference) +# return KgMetricResult(summary=f"Reference F1: {binary_classifier.f1()}") + +# #Option 2 the metrics are Binary Classification which allows for more detailed analysis +# def reference_binary_classification(kg: KgKg, reference: KgKg) -> KgMetricResult: +# binary_classifier = compute_binary_classifier(kg, reference) +# return KgMetricResult(summary=f"Reference binary classification: {binary_classifier.tp}, {binary_classifier.fp}, {binary_classifier.tn}, {binary_classifier.fn}") \ No newline at end of file diff --git a/src/kgpipe_eval/metrics/alignment_utils.py b/src/kgpipe_eval/metrics/alignment_utils.py new file mode 100644 index 0000000..5c19403 --- /dev/null +++ b/src/kgpipe_eval/metrics/alignment_utils.py @@ -0,0 +1,18 @@ +from kgpipe.common import KG +from typing import Literal + +CONFIG=None +# layz config dict +def get_config() -> dict: + global CONFIG + if CONFIG is None: + # TODO + pass + return CONFIG + +def get_aligned_entities(kg: KG, reference_kg: KG, method: Literal["exact", "fuzzy", "semantic"] = "exact") -> list[Entity]: + return kg.entities.intersection(reference_kg.entities) + +def get_aligned_triples(kg: KG, reference_kg: KG, method: Literal["exact", "fuzzy", "semantic"] = "exact") -> list[Triple]: + return kg.triples.intersection(reference_kg.triples) + diff --git a/src/kgpipe_eval/metrics/annotation_utils.py b/src/kgpipe_eval/metrics/annotation_utils.py new file mode 100644 index 0000000..765ff78 --- /dev/null +++ b/src/kgpipe_eval/metrics/annotation_utils.py @@ -0,0 +1,9 @@ + + +# Labels + +def get_labeled_entities(kg: KG, reference_kg: KG, method: Literal["exact", "fuzzy", "semantic"] = "exact") -> list[Entity]: + return kg.entities.intersection(reference_kg.entities) + +def get_labeled_triples(kg: KG, reference_kg: KG, method: Literal["exact", "fuzzy", "semantic"] = "exact") -> list[Triple]: + return kg.triples.intersection(reference_kg.triples) \ No newline at end of file diff --git a/src/kgpipe_eval/metrics/duplicates.py b/src/kgpipe_eval/metrics/duplicates.py new file mode 100644 index 0000000..3bb868c --- /dev/null +++ b/src/kgpipe_eval/metrics/duplicates.py @@ -0,0 +1,19 @@ + +def eval_duplicates(): + pass + + +# find all duplicate entities in the KG +# using +# - reference KG +# - fuzzy matching +# - exact matching +# - semantic matching +# - clustering +# - ... +# return a list of duplicate entities +# return a list of duplicate entities with the matching score +# return a list of duplicate entities with the matching score and the matching type +# return a list of duplicate entities with the matching score and the matching type and the matching details +# return a list of duplicate entities with the matching score and the matching type and the matching details and the matching details +# return a list of duplicate entities with the matching score and the matching type and the matching details and the matching details and the matching details \ No newline at end of file diff --git a/src/kgpipe_eval/metrics/entailment_utils.py b/src/kgpipe_eval/metrics/entailment_utils.py new file mode 100644 index 0000000..e69de29 diff --git a/src/kgpipe_eval/metrics/kg_utils.py b/src/kgpipe_eval/metrics/kg_utils.py new file mode 100644 index 0000000..bfd1232 --- /dev/null +++ b/src/kgpipe_eval/metrics/kg_utils.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, Protocol, Union, runtime_checkable, Optional, Tuple, Literal + +from rdflib import RDF, Graph +from rdflib.term import Identifier +from rdflib.term import Literal +from rdflib.term import URIRef + +from kgpipe.common import KG + + +KgLike = Union[KG, Graph, str, Path] + +Term = Union[Identifier, str, URIRef, Literal] + +Triple = tuple[Term, Term, Term] + +TriplePattern = Tuple[ + Optional[Term], Optional[Term], Optional[Term] +] + +@runtime_checkable +class TripleGraph(Protocol): + """ + TripleGraph is a protocol that defines the interface for a graph that can be used to evaluate metrics. + It is used to abstract the underlying graph implementation and allow for different graph implementations to be used. + + This is intentionally small: metrics should depend on *these* operations, + not on a specific in-memory representation (RDFLib Graph today; Spark later). + """ + + def triples(self, triple_pattern: TriplePattern) -> Iterable[Triple]: + pass + + def close(self) -> None: + pass + + def cache(self) -> None: + pass + +# def iter_triples(self) -> Iterable[Triple]: +# """Iterate (s, p, o) triples.""" + +# @property +# def triples(self) -> frozenset[Triple]: +# """Materialized triple set (may be expensive).""" + +# @property +# def entities(self) -> frozenset[Term]: +# """All subjects/objects that are IRIs or blank nodes (no literals).""" + +# @property +# def relations(self) -> frozenset[Term]: +# """All predicates.""" + +# @property +# def classes(self) -> frozenset[Term]: +# """All classes used in rdf:type assertions.""" + +# @property +# def class_occurrences(self) -> Mapping[Term, int]: +# """Class → number of rdf:type occurrences.""" + +@dataclass(frozen=True) +class SparkTripleGraph(TripleGraph): + """ + KG backend that exposes evaluation-friendly views derived from a Spark DataFrame. + """ + # df: SparkDataFrame + + def triples(self, triple_pattern: TriplePattern) -> Iterable[Triple]: + # return self.df.filter(triple_pattern).collect() + pass + + def close(self) -> None: + pass + + def cache(self) -> None: + pass + +@dataclass(frozen=True) +class RdfLibTripleGraph(TripleGraph): + """ + KG backend that exposes evaluation-friendly views derived from an RDFLib `Graph`. + + Accepts: + - `kgpipe.common.KG` (uses `get_graph()`) + - an RDFLib `Graph` + - a path/str (parsed by RDFLib) + """ + kg: KgLike + + def _graph(self) -> Graph: + if isinstance(self.kg, Graph): + return self.kg + if isinstance(self.kg, KG): + return self.kg.get_graph() + # Assume filesystem path + return Graph().parse(str(self.kg)) + + def triples(self, triple_pattern: TriplePattern) -> Iterable[Triple]: + g = self._graph() + # RDFLib yields (s, p, o) as Identifiers + return g.triples(triple_pattern) + +class KgManager: + """ + KgManager is a class that manages the loading and unloading of KGs. + It is used to abstract the underlying graph implementation and allow for different graph implementations to be used. + """ + + @staticmethod + def load_kg(kg: KG, backend: Literal["rdflib", "spark"] = "rdflib") -> TripleGraph: + if backend == "rdflib": + return RdfLibTripleGraph(kg=kg) + else: + raise ValueError(f"Unsupported backend: {backend}") + + @staticmethod + def load_kg_from_path(path: Path, backend: Literal["rdflib", "spark"] = "rdflib") -> TripleGraph: + if backend == "rdflib": + return RdfLibTripleGraph(kg=path) + else: + raise ValueError(f"Unsupported backend: {backend}") + + @staticmethod + def cache_kg(kg: TripleGraph) -> None: + kg.cache() + + @staticmethod + def unload_kg(kg: TripleGraph) -> None: + kg.close() diff --git a/src/kgpipe_eval/metrics/reference_entities.py b/src/kgpipe_eval/metrics/reference_entities.py new file mode 100644 index 0000000..0b14d66 --- /dev/null +++ b/src/kgpipe_eval/metrics/reference_entities.py @@ -0,0 +1,5 @@ +from kgpipe_eval.metrics.alignment_utils import get_aligned_entities +from kgpipe.common import KG +from typing import Literal + +# TODO \ No newline at end of file diff --git a/src/kgpipe_eval/metrics/reference_triples.py b/src/kgpipe_eval/metrics/reference_triples.py new file mode 100644 index 0000000..e69de29 diff --git a/src/kgpipe_eval/metrics/statistics.py b/src/kgpipe_eval/metrics/statistics.py new file mode 100644 index 0000000..e615e6c --- /dev/null +++ b/src/kgpipe_eval/metrics/statistics.py @@ -0,0 +1,47 @@ +from kgpipe.common import KG +from kgpipe_eval.metrics.kg_utils import TripleGraph, KgManager +from kgpipe_eval.api import Metric, MetricResult, MetricConfig +from functools import lru_cache + +from pydantic import BaseModel +from typing import Mapping +from collections import defaultdict + +from rdflib import RDF, RDFS +from rdflib.term import URIRef, Literal + +class CountMeasures(BaseModel): + entity_count: int + triple_count: int + property_count: int + class_count: int + property_occurrence: Mapping[str, int] + class_occurrence: Mapping[str, int] + +@lru_cache(maxsize=1000) +def count_measures(kg: TripleGraph) -> CountMeasures: + + entity_count = 0 # TODO requires distinct entities + triple_count = 0 + + class_occurrence = defaultdict(int) + property_occurrence = defaultdict(int) + + for s, p, o in kg.triples((None, None, None)): + triple_count += 1 + if p == RDF.type: + class_occurrence[str(o)] += 1 + property_occurrence[str(p)] += 1 + + return CountMeasures( + entity_count=entity_count, + property_count=len(property_occurrence.keys()), + triple_count=triple_count, + class_count=len(class_occurrence.keys()), + class_occurrence=class_occurrence, + property_occurrence=property_occurrence, + ) + +class CountMetric(Metric): + def compute(self, kg: TripleGraph) -> MetricResult: + return MetricResult(value=count_measures(kg)) \ No newline at end of file From 34b492737f91987e7a3711e742924a9d45d1e975 Mon Sep 17 00:00:00 2001 From: Marvin Hofer Date: Mon, 6 Apr 2026 11:04:02 +0200 Subject: [PATCH 39/96] feat(eval): stash --- src/kgpipe_eval/metrics/alignment_utils.py | 18 ----- src/kgpipe_eval/metrics/annotation_utils.py | 9 --- .../metrics/consistency_violations.py | 44 ++++++++++++ src/kgpipe_eval/metrics/duplicates.py | 43 +++++++++++- src/kgpipe_eval/metrics/entity_alignment.py | 45 ++++++++++++ src/kgpipe_eval/metrics/llm_annotation.py | 4 ++ src/kgpipe_eval/metrics/reference_entities.py | 5 -- src/kgpipe_eval/metrics/statistics.py | 20 ++++-- src/kgpipe_eval/metrics/triple_alignment.py | 23 +++++++ .../entailment_utils.py => test/__init__.py} | 0 src/kgpipe_eval/test/examples.py | 25 +++++++ .../test_alignment_eval.py} | 0 src/kgpipe_eval/test/test_llm_eval.py | 6 ++ src/kgpipe_eval/test/test_source_eval.py | 0 src/kgpipe_eval/test/test_statistics_eval.py | 7 ++ src/kgpipe_eval/test/utils.py | 11 +++ src/kgpipe_eval/utils/__init__.py | 0 src/kgpipe_eval/utils/alignment_utils.py | 68 +++++++++++++++++++ src/kgpipe_eval/utils/annotation_utils.py | 51 ++++++++++++++ src/kgpipe_eval/utils/entailment_utils.py | 7 ++ .../{metrics => utils}/kg_utils.py | 28 ++++++-- src/kgpipe_eval/utils/measurement_utils.py | 36 ++++++++++ src/kgpipe_eval/utils/verbalize_utils.py | 16 +++++ 23 files changed, 423 insertions(+), 43 deletions(-) delete mode 100644 src/kgpipe_eval/metrics/alignment_utils.py delete mode 100644 src/kgpipe_eval/metrics/annotation_utils.py create mode 100644 src/kgpipe_eval/metrics/consistency_violations.py create mode 100644 src/kgpipe_eval/metrics/entity_alignment.py create mode 100644 src/kgpipe_eval/metrics/llm_annotation.py delete mode 100644 src/kgpipe_eval/metrics/reference_entities.py create mode 100644 src/kgpipe_eval/metrics/triple_alignment.py rename src/kgpipe_eval/{metrics/entailment_utils.py => test/__init__.py} (100%) create mode 100644 src/kgpipe_eval/test/examples.py rename src/kgpipe_eval/{metrics/reference_triples.py => test/test_alignment_eval.py} (100%) create mode 100644 src/kgpipe_eval/test/test_llm_eval.py create mode 100644 src/kgpipe_eval/test/test_source_eval.py create mode 100644 src/kgpipe_eval/test/test_statistics_eval.py create mode 100644 src/kgpipe_eval/test/utils.py create mode 100644 src/kgpipe_eval/utils/__init__.py create mode 100644 src/kgpipe_eval/utils/alignment_utils.py create mode 100644 src/kgpipe_eval/utils/annotation_utils.py create mode 100644 src/kgpipe_eval/utils/entailment_utils.py rename src/kgpipe_eval/{metrics => utils}/kg_utils.py (85%) create mode 100644 src/kgpipe_eval/utils/measurement_utils.py create mode 100644 src/kgpipe_eval/utils/verbalize_utils.py diff --git a/src/kgpipe_eval/metrics/alignment_utils.py b/src/kgpipe_eval/metrics/alignment_utils.py deleted file mode 100644 index 5c19403..0000000 --- a/src/kgpipe_eval/metrics/alignment_utils.py +++ /dev/null @@ -1,18 +0,0 @@ -from kgpipe.common import KG -from typing import Literal - -CONFIG=None -# layz config dict -def get_config() -> dict: - global CONFIG - if CONFIG is None: - # TODO - pass - return CONFIG - -def get_aligned_entities(kg: KG, reference_kg: KG, method: Literal["exact", "fuzzy", "semantic"] = "exact") -> list[Entity]: - return kg.entities.intersection(reference_kg.entities) - -def get_aligned_triples(kg: KG, reference_kg: KG, method: Literal["exact", "fuzzy", "semantic"] = "exact") -> list[Triple]: - return kg.triples.intersection(reference_kg.triples) - diff --git a/src/kgpipe_eval/metrics/annotation_utils.py b/src/kgpipe_eval/metrics/annotation_utils.py deleted file mode 100644 index 765ff78..0000000 --- a/src/kgpipe_eval/metrics/annotation_utils.py +++ /dev/null @@ -1,9 +0,0 @@ - - -# Labels - -def get_labeled_entities(kg: KG, reference_kg: KG, method: Literal["exact", "fuzzy", "semantic"] = "exact") -> list[Entity]: - return kg.entities.intersection(reference_kg.entities) - -def get_labeled_triples(kg: KG, reference_kg: KG, method: Literal["exact", "fuzzy", "semantic"] = "exact") -> list[Triple]: - return kg.triples.intersection(reference_kg.triples) \ No newline at end of file diff --git a/src/kgpipe_eval/metrics/consistency_violations.py b/src/kgpipe_eval/metrics/consistency_violations.py new file mode 100644 index 0000000..7cd1141 --- /dev/null +++ b/src/kgpipe_eval/metrics/consistency_violations.py @@ -0,0 +1,44 @@ +from kgpipe_eval.api import Metric + +from pydantic import BaseModel +from kgpipe.common import KG + +class DisjointDomainConfig(BaseModel): + pass + +class DomainConfig(BaseModel): + pass + +class RangeConfig(BaseModel): + pass + +class RelationDirectionConfig(BaseModel): + pass + +class DisjointDomainMetric(Metric): + def compute(self, kg: KG, ref_kg: KG, config: DisjointDomainConfig): + pass + +class DomainMetric(Metric): + pass + +class RangeMetric(Metric): + pass + +class RelationDirectionMetric(Metric): + pass + +class DatatypeMetric(Metric): + pass + +class DatatypeFormatMetric(Metric): + pass + +# class OntologyClassCoverageMetric(): +# pass + +# class OntologyRelationCoverageMetric(): +# pass + +# class OntologyNamespaceCoverageMetric(): +# pass \ No newline at end of file diff --git a/src/kgpipe_eval/metrics/duplicates.py b/src/kgpipe_eval/metrics/duplicates.py index 3bb868c..1b05749 100644 --- a/src/kgpipe_eval/metrics/duplicates.py +++ b/src/kgpipe_eval/metrics/duplicates.py @@ -1,7 +1,46 @@ +from kgpipe.util.embeddings.st_emb import get_model +from kgpipe_eval.utils.alignment_utils import Alignment, align_entities_by_label_embedding +from kgpipe_eval.api import Metric, MetricResult, Measurement -def eval_duplicates(): - pass +from pydantic import BaseModel +from kgpipe.common import KG +import numpy as np +DEBUG = False + +class DuplicateConfig(BaseModel): + threshold: float = 0.5 + similarity_model: str = "cosine" + similarity_function: str = "cosine" + reference_kg: KG + +def eval_duplicates(kg: KG, config: DuplicateConfig): + """ + checks expected & integrated source entity overlap using label embeddings + """ + + alignments : list[Alignment] = align_entities_by_label_embedding(kg, ref_kg, threshold=config.threshold, model=config.similarity_model, similarity=config.similarity_function) + + duplicates = 0 + already_matched_references = set() + + for alignment in alignments: + if alignment.target in already_matched_references: + duplicates += 1 + already_matched_references.add(alignment.target) + + return duplicates + +class DuplicateMetric(Metric): + def compute(self, kg: KG, ref_kg: KG, config: DuplicateConfig): + duplicates = eval_duplicates(kg, ref_kg, config) + return MetricResult( + metric=self, + measurements=[ + Measurement(name="duplicates", value=duplicates, unit="number"), + ], + summary=f"Duplicates in the KG" + ) # find all duplicate entities in the KG # using diff --git a/src/kgpipe_eval/metrics/entity_alignment.py b/src/kgpipe_eval/metrics/entity_alignment.py new file mode 100644 index 0000000..8457fd0 --- /dev/null +++ b/src/kgpipe_eval/metrics/entity_alignment.py @@ -0,0 +1,45 @@ +from kgpipe_eval.utils.kg_utils import Term +from typing import NamedTuple +from kgpipe.util.embeddings.st_emb import get_model +from kgpipe.common import KG +from typing import Literal +from kgpipe_eval.utils.measurement_utils import BCMeasurement + +from kgpipe_eval.api import Metric +from pydantic import BaseModel +from rdflib import RDFS + +# TODO +# measures precision, recall, f1 score, etc. + +TODO = None + +def eval_entity_alignment(kg: KG, config: TODO): + pass + +def eval_entity_alignment_by_label_embedding(kg: KG, threshold: float = 0.5): + model = get_model() + # entity_dict = load_entity_dict(entity_dict_path) + # entity_labels = list(set([entity_dict[uri].entity_label for uri in entity_dict if entity_dict[uri].entity_label is not None])) + entity_labels = [] # TODO: get entity labels from the KG + entity_labels_embeddings = model.encode(entity_labels, convert_to_numpy=True, show_progress_bar=False) + + found_labels = [] + overlapping_entities = set() + overlapping_entities_strict = set() + + graph = kg.get_graph() + for s, p, label in graph.triples((None, RDFS.label, None)): + found_labels.append(str(label)) + + found_labels_embeddings = model.encode(found_labels, convert_to_numpy=True, show_progress_bar=False) + + return BCMeasurement( + tp=len(overlapping_entities), + fp=len(found_labels) - len(overlapping_entities), + tn=len(kg.entities) - len(found_labels), + fn=len(kg.entities) - len(overlapping_entities) + ) + +class EntityAlignmentMetric(Metric): + pass \ No newline at end of file diff --git a/src/kgpipe_eval/metrics/llm_annotation.py b/src/kgpipe_eval/metrics/llm_annotation.py new file mode 100644 index 0000000..49b099a --- /dev/null +++ b/src/kgpipe_eval/metrics/llm_annotation.py @@ -0,0 +1,4 @@ +from kgpipe_eval.api import Metric + +class LLM_KgAccuracyMetric(Metric): + pass \ No newline at end of file diff --git a/src/kgpipe_eval/metrics/reference_entities.py b/src/kgpipe_eval/metrics/reference_entities.py deleted file mode 100644 index 0b14d66..0000000 --- a/src/kgpipe_eval/metrics/reference_entities.py +++ /dev/null @@ -1,5 +0,0 @@ -from kgpipe_eval.metrics.alignment_utils import get_aligned_entities -from kgpipe.common import KG -from typing import Literal - -# TODO \ No newline at end of file diff --git a/src/kgpipe_eval/metrics/statistics.py b/src/kgpipe_eval/metrics/statistics.py index e615e6c..6ce0c89 100644 --- a/src/kgpipe_eval/metrics/statistics.py +++ b/src/kgpipe_eval/metrics/statistics.py @@ -1,6 +1,6 @@ from kgpipe.common import KG -from kgpipe_eval.metrics.kg_utils import TripleGraph, KgManager -from kgpipe_eval.api import Metric, MetricResult, MetricConfig +from kgpipe_eval.utils.kg_utils import TripleGraph, KgManager +from kgpipe_eval.api import Metric, MetricResult, Measurement from functools import lru_cache from pydantic import BaseModel @@ -18,7 +18,7 @@ class CountMeasures(BaseModel): property_occurrence: Mapping[str, int] class_occurrence: Mapping[str, int] -@lru_cache(maxsize=1000) +@lru_cache(maxsize=1) def count_measures(kg: TripleGraph) -> CountMeasures: entity_count = 0 # TODO requires distinct entities @@ -44,4 +44,16 @@ def count_measures(kg: TripleGraph) -> CountMeasures: class CountMetric(Metric): def compute(self, kg: TripleGraph) -> MetricResult: - return MetricResult(value=count_measures(kg)) \ No newline at end of file + return MetricResult( + metric=self, + measurements=[ + Measurement(name="entity_count", value=count_measures(kg).entity_count, unit="number"), + Measurement(name="triple_count", value=count_measures(kg).triple_count, unit="number"), + Measurement(name="property_count", value=count_measures(kg).property_count, unit="number"), + Measurement(name="class_count", value=count_measures(kg).class_count, unit="number"), + Measurement(name="property_occurrence", value=count_measures(kg).property_occurrence, unit="number"), + Measurement(name="class_occurrence", value=count_measures(kg).class_occurrence, unit="number"), + ], + summary=f"Measures of entities, triples, properties, classes, property occurrences, and class occurrences" + ) + diff --git a/src/kgpipe_eval/metrics/triple_alignment.py b/src/kgpipe_eval/metrics/triple_alignment.py new file mode 100644 index 0000000..e43d50b --- /dev/null +++ b/src/kgpipe_eval/metrics/triple_alignment.py @@ -0,0 +1,23 @@ +from pydantic import BaseModel +from typing import Literal + +from kgpipe.common import KG +from kgpipe_eval.utils.alignment_utils import Alignment + +# measures precision, recall, f1 score, etc. + +class TripleAlignmentConfig(BaseModel): + reference_kg: KG + similarity_threshold: float = 0.5 + similarity_model: str = "cosine" + similarity_function: str = "cosine" + +def eval_triple_alignment(method: Literal["exact", "fuzzy", "semantic"] = "exact"): + pass + +def eval_triple_alignment_by_label_embedding(method: Literal["exact", "fuzzy", "semantic"] = "exact"): + pass + + +class ReferenceTripleAlignmentMetric(Metric): + pass \ No newline at end of file diff --git a/src/kgpipe_eval/metrics/entailment_utils.py b/src/kgpipe_eval/test/__init__.py similarity index 100% rename from src/kgpipe_eval/metrics/entailment_utils.py rename to src/kgpipe_eval/test/__init__.py diff --git a/src/kgpipe_eval/test/examples.py b/src/kgpipe_eval/test/examples.py new file mode 100644 index 0000000..3637490 --- /dev/null +++ b/src/kgpipe_eval/test/examples.py @@ -0,0 +1,25 @@ +TEST_TURTLE_TRIPLES = """ +@prefix : . +@prefix rdf: . +@prefix rdfs: . +@prefix xsd: . + +:itemA rdf:type :Book ; + rdfs:label "itemA" ; + :bookTitle "The Hobbit, or There and Back Again" ; + :bookAuthor :authorTolkien ; + :isbn13 "9780261102217" . +""" + +REFERENCE_TURTLE_TRIPLES = """ +@prefix : . +@prefix rdf: . +@prefix rdfs: . +@prefix xsd: . + +:itemA rdf:type :Book ; + rdfs:label "itemA" ; + :bookTitle "The Hobbit, or There and Back Again" ; + :bookAuthor :authorTolkien ; + :isbn13 "9780261102217" . +""" \ No newline at end of file diff --git a/src/kgpipe_eval/metrics/reference_triples.py b/src/kgpipe_eval/test/test_alignment_eval.py similarity index 100% rename from src/kgpipe_eval/metrics/reference_triples.py rename to src/kgpipe_eval/test/test_alignment_eval.py diff --git a/src/kgpipe_eval/test/test_llm_eval.py b/src/kgpipe_eval/test/test_llm_eval.py new file mode 100644 index 0000000..c438e71 --- /dev/null +++ b/src/kgpipe_eval/test/test_llm_eval.py @@ -0,0 +1,6 @@ +import pytest + +@pytest.skip(reason="Long running test") +def test_llm_eval(): + pass + diff --git a/src/kgpipe_eval/test/test_source_eval.py b/src/kgpipe_eval/test/test_source_eval.py new file mode 100644 index 0000000..e69de29 diff --git a/src/kgpipe_eval/test/test_statistics_eval.py b/src/kgpipe_eval/test/test_statistics_eval.py new file mode 100644 index 0000000..2f1221a --- /dev/null +++ b/src/kgpipe_eval/test/test_statistics_eval.py @@ -0,0 +1,7 @@ +from kgpipe_eval.metrics.statistics import CountMetric +from kgpipe_eval.test.examples import TEST_TURTLE_TRIPLES, REFERENCE_TURTLE_TRIPLES + +def test_count_metric(): + metric = CountMetric() + report = metric.compute(TEST_TURTLE_TRIPLES) + render_metric_as_table(report, show_details=SHOW_DETAILS) \ No newline at end of file diff --git a/src/kgpipe_eval/test/utils.py b/src/kgpipe_eval/test/utils.py new file mode 100644 index 0000000..0e2b72c --- /dev/null +++ b/src/kgpipe_eval/test/utils.py @@ -0,0 +1,11 @@ +from pathlib import Path +from kgpipe.common import KG + +from kgpipe_eval.test.examples import * + +def get_test_kg() -> KG: + return KG() + +def get_reference_kg() -> KG: + return KG() + diff --git a/src/kgpipe_eval/utils/__init__.py b/src/kgpipe_eval/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/kgpipe_eval/utils/alignment_utils.py b/src/kgpipe_eval/utils/alignment_utils.py new file mode 100644 index 0000000..8f8d0fa --- /dev/null +++ b/src/kgpipe_eval/utils/alignment_utils.py @@ -0,0 +1,68 @@ +from kgpipe.common import KG +from typing import Literal, NamedTuple +from functools import lru_cache +from pydantic import BaseModel + +from kgpipe_eval.utils.kg_utils import TripleGraph, Term, Triple +from kgpipe.util.embeddings.st_emb import get_model + +from rdflib import RDFS, RDF +import numpy as np + +class AlignmentConfig(BaseModel): + model: str = "sentence-transformer" + similarity: str = "cosine" + threshold: float = 0.5 + +# TODO source entities csv to label only graph + +CONFIG=None +# layz config dict +def get_config() -> dict: + global CONFIG + if CONFIG is None: + # TODO + pass + return CONFIG + +EntityAlignment = NamedTuple("EntityAlignment", [("source", Term), ("target", Term)]) +TripleAlignment = NamedTuple("TripleAlignment", [("source", Triple), ("target", Triple)]) + +# Core alignment method interfaces + +@lru_cache(maxsize=1000) +def get_aligned_entities(kg: KG, reference_kg: KG, method: Literal["exact", "fuzzy", "semantic"] = "exact") -> list[Entity]: + return kg.entities.intersection(reference_kg.entities) + +def get_aligned_triples(kg: KG, reference_kg: KG, method: Literal["exact", "fuzzy", "semantic"] = "exact") -> list[Triple]: + return kg.triples.intersection(reference_kg.triples) + +# Helper methods + +def get_entity_uri_label_pairs(triple_graph: TripleGraph) -> list[tuple[Term, Term]]: + return [(s, label) for s, _, label in triple_graph.triples((None, RDFS.label, None))] + +# Specific alignment methods + +def align_entities_by_label_embedding(triple_graph: TripleGraph, ref_triple_graph: TripleGraph, model="TODO", similarity="cosine", threshold=0.5): + model = get_model() + ref_entity_labels = [str(label) for s, _, label in ref_triple_graph.triples((None, RDFS.label, None))] + ref_entity_labels_embeddings = model.encode(ref_entity_labels, convert_to_numpy=True, show_progress_bar=False) + + gen_entity_labels = [str(label) for s, _, label in triple_graph.triples((None, RDFS.label, None))] + gen_entity_labels_embeddings = model.encode(gen_entity_labels, convert_to_numpy=True, show_progress_bar=False) + + for s, _, label in triple_graph.triples((None, RDFS.label, None)): + gen_entity_labels.append(str(label)) + + sims = np.dot(gen_entity_labels_embeddings, ref_entity_labels_embeddings.T) + + alignments = [] + for i in range(sims.shape[0]): + best_j = np.argmax(sims[i]) + if sims[i][best_j] >= threshold: + alignments.append(EntityAlignment(source=gen_entity_labels[i], target=ref_entity_labels[best_j], score=sims[i][best_j])) + return alignments + +def align_by_label_alias_embedding(triple_graph: TripleGraph, model="", similarity="cosine", threshold=0.5): + pass diff --git a/src/kgpipe_eval/utils/annotation_utils.py b/src/kgpipe_eval/utils/annotation_utils.py new file mode 100644 index 0000000..c196a77 --- /dev/null +++ b/src/kgpipe_eval/utils/annotation_utils.py @@ -0,0 +1,51 @@ +from typing import Literal + +# Labels + +def get_labeled_entities(kg: KG, reference_kg: KG, method: Literal["exact", "fuzzy", "semantic"] = "exact") -> list[Entity]: + return kg.entities.intersection(reference_kg.entities) + +def get_labeled_triples(kg: KG, reference_kg: KG, method: Literal["exact", "fuzzy", "semantic"] = "exact") -> list[Triple]: + return kg.triples.intersection(reference_kg.triples) + + +def label_triples_with_llm(Triple): + """ +You are validating RDF triples. + +Task 1: +For each triple, decide whether it is: +- plausible in isolation +- implausible in isolation +- unclear + +Task 2: +Considering that all triples refer to the same subject node, decide whether the set is: +- coherent +- ambiguous +- conflated +- temporally inconsistent +- geographically inconsistent + +Task 3: +Explain which triples are mutually incompatible and why. +{ + "triple_labels": [ + { + "triple": ":Paris :locatedIn :France .", + "label": "plausible_in_isolation" + }, + { + "triple": ":Paris :population \"2,100,000\" .", + "label": "plausible_in_isolation" + }, + { + "triple": ":Paris :locatedIn :Texas .", + "label": "plausible_in_isolation" + } + ], + "entity_label": "conflated", + "graph_label": "contextually_incompatible", + "explanation": "The subject :Paris appears to merge Paris, France and Paris, Texas." +} + """ \ No newline at end of file diff --git a/src/kgpipe_eval/utils/entailment_utils.py b/src/kgpipe_eval/utils/entailment_utils.py new file mode 100644 index 0000000..19e8cb0 --- /dev/null +++ b/src/kgpipe_eval/utils/entailment_utils.py @@ -0,0 +1,7 @@ + + +def check_entailment(): + pass + +def check_entailment_by_llm(): + pass \ No newline at end of file diff --git a/src/kgpipe_eval/metrics/kg_utils.py b/src/kgpipe_eval/utils/kg_utils.py similarity index 85% rename from src/kgpipe_eval/metrics/kg_utils.py rename to src/kgpipe_eval/utils/kg_utils.py index bfd1232..13dbcde 100644 --- a/src/kgpipe_eval/metrics/kg_utils.py +++ b/src/kgpipe_eval/utils/kg_utils.py @@ -4,14 +4,11 @@ from pathlib import Path from typing import Iterable, Protocol, Union, runtime_checkable, Optional, Tuple, Literal -from rdflib import RDF, Graph -from rdflib.term import Identifier -from rdflib.term import Literal -from rdflib.term import URIRef +from rdflib import RDF, Graph, RDFS +from rdflib.term import Identifier, Literal, URIRef from kgpipe.common import KG - KgLike = Union[KG, Graph, str, Path] Term = Union[Identifier, str, URIRef, Literal] @@ -35,6 +32,15 @@ class TripleGraph(Protocol): def triples(self, triple_pattern: TriplePattern) -> Iterable[Triple]: pass + def subjects(self) -> Iterable[Term]: + pass + + def labels(self, term: Term) -> Literal: + pass + + def types(self, term: Term) -> Iterable[Term]: + pass + def close(self) -> None: pass @@ -106,6 +112,18 @@ def triples(self, triple_pattern: TriplePattern) -> Iterable[Triple]: # RDFLib yields (s, p, o) as Identifiers return g.triples(triple_pattern) + def subjects(self) -> Iterable[Term]: + g = self._graph() + return g.subjects(unique=True) + + def labels(self, term: Term) -> Literal: + g = self._graph() + return g.triples((term, RDFS.label, None)) + + def types(self, term: Term) -> Iterable[Term]: + g = self._graph() + return g.triples((term, RDF.type, None)) + class KgManager: """ KgManager is a class that manages the loading and unloading of KGs. diff --git a/src/kgpipe_eval/utils/measurement_utils.py b/src/kgpipe_eval/utils/measurement_utils.py new file mode 100644 index 0000000..8cca7e7 --- /dev/null +++ b/src/kgpipe_eval/utils/measurement_utils.py @@ -0,0 +1,36 @@ +from pydantic import BaseModel + +class BinaryClassificationMeasurement(BaseModel): + tp: int + fp: int + tn: int + fn: int + + def accuracy(self) -> float: + return (self.tp + self.tn) / (self.tp + self.tn + self.fp + self.fn) + + def precision(self) -> float: + return self.tp / (self.tp + self.fp) + + def recall(self) -> float: + return self.tp / (self.tp + self.fn) + + def f1_score(self) -> float: + return 2 * self.precision() * self.recall() / (self.precision() + self.recall()) + + def __str__(self): + return f"tp: {self.tp}, fp: {self.fp}, tn: {self.tn}, fn: {self.fn}, accuracy: {self.accuracy()}, precision: {self.precision()}, recall: {self.recall()}, f1_score: {self.f1_score()}" + + def __dict__(self): + return { + "tp": self.tp, + "fp": self.fp, + "tn": self.tn, + "fn": self.fn, + "accuracy": self.accuracy(), + "precision": self.precision(), + "recall": self.recall(), + "f1_score": self.f1_score() + } + +BCMeasurement = BinaryClassificationMeasurement \ No newline at end of file diff --git a/src/kgpipe_eval/utils/verbalize_utils.py b/src/kgpipe_eval/utils/verbalize_utils.py new file mode 100644 index 0000000..7b31cfc --- /dev/null +++ b/src/kgpipe_eval/utils/verbalize_utils.py @@ -0,0 +1,16 @@ +from kgpipe_eval.utils.kg_utils import Triple, TripleGraph, TriplePattern + +def verbalize_triple_simple(triple: Triple, TripleGraph) -> str: + """ + using label of subject, predicate, object to verbalize the triple + """ + return f"{triple[0]} {triple[1]} {triple[2]}" + +def verbalize_triples(triples: list[Triple]) -> list[str]: + pass + +def verbalize_triple_graph(triple_graph: TripleGraph) -> list[str]: + pass + +def verbalize_triple_graph_subject_groups(triple_graph: TripleGraph) -> list[list[str]]: + pass \ No newline at end of file From 9454790bbf486924decf87d67f3432681bd91aa6 Mon Sep 17 00:00:00 2001 From: Marvin Date: Wed, 8 Apr 2026 15:58:50 +0200 Subject: [PATCH 40/96] feat(eval): added refactored metric impls --- src/kgpipe_eval/api.py | 23 ++-- src/kgpipe_eval/metrics/__init__.py | 26 +++- .../metrics/consistency_violations.py | 40 +++--- src/kgpipe_eval/metrics/duplicates.py | 41 ++++--- src/kgpipe_eval/metrics/entity_alignment.py | 114 +++++++++++++----- src/kgpipe_eval/metrics/statistics.py | 39 ++++-- src/kgpipe_eval/metrics/triple_alignment.py | 25 ++-- 7 files changed, 219 insertions(+), 89 deletions(-) diff --git a/src/kgpipe_eval/api.py b/src/kgpipe_eval/api.py index 8b37e9e..36c821a 100644 --- a/src/kgpipe_eval/api.py +++ b/src/kgpipe_eval/api.py @@ -1,8 +1,8 @@ -from dataclasses import dataclass -from functools import lru_cache +from __future__ import annotations + from abc import ABC, abstractmethod -from typing import Callable -from kgpipe.common.model.kg import KgKg +from dataclasses import dataclass +from typing import Any # MetricConfig (rich, typed, input) @@ -20,7 +20,7 @@ class MetricConfig: @dataclass(frozen=True) class Measurement: name: str - value: int | float | str | bool + value: Any unit: str | None = None @dataclass(frozen=True) @@ -30,11 +30,18 @@ class MetricResult: summary: str | None = None # TODO metadata/properties: dict[str, int | float | str | bool] = field(default_factory=dict) -@dataclass(frozen=True) -class Metric: +class Metric(ABC): + """ + Minimal metric interface for the `kgpipe eval-new` CLI. + + Metrics are instantiated (usually with default config) and then run via `compute(...)`. + """ + key: str description: str - compute: Callable[[KgKg, MetricConfig], MetricResult] + + @abstractmethod + def compute(self, *args: Any, **kwargs: Any) -> MetricResult: ... # --- diff --git a/src/kgpipe_eval/metrics/__init__.py b/src/kgpipe_eval/metrics/__init__.py index a1ccd07..fb29624 100644 --- a/src/kgpipe_eval/metrics/__init__.py +++ b/src/kgpipe_eval/metrics/__init__.py @@ -1,4 +1,28 @@ - +from .statistics import CountMetric +from .triple_alignment import TripleAlignmentMetric +from .entity_alignment import EntityAlignmentMetric +from .duplicates import DuplicateMetric +from .consistency_violations import ( + DisjointDomainMetric, + DomainMetric, + RangeMetric, + RelationDirectionMetric, + DatatypeMetric, + DatatypeFormatMetric, +) + +__all__ = [ + "CountMetric", + "TripleAlignmentMetric", + "EntityAlignmentMetric", + "DuplicateMetric", + "DisjointDomainMetric", + "DomainMetric", + "RangeMetric", + "RelationDirectionMetric", + "DatatypeMetric", + "DatatypeFormatMetric", +] # @dataclass(frozen=True) # class BinaryClassificationStats: diff --git a/src/kgpipe_eval/metrics/consistency_violations.py b/src/kgpipe_eval/metrics/consistency_violations.py index 7cd1141..6e0ddd4 100644 --- a/src/kgpipe_eval/metrics/consistency_violations.py +++ b/src/kgpipe_eval/metrics/consistency_violations.py @@ -1,38 +1,44 @@ from kgpipe_eval.api import Metric -from pydantic import BaseModel +from pydantic import BaseModel, model_validator, ConfigDict from kgpipe.common import KG +from pathlib import Path +from kgpipe_eval.utils.kg_utils import TripleGraph -class DisjointDomainConfig(BaseModel): - pass +class ConsistencyViolationsConfig(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + reference_kg: KG + ontology_path: Path -class DomainConfig(BaseModel): - pass - -class RangeConfig(BaseModel): - pass - -class RelationDirectionConfig(BaseModel): - pass + @model_validator(mode="after") + def _require_reference_kg_or_ontology_path(self): + if self.reference_kg is None and self.ontology_path is None: + raise ValueError("Provide either `reference_kg` or `ontology_path`.") + return self class DisjointDomainMetric(Metric): - def compute(self, kg: KG, ref_kg: KG, config: DisjointDomainConfig): + def compute(self, kg: TripleGraph, config: ConsistencyViolationsConfig): pass class DomainMetric(Metric): - pass + def compute(self, kg: TripleGraph, config: ConsistencyViolationsConfig): + pass class RangeMetric(Metric): - pass + def compute(self, kg: TripleGraph, config: ConsistencyViolationsConfig): + pass class RelationDirectionMetric(Metric): - pass + def compute(self, kg: TripleGraph, config: ConsistencyViolationsConfig): + pass class DatatypeMetric(Metric): - pass + def compute(self, kg: TripleGraph, config: ConsistencyViolationsConfig): + pass class DatatypeFormatMetric(Metric): - pass + def compute(self, kg: TripleGraph, config: ConsistencyViolationsConfig): + pass # class OntologyClassCoverageMetric(): # pass diff --git a/src/kgpipe_eval/metrics/duplicates.py b/src/kgpipe_eval/metrics/duplicates.py index 1b05749..869f3ff 100644 --- a/src/kgpipe_eval/metrics/duplicates.py +++ b/src/kgpipe_eval/metrics/duplicates.py @@ -1,43 +1,56 @@ -from kgpipe.util.embeddings.st_emb import get_model -from kgpipe_eval.utils.alignment_utils import Alignment, align_entities_by_label_embedding +from kgpipe_eval.utils.alignment_utils import EntityAlignment, align_entities_by_label_embedding, EntityAlignmentConfig from kgpipe_eval.api import Metric, MetricResult, Measurement +from kgpipe_eval.utils.kg_utils import Term, TripleGraph -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from kgpipe.common import KG import numpy as np DEBUG = False class DuplicateConfig(BaseModel): - threshold: float = 0.5 - similarity_model: str = "cosine" - similarity_function: str = "cosine" - reference_kg: KG + model_config = ConfigDict(arbitrary_types_allowed=True) + entity_alignment_config: EntityAlignmentConfig -def eval_duplicates(kg: KG, config: DuplicateConfig): +class DuplicateMeasures(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + duplicates: int + total_references: int + already_matched_references: set[Term] + +def eval_duplicates(kg: TripleGraph, config: DuplicateConfig): """ checks expected & integrated source entity overlap using label embeddings """ - alignments : list[Alignment] = align_entities_by_label_embedding(kg, ref_kg, threshold=config.threshold, model=config.similarity_model, similarity=config.similarity_function) + alignments : list[EntityAlignment] = align_entities_by_label_embedding(kg, config.entity_alignment_config) - duplicates = 0 + duplicates = set() already_matched_references = set() for alignment in alignments: if alignment.target in already_matched_references: - duplicates += 1 + duplicates.add(alignment.target) already_matched_references.add(alignment.target) + if DEBUG: + print("Duplicates:") + for alignment in alignments: + if alignment.target in duplicates: + print(alignment.target, alignment.source, alignment.score) + return duplicates class DuplicateMetric(Metric): - def compute(self, kg: KG, ref_kg: KG, config: DuplicateConfig): - duplicates = eval_duplicates(kg, ref_kg, config) + def compute(self, kg: TripleGraph, config: DuplicateConfig): + duplicates = eval_duplicates(kg, config) + entity_count = len(list(kg.entities())) return MetricResult( metric=self, measurements=[ - Measurement(name="duplicates", value=duplicates, unit="number"), + Measurement(name="duplicates", value=len(duplicates), unit="number"), + Measurement(name="entity_count", value=entity_count, unit="number"), + Measurement(name="duplicates_ratio", value=len(duplicates) / entity_count, unit="percentage"), ], summary=f"Duplicates in the KG" ) diff --git a/src/kgpipe_eval/metrics/entity_alignment.py b/src/kgpipe_eval/metrics/entity_alignment.py index 8457fd0..ae68f40 100644 --- a/src/kgpipe_eval/metrics/entity_alignment.py +++ b/src/kgpipe_eval/metrics/entity_alignment.py @@ -1,45 +1,99 @@ -from kgpipe_eval.utils.kg_utils import Term -from typing import NamedTuple -from kgpipe.util.embeddings.st_emb import get_model from kgpipe.common import KG -from typing import Literal + +from kgpipe_eval.api import Metric, Measurement, MetricResult + from kgpipe_eval.utils.measurement_utils import BCMeasurement +from kgpipe_eval.utils.alignment_utils import align_entities_by_label_embedding, EntityAlignmentConfig, load_entity_uri_label_type_pairs, get_entity_uri_label_type_pairs + +# Core Interface + +def eval_entity_alignment(kg: KG, config: EntityAlignmentConfig): + if config.method == "label_embedding": + alignments = eval_entity_alignment_by_label_embedding(kg, config) + elif config.method == "label_alias_embedding": + alignments = eval_entity_alignment_by_label_alias_embedding(kg, config) + elif config.method == "label_embedding_and_type": + alignments = eval_entity_alignment_by_label_embedding_and_type(kg, config) + else: + raise ValueError(f"Invalid method: {config.method}") + return alignments + +# Specific Implementations + +def eval_entity_alignment_by_label_embedding_and_type(kg: KG, config: EntityAlignmentConfig): + alignments = align_entities_by_label_embedding(kg, config) -from kgpipe_eval.api import Metric -from pydantic import BaseModel -from rdflib import RDFS + ref_entity_uri_label_type_pairs = load_entity_uri_label_type_pairs(config) + gen_entity_uri_label_type_pairs = list(get_entity_uri_label_type_pairs(kg)) -# TODO -# measures precision, recall, f1 score, etc. + ref_types = {pair.uri: pair.type for pair in ref_entity_uri_label_type_pairs if pair.type is not None} + # TODO gen_types can be multiple types, we need to handle this + gen_types = {pair.uri: pair.type for pair in gen_entity_uri_label_type_pairs if pair.type is not None} -TODO = None + filtered_alignments = [] + for alignment in alignments: + if alignment.target in ref_types and alignment.source in gen_types: + if ref_types[alignment.target] == gen_types[alignment.source]: + filtered_alignments.append(alignment) -def eval_entity_alignment(kg: KG, config: TODO): - pass + ref_uris = set(pair.uri for pair in ref_entity_uri_label_type_pairs) + gen_uris = set(pair.uri for pair in gen_entity_uri_label_type_pairs) + aligned_gen_uris = set(alignment.target for alignment in filtered_alignments) + aligned_ref_uris = set(alignment.source for alignment in filtered_alignments) -def eval_entity_alignment_by_label_embedding(kg: KG, threshold: float = 0.5): - model = get_model() - # entity_dict = load_entity_dict(entity_dict_path) - # entity_labels = list(set([entity_dict[uri].entity_label for uri in entity_dict if entity_dict[uri].entity_label is not None])) - entity_labels = [] # TODO: get entity labels from the KG - entity_labels_embeddings = model.encode(entity_labels, convert_to_numpy=True, show_progress_bar=False) + tp = len(ref_uris & aligned_gen_uris) # generated entities that are also in the reference + fp = len(gen_uris - aligned_ref_uris) # generated entities that are not in the reference + tn = 0 + fn = len(ref_uris - aligned_gen_uris) # missing generated entities that are in the reference - found_labels = [] - overlapping_entities = set() - overlapping_entities_strict = set() + return BCMeasurement( + tp=tp, + fp=fp, + tn=tn, + fn=fn + ) + +def eval_entity_alignment_by_label_embedding(kg: KG, config: EntityAlignmentConfig): + alignments = align_entities_by_label_embedding(kg, config) - graph = kg.get_graph() - for s, p, label in graph.triples((None, RDFS.label, None)): - found_labels.append(str(label)) + ref_entity_uri_label_type_pairs = load_entity_uri_label_type_pairs(config) + gen_entity_uri_label_type_pairs = list(get_entity_uri_label_type_pairs(kg)) - found_labels_embeddings = model.encode(found_labels, convert_to_numpy=True, show_progress_bar=False) + ref_uris = set(pair.uri for pair in ref_entity_uri_label_type_pairs) + gen_uris = set(pair.uri for pair in gen_entity_uri_label_type_pairs) + aligned_gen_uris = set(alignment.target for alignment in alignments) + aligned_ref_uris = set(alignment.source for alignment in alignments) + + tp = len(ref_uris & aligned_gen_uris) # generated entities that are also in the reference + fp = len(gen_uris - aligned_ref_uris) # generated entities that are not in the reference + tn = 0 + fn = len(ref_uris - aligned_gen_uris) # missing generated entities that are in the reference return BCMeasurement( - tp=len(overlapping_entities), - fp=len(found_labels) - len(overlapping_entities), - tn=len(kg.entities) - len(found_labels), - fn=len(kg.entities) - len(overlapping_entities) + tp=tp, + fp=fp, + tn=tn, + fn=fn ) +def eval_entity_alignment_by_label_alias_embedding(kg: KG, config: EntityAlignmentConfig): + raise NotImplementedError("Label alias embedding alignment is not implemented yet") + +# Metric Implementation + class EntityAlignmentMetric(Metric): - pass \ No newline at end of file + def compute(self, kg: KG, config: EntityAlignmentConfig): + alignments: BCMeasurement = eval_entity_alignment(kg, config) + return MetricResult( + metric=self, + measurements=[ + Measurement(name="tp", value=alignments.tp, unit="number"), + Measurement(name="fp", value=alignments.fp, unit="number"), + Measurement(name="tn", value=alignments.tn, unit="number"), + Measurement(name="fn", value=alignments.fn, unit="number"), + Measurement(name="precision", value=alignments.precision(), unit="percentage"), + Measurement(name="recall", value=alignments.recall(), unit="percentage"), + Measurement(name="f1_score", value=alignments.f1_score(), unit="percentage"), + ], + summary=f"Entity alignment by {config.method}" + ) \ No newline at end of file diff --git a/src/kgpipe_eval/metrics/statistics.py b/src/kgpipe_eval/metrics/statistics.py index 6ce0c89..191776d 100644 --- a/src/kgpipe_eval/metrics/statistics.py +++ b/src/kgpipe_eval/metrics/statistics.py @@ -1,5 +1,4 @@ -from kgpipe.common import KG -from kgpipe_eval.utils.kg_utils import TripleGraph, KgManager +from kgpipe_eval.utils.kg_utils import TripleGraph from kgpipe_eval.api import Metric, MetricResult, Measurement from functools import lru_cache @@ -18,14 +17,17 @@ class CountMeasures(BaseModel): property_occurrence: Mapping[str, int] class_occurrence: Mapping[str, int] -@lru_cache(maxsize=1) +# @lru_cache(maxsize=1) def count_measures(kg: TripleGraph) -> CountMeasures: - entity_count = 0 # TODO requires distinct entities triple_count = 0 + subject_count = 0 # TODO misses shallow object entities class_occurrence = defaultdict(int) property_occurrence = defaultdict(int) + + for _ in kg.subjects(): + subject_count += 1 for s, p, o in kg.triples((None, None, None)): triple_count += 1 @@ -34,7 +36,7 @@ def count_measures(kg: TripleGraph) -> CountMeasures: property_occurrence[str(p)] += 1 return CountMeasures( - entity_count=entity_count, + entity_count=subject_count, property_count=len(property_occurrence.keys()), triple_count=triple_count, class_count=len(class_occurrence.keys()), @@ -43,17 +45,32 @@ def count_measures(kg: TripleGraph) -> CountMeasures: ) class CountMetric(Metric): + key = "CountMetric" + description = "Counts triples/classes/properties (basic statistics)." + def compute(self, kg: TripleGraph) -> MetricResult: + counts = count_measures(kg) return MetricResult( metric=self, measurements=[ - Measurement(name="entity_count", value=count_measures(kg).entity_count, unit="number"), - Measurement(name="triple_count", value=count_measures(kg).triple_count, unit="number"), - Measurement(name="property_count", value=count_measures(kg).property_count, unit="number"), - Measurement(name="class_count", value=count_measures(kg).class_count, unit="number"), - Measurement(name="property_occurrence", value=count_measures(kg).property_occurrence, unit="number"), - Measurement(name="class_occurrence", value=count_measures(kg).class_occurrence, unit="number"), + Measurement(name="entity_count", value=counts.entity_count, unit="number"), + Measurement(name="triple_count", value=counts.triple_count, unit="number"), + Measurement(name="property_count", value=counts.property_count, unit="number"), + Measurement(name="class_count", value=counts.class_count, unit="number"), + Measurement(name="property_occurrence", value=counts.property_occurrence, unit="dictionary"), + Measurement(name="class_occurrence", value=counts.class_occurrence, unit="dictionary"), ], summary=f"Measures of entities, triples, properties, classes, property occurrences, and class occurrences" ) +class DegreeMetric(Metric): + # def compute(self, kg: TripleGraph) -> MetricResult: + # degrees = degree_measures(kg) + # return MetricResult( + # metric=self, + # measurements=[ + # Measurement(name="degree", value=degrees.degree, unit="number"), + # ], + # summary=f"Measures of degrees" + # ) + pass \ No newline at end of file diff --git a/src/kgpipe_eval/metrics/triple_alignment.py b/src/kgpipe_eval/metrics/triple_alignment.py index e43d50b..7934ff6 100644 --- a/src/kgpipe_eval/metrics/triple_alignment.py +++ b/src/kgpipe_eval/metrics/triple_alignment.py @@ -1,23 +1,32 @@ -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from typing import Literal from kgpipe.common import KG -from kgpipe_eval.utils.alignment_utils import Alignment +from kgpipe_eval.metrics.entity_alignment import EntityAlignmentConfig +from kgpipe_eval.utils.measurement_utils import BCMeasurement +from kgpipe_eval.api import Metric # measures precision, recall, f1 score, etc. class TripleAlignmentConfig(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) reference_kg: KG - similarity_threshold: float = 0.5 - similarity_model: str = "cosine" - similarity_function: str = "cosine" + entity_alignment_config: EntityAlignmentConfig + value_sim_threshold: float = 0.5 -def eval_triple_alignment(method: Literal["exact", "fuzzy", "semantic"] = "exact"): - pass +# def eval_triple_alignment(method: Literal["exact", "fuzzy", "semantic"] = "exact"): +# pass def eval_triple_alignment_by_label_embedding(method: Literal["exact", "fuzzy", "semantic"] = "exact"): pass +def eval_triple_alignment_by_label_embedding_soft_literals(method: Literal["exact", "fuzzy", "semantic"] = "exact"): + pass + class ReferenceTripleAlignmentMetric(Metric): - pass \ No newline at end of file + pass + + +# Backward-compatibility alias (imported by `kgpipe_eval.metrics.__init__`). +TripleAlignmentMetric = ReferenceTripleAlignmentMetric \ No newline at end of file From 4bb2c61232ce8f205015f045b0d90c97b9d0224a Mon Sep 17 00:00:00 2001 From: Marvin Date: Wed, 8 Apr 2026 15:59:30 +0200 Subject: [PATCH 41/96] feat(eval): yaml config loader and evaluator impl --- src/kgpipe/cli/eval_new.py | 175 +++++++++++++++++++++++++ src/kgpipe_eval/config/manager.py | 208 ++++++++++++++++++++++++++++++ src/kgpipe_eval/evaluator.py | 65 ++++++++++ 3 files changed, 448 insertions(+) create mode 100644 src/kgpipe/cli/eval_new.py create mode 100644 src/kgpipe_eval/config/manager.py create mode 100644 src/kgpipe_eval/evaluator.py diff --git a/src/kgpipe/cli/eval_new.py b/src/kgpipe/cli/eval_new.py new file mode 100644 index 0000000..a11eb17 --- /dev/null +++ b/src/kgpipe/cli/eval_new.py @@ -0,0 +1,175 @@ +import click +from rich.console import Console +from rich.table import Table +from typing import List, Optional, Sequence, Any +import json +from pathlib import Path + +from kgpipe_eval.metrics.statistics import CountMetric +from kgpipe_eval.metrics.duplicates import DuplicateMetric +from kgpipe_eval.metrics.entity_alignment import EntityAlignmentMetric +from kgpipe_eval.utils.kg_utils import KgManager +from kgpipe_eval.config.manager import load_metric_configs +from kgpipe_eval.evaluator import Evaluator +# from kgpipe_eval.metrics.semantic import OntologyClassCoverageMetric, OntologyRelationCoverageMetric, OntologyNamespaceCoverageMetric +# from kgpipe_eval.metrics.reference import PrecisionMetric, RecallMetric, F1ScoreMetric +# from kgpipe_eval.metrics.efficiency import RuntimeMetric, MemoryUsageMetric, CostMetric +# from kgpipe_eval.metrics.quality import QualityMetric +# from kgpipe_eval.metrics.completeness import CompletenessMetric +# from kgpipe_eval.metrics.accuracy import AccuracyMetric + +console = Console() + + +def _available_metric_instances() -> dict[str, Any]: + # Keep this explicit until the metrics package is more complete/stable. + return { + "CountMetric": CountMetric(), + "DuplicateMetric": DuplicateMetric(), + "EntityAlignmentMetric": EntityAlignmentMetric(), + } + +def _normalize_key(k: str) -> str: + return k.strip().lower().replace("-", "_") + + +def _metric_key(metric: Any) -> str: + return getattr(metric, "key", metric.__class__.__name__) + + +def _build_confs_for_selected_metrics( + selected_metric_instances: list[Any], + loaded_confs: dict[str, Any], +) -> dict[str, Any]: + """ + Convert configs loaded from YAML (keyed by YAML metric id) into a dict keyed by + metric class name / `.key` (what Evaluator uses). + """ + confs_by_norm = {_normalize_key(k): v for k, v in loaded_confs.items()} + out: dict[str, Any] = {} + + # Common YAML → class-name aliases + alias_to_metric_key: dict[str, str] = { + "duplicates": "DuplicateMetric", + "duplicate": "DuplicateMetric", + "entity_align": "EntityAlignmentMetric", + "entity_alignment": "EntityAlignmentMetric", + } + + for metric in selected_metric_instances: + mkey = _metric_key(metric) + norm_mkey = _normalize_key(mkey) + norm_cls = _normalize_key(metric.__class__.__name__) + + cfg = ( + confs_by_norm.get(norm_mkey) + or confs_by_norm.get(norm_cls) + or confs_by_norm.get(_normalize_key(alias_to_metric_key.get(norm_mkey, ""))) + or confs_by_norm.get(_normalize_key(alias_to_metric_key.get(norm_cls, ""))) + or confs_by_norm.get(norm_mkey.replace("metric", "")) + or confs_by_norm.get(norm_cls.replace("metric", "")) + ) + + if cfg is not None: + out[mkey] = cfg + out[metric.__class__.__name__] = cfg + + return out + + + +def _render_results_table(kg_path: str, metric_key: str, measurements: Sequence[Any], summary: Optional[str]) -> None: + table = Table(title=f"{Path(kg_path).name} — {metric_key}") + table.add_column("Measurement", style="cyan") + table.add_column("Value", style="green") + table.add_column("Unit", style="magenta") + + for m in measurements: + unit = getattr(m, "unit", None) + value = getattr(m, "value", None) + name = getattr(m, "name", None) + table.add_row(str(name), json.dumps(value, ensure_ascii=False, default=str) if not isinstance(value, (str, int, float, bool)) else str(value), "" if unit is None else str(unit)) + + console.print(table) + if summary: + console.print(f"[dim]{summary}[/dim]") + console.print("") + + +def _results_to_json_rows(kg_path: str, metric_key: str, measurements: Sequence[Any], summary: Optional[str]) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for m in measurements: + rows.append( + { + "kg_path": kg_path, + "metric": metric_key, + "measurement": getattr(m, "name", None), + "value": getattr(m, "value", None), + "unit": getattr(m, "unit", None), + "summary": summary, + } + ) + return rows + + +@click.command() +@click.argument("kg_paths", nargs=-1, type=click.Path(exists=True)) +@click.option( + "--config", + "-c", + type=click.Path(exists=True), + help="Path to metric config file" +) +@click.option( + "--metrics", + "-m", + multiple=True, + type=click.Choice(sorted(_available_metric_instances().keys())), + help="Metrics to compute" +) +@click.option( + "--output", + "-o", + type=click.Path(dir_okay=False), + help="Write results to a JSON file (list of measurement rows).", +) +@click.pass_context +def eval_new_cmd(ctx: click.Context, kg_paths: List[str], config: Optional[str], metrics: tuple, output: Optional[str]): + """ + Compute selected metrics for one or more KGs. + + KG_PATHS: one or more RDF files/directories that RDFLib can parse. + """ + metric_instances = _available_metric_instances() + selected_metrics = list(metrics) if metrics else list(metric_instances.keys()) + + unknown = [m for m in selected_metrics if m not in metric_instances] + if unknown: + raise click.ClickException(f"Unknown metrics: {', '.join(unknown)}") + + loaded_metric_confs: dict[str, Any] = {} + if config: + loaded_metric_confs = load_metric_configs(config) + + all_rows: list[dict[str, Any]] = [] + + for kg_path in kg_paths: + console.print(f"[bold blue]Evaluating:[/bold blue] {kg_path}") + kg_graph = KgManager.load_kg_from_path(Path(kg_path)) + try: + selected_metric_instances = [metric_instances[k] for k in selected_metrics] + confs = _build_confs_for_selected_metrics(selected_metric_instances, loaded_metric_confs) + + results = Evaluator().run(kg=kg_graph, metrics=selected_metric_instances, confs=confs) + for res in results: + metric_key = _metric_key(res.metric) + _render_results_table(kg_path, metric_key, res.measurements, getattr(res, "summary", None)) + all_rows.extend(_results_to_json_rows(kg_path, metric_key, res.measurements, getattr(res, "summary", None))) + finally: + KgManager.unload_kg(kg_graph) + + if output: + out_path = Path(output) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps(all_rows, indent=2, ensure_ascii=False, default=str) + "\n", encoding="utf-8") + console.print(f"[green]✓ Saved results to[/green] {output}") \ No newline at end of file diff --git a/src/kgpipe_eval/config/manager.py b/src/kgpipe_eval/config/manager.py new file mode 100644 index 0000000..c245681 --- /dev/null +++ b/src/kgpipe_eval/config/manager.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any, Mapping, MutableMapping + +import yaml +from pydantic import BaseModel + +from kgpipe.common import KG +from kgpipe.common.model.data import DataFormat + +from kgpipe_eval.metrics.duplicates import DuplicateConfig +from kgpipe_eval.metrics.triple_alignment import TripleAlignmentConfig +from kgpipe_eval.metrics.consistency_violations import ConsistencyViolationsConfig +from kgpipe_eval.utils.alignment_utils import EntityAlignmentConfig + + +MetricConfigModel = BaseModel + + +def _deep_merge_dict(base: Mapping[str, Any], override: Mapping[str, Any]) -> dict[str, Any]: + """ + Merge override into base recursively (override wins). + """ + out: dict[str, Any] = dict(base) + for k, v in override.items(): + if ( + k in out + and isinstance(out[k], Mapping) + and isinstance(v, Mapping) + ): + out[k] = _deep_merge_dict(out[k], v) + else: + out[k] = v + return out + + +def _kg_from_path(path: Path, *, name: str | None = None) -> KG: + """ + Build a minimal `kgpipe.common.KG` from a filesystem path. + + Notes: + - We infer `format` from the file suffix when possible, otherwise fall back to JSON. + - The KG object lazily parses the graph when `get_graph()` is called. + """ + suffix = path.suffix.lower().lstrip(".") + try: + fmt = DataFormat(suffix) + except Exception: + fmt = DataFormat.JSON + + return KG( + id=str(path), + name=(name or path.stem), + path=path, + format=fmt, + ) + + +def _resolve_entity_alignment_config( + metric_cfg: Mapping[str, Any], + named: Mapping[str, Mapping[str, Any]], +) -> dict[str, Any]: + """ + Resolve an entity alignment config from either: + - inline: `entity_alignment_config: {...}` + - ref: `entity_alignment_config_ref: name` + Optionally supports both; inline values override the referenced dict. + """ + inline = metric_cfg.get("entity_alignment_config") or {} + ref_name = metric_cfg.get("entity_alignment_config_ref") + if ref_name is None: + if not isinstance(inline, Mapping): + raise TypeError("`entity_alignment_config` must be a mapping if provided.") + return dict(inline) + + if not isinstance(ref_name, str) or not ref_name: + raise TypeError("`entity_alignment_config_ref` must be a non-empty string.") + if ref_name not in named: + raise KeyError(f"Unknown entity alignment config ref: {ref_name!r}") + + if not isinstance(inline, Mapping): + raise TypeError("`entity_alignment_config` must be a mapping if provided.") + return _deep_merge_dict(named[ref_name], inline) + + +def load_metric_configs(config_path: str | Path) -> dict[str, MetricConfigModel]: + """ + Load a single YAML file that defines metric configs and optional shared sub-configs. + + Expected YAML structure (minimal): + + ```yaml + entity_alignment_configs: + default: + method: label_embedding + verified_entities_path: path/to/entities.csv + entity_sim_threshold: 0.95 + + metrics: + entity_align: + entity_alignment_config_ref: default + + duplicates: + entity_alignment_config_ref: default + + triple_alignment: + reference_kg_path: path/to/reference.nt + entity_alignment_config_ref: default + value_sim_threshold: 0.5 + ``` + + Returned dict keys are metric keys (e.g. "duplicates") and values are instantiated + Pydantic config objects (e.g. `DuplicateConfig`). + """ + path = Path(config_path) + raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + if not isinstance(raw, Mapping): + raise TypeError("Top-level YAML must be a mapping/dict.") + + named_entity_alignment: dict[str, dict[str, Any]] = {} + raw_named = raw.get("entity_alignment_configs") or {} + if raw_named: + if not isinstance(raw_named, Mapping): + raise TypeError("`entity_alignment_configs` must be a mapping/dict.") + for k, v in raw_named.items(): + if not isinstance(k, str) or not k: + raise TypeError("`entity_alignment_configs` keys must be non-empty strings.") + if not isinstance(v, Mapping): + raise TypeError(f"`entity_alignment_configs.{k}` must be a mapping/dict.") + named_entity_alignment[k] = dict(v) + + metrics_raw = raw.get("metrics") or {} + if not isinstance(metrics_raw, Mapping): + raise TypeError("`metrics` must be a mapping/dict.") + + out: dict[str, MetricConfigModel] = {} + for metric_key, metric_cfg_any in metrics_raw.items(): + if not isinstance(metric_key, str) or not metric_key: + raise TypeError("Metric keys in `metrics` must be non-empty strings.") + if metric_cfg_any is None: + metric_cfg: dict[str, Any] = {} + elif isinstance(metric_cfg_any, Mapping): + metric_cfg = dict(metric_cfg_any) + else: + raise TypeError(f"`metrics.{metric_key}` must be a mapping/dict.") + + # --- Metric-specific instantiation rules + if metric_key in {"entity_align", "entity_alignment"}: + entity_cfg_dict = _resolve_entity_alignment_config(metric_cfg, named_entity_alignment) + # Allow `reference_kg_path` convenience here too + if "reference_kg_path" in entity_cfg_dict and "reference_kg" not in entity_cfg_dict: + ref_path = Path(entity_cfg_dict.pop("reference_kg_path")) + entity_cfg_dict["reference_kg"] = _kg_from_path(ref_path) + out[metric_key] = EntityAlignmentConfig.model_validate(entity_cfg_dict) + continue + + if metric_key in {"duplicates", "duplicate"}: + entity_cfg_dict = _resolve_entity_alignment_config(metric_cfg, named_entity_alignment) + if "reference_kg_path" in entity_cfg_dict and "reference_kg" not in entity_cfg_dict: + ref_path = Path(entity_cfg_dict.pop("reference_kg_path")) + entity_cfg_dict["reference_kg"] = _kg_from_path(ref_path) + out[metric_key] = DuplicateConfig.model_validate( + { + "entity_alignment_config": EntityAlignmentConfig.model_validate(entity_cfg_dict), + } + ) + continue + + if metric_key in {"triple_alignment", "triple_align"}: + cfg_dict: dict[str, Any] = dict(metric_cfg) + entity_cfg_dict = _resolve_entity_alignment_config(metric_cfg, named_entity_alignment) + if "reference_kg_path" in entity_cfg_dict and "reference_kg" not in entity_cfg_dict: + ref_path = Path(entity_cfg_dict.pop("reference_kg_path")) + entity_cfg_dict["reference_kg"] = _kg_from_path(ref_path) + cfg_dict["entity_alignment_config"] = EntityAlignmentConfig.model_validate(entity_cfg_dict) + + # Allow YAML to specify a path rather than an in-memory KG object + if "reference_kg_path" in cfg_dict and "reference_kg" not in cfg_dict: + ref_path = Path(cfg_dict.pop("reference_kg_path")) + cfg_dict["reference_kg"] = _kg_from_path(ref_path) + + out[metric_key] = TripleAlignmentConfig.model_validate(cfg_dict) + continue + + if metric_key in { + "consistency_violations", + "disjoint_domain", + "domain", + "range", + "relation_direction", + "datatype", + "datatype_format", + }: + cfg_dict = dict(metric_cfg) + if "reference_kg_path" in cfg_dict and "reference_kg" not in cfg_dict: + ref_path = Path(cfg_dict.pop("reference_kg_path")) + cfg_dict["reference_kg"] = _kg_from_path(ref_path) + out[metric_key] = ConsistencyViolationsConfig.model_validate(cfg_dict) + continue + + raise KeyError( + f"Unknown metric key {metric_key!r} in config. " + "Add it to `kgpipe_eval.config.manager.load_metric_configs`." + ) + + return out + diff --git a/src/kgpipe_eval/evaluator.py b/src/kgpipe_eval/evaluator.py new file mode 100644 index 0000000..853a2c6 --- /dev/null +++ b/src/kgpipe_eval/evaluator.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import inspect +from dataclasses import dataclass +from typing import Any, Dict, Iterable, List, Mapping, Sequence + +from kgpipe_eval.api import Metric, MetricResult +from kgpipe_eval.utils.kg_utils import TripleGraph + + +def _metric_key(metric: Metric) -> str: + return getattr(metric, "key", metric.__class__.__name__) + + +@dataclass +class Evaluator: + """ + Execute multiple metrics against a KG and pass the right config (if any). + """ + + def run( + self, + kg: TripleGraph, + metrics: Sequence[Metric], + confs: Mapping[str, Any] | None = None, + ) -> List[MetricResult]: + confs = dict(confs or {}) + results: List[MetricResult] = [] + + for metric in metrics: + key = _metric_key(metric) + cfg = confs.get(key, confs.get(key.lower())) + + compute = getattr(metric, "compute", None) + if compute is None: + raise TypeError(f"Metric {key!r} has no compute() method") + + sig = inspect.signature(compute) + # Bound method: typically (kg) or (kg, config) + params = [ + p for p in sig.parameters.values() + if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD) + ] + + try: + if len(params) <= 1: + # compute(self) or compute(self, kg) -- call without config + res = compute(kg) if len(params) == 1 else compute() + else: + # compute(self, kg, config, ...) + if cfg is None: + raise KeyError( + f"Missing config for metric {key!r}. " + f"Provide `confs[{key!r}]`." + ) + res = compute(kg, cfg) + except Exception as e: + raise RuntimeError(f"Failed running metric {key!r}") from e + + if not isinstance(res, MetricResult): + raise TypeError(f"Metric {key!r} returned {type(res)!r}, expected MetricResult") + results.append(res) + + return results + From 584da5a8c9251cecb01276dc8b5b5924679874c0 Mon Sep 17 00:00:00 2001 From: Marvin Date: Wed, 8 Apr 2026 16:00:04 +0200 Subject: [PATCH 42/96] feat(eval): tests for refactor --- src/kgpipe_eval/test/examples.py | 172 +++++++++++++++++- src/kgpipe_eval/test/test_alignment_eval.py | 32 ++++ src/kgpipe_eval/test/test_config_manager.py | 52 ++++++ src/kgpipe_eval/test/test_consistency_eval.py | 0 src/kgpipe_eval/test/test_duplicates_eval.py | 20 ++ src/kgpipe_eval/test/test_evaluator.py | 32 ++++ src/kgpipe_eval/test/test_kg_utils.py | 29 +++ src/kgpipe_eval/test/test_statistics_eval.py | 7 +- src/kgpipe_eval/test/utils.py | 88 ++++++++- 9 files changed, 414 insertions(+), 18 deletions(-) create mode 100644 src/kgpipe_eval/test/test_config_manager.py create mode 100644 src/kgpipe_eval/test/test_consistency_eval.py create mode 100644 src/kgpipe_eval/test/test_duplicates_eval.py create mode 100644 src/kgpipe_eval/test/test_evaluator.py create mode 100644 src/kgpipe_eval/test/test_kg_utils.py diff --git a/src/kgpipe_eval/test/examples.py b/src/kgpipe_eval/test/examples.py index 3637490..dc7aeb3 100644 --- a/src/kgpipe_eval/test/examples.py +++ b/src/kgpipe_eval/test/examples.py @@ -1,25 +1,177 @@ +SEED_TURTLE_TRIPLES = """ +@prefix : . +@prefix o: . +@prefix rdf: . +@prefix rdfs: . +@prefix xsd: . + +:store1 rdf:type o:BookStore ; + rdfs:label "Example Books (Downtown)"@en ; + :countryCode "US" ; + :hasInventory :itemA, :itemB, :itemC . + +:publisherHC rdf:type o:Publisher ; + rdfs:label "HarperCollins" ; + :countryCode "GB" . +""" + TEST_TURTLE_TRIPLES = """ -@prefix : . +@prefix : . +@prefix o: . @prefix rdf: . @prefix rdfs: . @prefix xsd: . -:itemA rdf:type :Book ; - rdfs:label "itemA" ; - :bookTitle "The Hobbit, or There and Back Again" ; +# Entities designed to exercise alignment corner-cases: +# - multiple entities per type (Book/Author/Publisher/Store) +# - missing / extra attributes across graphs +# - literal variations (lang tags, datatypes, different lexical forms) +# - ambiguous labels (near-duplicates, casing differences) +# - multi-valued properties + +:store1 rdf:type o:BookStore ; + rdfs:label "Example Books (Downtown)"@en ; + :countryCode "US" ; + :hasInventory :itemA, :itemB, :itemC . + +:publisherHC rdf:type o:Publisher ; + rdfs:label "HarperCollins" ; + :countryCode "GB" . + +# different wrong type +:publisherPenguin rdf:type o:Author ; + rdfs:label "Penguin Books"@en ; + :countryCode "GB" . + +:authorTolkien rdf:type o:Author ; + rdfs:label "J. R. R. Tolkien" ; + :born "1892-01-03"^^xsd:date ; + :died "1973-09-02"^^xsd:date ; + :sameAs . + +:authorRowling rdf:type o:Author ; + rdfs:label "J.K. Rowling" ; + :born "1965-07-31"^^xsd:date . + +:itemA rdf:type o:Book ; + rdfs:label "The Hobbit"@en ; + :bookTitle "The Hobbit, or There and Back Again"@en ; + :bookAuthor :authorTolkien ; + :publisher :publisherHC ; + :isbn13 "9780261102217" ; + :pageCount "310"^^xsd:integer ; + :tags "fantasy", "classic" ; + :inSeries :seriesMiddleEarth . + +:itemB rdf:type o:Book ; + rdfs:label "The Hobbit (Illustrated)"@en ; + :bookTitle "The Hobbit"@en ; :bookAuthor :authorTolkien ; - :isbn13 "9780261102217" . + :publisher :publisherHC ; + :isbn13 "978-0-261-10221-7" ; # lexical variation + :pageCount 320 ; # integer without explicit datatype + :publicationYear "1997"^^xsd:gYear . + +:itemC rdf:type o:Book ; + rdfs:label "Harry Potter and the Philosopher's Stone"@en ; + :bookTitle "Harry Potter and the Philosopher's Stone"@en ; + :bookAuthor :authorRowling ; + :publisher :publisherPenguin ; + :isbn13 "9780747532699" ; + :pageCount "223"^^xsd:integer . + +# Same label, different type (common edge case for label-only alignment) +:hobbit rdf:type o:Film ; + rdfs:label "The Hobbit"@en ; + :releaseYear "2012"^^xsd:gYear . + +# Missing rdf:type but has label (edge case for type-aware matching) +:unknownEntity rdfs:label "HarperCollins" . + +:seriesMiddleEarth rdf:type o:Series ; + rdfs:label "Middle-earth Legendarium"@en . + +# false positive unexpected entity +:unexpectedEntity rdf:type o:Book ; + rdfs:label "Unexpected Entity"@en . """ REFERENCE_TURTLE_TRIPLES = """ -@prefix : . +@prefix : . +@prefix o: . @prefix rdf: . @prefix rdfs: . @prefix xsd: . -:itemA rdf:type :Book ; - rdfs:label "itemA" ; - :bookTitle "The Hobbit, or There and Back Again" ; +# Reference graph intentionally differs from TEST_TURTLE_TRIPLES: +# - different labels / casing / punctuation +# - extra / missing properties +# - alternate modeling (blank nodes, different predicates) +# - near-duplicate entities to test ambiguity + +:storeMain rdf:type o:BookStore ; + rdfs:label "Example Books - Downtown"@en ; + :countryCode "USA" ; # lexical variation + :hasInventory :refItemA, :refItemC . + +:publisherHC rdf:type o:Publisher ; + rdfs:label "Harper Collins"@en ; # spacing difference + :countryCode "UK" . + +:publisherPenguin rdf:type o:Publisher ; + rdfs:label "Penguin"@en ; + :countryCode "GB" . + +:authorTolkien rdf:type o:Author ; + rdfs:label "J.R.R. Tolkien" ; # punctuation difference + :born "1892-01-03"^^xsd:date ; + :sameAs ; + :nameParts [ :given "John" ; :middle "Ronald Reuel" ; :family "Tolkien" ] . + +:authorRowling rdf:type o:Author ; + rdfs:label "Joanne Rowling"@en ; # alias-ish label + :born "1965-07-31"^^xsd:date . + +:refItemA rdf:type o:Book ; + rdfs:label "The Hobbit"@en ; + :title "The Hobbit, or There and Back Again"@en ; # different predicate :bookAuthor :authorTolkien ; - :isbn13 "9780261102217" . + :publisher :publisherHC ; + :isbn13 "9780261102217" ; + :pageCount "310"^^xsd:integer ; + :tags "classic" . # missing one tag compared to test + +# Same-work but modeled as a separate edition entity +:refItemA_Edition1 rdf:type o:Edition ; + rdfs:label "The Hobbit (1st edition)"@en ; + :about :refItemA ; + :publicationYear "1937"^^xsd:gYear . + +:refItemC rdf:type o:Book ; + rdfs:label "Harry Potter and the Philosopher’s Stone"@en ; # curly apostrophe + :bookTitle "Harry Potter and the Philosopher's Stone"@en ; + :bookAuthor :authorRowling ; + :publisher :publisherPenguin ; + :isbn13 "9780747532699" ; + :pageCount "223"^^xsd:integer ; + :tags "fantasy"@en . + +# Near-duplicate label (to trigger ambiguity in label similarity) +:refItemC_US rdf:type o:Book ; + rdfs:label "Harry Potter and the Sorcerer's Stone"@en ; + :sameAs :refItemC . +""" + +VERIFIED_ENTITIES = """ +dataset,entity_id,entity_label,entity_type +test,http://example.org/reference_bookstore/itemA,The Hobbit,o:Book +test,http://example.org/reference_bookstore/itemB,The Hobbit (Illustrated),o:Book +test,http://example.org/reference_bookstore/itemC,Harry Potter and the Philosopher's Stone,o:Book +test,http://example.org/reference_bookstore/authorTolkien,J. R. R. Tolkien,o:Author +test,http://example.org/reference_bookstore/authorRowling,J.K. Rowling,o:Author +test,http://example.org/reference_bookstore/publisherHC,HarperCollins,o:Publisher +test,http://example.org/reference_bookstore/publisherPenguin,Penguin Books,o:Publisher +test,http://example.org/reference_bookstore/store1,Example Books (Downtown),o:BookStore +test,http://example.org/reference_bookstore/seriesMiddleEarth,Middle-earth Legendarium,o:Series +test,http://example.org/reference_bookstore/missingEntity,Gone with the Wind,o:Book """ \ No newline at end of file diff --git a/src/kgpipe_eval/test/test_alignment_eval.py b/src/kgpipe_eval/test/test_alignment_eval.py index e69de29..b8ddc9e 100644 --- a/src/kgpipe_eval/test/test_alignment_eval.py +++ b/src/kgpipe_eval/test/test_alignment_eval.py @@ -0,0 +1,32 @@ +import json + +from kgpipe_eval.utils.alignment_utils import EntityAlignmentConfig +from kgpipe_eval.metrics.entity_alignment import EntityAlignmentMetric +from kgpipe_eval.test.utils import get_test_kg, get_verified_entities_path, render_metric_result +from kgpipe_eval.utils.kg_utils import KgManager +from kgpipe_eval.api import MetricResult + + +def test_align_entities_by_label_embedding(): + config = EntityAlignmentConfig( + method="label_embedding", + reference_kg=None, + verified_entities_path=get_verified_entities_path(), + verified_entities_delimiter=",", + entity_sim_threshold=0.95 + ) + tg = KgManager.load_kg(get_test_kg()) + metric_result : MetricResult = EntityAlignmentMetric().compute(tg, config) + print(render_metric_result(metric_result)) + +def test_align_entities_by_label_embedding_and_type(): + config = EntityAlignmentConfig( + method="label_embedding_and_type", + reference_kg=None, + verified_entities_path=get_verified_entities_path(), + verified_entities_delimiter=",", + entity_sim_threshold=0.95 + ) + tg = KgManager.load_kg(get_test_kg()) + metric_result : MetricResult = EntityAlignmentMetric().compute(tg, config) + print(render_metric_result(metric_result)) \ No newline at end of file diff --git a/src/kgpipe_eval/test/test_config_manager.py b/src/kgpipe_eval/test/test_config_manager.py new file mode 100644 index 0000000..78e8cc5 --- /dev/null +++ b/src/kgpipe_eval/test/test_config_manager.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from pathlib import Path + +from kgpipe_eval.config.manager import load_metric_configs +from kgpipe_eval.metrics.duplicates import DuplicateConfig +from kgpipe_eval.metrics.triple_alignment import TripleAlignmentConfig +from kgpipe_eval.utils.alignment_utils import EntityAlignmentConfig + + +def test_load_metric_configs_resolves_entity_alignment_refs(tmp_path: Path) -> None: + cfg = tmp_path / "eval.yaml" + cfg.write_text( + """ +entity_alignment_configs: + default: + method: label_embedding + verified_entities_path: tmp_test_data/verified_entities.csv + verified_entities_delimiter: "," + entity_sim_threshold: 0.95 + +metrics: + entity_align: + entity_alignment_config_ref: default + + duplicates: + entity_alignment_config_ref: default + + triple_alignment: + reference_kg_path: tmp_test_data/reference.nt + entity_alignment_config_ref: default + value_sim_threshold: 0.6 +""".lstrip(), + encoding="utf-8", + ) + + loaded = load_metric_configs(cfg) + assert "entity_align" in loaded + assert "duplicates" in loaded + assert "triple_alignment" in loaded + + assert isinstance(loaded["entity_align"], EntityAlignmentConfig) + assert isinstance(loaded["duplicates"], DuplicateConfig) + assert isinstance(loaded["triple_alignment"], TripleAlignmentConfig) + + assert loaded["entity_align"].verified_entities_delimiter == "," + assert loaded["duplicates"].entity_alignment_config.verified_entities_delimiter == "," + assert loaded["triple_alignment"].entity_alignment_config.verified_entities_delimiter == "," + + # reference_kg is constructed from reference_kg_path + assert loaded["triple_alignment"].reference_kg.path.as_posix().endswith("tmp_test_data/reference.nt") + diff --git a/src/kgpipe_eval/test/test_consistency_eval.py b/src/kgpipe_eval/test/test_consistency_eval.py new file mode 100644 index 0000000..e69de29 diff --git a/src/kgpipe_eval/test/test_duplicates_eval.py b/src/kgpipe_eval/test/test_duplicates_eval.py new file mode 100644 index 0000000..1c21d89 --- /dev/null +++ b/src/kgpipe_eval/test/test_duplicates_eval.py @@ -0,0 +1,20 @@ +from kgpipe_eval.metrics.duplicates import DuplicateConfig +from kgpipe_eval.utils.alignment_utils import EntityAlignmentConfig +from kgpipe_eval.test.utils import get_verified_entities_path +from kgpipe_eval.api import MetricResult +from kgpipe_eval.metrics.duplicates import DuplicateMetric +from kgpipe_eval.test.utils import get_test_kg, render_metric_result +from kgpipe_eval.utils.kg_utils import KgManager + +def test_duplicates_eval(): + config = DuplicateConfig( + entity_alignment_config=EntityAlignmentConfig( + method="label_embedding", + reference_kg=None, + verified_entities_path=get_verified_entities_path(), + verified_entities_delimiter=",", + entity_sim_threshold=0.95 + ) + ) + metric_result : MetricResult = DuplicateMetric().compute(KgManager.load_kg(get_test_kg()), config) + print(render_metric_result(metric_result)) diff --git a/src/kgpipe_eval/test/test_evaluator.py b/src/kgpipe_eval/test/test_evaluator.py new file mode 100644 index 0000000..2e2c03b --- /dev/null +++ b/src/kgpipe_eval/test/test_evaluator.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from kgpipe_eval.evaluator import Evaluator +from kgpipe_eval.metrics.statistics import CountMetric +from kgpipe_eval.metrics.duplicates import DuplicateMetric, DuplicateConfig +from kgpipe_eval.utils.alignment_utils import EntityAlignmentConfig +from kgpipe_eval.test.utils import get_test_kg, get_verified_entities_path +from kgpipe_eval.utils.kg_utils import KgManager + + +def test_evaluator_runs_metrics_with_and_without_config() -> None: + kg = KgManager.load_kg(get_test_kg()) + try: + dup_cfg = DuplicateConfig( + entity_alignment_config=EntityAlignmentConfig( + method="label_embedding", + verified_entities_path=get_verified_entities_path(), + verified_entities_delimiter=",", + entity_sim_threshold=0.95, + ) + ) + + metrics = [CountMetric(), DuplicateMetric()] + confs = {"DuplicateMetric": dup_cfg} + + results = Evaluator().run(kg=kg, metrics=metrics, confs=confs) + assert len(results) == 2 + assert results[0].metric.__class__.__name__ == "CountMetric" + assert results[1].metric.__class__.__name__ == "DuplicateMetric" + finally: + KgManager.unload_kg(kg) + diff --git a/src/kgpipe_eval/test/test_kg_utils.py b/src/kgpipe_eval/test/test_kg_utils.py new file mode 100644 index 0000000..39f10f0 --- /dev/null +++ b/src/kgpipe_eval/test/test_kg_utils.py @@ -0,0 +1,29 @@ +from kgpipe_eval.utils.kg_utils import KgManager +from kgpipe_eval.test.utils import get_test_kg, get_reference_kg +from pathlib import Path + +tmp_dir = Path("tmp_test_data") + +def test_substract_kg(): + # TODO test can be improved / cleaned up + kg = get_test_kg() + kg_graph = KgManager.load_kg(kg) + kg_path = kg.path + + # read kg + with open(kg_path, "r") as f: + triples = f.readlines() + sample_triples = triples[:10] + other_kg_path = tmp_dir / "other_kg.nt" + with open(other_kg_path, "w") as f: + f.write("\n".join(sample_triples)) + other_kg_graph = KgManager.load_kg(other_kg_path) + + substracted_kg_graph = KgManager.substract_kg(kg_graph, other_kg_graph) + len_kg_triples = len(list(kg_graph.triples((None, None, None)))) + len_other_kg_triples = len(list(other_kg_graph.triples((None, None, None)))) + len_substracted_kg_triples = len(list(substracted_kg_graph.triples((None, None, None)))) + # print(f"len_kg_triples: {len_kg_triples}") + # print(f"len_other_kg_triples: {len_other_kg_triples}") + # print(f"len_substracted_kg_triples: {len_substracted_kg_triples}") + assert len_substracted_kg_triples == len_kg_triples - len_other_kg_triples \ No newline at end of file diff --git a/src/kgpipe_eval/test/test_statistics_eval.py b/src/kgpipe_eval/test/test_statistics_eval.py index 2f1221a..67d803d 100644 --- a/src/kgpipe_eval/test/test_statistics_eval.py +++ b/src/kgpipe_eval/test/test_statistics_eval.py @@ -1,7 +1,8 @@ from kgpipe_eval.metrics.statistics import CountMetric -from kgpipe_eval.test.examples import TEST_TURTLE_TRIPLES, REFERENCE_TURTLE_TRIPLES +from kgpipe_eval.test.utils import get_test_kg +from kgpipe_eval.utils.kg_utils import KgManager def test_count_metric(): metric = CountMetric() - report = metric.compute(TEST_TURTLE_TRIPLES) - render_metric_as_table(report, show_details=SHOW_DETAILS) \ No newline at end of file + report = metric.compute(KgManager.load_kg(get_test_kg())) + print(report) \ No newline at end of file diff --git a/src/kgpipe_eval/test/utils.py b/src/kgpipe_eval/test/utils.py index 0e2b72c..602c752 100644 --- a/src/kgpipe_eval/test/utils.py +++ b/src/kgpipe_eval/test/utils.py @@ -1,11 +1,89 @@ from pathlib import Path from kgpipe.common import KG - +from kgpipe.common.model.data import DataFormat from kgpipe_eval.test.examples import * +from kgpipe_eval.api import MetricResult +from rdflib import Graph +import json +from collections.abc import Mapping, Sequence + +tmp_dir = Path("tmp_test_data") + +if not tmp_dir.exists(): + tmp_dir.mkdir(parents=True, exist_ok=True) + + +def get_test_kg(sample_size: int = -1) -> KG: + test_triples = TEST_TURTLE_TRIPLES + if sample_size > 0: + test_triples = test_triples[:sample_size] + # write test_triples to a file + g = Graph() + g.parse(data=test_triples, format="turtle") + g.serialize(destination=tmp_dir / "test.nt", format="ntriples") + return KG("test", name="test", path=tmp_dir / "test.nt", format=DataFormat.RDF_NTRIPLES) + +def get_reference_kg(sample_size: int = -1) -> KG: + reference_triples = REFERENCE_TURTLE_TRIPLES + if sample_size > 0: + reference_triples = reference_triples[:sample_size] + # write reference_triples to a file + g = Graph() + g.parse(data=reference_triples, format="turtle") + g.serialize(destination=tmp_dir / "reference.nt", format="ntriples") + return KG("reference", name="reference", path=tmp_dir / "reference.nt", format=DataFormat.RDF_NTRIPLES) + +def get_verified_entities_path() -> Path: + path = tmp_dir / "verified_entities.csv" + with open(path, "w") as f: + # Avoid a leading blank line which breaks csv.DictReader header parsing + f.write(VERIFIED_ENTITIES.lstrip().replace("o:", "http://example.org/ontology/")) + return path + +def render_metric_result(metric_result: MetricResult) -> str: + def _metric_key(mr: MetricResult) -> str: + metric = mr.metric + return getattr(metric, "key", metric.__class__.__name__) + + def _fmt_value(v) -> str: + if isinstance(v, float): + # stable, compact representation for test output + return f"{v:.6g}" + if isinstance(v, (int, bool)) or v is None: + return str(v) + if isinstance(v, str): + return v + if isinstance(v, Mapping): + return json.dumps(v, indent=2, sort_keys=True, default=str) + if isinstance(v, Sequence) and not isinstance(v, (str, bytes, bytearray)): + return json.dumps(v, indent=2, sort_keys=True, default=str) + return str(v) + + key = _metric_key(metric_result) + summary = metric_result.summary or "" + + ms = sorted(metric_result.measurements, key=lambda m: m.name) + name_w = max([len("measurement"), *(len(m.name) for m in ms)] or [len("measurement")]) + unit_w = max([len("unit"), *(len(m.unit or "") for m in ms)] or [len("unit")]) + + lines: list[str] = [] + lines.append(f"metric: {key}") + if summary: + lines.append(f"summary: {summary}") + if not ms: + lines.append("(no measurements)") + return "\n".join(lines) -def get_test_kg() -> KG: - return KG() + lines.append("") + lines.append(f"{'measurement':<{name_w}} {'value'}{' ' * max(1, 2)}{'unit':<{unit_w}}") + lines.append(f"{'-' * name_w} {'-' * 20} {'-' * unit_w}") -def get_reference_kg() -> KG: - return KG() + for m in ms: + unit = m.unit or "" + rendered = _fmt_value(m.value) + rendered_lines = rendered.splitlines() or [""] + lines.append(f"{m.name:<{name_w}} {rendered_lines[0]:<20} {unit:<{unit_w}}") + for cont in rendered_lines[1:]: + lines.append(f"{'':<{name_w}} {cont}") + return "\n".join(lines) \ No newline at end of file From 67d964798111cee8006e943e6b35a7066d2708aa Mon Sep 17 00:00:00 2001 From: Marvin Date: Wed, 8 Apr 2026 16:00:38 +0200 Subject: [PATCH 43/96] feat(eval): commited missing util functions --- src/kgpipe_eval/utils/alignment_utils.py | 98 +++++++++++++++------- src/kgpipe_eval/utils/kg_utils.py | 23 ++++- src/kgpipe_eval/utils/measurement_utils.py | 23 +++-- 3 files changed, 107 insertions(+), 37 deletions(-) diff --git a/src/kgpipe_eval/utils/alignment_utils.py b/src/kgpipe_eval/utils/alignment_utils.py index 8f8d0fa..d5c6adb 100644 --- a/src/kgpipe_eval/utils/alignment_utils.py +++ b/src/kgpipe_eval/utils/alignment_utils.py @@ -1,67 +1,105 @@ from kgpipe.common import KG -from typing import Literal, NamedTuple +from typing import Literal, NamedTuple, Optional from functools import lru_cache -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict, model_validator from kgpipe_eval.utils.kg_utils import TripleGraph, Term, Triple from kgpipe.util.embeddings.st_emb import get_model from rdflib import RDFS, RDF +from kgpipe.datasets.multipart_multisource import read_entities_csv, EntitiesRow import numpy as np - -class AlignmentConfig(BaseModel): - model: str = "sentence-transformer" - similarity: str = "cosine" - threshold: float = 0.5 +from pathlib import Path # TODO source entities csv to label only graph -CONFIG=None -# layz config dict -def get_config() -> dict: - global CONFIG - if CONFIG is None: - # TODO - pass - return CONFIG +class EntityAlignmentConfig(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + method: Literal["label_embedding", "label_alias_embedding", "label_embedding_and_type"] = "label_embedding" + reference_kg: Optional[KG] = None + verified_entities_path: Optional[Path] = None + verified_entities_delimiter: str = "\t" + entity_sim_threshold: float = 0.95 + + # value_sim_threshold: float = 0.5 + + @model_validator(mode="after") + def _require_reference_source(self): + if self.reference_kg is None and self.verified_entities_path is None: + raise ValueError("Provide either `reference_kg` or `verified_entities_path`.") + return self + -EntityAlignment = NamedTuple("EntityAlignment", [("source", Term), ("target", Term)]) +EntityAlignment = NamedTuple("EntityAlignment", [("source", Term), ("target", Term), ("score", float)]) TripleAlignment = NamedTuple("TripleAlignment", [("source", Triple), ("target", Triple)]) # Core alignment method interfaces @lru_cache(maxsize=1000) -def get_aligned_entities(kg: KG, reference_kg: KG, method: Literal["exact", "fuzzy", "semantic"] = "exact") -> list[Entity]: +def get_aligned_entities(kg: KG, reference_kg: KG, method: Literal["exact", "fuzzy", "semantic"] = "exact") -> list[EntityAlignment]: return kg.entities.intersection(reference_kg.entities) -def get_aligned_triples(kg: KG, reference_kg: KG, method: Literal["exact", "fuzzy", "semantic"] = "exact") -> list[Triple]: +def get_aligned_triples(kg: KG, reference_kg: KG, method: Literal["exact", "fuzzy", "semantic"] = "exact") -> list[TripleAlignment]: return kg.triples.intersection(reference_kg.triples) # Helper methods -def get_entity_uri_label_pairs(triple_graph: TripleGraph) -> list[tuple[Term, Term]]: - return [(s, label) for s, _, label in triple_graph.triples((None, RDFS.label, None))] +# def get_entity_uri_label_pairs(triple_graph: TripleGraph) -> list[tuple[Term, Term]]: +# return [(s, label) for s, _, label in triple_graph.triples((None, RDFS.label, None))] + +UriLabelTypePair = NamedTuple("UriLabelTypePair", [("uri", Term), ("label", Term), ("type", Term)]) + +def get_entity_uri_label_type_pairs(kg: KG) -> list[UriLabelTypePair]: + label_by_uri = {} + type_by_uri = {} + for s, p, o in kg.triples((None, RDFS.label, None)): + label_by_uri[str(s)] = str(o) + for s, p, o in kg.triples((None, RDF.type, None)): + type_by_uri[str(s)] = str(o) + for uri in label_by_uri: + if uri in type_by_uri: + yield UriLabelTypePair(uri=uri, label=label_by_uri[uri], type=type_by_uri[uri]) + else: + yield UriLabelTypePair(uri=uri, label=label_by_uri[uri], type=None) + +def load_verified_entities(path: Path, delimiter: str = "\t") -> list[UriLabelTypePair]: + """ + """ + if path.name.endswith(".json"): + raise ValueError("JSON format not supported for verified entities") + elif path.name.endswith(".csv"): + return [UriLabelTypePair(uri=entity.entity_id, label=entity.entity_label, type=entity.entity_type) for entity in read_entities_csv(path=path, delimiter=delimiter)] + else: + raise ValueError(f"Unsupported file type: {path}") + +def load_entity_uri_label_type_pairs(config: EntityAlignmentConfig) -> list[UriLabelTypePair]: + if config.verified_entities_path is not None: + return load_verified_entities(config.verified_entities_path, delimiter=config.verified_entities_delimiter) + elif config.reference_kg is not None: + return get_entity_uri_label_type_pairs(config.reference_kg) + else: + raise ValueError("No verified entities path or reference KG provided") # Specific alignment methods -def align_entities_by_label_embedding(triple_graph: TripleGraph, ref_triple_graph: TripleGraph, model="TODO", similarity="cosine", threshold=0.5): +def align_entities_by_label_embedding(tg: TripleGraph, config: EntityAlignmentConfig) -> list[EntityAlignment]: model = get_model() - ref_entity_labels = [str(label) for s, _, label in ref_triple_graph.triples((None, RDFS.label, None))] - ref_entity_labels_embeddings = model.encode(ref_entity_labels, convert_to_numpy=True, show_progress_bar=False) + ref_entity_uri_label_type_pairs = load_entity_uri_label_type_pairs(config) + ref_labels = [pair.label for pair in ref_entity_uri_label_type_pairs] + ref_labels_embeddings = model.encode(ref_labels, convert_to_numpy=True, show_progress_bar=False) - gen_entity_labels = [str(label) for s, _, label in triple_graph.triples((None, RDFS.label, None))] - gen_entity_labels_embeddings = model.encode(gen_entity_labels, convert_to_numpy=True, show_progress_bar=False) + gen_entity_uri_label_type_pairs = list(get_entity_uri_label_type_pairs(tg)) + gen_labels = [pair.label for pair in gen_entity_uri_label_type_pairs] + gen_labels_embeddings = model.encode(gen_labels, convert_to_numpy=True, show_progress_bar=False) - for s, _, label in triple_graph.triples((None, RDFS.label, None)): - gen_entity_labels.append(str(label)) - sims = np.dot(gen_entity_labels_embeddings, ref_entity_labels_embeddings.T) + sims = np.dot(gen_labels_embeddings, ref_labels_embeddings.T) alignments = [] for i in range(sims.shape[0]): best_j = np.argmax(sims[i]) - if sims[i][best_j] >= threshold: - alignments.append(EntityAlignment(source=gen_entity_labels[i], target=ref_entity_labels[best_j], score=sims[i][best_j])) + if sims[i][best_j] >= config.entity_sim_threshold: + alignments.append(EntityAlignment(source=gen_entity_uri_label_type_pairs[i].uri, target=ref_entity_uri_label_type_pairs[best_j].uri, score=sims[i][best_j])) return alignments def align_by_label_alias_embedding(triple_graph: TripleGraph, model="", similarity="cosine", threshold=0.5): diff --git a/src/kgpipe_eval/utils/kg_utils.py b/src/kgpipe_eval/utils/kg_utils.py index 13dbcde..348ad4d 100644 --- a/src/kgpipe_eval/utils/kg_utils.py +++ b/src/kgpipe_eval/utils/kg_utils.py @@ -35,6 +35,9 @@ def triples(self, triple_pattern: TriplePattern) -> Iterable[Triple]: def subjects(self) -> Iterable[Term]: pass + def entities(self) -> Iterable[Term]: + pass + def labels(self, term: Term) -> Literal: pass @@ -116,6 +119,9 @@ def subjects(self) -> Iterable[Term]: g = self._graph() return g.subjects(unique=True) + def entities(self) -> Iterable[Term]: + return self.subjects() # TODO inlcude objects that are not subjects + def labels(self, term: Term) -> Literal: g = self._graph() return g.triples((term, RDFS.label, None)) @@ -131,7 +137,7 @@ class KgManager: """ @staticmethod - def load_kg(kg: KG, backend: Literal["rdflib", "spark"] = "rdflib") -> TripleGraph: + def load_kg(kg: KgLike, backend: Literal["rdflib", "spark"] = "rdflib") -> TripleGraph: if backend == "rdflib": return RdfLibTripleGraph(kg=kg) else: @@ -151,3 +157,18 @@ def cache_kg(kg: TripleGraph) -> None: @staticmethod def unload_kg(kg: TripleGraph) -> None: kg.close() + + + @staticmethod + def substract_kg(kg: TripleGraph, other_kg: TripleGraph) -> TripleGraph: + """ + Substract the other_kg from the kg. + """ + # TODO can be improved later by using a more efficient algorithm + triples = kg._graph().triples((None, None, None)) + other_triples = other_kg._graph() + new_graph = Graph() + for triple in triples: + if triple not in other_triples: + new_graph.add(triple) + return RdfLibTripleGraph(kg=new_graph) \ No newline at end of file diff --git a/src/kgpipe_eval/utils/measurement_utils.py b/src/kgpipe_eval/utils/measurement_utils.py index 8cca7e7..065338b 100644 --- a/src/kgpipe_eval/utils/measurement_utils.py +++ b/src/kgpipe_eval/utils/measurement_utils.py @@ -7,21 +7,32 @@ class BinaryClassificationMeasurement(BaseModel): fn: int def accuracy(self) -> float: - return (self.tp + self.tn) / (self.tp + self.tn + self.fp + self.fn) + denom = (self.tp + self.tn + self.fp + self.fn) + return (self.tp + self.tn) / denom if denom else 0.0 def precision(self) -> float: - return self.tp / (self.tp + self.fp) + denom = (self.tp + self.fp) + return self.tp / denom if denom else 0.0 def recall(self) -> float: - return self.tp / (self.tp + self.fn) + denom = (self.tp + self.fn) + return self.tp / denom if denom else 0.0 def f1_score(self) -> float: - return 2 * self.precision() * self.recall() / (self.precision() + self.recall()) + p = self.precision() + r = self.recall() + denom = (p + r) + return 2 * p * r / denom if denom else 0.0 def __str__(self): return f"tp: {self.tp}, fp: {self.fp}, tn: {self.tn}, fn: {self.fn}, accuracy: {self.accuracy()}, precision: {self.precision()}, recall: {self.recall()}, f1_score: {self.f1_score()}" - def __dict__(self): + def to_dict(self) -> dict: + """ + Convenience export including derived measures. + + Note: do not override BaseModel internals like `__dict__`. + """ return { "tp": self.tp, "fp": self.fp, @@ -30,7 +41,7 @@ def __dict__(self): "accuracy": self.accuracy(), "precision": self.precision(), "recall": self.recall(), - "f1_score": self.f1_score() + "f1_score": self.f1_score(), } BCMeasurement = BinaryClassificationMeasurement \ No newline at end of file From eba506365ac0c89a5e8b70e841b799d2a0157317 Mon Sep 17 00:00:00 2001 From: Marvin Date: Thu, 9 Apr 2026 23:38:38 +0200 Subject: [PATCH 44/96] exp(moviekg): new eval api implementation --- .../src/moviekg/datasets/tmp_remove_seeds.py | 33 +++++ .../moviekg/evaluation/test_eval_refactor.py | 126 ++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 experiments/moviekg/src/moviekg/datasets/tmp_remove_seeds.py create mode 100644 experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py diff --git a/experiments/moviekg/src/moviekg/datasets/tmp_remove_seeds.py b/experiments/moviekg/src/moviekg/datasets/tmp_remove_seeds.py new file mode 100644 index 0000000..b29c0b7 --- /dev/null +++ b/experiments/moviekg/src/moviekg/datasets/tmp_remove_seeds.py @@ -0,0 +1,33 @@ +from moviekg.evaluation.test_eval_refactor import KgBenchData + +""" +for every verified_seed remove in the bench data remove the seed entities and store as verified_entities_no_seed.csv +""" + +import pandas as pd +from pathlib import Path + +bench_data = KgBenchData.from_path(Path("/home/marvin/phd/data/moviekg/datasets/film_10k")) + +for i in range(1, 4): + seed = bench_data.dataset.splits[f"split_{0}"].kg_reference.meta.entities.file + current = bench_data.dataset.splits[f"split_{i}"].kg_reference.meta.entities.file + current_path = bench_data.dataset.splits[f"split_{i}"].kg_reference.meta.entities.file + current_new = current_path.with_name(f"{current_path.stem}_no_seed{current_path.suffix}") + + # remove all lines from current that are in seed and save to new file + with open(current, "r") as f: + current_lines = f.readlines() + with open(seed, "r") as f: + seed_lines = f.readlines() + with open(current_new, "w") as f: + if not current_lines: + continue + + # Preserve header (assumes first line is the CSV header) + f.write(current_lines[0]) + + seed_set = set(seed_lines[1:] if seed_lines else []) + for line in current_lines[1:]: + if line not in seed_set: + f.write(line) diff --git a/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py b/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py new file mode 100644 index 0000000..285bae2 --- /dev/null +++ b/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py @@ -0,0 +1,126 @@ +from kgpipe_eval.metrics import CountMetric, DuplicateMetric +from typing import List +from kgpipe_eval.api import MetricConfig, MetricResult +from kgpipe_eval.metrics.statistics import CountMetric +from kgpipe_eval.metrics.duplicates import DuplicateConfig, DuplicateMetric +from kgpipe_eval.metrics.entity_alignment import EntityAlignmentMetric +from kgpipe_eval.utils.alignment_utils import EntityAlignmentConfig +from kgpipe_eval.utils.kg_utils import KgLike, KgManager +from kgpipe_eval.evaluator import Evaluator +from pydantic import BaseModel, ConfigDict + +from kgpipe.datasets.multipart_multisource import Dataset, load_dataset +from kgpipe_eval.test.utils import render_metric_result +from pathlib import Path +import pytest +from kgpipe.common.model.pipeline import KgPipePlan, KgPipeReport +from kgpipe.common.model.kg import KG +from kgpipe.common.model.data import DataFormat +import json +# TODO +# [ ] Dataset Reader (split,ref,source,metadata) +# [ ] Pipeline Results Reader (stage,kg,plan,report,tmp_file) + + +# TODO clearify +# substract seed from kg_1 and kg_1 from kg_2, or only seed from kg_1 and kg_2 + + +EX_BENCH_DATA_PATH = Path("/home/marvin/phd/data/moviekg/datasets/film_10k") +EX_INC_PIPE_DATA_PATH = Path("/home/marvin/phd/data/moviekg/output/large/rdf_a") + +# TODO is a wrapper interface for now, Dataset needs refactor later +# TODO can be abstracted and implemented to have direct method per type, so dict is not needed for access +class KgBenchData(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + dataset: Dataset + + @staticmethod + def from_path(path: Path) -> 'KgBenchData': + dataset = load_dataset(path) + return KgBenchData(dataset=dataset) + + def get_verified_entities_path(self, i: int, source_type: str) -> Path: + current_path = self.dataset.splits[f"split_{i}"].kg_reference.meta.entities.file + current_new = current_path.with_name(f"{current_path.stem}_no_seed{current_path.suffix}") + return current_new + +class KgPipeData(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + result_kg: KgLike # name=rdf_a_1 + plan: KgPipePlan + report: KgPipeReport + tmp_dir: Path + + @staticmethod + def from_path(path: Path | str) -> 'KgPipeData': + path = Path(path) + plan = KgPipePlan.from_path(path / "exec-plan.json") + report = KgPipeReport.from_path(path / "exec-report.json") + tmp_dir = path / "tmp" + return KgPipeData( + result_kg=KG(name=path.name, id=path.name, path=path / "result.nt", format=DataFormat.RDF_NTRIPLES), + plan=plan, + report=report, + tmp_dir=tmp_dir + ) + +def build_config_dict(i: int, pipe_data: KgPipeData, bench_data: KgBenchData) -> dict[str, MetricConfig]: + dup_cfg = DuplicateConfig( + entity_alignment_config=EntityAlignmentConfig( + method="label_embedding", + verified_entities_path=bench_data.get_verified_entities_path(i=i, source_type="todo"), + verified_entities_delimiter="\t", + entity_sim_threshold=0.95, + ) + ) + + ent_cfg = EntityAlignmentConfig( + method="label_embedding_and_type", + verified_entities_path=bench_data.get_verified_entities_path(i=i, source_type="rdf"), # TODO type needs to be derived from pipe_data + verified_entities_delimiter="\t", + entity_sim_threshold=0.95 + ) + + return { + "DuplicateMetric": dup_cfg, + "EntityAlignmentMetric": ent_cfg, + } + + +def evaluate_stage(i: int, pipe_data: KgPipeData, bench_data: KgBenchData) -> List[MetricResult]: + tg = KgManager.load_kg(pipe_data.result_kg) + metrics = [ + CountMetric(), + EntityAlignmentMetric(), + DuplicateMetric() + ] + config_dict = build_config_dict(i, pipe_data, bench_data) + return Evaluator().run(tg, metrics, config_dict) + +# EX_PIPE_DATA_PATH = Path("/home/marvin/phd/data/moviekg/output/small/rdf_a/stage_1") +# def test_evaluate_stage(): +# if not EX_PIPE_DATA_PATH.exists() or not EX_BENCH_DATA_PATH.exists(): +# pytest.skip("Local MovieKG eval data not available; test is an integration/WIP scaffold.") +# pipe_data = KgPipeData.from_path(EX_PIPE_DATA_PATH) +# bench_data = KgBenchData.from_path(EX_BENCH_DATA_PATH) +# results = evaluate_stage(1, pipe_data, bench_data) + +# # render +# print() # avoids pytest output being interleaved with print statements +# for result in results: +# print(render_metric_result(result, truncate=True, truncate_value=3)) + +def test_evaluate_inc_stage(): + if not EX_INC_PIPE_DATA_PATH.exists() or not EX_BENCH_DATA_PATH.exists(): + pytest.skip("Local MovieKG inc data not available; test is an integration/WIP scaffold.") + + for i in range(1, 4): + pipe_data = KgPipeData.from_path(EX_INC_PIPE_DATA_PATH / f"stage_{i}") + bench_data = KgBenchData.from_path(EX_BENCH_DATA_PATH) + results = evaluate_stage(i, pipe_data, bench_data) + + # render + print() # avoids pytest output being interleaved with print statements + for result in results: + print(render_metric_result(result, truncate=True, truncate_value=3)) \ No newline at end of file From ce1c09d078ef6232e9f1f62c89d5dcb1725c694a Mon Sep 17 00:00:00 2001 From: Marvin Date: Fri, 10 Apr 2026 15:03:30 +0200 Subject: [PATCH 45/96] feat(eval): finished structure and api of new eval, addded some working metrics --- src/kgpipe_eval/config/manager.py | 128 ++++++++++++- src/kgpipe_eval/evaluator.py | 3 + src/kgpipe_eval/metrics/entity_alignment.py | 14 ++ src/kgpipe_eval/test/test_config_manager.py | 52 +++++- src/kgpipe_eval/test/test_llm_eval.py | 6 +- src/kgpipe_eval/test/test_metric_utils.py | 64 +++++++ src/kgpipe_eval/test/utils.py | 51 +---- src/kgpipe_eval/utils/alignment_utils.py | 7 +- src/kgpipe_eval/utils/kg_utils.py | 8 +- src/kgpipe_eval/utils/metric_utils.py | 197 ++++++++++++++++++++ 10 files changed, 470 insertions(+), 60 deletions(-) create mode 100644 src/kgpipe_eval/test/test_metric_utils.py create mode 100644 src/kgpipe_eval/utils/metric_utils.py diff --git a/src/kgpipe_eval/config/manager.py b/src/kgpipe_eval/config/manager.py index c245681..d7d5d81 100644 --- a/src/kgpipe_eval/config/manager.py +++ b/src/kgpipe_eval/config/manager.py @@ -1,7 +1,8 @@ from __future__ import annotations from pathlib import Path -from typing import Any, Mapping, MutableMapping +from typing import Any, Mapping +import re import yaml from pydantic import BaseModel @@ -16,6 +17,54 @@ MetricConfigModel = BaseModel +REQUIRED = "" + +_VAR_PATTERN = re.compile(r"^\$(\w+)$|^\$\{(\w+)\}$") + + +def _interpolate_vars(obj: Any, vars_map: Mapping[str, Any]) -> Any: + """ + Recursively interpolate simple $var / ${var} references inside YAML-loaded data. + + Only replaces when the *entire* string is a reference token. + """ + if isinstance(obj, str): + m = _VAR_PATTERN.match(obj.strip()) + if not m: + return obj + name = m.group(1) or m.group(2) + if name in vars_map: + return vars_map[name] + return obj + if isinstance(obj, list): + return [_interpolate_vars(v, vars_map) for v in obj] + if isinstance(obj, dict): + return {k: _interpolate_vars(v, vars_map) for k, v in obj.items()} + return obj + + +def _resolve_paths(obj: Any, *, base_dir: Path) -> Any: + """ + Recursively resolve relative paths for common config keys. + + - For keys ending with `_path` or `_kg_path`, if the value is a str/Path and + relative, make it absolute by joining with `base_dir`. + - For `reference_kg` when passed as str/Path, treat it as a path too. + """ + if isinstance(obj, list): + return [_resolve_paths(v, base_dir=base_dir) for v in obj] + if isinstance(obj, dict): + out: dict[str, Any] = {} + for k, v in obj.items(): + vv = _resolve_paths(v, base_dir=base_dir) + if isinstance(vv, (str, Path)): + if k == "reference_kg" or k.endswith("_path") or k.endswith("_kg_path"): + p = Path(vv) + if not p.is_absolute(): + vv = (base_dir / p).resolve() + out[k] = vv + return out + return obj def _deep_merge_dict(base: Mapping[str, Any], override: Mapping[str, Any]) -> dict[str, Any]: @@ -118,6 +167,13 @@ def load_metric_configs(config_path: str | Path) -> dict[str, MetricConfigModel] if not isinstance(raw, Mapping): raise TypeError("Top-level YAML must be a mapping/dict.") + # Allow simple variable indirection like: + # reference_kg: test.ttl + # ... reference_kg: $reference_kg + vars_map = {k: v for k, v in raw.items() if isinstance(k, str)} + raw = _interpolate_vars(raw, vars_map) + raw = _resolve_paths(raw, base_dir=path.parent) + named_entity_alignment: dict[str, dict[str, Any]] = {} raw_named = raw.get("entity_alignment_configs") or {} if raw_named: @@ -152,6 +208,9 @@ def load_metric_configs(config_path: str | Path) -> dict[str, MetricConfigModel] if "reference_kg_path" in entity_cfg_dict and "reference_kg" not in entity_cfg_dict: ref_path = Path(entity_cfg_dict.pop("reference_kg_path")) entity_cfg_dict["reference_kg"] = _kg_from_path(ref_path) + # Backward compatible: accept `reference_kg: "/path/to/file.nt"` in YAML + if isinstance(entity_cfg_dict.get("reference_kg"), (str, Path)): + entity_cfg_dict["reference_kg"] = _kg_from_path(Path(entity_cfg_dict["reference_kg"])) out[metric_key] = EntityAlignmentConfig.model_validate(entity_cfg_dict) continue @@ -160,6 +219,8 @@ def load_metric_configs(config_path: str | Path) -> dict[str, MetricConfigModel] if "reference_kg_path" in entity_cfg_dict and "reference_kg" not in entity_cfg_dict: ref_path = Path(entity_cfg_dict.pop("reference_kg_path")) entity_cfg_dict["reference_kg"] = _kg_from_path(ref_path) + if isinstance(entity_cfg_dict.get("reference_kg"), (str, Path)): + entity_cfg_dict["reference_kg"] = _kg_from_path(Path(entity_cfg_dict["reference_kg"])) out[metric_key] = DuplicateConfig.model_validate( { "entity_alignment_config": EntityAlignmentConfig.model_validate(entity_cfg_dict), @@ -173,6 +234,8 @@ def load_metric_configs(config_path: str | Path) -> dict[str, MetricConfigModel] if "reference_kg_path" in entity_cfg_dict and "reference_kg" not in entity_cfg_dict: ref_path = Path(entity_cfg_dict.pop("reference_kg_path")) entity_cfg_dict["reference_kg"] = _kg_from_path(ref_path) + if isinstance(entity_cfg_dict.get("reference_kg"), (str, Path)): + entity_cfg_dict["reference_kg"] = _kg_from_path(Path(entity_cfg_dict["reference_kg"])) cfg_dict["entity_alignment_config"] = EntityAlignmentConfig.model_validate(entity_cfg_dict) # Allow YAML to specify a path rather than an in-memory KG object @@ -196,6 +259,8 @@ def load_metric_configs(config_path: str | Path) -> dict[str, MetricConfigModel] if "reference_kg_path" in cfg_dict and "reference_kg" not in cfg_dict: ref_path = Path(cfg_dict.pop("reference_kg_path")) cfg_dict["reference_kg"] = _kg_from_path(ref_path) + if isinstance(cfg_dict.get("reference_kg"), (str, Path)): + cfg_dict["reference_kg"] = _kg_from_path(Path(cfg_dict["reference_kg"])) out[metric_key] = ConsistencyViolationsConfig.model_validate(cfg_dict) continue @@ -206,3 +271,64 @@ def load_metric_configs(config_path: str | Path) -> dict[str, MetricConfigModel] return out + +def generate_default_config_dict() -> dict[str, Any]: + """ + Generate a complete default YAML config structure for all supported metric configs. + + This is intended as a *template* for users. Required values are filled with the + placeholder string `""`. + """ + # Shared sub-config defaults + entity_alignment_default = { + "method": "label_embedding", + # Prefer a path-based template: avoids embedding runtime `KG` objects into YAML. + "verified_entities_path": REQUIRED, + "verified_entities_delimiter": EntityAlignmentConfig.model_fields["verified_entities_delimiter"].default, + "entity_sim_threshold": EntityAlignmentConfig.model_fields["entity_sim_threshold"].default, + } + + return { + "entity_alignment_configs": { + "default": entity_alignment_default, + }, + "metrics": { + # Standalone metric uses EntityAlignmentConfig directly via a ref. + "entity_align": { + "entity_alignment_config_ref": "default", + }, + "duplicates": { + "entity_alignment_config_ref": "default", + }, + "triple_alignment": { + "reference_kg_path": REQUIRED, + "entity_alignment_config_ref": "default", + "value_sim_threshold": TripleAlignmentConfig.model_fields["value_sim_threshold"].default, + }, + # Consistency config currently requires both fields at type-level; + # template includes both so users can fill in one/both. + "consistency_violations": { + "reference_kg_path": REQUIRED, + "ontology_path": REQUIRED, + }, + }, + } + + +def generate_default_config_yaml() -> str: + """ + Return a YAML string (template) for `load_metric_configs`. + """ + cfg = generate_default_config_dict() + # Keep output stable and readable. + return yaml.safe_dump(cfg, sort_keys=False, default_flow_style=False) + + +def write_default_config_yaml(path: str | Path) -> Path: + """ + Write a default template YAML to disk and return the written path. + """ + out_path = Path(path) + out_path.write_text(generate_default_config_yaml(), encoding="utf-8") + return out_path + diff --git a/src/kgpipe_eval/evaluator.py b/src/kgpipe_eval/evaluator.py index 853a2c6..ec2f045 100644 --- a/src/kgpipe_eval/evaluator.py +++ b/src/kgpipe_eval/evaluator.py @@ -3,6 +3,7 @@ import inspect from dataclasses import dataclass from typing import Any, Dict, Iterable, List, Mapping, Sequence +import traceback from kgpipe_eval.api import Metric, MetricResult from kgpipe_eval.utils.kg_utils import TripleGraph @@ -55,6 +56,8 @@ def run( ) res = compute(kg, cfg) except Exception as e: + print(f"Failed running metric {key!r}: {e}") + print(traceback.format_exc()) raise RuntimeError(f"Failed running metric {key!r}") from e if not isinstance(res, MetricResult): diff --git a/src/kgpipe_eval/metrics/entity_alignment.py b/src/kgpipe_eval/metrics/entity_alignment.py index ae68f40..3d9c0b6 100644 --- a/src/kgpipe_eval/metrics/entity_alignment.py +++ b/src/kgpipe_eval/metrics/entity_alignment.py @@ -26,6 +26,20 @@ def eval_entity_alignment_by_label_embedding_and_type(kg: KG, config: EntityAlig ref_entity_uri_label_type_pairs = load_entity_uri_label_type_pairs(config) gen_entity_uri_label_type_pairs = list(get_entity_uri_label_type_pairs(kg)) + # print ref and gen pairs for testing + # print("--------------------------------") + # print("ref_entity_uri_label_type_pairs") + # for pair in ref_entity_uri_label_type_pairs: + # print(pair) + # print("--------------------------------") + # print("gen_entity_uri_label_type_pairs") + # for pair in gen_entity_uri_label_type_pairs: + # print(pair) + # print("--------------------------------") + # print("alignments") + # for alignment in alignments: + # print(alignment) + ref_types = {pair.uri: pair.type for pair in ref_entity_uri_label_type_pairs if pair.type is not None} # TODO gen_types can be multiple types, we need to handle this gen_types = {pair.uri: pair.type for pair in gen_entity_uri_label_type_pairs if pair.type is not None} diff --git a/src/kgpipe_eval/test/test_config_manager.py b/src/kgpipe_eval/test/test_config_manager.py index 78e8cc5..37af30d 100644 --- a/src/kgpipe_eval/test/test_config_manager.py +++ b/src/kgpipe_eval/test/test_config_manager.py @@ -2,7 +2,7 @@ from pathlib import Path -from kgpipe_eval.config.manager import load_metric_configs +from kgpipe_eval.config.manager import load_metric_configs, generate_default_config_dict from kgpipe_eval.metrics.duplicates import DuplicateConfig from kgpipe_eval.metrics.triple_alignment import TripleAlignmentConfig from kgpipe_eval.utils.alignment_utils import EntityAlignmentConfig @@ -50,3 +50,53 @@ def test_load_metric_configs_resolves_entity_alignment_refs(tmp_path: Path) -> N # reference_kg is constructed from reference_kg_path assert loaded["triple_alignment"].reference_kg.path.as_posix().endswith("tmp_test_data/reference.nt") + +def test_generate_default_config_dict_has_all_sections() -> None: + cfg = generate_default_config_dict() + assert "entity_alignment_configs" in cfg + assert "metrics" in cfg + assert "default" in cfg["entity_alignment_configs"] + assert "verified_entities_path" in cfg["entity_alignment_configs"]["default"] + + metrics = cfg["metrics"] + assert "entity_align" in metrics + assert "duplicates" in metrics + assert "triple_alignment" in metrics + assert "consistency_violations" in metrics + + +def test_load_metric_configs_interpolates_vars_and_resolves_paths(tmp_path: Path) -> None: + # mirror the style used in experiments/examples/scripts/run_eval.yaml + cfg = tmp_path / "run_eval.yaml" + (tmp_path / "test.ttl").write_text( + """ +@prefix : . +@prefix rdfs: . +:a rdfs:label "A" . +""".lstrip(), + encoding="utf-8", + ) + cfg.write_text( + """ +reference_kg: test.ttl + +entity_alignment_configs: + default: + method: label_embedding + reference_kg: $reference_kg + entity_sim_threshold: 0.95 + +metrics: + duplicates: + entity_alignment_config_ref: default +""".lstrip(), + encoding="utf-8", + ) + + loaded = load_metric_configs(cfg) + assert isinstance(loaded["duplicates"], DuplicateConfig) + # reference_kg should be a KG whose path resolves relative to cfg location + kg = loaded["duplicates"].entity_alignment_config.reference_kg + assert kg is not None + assert kg.path == (tmp_path / "test.ttl").resolve() + diff --git a/src/kgpipe_eval/test/test_llm_eval.py b/src/kgpipe_eval/test/test_llm_eval.py index c438e71..b02ced2 100644 --- a/src/kgpipe_eval/test/test_llm_eval.py +++ b/src/kgpipe_eval/test/test_llm_eval.py @@ -1,6 +1,6 @@ import pytest -@pytest.skip(reason="Long running test") -def test_llm_eval(): - pass +# @pytest.skip(reason="Long running test") +# def test_llm_eval(): +# pass diff --git a/src/kgpipe_eval/test/test_metric_utils.py b/src/kgpipe_eval/test/test_metric_utils.py new file mode 100644 index 0000000..6ddc8c8 --- /dev/null +++ b/src/kgpipe_eval/test/test_metric_utils.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from kgpipe_eval.utils.metric_utils import eval_results_jsons_to_rows, write_eval_csv + + +def test_eval_results_json_to_rows_and_csv(tmp_path: Path) -> None: + # Create a fake output structure: //stage_1/eval_results.json + p = tmp_path / "rdf_a" / "stage_1" + p.mkdir(parents=True) + + (p / "eval_results.json").write_text( + json.dumps( + [ + { + "metric": "DuplicateMetric", + "summary": "Duplicates in the KG", + "measurements": [ + {"name": "duplicates", "value": 3, "unit": "number"}, + {"name": "entity_count", "value": 10, "unit": "number"}, + {"name": "duplicates_ratio", "value": 0.3, "unit": "percentage"}, + ], + } + ] + ) + ) + + allowlist = { + "DuplicateMetric": { + "duplicates": "number", + "entity_count": "number", + "duplicates_ratio": "percentage", + } + } + + rows = eval_results_jsons_to_rows([p / "eval_results.json"], allowlist=allowlist) + assert rows == [ + { + "pipeline": "rdf_a", + "stage": "stage_1", + "DuplicateMetric__duplicates__number": 3, + "DuplicateMetric__entity_count__number": 10, + "DuplicateMetric__duplicates_ratio__percentage": 0.3, + } + ] + + out_csv = tmp_path / "out.csv" + write_eval_csv([p / "eval_results.json"], out_path=out_csv, allowlist=allowlist) + txt = out_csv.read_text() + + # Header + one row, with stable columns including pipeline/stage and allowlist columns. + lines = [l for l in txt.splitlines() if l.strip()] + assert len(lines) == 2 + assert lines[0].split(",") == [ + "pipeline", + "stage", + "DuplicateMetric__duplicates__number", + "DuplicateMetric__duplicates_ratio__percentage", + "DuplicateMetric__entity_count__number", + ] + assert lines[1].split(",") == ["rdf_a", "stage_1", "3", "0.3", "10"] + diff --git a/src/kgpipe_eval/test/utils.py b/src/kgpipe_eval/test/utils.py index 602c752..afe8034 100644 --- a/src/kgpipe_eval/test/utils.py +++ b/src/kgpipe_eval/test/utils.py @@ -3,6 +3,7 @@ from kgpipe.common.model.data import DataFormat from kgpipe_eval.test.examples import * from kgpipe_eval.api import MetricResult +from kgpipe_eval.utils.metric_utils import render_metric_result from rdflib import Graph import json from collections.abc import Mapping, Sequence @@ -38,52 +39,4 @@ def get_verified_entities_path() -> Path: with open(path, "w") as f: # Avoid a leading blank line which breaks csv.DictReader header parsing f.write(VERIFIED_ENTITIES.lstrip().replace("o:", "http://example.org/ontology/")) - return path - -def render_metric_result(metric_result: MetricResult) -> str: - def _metric_key(mr: MetricResult) -> str: - metric = mr.metric - return getattr(metric, "key", metric.__class__.__name__) - - def _fmt_value(v) -> str: - if isinstance(v, float): - # stable, compact representation for test output - return f"{v:.6g}" - if isinstance(v, (int, bool)) or v is None: - return str(v) - if isinstance(v, str): - return v - if isinstance(v, Mapping): - return json.dumps(v, indent=2, sort_keys=True, default=str) - if isinstance(v, Sequence) and not isinstance(v, (str, bytes, bytearray)): - return json.dumps(v, indent=2, sort_keys=True, default=str) - return str(v) - - key = _metric_key(metric_result) - summary = metric_result.summary or "" - - ms = sorted(metric_result.measurements, key=lambda m: m.name) - name_w = max([len("measurement"), *(len(m.name) for m in ms)] or [len("measurement")]) - unit_w = max([len("unit"), *(len(m.unit or "") for m in ms)] or [len("unit")]) - - lines: list[str] = [] - lines.append(f"metric: {key}") - if summary: - lines.append(f"summary: {summary}") - if not ms: - lines.append("(no measurements)") - return "\n".join(lines) - - lines.append("") - lines.append(f"{'measurement':<{name_w}} {'value'}{' ' * max(1, 2)}{'unit':<{unit_w}}") - lines.append(f"{'-' * name_w} {'-' * 20} {'-' * unit_w}") - - for m in ms: - unit = m.unit or "" - rendered = _fmt_value(m.value) - rendered_lines = rendered.splitlines() or [""] - lines.append(f"{m.name:<{name_w}} {rendered_lines[0]:<20} {unit:<{unit_w}}") - for cont in rendered_lines[1:]: - lines.append(f"{'':<{name_w}} {cont}") - - return "\n".join(lines) \ No newline at end of file + return path \ No newline at end of file diff --git a/src/kgpipe_eval/utils/alignment_utils.py b/src/kgpipe_eval/utils/alignment_utils.py index d5c6adb..80546a4 100644 --- a/src/kgpipe_eval/utils/alignment_utils.py +++ b/src/kgpipe_eval/utils/alignment_utils.py @@ -3,7 +3,7 @@ from functools import lru_cache from pydantic import BaseModel, ConfigDict, model_validator -from kgpipe_eval.utils.kg_utils import TripleGraph, Term, Triple +from kgpipe_eval.utils.kg_utils import TripleGraph, Term, Triple, KgLike, KgManager from kgpipe.util.embeddings.st_emb import get_model from rdflib import RDFS, RDF @@ -16,7 +16,7 @@ class EntityAlignmentConfig(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) method: Literal["label_embedding", "label_alias_embedding", "label_embedding_and_type"] = "label_embedding" - reference_kg: Optional[KG] = None + reference_kg: Optional[KgLike] = None verified_entities_path: Optional[Path] = None verified_entities_delimiter: str = "\t" entity_sim_threshold: float = 0.95 @@ -76,7 +76,8 @@ def load_entity_uri_label_type_pairs(config: EntityAlignmentConfig) -> list[UriL if config.verified_entities_path is not None: return load_verified_entities(config.verified_entities_path, delimiter=config.verified_entities_delimiter) elif config.reference_kg is not None: - return get_entity_uri_label_type_pairs(config.reference_kg) + # `get_entity_uri_label_type_pairs` is a generator; downstream alignment uses indexing. + return list(get_entity_uri_label_type_pairs(KgManager.load_kg(config.reference_kg))) else: raise ValueError("No verified entities path or reference KG provided") diff --git a/src/kgpipe_eval/utils/kg_utils.py b/src/kgpipe_eval/utils/kg_utils.py index 348ad4d..09b3410 100644 --- a/src/kgpipe_eval/utils/kg_utils.py +++ b/src/kgpipe_eval/utils/kg_utils.py @@ -105,10 +105,12 @@ class RdfLibTripleGraph(TripleGraph): def _graph(self) -> Graph: if isinstance(self.kg, Graph): return self.kg - if isinstance(self.kg, KG): + elif isinstance(self.kg, KG): return self.kg.get_graph() - # Assume filesystem path - return Graph().parse(str(self.kg)) + elif isinstance(self.kg, Path): + return Graph().parse(str(self.kg)) + else: + raise ValueError(f"Unsupported KG type: {type(self.kg)}") def triples(self, triple_pattern: TriplePattern) -> Iterable[Triple]: g = self._graph() diff --git a/src/kgpipe_eval/utils/metric_utils.py b/src/kgpipe_eval/utils/metric_utils.py new file mode 100644 index 0000000..71e0c71 --- /dev/null +++ b/src/kgpipe_eval/utils/metric_utils.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +import csv +import json +from dataclasses import dataclass +from pathlib import Path +from collections.abc import Mapping, Sequence +from typing import Any, Iterable + +JsonValue = Any + + +@dataclass(frozen=True) +class MeasurementKey: + metric: str + measurement: str + unit: str + + +Allowlist = Mapping[str, Mapping[str, str]] + +from kgpipe_eval.api import MetricResult + + +def render_metric_result(metric_result: MetricResult, truncate: bool = False, truncate_value: int = 5) -> str: + """ + Render a MetricResult into a human-readable table-like string. + + This is intended for CLI/test output (not machine-parseable export). + """ + + def _metric_key(mr: MetricResult) -> str: + metric = mr.metric + return getattr(metric, "key", metric.__class__.__name__) + + def _fmt_value(v: Any) -> str: + if isinstance(v, float): + # stable, compact representation for test output + return f"{v:.6g}" + if isinstance(v, (int, bool)) or v is None: + return str(v) + if isinstance(v, str): + if truncate: + lines = v.splitlines()[:truncate_value] + return "\n".join(lines) + "\n..." + return v + if isinstance(v, Mapping): + rendered = json.dumps(v, indent=2, sort_keys=True, default=str) + if truncate: + return "\n".join(rendered.splitlines()[:truncate_value]) + "\n..." + return rendered + if isinstance(v, Sequence) and not isinstance(v, (str, bytes, bytearray)): + rendered = json.dumps(v, indent=2, sort_keys=True, default=str) + if truncate: + return "\n".join(rendered.splitlines()[:truncate_value]) + "\n..." + return rendered + return str(v) + + key = _metric_key(metric_result) + summary = metric_result.summary or "" + + ms = sorted(metric_result.measurements, key=lambda m: m.name) + name_w = max([len("measurement"), *(len(m.name) for m in ms)] or [len("measurement")]) + unit_w = max([len("unit"), *(len(m.unit or "") for m in ms)] or [len("unit")]) + + lines: list[str] = [] + lines.append("=" * 80) + lines.append(f"metric: {key}") + if summary: + lines.append(f"summary: {summary}") + if not ms: + lines.append("(no measurements)") + return "\n".join(lines) + + lines.append("") + lines.append(f"{'measurement':<{name_w}} {'value'}{' ' * max(1, 2)}{'unit':<{unit_w}}") + lines.append(f"{'-' * name_w} {'-' * 20} {'-' * unit_w}") + + for m in ms: + unit = m.unit or "" + rendered = _fmt_value(m.value) + rendered_lines = rendered.splitlines() or [""] + lines.append(f"{m.name:<{name_w}} {rendered_lines[0]:<20} {unit:<{unit_w}}") + for cont in rendered_lines[1:]: + lines.append(f"{'':<{name_w}} {cont}") + + return "\n".join(lines) + + +def parse_eval_results(path: Path) -> dict[MeasurementKey, JsonValue]: + """ + Parse a single `eval_results.json` and return a flattened mapping. + + Expected file schema (per entry): + - metric: str + - measurements: [{name: str, value: any-json, unit: str|null}, ...] + """ + raw = json.loads(path.read_text()) + if not isinstance(raw, list): + raise ValueError(f"{path} must contain a JSON list, got {type(raw).__name__}") + + out: dict[MeasurementKey, JsonValue] = {} + for entry in raw: + if not isinstance(entry, Mapping): + raise ValueError(f"{path} entries must be objects, got {type(entry).__name__}") + + metric = entry.get("metric") + if not isinstance(metric, str) or not metric: + raise ValueError(f"{path} entry missing 'metric' string") + + measurements = entry.get("measurements", []) + if not isinstance(measurements, list): + raise ValueError(f"{path} entry 'measurements' must be a list") + + for m in measurements: + if not isinstance(m, Mapping): + continue + name = m.get("name") + unit = m.get("unit") + if not isinstance(name, str) or not name: + continue + if unit is None: + unit = "" + if not isinstance(unit, str): + unit = str(unit) + out[MeasurementKey(metric=metric, measurement=name, unit=unit)] = m.get("value") + + return out + + +def allowlist_to_columns(allowlist: Allowlist) -> list[str]: + cols: list[str] = [] + for metric in sorted(allowlist.keys()): + for measurement in sorted(allowlist[metric].keys()): + unit = allowlist[metric][measurement] + cols.append(f"{metric}__{measurement}__{unit}") + return cols + + +def eval_results_jsons_to_rows( + paths: Sequence[Path], + *, + allowlist: Allowlist, +) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + + for path in paths: + if path.name != "eval_results.json": + raise ValueError(f"Expected eval_results.json file, got {path}") + stage_dir = path.parent + stage = stage_dir.name + if not stage.startswith("stage_"): + raise ValueError(f"Expected stage directory named stage_*, got {stage_dir}") + + pipeline_dir = stage_dir.parent + pipeline = pipeline_dir.name + if not pipeline: + raise ValueError(f"Could not derive pipeline name from {path}") + + flat = parse_eval_results(path) + + row: dict[str, Any] = {"pipeline": pipeline, "stage": stage} + for metric, measurements in allowlist.items(): + for measurement, unit in measurements.items(): + key = MeasurementKey(metric=metric, measurement=measurement, unit=unit) + col = f"{metric}__{measurement}__{unit}" + row[col] = flat.get(key, "") + + rows.append(row) + + return rows + + +def write_eval_csv( + paths: Sequence[Path], + *, + out_path: Path, + allowlist: Allowlist, + delimiter: str = ",", + round_ndigits: int | None = None, +) -> None: + rows = eval_results_jsons_to_rows(paths, allowlist=allowlist) + columns = ["pipeline", "stage", *allowlist_to_columns(allowlist)] + + out_path.parent.mkdir(parents=True, exist_ok=True) + with out_path.open("w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=columns, extrasaction="ignore", delimiter=delimiter) + writer.writeheader() + for r in rows: + # Ensure blanks for missing keys + row = {k: r.get(k, "") for k in columns} + if round_ndigits is not None: + for k, v in list(row.items()): + if isinstance(v, float): + row[k] = round(v, round_ndigits) + writer.writerow(row) + From a223d83a0093b4ec204e03f37047f1e354b7a5bd Mon Sep 17 00:00:00 2001 From: Marvin Date: Fri, 10 Apr 2026 15:04:12 +0200 Subject: [PATCH 46/96] feat(eval): changes to core, for new eval --- src/kgpipe/cli/eval_new.py | 194 +++++++++++++++++-- src/kgpipe/cli/main.py | 2 + src/kgpipe/common/model/pipeline.py | 15 +- src/kgpipe/datasets/multipart_multisource.py | 4 +- 4 files changed, 198 insertions(+), 17 deletions(-) diff --git a/src/kgpipe/cli/eval_new.py b/src/kgpipe/cli/eval_new.py index a11eb17..3c9b442 100644 --- a/src/kgpipe/cli/eval_new.py +++ b/src/kgpipe/cli/eval_new.py @@ -4,12 +4,14 @@ from typing import List, Optional, Sequence, Any import json from pathlib import Path +import codecs from kgpipe_eval.metrics.statistics import CountMetric from kgpipe_eval.metrics.duplicates import DuplicateMetric from kgpipe_eval.metrics.entity_alignment import EntityAlignmentMetric from kgpipe_eval.utils.kg_utils import KgManager -from kgpipe_eval.config.manager import load_metric_configs +from kgpipe_eval.utils.metric_utils import MeasurementKey, parse_eval_results, write_eval_csv +from kgpipe_eval.config.manager import load_metric_configs, write_default_config_yaml from kgpipe_eval.evaluator import Evaluator # from kgpipe_eval.metrics.semantic import OntologyClassCoverageMetric, OntologyRelationCoverageMetric, OntologyNamespaceCoverageMetric # from kgpipe_eval.metrics.reference import PrecisionMetric, RecallMetric, F1ScoreMetric @@ -20,6 +22,45 @@ console = Console() +_DEFAULT_EVAL_RESULTS_ALLOWLIST = { + "DuplicateMetric": { + "duplicates": "number", + "entity_count": "number", + "duplicates_ratio": "percentage", + } +} + +def _measurement_key_to_col(k: MeasurementKey) -> str: + return f"{k.metric}__{k.measurement}__{k.unit}" + + +def _col_to_measurement_key(col: str) -> MeasurementKey: + parts = col.split("__") + if len(parts) != 3 or not all(parts): + raise click.ClickException( + f"Invalid selection '{col}'. Expected format: ____" + ) + return MeasurementKey(metric=parts[0], measurement=parts[1], unit=parts[2]) + + +def _available_eval_result_keys(paths: list[Path]) -> list[MeasurementKey]: + keys: set[MeasurementKey] = set() + for p in paths: + flat = parse_eval_results(p) + keys.update(flat.keys()) + return sorted(keys, key=_measurement_key_to_col) + +def _decode_single_char_delimiter(delimiter: str) -> str: + """ + Allow passing common escape sequences like '\\t' for tab. + """ + decoded = codecs.decode(delimiter, "unicode_escape") if "\\" in delimiter else delimiter + if len(decoded) != 1: + raise click.ClickException( + f"--delimiter must be a single character (you passed {delimiter!r} -> {decoded!r})" + ) + return decoded + def _available_metric_instances() -> dict[str, Any]: # Keep this explicit until the metrics package is more complete/stable. @@ -61,13 +102,20 @@ def _build_confs_for_selected_metrics( norm_mkey = _normalize_key(mkey) norm_cls = _normalize_key(metric.__class__.__name__) + # Try common YAML ids derived from metric names + base_from_key = norm_mkey.replace("_metric", "").replace("metric", "") + base_from_cls = norm_cls.replace("_metric", "").replace("metric", "") + cfg = ( confs_by_norm.get(norm_mkey) or confs_by_norm.get(norm_cls) or confs_by_norm.get(_normalize_key(alias_to_metric_key.get(norm_mkey, ""))) or confs_by_norm.get(_normalize_key(alias_to_metric_key.get(norm_cls, ""))) - or confs_by_norm.get(norm_mkey.replace("metric", "")) - or confs_by_norm.get(norm_cls.replace("metric", "")) + or confs_by_norm.get(base_from_key) + or confs_by_norm.get(base_from_cls) + # plural fallback (e.g. DuplicateMetric -> duplicates) + or confs_by_norm.get(f"{base_from_key}s") + or confs_by_norm.get(f"{base_from_cls}s") ) if cfg is not None: @@ -112,20 +160,27 @@ def _results_to_json_rows(kg_path: str, metric_key: str, measurements: Sequence[ return rows -@click.command() +@click.group(name="eval-new") +def eval_new_cmd() -> None: + """ + Evaluation commands for the new metric framework. + """ + + +@eval_new_cmd.command(name="run") @click.argument("kg_paths", nargs=-1, type=click.Path(exists=True)) @click.option( - "--config", - "-c", - type=click.Path(exists=True), - help="Path to metric config file" + "--config", + "-c", + type=click.Path(exists=True), + help="Path to metric config file", ) @click.option( - "--metrics", - "-m", - multiple=True, + "--metrics", + "-m", + multiple=True, type=click.Choice(sorted(_available_metric_instances().keys())), - help="Metrics to compute" + help="Metrics to compute", ) @click.option( "--output", @@ -134,7 +189,7 @@ def _results_to_json_rows(kg_path: str, metric_key: str, measurements: Sequence[ help="Write results to a JSON file (list of measurement rows).", ) @click.pass_context -def eval_new_cmd(ctx: click.Context, kg_paths: List[str], config: Optional[str], metrics: tuple, output: Optional[str]): +def run_cmd(ctx: click.Context, kg_paths: List[str], config: Optional[str], metrics: tuple, output: Optional[str]) -> None: """ Compute selected metrics for one or more KGs. @@ -172,4 +227,115 @@ def eval_new_cmd(ctx: click.Context, kg_paths: List[str], config: Optional[str], out_path = Path(output) out_path.parent.mkdir(parents=True, exist_ok=True) out_path.write_text(json.dumps(all_rows, indent=2, ensure_ascii=False, default=str) + "\n", encoding="utf-8") - console.print(f"[green]✓ Saved results to[/green] {output}") \ No newline at end of file + console.print(f"[green]✓ Saved results to[/green] {output}") + + +@eval_new_cmd.command(name="init-config") +@click.argument("output_path", type=click.Path(dir_okay=False), default="eval.default.yaml", required=False) +def init_config_cmd(output_path: str) -> None: + """ + Write a default metric-config template YAML to OUTPUT_PATH. + """ + out = write_default_config_yaml(output_path) + console.print(f"[green]✓ Wrote default config to[/green] {out}") + + +@eval_new_cmd.command(name="to-csv") +@click.argument("eval_json_paths", nargs=-1, type=click.Path(exists=True, dir_okay=False)) +@click.option( + "--glob", + "glob_pattern", + type=str, + help="Optional glob pattern (expanded by the shell) for eval_results.json files.", +) +@click.option( + "--select", + "-s", + "selected_cols", + multiple=True, + help="Select columns to include (repeatable). Format: ____. If omitted, defaults are used.", +) +@click.option( + "--list-keys", + is_flag=True, + help="Print available column keys found in the inputs and exit.", +) +@click.option( + "--round", + "round_ndigits", + type=int, + default=None, + help="Round float values to N decimal digits before writing CSV.", +) +@click.option( + "--delimiter", + "delimiter", + type=str, + default=",", + show_default=True, + help="CSV delimiter character (supports escapes like '\\t').", +) +@click.option( + "--output", + "-o", + "output_csv", + type=click.Path(dir_okay=False), + required=True, + help="Path to write the CSV table to.", +) +def to_csv_cmd( + eval_json_paths: List[str], + glob_pattern: Optional[str], + selected_cols: tuple[str, ...], + list_keys: bool, + round_ndigits: Optional[int], + delimiter: str, + output_csv: str, +) -> None: + """ + Convert one or more `eval_results.json` files into a CSV table. + + The CSV contains one row per (pipeline, stage), derived from file paths like: + `/stage_/eval_results.json` + + Columns follow: `____`. + """ + paths: list[Path] = [Path(p) for p in eval_json_paths] + if glob_pattern: + paths.extend(sorted(Path().glob(glob_pattern))) + + if not paths: + raise click.ClickException("No input files provided. Pass paths or --glob.") + + available = _available_eval_result_keys(paths) + console.print("[bold]Available keys in inputs:[/bold]") + for k in available: + console.print(f" - {_measurement_key_to_col(k)}") + + if list_keys: + return + + allowlist = _DEFAULT_EVAL_RESULTS_ALLOWLIST + if selected_cols: + available_cols = {_measurement_key_to_col(k) for k in available} + missing = [c for c in selected_cols if c not in available_cols] + if missing: + raise click.ClickException( + "Selected keys not found in inputs:\n" + "\n".join(f"- {m}" for m in missing) + ) + + allowlist = {} + for c in selected_cols: + k = _col_to_measurement_key(c) + allowlist.setdefault(k.metric, {})[k.measurement] = k.unit + + out_path = Path(output_csv) + delimiter = _decode_single_char_delimiter(delimiter) + write_eval_csv( + paths, + out_path=out_path, + allowlist=allowlist, + delimiter=delimiter, + round_ndigits=round_ndigits, + ) + console.print(f"[green]✓ Wrote CSV to[/green] {out_path}") \ No newline at end of file diff --git a/src/kgpipe/cli/main.py b/src/kgpipe/cli/main.py index fba6104..6f3e0c0 100644 --- a/src/kgpipe/cli/main.py +++ b/src/kgpipe/cli/main.py @@ -20,6 +20,7 @@ from .clean import clean_cmd from .task import task_cmd from .discover import discover_cmd +from .eval_new import eval_new_cmd # from .rank import rank_cmd # Initialize Rich console for pretty output console = Console() @@ -81,6 +82,7 @@ def cli(ctx: click.Context, config: Optional[str], verbose: bool, quiet: bool): cli.add_command(clean_cmd) cli.add_command(task_cmd) cli.add_command(discover_cmd) +cli.add_command(eval_new_cmd) # cli.add_command(rank_cmd) if __name__ == "__main__": diff --git a/src/kgpipe/common/model/pipeline.py b/src/kgpipe/common/model/pipeline.py index 00495ef..8420c02 100644 --- a/src/kgpipe/common/model/pipeline.py +++ b/src/kgpipe/common/model/pipeline.py @@ -36,7 +36,13 @@ class KgPipePlan(BaseModel): seed: Optional[Data] = None source: Optional[Data] = None result: Optional[Data] = None - + + @staticmethod + def from_path(json_file: str) -> 'KgPipePlan': + with open(json_file, "r") as f: + json_data = json.load(f) + return KgPipePlan(**json_data) + # def __str__(self) -> str: # return f"KgTaskReport({self.task_name}, {self.status}, {self.duration:.2f}s)" @@ -51,6 +57,13 @@ class KgStageReport(BaseModel): status: str error: Optional[str] = None + @staticmethod + def from_path(json_file: str) -> 'KgStageReport': + with open(json_file, "r") as f: + json_data = json.load(f) + return KgStageReport(**json_data) + +KgPipeReport = KgStageReport KgPipelineRun = KgStageReport # @dataclass diff --git a/src/kgpipe/datasets/multipart_multisource.py b/src/kgpipe/datasets/multipart_multisource.py index 1389221..22f1d93 100644 --- a/src/kgpipe/datasets/multipart_multisource.py +++ b/src/kgpipe/datasets/multipart_multisource.py @@ -90,8 +90,8 @@ def _check(self): def read_csv(self) -> List[MatchesRow]: return read_matches_csv(self.file) -def read_entities_csv(path: Path) -> List[EntitiesRow]: - return [EntitiesRow(entity_id=row["entity_id"], entity_label=row["entity_label"], entity_type=row["entity_type"], dataset=row["dataset"]) for row in csv.DictReader(path.open("r"), delimiter="\t")] +def read_entities_csv(path: Path, delimiter: str = "\t") -> List[EntitiesRow]: + return [EntitiesRow(entity_id=row["entity_id"], entity_label=row["entity_label"], entity_type=row["entity_type"], dataset=row["dataset"]) for row in csv.DictReader(path.open("r"), delimiter=delimiter)] class VerifiedEntities(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) From 32b57909e2c708b43b4f458b317a4cadac71ac68 Mon Sep 17 00:00:00 2001 From: Marvin Date: Fri, 10 Apr 2026 15:04:41 +0200 Subject: [PATCH 47/96] exp(moviekg): new eval for duprate and entity count using new eval api --- .../moviekg/evaluation/test_eval_refactor.py | 96 ++++++++++++++++--- 1 file changed, 82 insertions(+), 14 deletions(-) diff --git a/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py b/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py index 285bae2..3fdee2f 100644 --- a/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py +++ b/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py @@ -17,6 +17,13 @@ from kgpipe.common.model.kg import KG from kgpipe.common.model.data import DataFormat import json +from dataclasses import asdict + +try: + from moviekg import config as moviekg_config +except Exception as e: + # These are integration-style tests that depend on local env/config files. + pytest.skip(f"MovieKG config not available for eval integration test: {e}", allow_module_level=True) # TODO # [ ] Dataset Reader (split,ref,source,metadata) # [ ] Pipeline Results Reader (stage,kg,plan,report,tmp_file) @@ -27,7 +34,7 @@ EX_BENCH_DATA_PATH = Path("/home/marvin/phd/data/moviekg/datasets/film_10k") -EX_INC_PIPE_DATA_PATH = Path("/home/marvin/phd/data/moviekg/output/large/rdf_a") +# EX_INC_PIPE_DATA_PATH = Path("/home/marvin/phd/data/moviekg/output/large/rdf_a") # TODO is a wrapper interface for now, Dataset needs refactor later # TODO can be abstracted and implemented to have direct method per type, so dict is not needed for access @@ -98,6 +105,32 @@ def evaluate_stage(i: int, pipe_data: KgPipeData, bench_data: KgBenchData) -> Li config_dict = build_config_dict(i, pipe_data, bench_data) return Evaluator().run(tg, metrics, config_dict) + +def _stage_dirs(output_dir: Path) -> list[Path]: + stage_dirs = [p for p in output_dir.iterdir() if p.is_dir() and p.name.startswith("stage_")] + # stage_1, stage_2, ... + stage_dirs.sort(key=lambda p: int(p.name.split("_", 1)[1])) + return stage_dirs + + +def _metric_results_to_jsonable(results: list[MetricResult]) -> list[dict]: + """ + Convert `MetricResult` dataclasses to JSON-serializable dicts. + + `MetricResult.metric` is an object instance, so we store its key/classname. + """ + out: list[dict] = [] + for r in results: + metric_key = getattr(r.metric, "key", None) or r.metric.__class__.__name__ + out.append( + { + "metric": metric_key, + "summary": r.summary, + "measurements": [asdict(m) for m in r.measurements], + } + ) + return out + # EX_PIPE_DATA_PATH = Path("/home/marvin/phd/data/moviekg/output/small/rdf_a/stage_1") # def test_evaluate_stage(): # if not EX_PIPE_DATA_PATH.exists() or not EX_BENCH_DATA_PATH.exists(): @@ -111,16 +144,51 @@ def evaluate_stage(i: int, pipe_data: KgPipeData, bench_data: KgBenchData) -> Li # for result in results: # print(render_metric_result(result, truncate=True, truncate_value=3)) -def test_evaluate_inc_stage(): - if not EX_INC_PIPE_DATA_PATH.exists() or not EX_BENCH_DATA_PATH.exists(): - pytest.skip("Local MovieKG inc data not available; test is an integration/WIP scaffold.") - - for i in range(1, 4): - pipe_data = KgPipeData.from_path(EX_INC_PIPE_DATA_PATH / f"stage_{i}") - bench_data = KgBenchData.from_path(EX_BENCH_DATA_PATH) - results = evaluate_stage(i, pipe_data, bench_data) - - # render - print() # avoids pytest output being interleaved with print statements - for result in results: - print(render_metric_result(result, truncate=True, truncate_value=3)) \ No newline at end of file +# def test_evaluate_inc_stage(): +# if not EX_INC_PIPE_DATA_PATH.exists() or not EX_BENCH_DATA_PATH.exists(): +# pytest.skip("Local MovieKG inc data not available; test is an integration/WIP scaffold.") + +# for i in range(1, 4): +# pipe_data = KgPipeData.from_path(EX_INC_PIPE_DATA_PATH / f"stage_{i}") +# bench_data = KgBenchData.from_path(EX_BENCH_DATA_PATH) +# results = evaluate_stage(i, pipe_data, bench_data) + +# # render +# print() # avoids pytest output being interleaved with print statements +# for result in results: +# print(render_metric_result(result, truncate=True, truncate_value=3)) + +@pytest.mark.parametrize( + "pipeline_name", + list[str](moviekg_config.pipeline_types.keys()) + list[str](moviekg_config.llm_pipeline_types.keys()), +) +def test_evaluate_new(pipeline_name: str): + """ + Boilerplate integration test that runs the new eval API for each pipeline + output under `OUTPUT_ROOT//stage_*`. + """ + output_dir = moviekg_config.OUTPUT_ROOT / pipeline_name + + if not output_dir.exists(): + pytest.skip(f"Pipeline output directory {output_dir} not found") + + stage_dirs = _stage_dirs(output_dir) + if not stage_dirs: + pytest.skip(f"No stage directories found under {output_dir}") + + # Uses the dataset selected/configured via `moviekg.config` env vars. + bench_data = KgBenchData.from_path(EX_BENCH_DATA_PATH) + + for stage_dir in stage_dirs: + i = int(stage_dir.name.split("_", 1)[1]) + pipe_data = KgPipeData.from_path(stage_dir) + results = evaluate_stage(i=i, pipe_data=pipe_data, bench_data=bench_data) + + eval_results = _metric_results_to_jsonable(results) + with open(stage_dir / "eval_results.json", "w") as f: + json.dump(eval_results, f, indent=2) + print(f"Wrote results to {stage_dir / 'eval_results.json'}") + + # Smoke checks: we got metric results back for this stage. + assert isinstance(results, list) + assert results \ No newline at end of file From cd69c4e1a07484c89e8942d3a61e45b417f9c3d1 Mon Sep 17 00:00:00 2001 From: Marvin Date: Fri, 27 Feb 2026 10:48:31 +0100 Subject: [PATCH 48/96] feature: parameter: vis scatter plot --- docs/metrics/entity_coverage.md | 66 +++++++++++++++++-- .../tests/test_visualization.py | 3 + .../visualization/__init__.py | 3 + 3 files changed, 65 insertions(+), 7 deletions(-) diff --git a/docs/metrics/entity_coverage.md b/docs/metrics/entity_coverage.md index 3e76cb3..84b4ff1 100644 --- a/docs/metrics/entity_coverage.md +++ b/docs/metrics/entity_coverage.md @@ -1,21 +1,73 @@ +# Entity Coverage Metric +The Entity Coverage metric evaluates how well source entities are integrated into the target knowledge graph. It measures the overlap between expected source entities and the entities actually present in the generated knowledge graph. +## Source Entity Integration Score -# Source Entitiy Integration Score +The metric compares a set of expected source entities (provided as a reference file) against the entities found in the knowledge graph. It calculates coverage based on entity URIs and labels. -# Entity Integration Score +## Input Format +The expected entities are provided in a CSV or JSON file with the following structure: + +**CSV Format:** ``` URI, LABEL, TYPE +http://example.org/entity1, "Entity Label 1", EntityType +http://example.org/entity2, "Entity Label 2", EntityType +``` + +**JSON Format:** +```json +{ + "http://example.org/entity1": { + "entity_label": "Entity Label 1", + "entity_type": "EntityType" + }, + "http://example.org/entity2": { + "entity_label": "Entity Label 2", + "entity_type": "EntityType" + } +} +``` + +## Calculation + +The metric performs the following steps: + +1. **Load expected entities**: Reads the entity dictionary from the provided file path +2. **Extract entity identifiers**: Collects URIs and labels from the expected entities +3. **Find entities in KG**: Searches the knowledge graph for entities matching by URI or label (using `rdfs:label`) +4. **Calculate overlap**: Counts how many expected entities are found in the KG + +The coverage score is calculated as: + ``` +coverage = overlapping_entities_count / expected_entities_count +``` + +Where: +- `overlapping_entities_count`: Number of expected entities found in the KG +- `expected_entities_count`: Total number of entities in the reference file -Set of entity type pairs -Make overlap on entity_type pairs +## Variants -intesection= -precission -recall= +The framework provides several variants of entity coverage metrics: +- **SourceEntityCoverageMetric**: Strict matching by URI and label +- **SourceEntityCoverageMetricSoft**: Fuzzy matching using label embeddings (threshold 0.95) +- **SourceTypedEntityCoverageMetric**: Matching based on entity type pairs, calculating precision and recall on entity-type combinations +## Usage +To use this metric in evaluation, provide the path to the verified source entities file in the reference configuration: + +```python +from kgpipe.evaluation.aspects.reference import ReferenceConfig + +config = ReferenceConfig( + VERIFIED_SOURCE_ENTITIES="path/to/entities.csv" +) +``` +The metric will automatically be included when evaluating with the `REFERENCE` aspect. diff --git a/src/kgpipe_parameters/tests/test_visualization.py b/src/kgpipe_parameters/tests/test_visualization.py index 0b19589..187b826 100644 --- a/src/kgpipe_parameters/tests/test_visualization.py +++ b/src/kgpipe_parameters/tests/test_visualization.py @@ -174,3 +174,6 @@ def test_scatter_too_few_points(self, tmp_path): path = viz.plot_embedding_scatter() assert path.exists() + + + diff --git a/src/kgpipe_parameters/visualization/__init__.py b/src/kgpipe_parameters/visualization/__init__.py index 0b7baa9..0bb436b 100644 --- a/src/kgpipe_parameters/visualization/__init__.py +++ b/src/kgpipe_parameters/visualization/__init__.py @@ -4,3 +4,6 @@ __all__ = ["ParameterVisualizer"] + + + From faa8cd230b678a98a3cb62ab32171e75c0e7de1c Mon Sep 17 00:00:00 2001 From: Marvin Date: Thu, 19 Mar 2026 18:06:15 +0100 Subject: [PATCH 49/96] exp(params): added agreement-maker-light --- experiments/param-opti/input/Parameters.md | 17 +++++++++++++++++ .../input/am_light/parameters.properties | 3 +++ experiments/param-opti/input/am_light/repo.url | 1 + 3 files changed, 21 insertions(+) create mode 100644 experiments/param-opti/input/Parameters.md create mode 100644 experiments/param-opti/input/am_light/parameters.properties create mode 100644 experiments/param-opti/input/am_light/repo.url diff --git a/experiments/param-opti/input/Parameters.md b/experiments/param-opti/input/Parameters.md new file mode 100644 index 0000000..2dac3e7 --- /dev/null +++ b/experiments/param-opti/input/Parameters.md @@ -0,0 +1,17 @@ + +# Entity Matching +Algo +Cluster +Threshold + +# Ontology Matching +Algo +Cluster +Threshold + +# Entity Linking + +# Relation Linking + +# Fusion +Method \ No newline at end of file diff --git a/experiments/param-opti/input/am_light/parameters.properties b/experiments/param-opti/input/am_light/parameters.properties new file mode 100644 index 0000000..9a296d7 --- /dev/null +++ b/experiments/param-opti/input/am_light/parameters.properties @@ -0,0 +1,3 @@ +# manual file for parameters +similarity_threshold=0.7 +similarity_threshold_mapping=SIMILARITY_THRESHOLD diff --git a/experiments/param-opti/input/am_light/repo.url b/experiments/param-opti/input/am_light/repo.url new file mode 100644 index 0000000..7d29f7d --- /dev/null +++ b/experiments/param-opti/input/am_light/repo.url @@ -0,0 +1 @@ +https://github.com/AgreementMakerLight/AML-Project.git \ No newline at end of file From a46f7c66c767ab8fddacd2d7f9dde6aed404ba3b Mon Sep 17 00:00:00 2001 From: Marvin Date: Thu, 19 Mar 2026 18:06:48 +0100 Subject: [PATCH 50/96] feat(params): init config_mapper idea (global to local tool specific param names) --- src/kgpipe_parameters/config_mapper.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/kgpipe_parameters/config_mapper.py diff --git a/src/kgpipe_parameters/config_mapper.py b/src/kgpipe_parameters/config_mapper.py new file mode 100644 index 0000000..a9d854b --- /dev/null +++ b/src/kgpipe_parameters/config_mapper.py @@ -0,0 +1,20 @@ + +""" +Maps a GLOBAL configuration to a local Parameter of a task implementation. +""" + +from kgpipe.common.model.configuration import Parameter, ConfigurationProfile +from kgpipe.common.model.task import KgTask, Data, TaskInput, TaskOutput, KgTask + +class ConfigMapper: + def __init__(self, task: KgTask): + self.task = task + + def map_config(self, config: ConfigurationMapping): + return self.task.config + + + + +def example_task(i: TaskInput, o: TaskOutput, p: ConfigurationProfile): + pass \ No newline at end of file From 86945dccc2d1a3e6c921e5ac29d962568f25158a Mon Sep 17 00:00:00 2001 From: Marvin Date: Thu, 2 Apr 2026 18:20:35 +0200 Subject: [PATCH 51/96] stash --- experiments/param-opti/README.md | 5 ++++ .../param_opti/pipeline_selection/__init__.py | 0 .../pipeline_selection/test_configuration.py | 26 +++++++++++++++++++ .../src/param_opti/tasks/__init__.py | 0 .../src/param_opti/tasks/agreementmaker.py | 14 ++++++++++ 5 files changed, 45 insertions(+) create mode 100644 experiments/param-opti/src/param_opti/pipeline_selection/__init__.py create mode 100644 experiments/param-opti/src/param_opti/pipeline_selection/test_configuration.py create mode 100644 experiments/param-opti/src/param_opti/tasks/__init__.py create mode 100644 experiments/param-opti/src/param_opti/tasks/agreementmaker.py diff --git a/experiments/param-opti/README.md b/experiments/param-opti/README.md index 8c62e8c..ad7e561 100644 --- a/experiments/param-opti/README.md +++ b/experiments/param-opti/README.md @@ -98,3 +98,8 @@ Results are saved as JSON files in `output/`: A `_summary.json` file is also generated with aggregate statistics. +# Configuration Apsects + +1. Task Assignment: Selecting +2. Task Tunning +3. \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/pipeline_selection/__init__.py b/experiments/param-opti/src/param_opti/pipeline_selection/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/experiments/param-opti/src/param_opti/pipeline_selection/test_configuration.py b/experiments/param-opti/src/param_opti/pipeline_selection/test_configuration.py new file mode 100644 index 0000000..926605f --- /dev/null +++ b/experiments/param-opti/src/param_opti/pipeline_selection/test_configuration.py @@ -0,0 +1,26 @@ +from random import random, seed, sample +from typing import List + + + +def entity_matching_a() -> List[str]: + seed(42) + # select 5 positive values and 5 negative values + positive_values=["+A", "+B", "+C", "+D", "+E", "+F", "+G", "+H", "+I", "+J", "+K", "+L", "+M", "+N", "+O", "+P", "+Q", "+R", "+S", "+T", "+U", "+V", "+W", "+X", "+Y", "+Z"] + negative_values=["-A", "-B", "-C", "-D", "-E", "-F", "-G", "-H", "-I", "-J", "-K", "-L", "-M", "-N", "-O", "-P", "-Q", "-R", "-S", "-T", "-U", "-V", "-W", "-X", "-Y", "-Z"] + positive_values = sample(positive_values, 5) + negative_values = sample(negative_values, 5) + return positive_values + negative_values + +def schmea_matching_a(): pass + +def test_selecting_pipelines(): pass + + +def test_run(): + + values = entity_matching_a() + print(values) + values2 = entity_matching_a() + print(values2) + # print(values == values2) \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/tasks/__init__.py b/experiments/param-opti/src/param_opti/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/experiments/param-opti/src/param_opti/tasks/agreementmaker.py b/experiments/param-opti/src/param_opti/tasks/agreementmaker.py new file mode 100644 index 0000000..716457b --- /dev/null +++ b/experiments/param-opti/src/param_opti/tasks/agreementmaker.py @@ -0,0 +1,14 @@ +from kgpipe.common import Data, DataFormat, KgTask, Registry, TaskInput, TaskOutput, BasicTaskCategoryCatalog + +@Registry.task( + input_spec={"source": DataFormat.RDF, "target": DataFormat.RDF}, + output_spec={"output": DataFormat.AGREEMENTMAKER_RDF}, + description="Perform entity matching using AgreementMaker", + category=[BasicTaskCategoryCatalog.entity_matching] +) +def entity_matching_aggrement_maker(inputs: TaskInput, outputs: TaskOutput): + """Perform entity matching using AgreementMaker.""" + source_data = inputs["source"] + target_data = inputs["target"] + output_data = outputs["output"] + return output_data \ No newline at end of file From 41e4d88e7f7b6208c6075f7b9eecd5471474ab55 Mon Sep 17 00:00:00 2001 From: Marvin Date: Fri, 10 Apr 2026 15:14:44 +0200 Subject: [PATCH 52/96] exp(conf): added mockup experiments for paper --- experiments/param-opti/.gitignore | 3 ++- experiments/param-opti/README.md | 39 +++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/experiments/param-opti/.gitignore b/experiments/param-opti/.gitignore index 3d632f9..a4d684a 100644 --- a/experiments/param-opti/.gitignore +++ b/experiments/param-opti/.gitignore @@ -1,2 +1,3 @@ output/ -repos/ \ No newline at end of file +repos/ +output_qap_mock/ \ No newline at end of file diff --git a/experiments/param-opti/README.md b/experiments/param-opti/README.md index ad7e561..91e2f0f 100644 --- a/experiments/param-opti/README.md +++ b/experiments/param-opti/README.md @@ -2,6 +2,44 @@ This experiment extracts and analyzes configuration parameters from open-source data integration tools using the `kgpipe_parameters` extraction module. +## Paper mock experiments (Quality-Aware Pipelines) + +This directory also contains a **self-contained mock** of the experiments described in `Quality_Aware_Pipelines.pdf` (Section 6, “Experimental Evaluation”). + +- **What it is**: a small simulation of (a) a pipeline configuration space (implementations + thresholds), (b) a “true” end-to-end quality objective (accuracy/coverage/consistency aggregated), (c) an approximate quality estimator \( \hat{Q} \), and (d) search strategies (Default, Random Search, Quality-Aware Search). +- **What it is not**: it does **not** run KGpipe or reproduce the paper’s numbers. It’s meant as a scaffolding to iterate on the experimental protocol and factor out cleaner subpackages later. + +### Run the mock experiments + +From `experiments/param-opti`: + +```bash +python3 run_qap_mock.py all +python3 run_qap_mock.py exp1 # search effectiveness (Table-2-like) +python3 run_qap_mock.py exp2 # estimation reliability (corr/MAE/top-k) +python3 run_qap_mock.py exp3 # impl-only vs param-only vs joint +``` + +Outputs are written to `output_qap_mock/` (JSON). + +#### “Mock → real” execution mode + +The `qap_mock` package can now execute **real KGpipe tasks** (instead of purely simulated formulas) when dependencies are installed. + +- **Install dependencies** (from repo root): + +```bash +python3 -m pip install -e . +``` + +- **Enable docker-backed tasks** (PARIS, CoreNLP) for richer pipelines: + +```bash +export QAP_MOCK_USE_DOCKER=1 +``` + +Without `QAP_MOCK_USE_DOCKER=1`, `qap_mock` will use non-docker fallbacks where available (e.g., union-only RDF fusion and a lightweight pattern-based IE) so the experiment harness stays runnable. + ## Directory Structure ``` @@ -14,6 +52,7 @@ param-opti/ │ └── repo.url ├── repos/ # Cloned repositories (auto-populated) ├── output/ # Extraction results (JSON) +├── output_qap_mock/ # Mock paper experiment results (JSON) ├── src/ │ └── param_opti/ # Experiment code └── run_experiment.py # Main entry point From 28811be40f4f66b87eebcfeac1cd20afa51ca182 Mon Sep 17 00:00:00 2001 From: Marvin Date: Fri, 10 Apr 2026 15:15:28 +0200 Subject: [PATCH 53/96] exp(conf): missing mockup code --- experiments/param-opti/run_qap_mock.py | 26 ++ .../param-opti/src/qap_mock/__init__.py | 15 + .../param-opti/src/qap_mock/__main__.py | 57 +++ .../param-opti/src/qap_mock/experiments.py | 204 +++++++++ experiments/param-opti/src/qap_mock/models.py | 37 ++ .../param-opti/src/qap_mock/objectives.py | 226 ++++++++++ .../param-opti/src/qap_mock/pipeline_util.py | 414 ++++++++++++++++++ experiments/param-opti/src/qap_mock/search.py | 138 ++++++ .../param-opti/src/qap_mock/search_space.py | 143 ++++++ experiments/param-opti/src/qap_mock/stats.py | 58 +++ 10 files changed, 1318 insertions(+) create mode 100644 experiments/param-opti/run_qap_mock.py create mode 100644 experiments/param-opti/src/qap_mock/__init__.py create mode 100644 experiments/param-opti/src/qap_mock/__main__.py create mode 100644 experiments/param-opti/src/qap_mock/experiments.py create mode 100644 experiments/param-opti/src/qap_mock/models.py create mode 100644 experiments/param-opti/src/qap_mock/objectives.py create mode 100644 experiments/param-opti/src/qap_mock/pipeline_util.py create mode 100644 experiments/param-opti/src/qap_mock/search.py create mode 100644 experiments/param-opti/src/qap_mock/search_space.py create mode 100644 experiments/param-opti/src/qap_mock/stats.py diff --git a/experiments/param-opti/run_qap_mock.py b/experiments/param-opti/run_qap_mock.py new file mode 100644 index 0000000..162042f --- /dev/null +++ b/experiments/param-opti/run_qap_mock.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +""" +Quick runner for the "Quality Aware Knowledge Graph Pipeline Configurations" +paper mock experiments. + +Run from this directory: + python run_qap_mock.py exp1 + python run_qap_mock.py exp2 + python run_qap_mock.py exp3 + python run_qap_mock.py all +""" + +import sys +from pathlib import Path + +# Add local experiment src + project src to path +exp_src_path = Path(__file__).parent / "src" +repo_src_path = Path(__file__).resolve().parents[2] / "src" +sys.path.insert(0, str(exp_src_path)) +sys.path.insert(0, str(repo_src_path)) + +from qap_mock.__main__ import main # noqa: E402 + +if __name__ == "__main__": + raise SystemExit(main()) + diff --git a/experiments/param-opti/src/qap_mock/__init__.py b/experiments/param-opti/src/qap_mock/__init__.py new file mode 100644 index 0000000..ddd4d3e --- /dev/null +++ b/experiments/param-opti/src/qap_mock/__init__.py @@ -0,0 +1,15 @@ +""" +Mock implementation of the experiments described in `Quality_Aware_Pipelines.pdf`. + +This package is intentionally self-contained and does not depend on KGpipe. +It simulates: +- A small configuration space (implementations + parameters) +- A "true" end-to-end quality objective +- A correlated approximate quality estimator +- Search strategies (default, random, quality-aware) +""" + +from .models import PipelineFamily, SearchMethod + +__all__ = ["PipelineFamily", "SearchMethod"] + diff --git a/experiments/param-opti/src/qap_mock/__main__.py b/experiments/param-opti/src/qap_mock/__main__.py new file mode 100644 index 0000000..405c19e --- /dev/null +++ b/experiments/param-opti/src/qap_mock/__main__.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from .experiments import ( + experiment_1_search_effectiveness, + experiment_2_estimation_reliability, + experiment_3_dimension_impact, +) + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser( + description="Mock experiments for Quality_Aware_Pipelines.pdf (quality-aware search)" + ) + p.add_argument( + "which", + choices=["exp1", "exp2", "exp3", "all"], + help="Which experiment(s) to run", + ) + p.add_argument( + "--outdir", + type=Path, + default=Path(__file__).parent.parent.parent / "output_qap_mock", + help="Output directory for JSON results", + ) + p.add_argument("--budget", type=int, default=20, help="Evaluation budget B (exp1/exp3)") + p.add_argument("--runs", type=int, default=5, help="Number of runs/seeds (exp1/exp3)") + p.add_argument("--samples", type=int, default=60, help="Number of sampled configs (exp2)") + + args = p.parse_args(argv) + + results: dict[str, object] = {} + + if args.which in ("exp1", "all"): + results["exp1"] = experiment_1_search_effectiveness( + outdir=args.outdir, budget=args.budget, runs=args.runs + ) + if args.which in ("exp2", "all"): + results["exp2"] = experiment_2_estimation_reliability( + outdir=args.outdir, n_samples=args.samples + ) + if args.which in ("exp3", "all"): + results["exp3"] = experiment_3_dimension_impact( + outdir=args.outdir, budget=args.budget, runs=args.runs + ) + + # Short stdout summary so it's easy to sanity-check runs. + print(json.dumps({"outdir": str(args.outdir), "ran": list(results.keys())}, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) + diff --git a/experiments/param-opti/src/qap_mock/experiments.py b/experiments/param-opti/src/qap_mock/experiments.py new file mode 100644 index 0000000..e0ec17c --- /dev/null +++ b/experiments/param-opti/src/qap_mock/experiments.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +import json +import random +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, List, Optional + +from .models import PipelineFamily, SearchMethod, SearchSpaceMode +from .search import ( + best_so_far_curve, + evals_to_fraction_of_final_best, + run_search, +) +from .search_space import get_family_space, sample_config +from .stats import mae, mean, pearsonr, spearmanr, stdev, topk_agreement +from .objectives import evaluate_true_quality, estimate_quality_from_config + + +@dataclass +class Exp1Cell: + mean_best: float + std_best: float + mean_evals_to_95: Optional[float] + + def as_dict(self) -> dict: + return { + "best_score_mean": self.mean_best, + "best_score_std": self.std_best, + "evals_to_95_mean": self.mean_evals_to_95, + } + + +def _ensure_outdir(outdir: Path) -> None: + outdir.mkdir(parents=True, exist_ok=True) + + +def experiment_1_search_effectiveness( + *, + outdir: Path, + budget: int = 20, + runs: int = 5, + base_seed: int = 7, +) -> dict: + """ + Mirrors Section 6.3 / Table 2 narrative: + - Compare Default, Random Search, Quality-Aware Search + - Fixed budget B=20 + - Report best achieved score (mean ± std over 5 runs) + - Report mean evaluations to reach 95% of each run's final best + """ + _ensure_outdir(outdir) + + methods = [SearchMethod.DEFAULT] #, SearchMethod.RANDOM, SearchMethod.QUALITY_AWARE] + families = [PipelineFamily.RDF, PipelineFamily.TEXT] + + table: Dict[str, Dict[str, Exp1Cell]] = {} + raw: Dict[str, Dict[str, List[dict]]] = {} + + for fam in families: + fam_key = fam.value + table[fam_key] = {} + raw[fam_key] = {} + + for m in methods: + seeds = [base_seed + i for i in range(runs)] + bests: List[float] = [] + evals95: List[float] = [] + raw_runs: List[dict] = [] + + for i, s in enumerate(seeds): + recs = run_search( + seed=10_000 * (i + 1) + s, + family=fam, + method=m, + budget=budget, + mode=SearchSpaceMode.JOINT, + ) + curve = best_so_far_curve(recs) + bests.append(curve[-1]) + e95 = evals_to_fraction_of_final_best(curve, 0.95) + if e95 is not None: + evals95.append(float(e95)) + + raw_runs.append( + { + "seed": s, + "curve_best_so_far": curve, + } + ) + + cell = Exp1Cell( + mean_best=mean(bests), + std_best=stdev(bests) if m != SearchMethod.DEFAULT else float("nan"), + mean_evals_to_95=mean(evals95) if (m != SearchMethod.DEFAULT and evals95) else None, + ) + table[fam_key][m.value] = cell + raw[fam_key][m.value] = raw_runs + + result = { + "budget": budget, + "runs": runs, + "table": { + fam: {meth: cell.as_dict() for meth, cell in methods_.items()} + for fam, methods_ in table.items() + }, + "raw": raw, + } + + (outdir / "exp1_search_effectiveness.json").write_text(json.dumps(result, indent=2)) + return result + + +def experiment_2_estimation_reliability( + *, + outdir: Path, + n_samples: int = 60, + seed: int = 23, + topk: int = 10, +) -> dict: + """ + Mirrors Section 6.4 narrative: + - sample configurations + - compute estimated vs true scores + - compute correlation (Pearson/Spearman), MAE, top-k agreement + """ + _ensure_outdir(outdir) + + rng = random.Random(seed) + families = [PipelineFamily.RDF, PipelineFamily.TEXT] + + out: Dict[str, dict] = {"n_samples": n_samples, "topk": topk, "by_family": {}} + + for fam in families: + true_scores: List[float] = [] + est_scores: List[float] = [] + + for _ in range(n_samples): + cfg = sample_config(rng, fam, mode=SearchSpaceMode.JOINT) + true = evaluate_true_quality(rng, cfg).total + est = estimate_quality_from_config(rng, cfg) + true_scores.append(true) + est_scores.append(est) + + fam_key = fam.value + out["by_family"][fam_key] = { + "pearson": pearsonr(est_scores, true_scores), + "spearman": spearmanr(est_scores, true_scores), + "mae": mae(est_scores, true_scores), + "topk_agreement": topk_agreement(est_scores, true_scores, topk), + } + + (outdir / "exp2_estimation_reliability.json").write_text(json.dumps(out, indent=2)) + return out + + +def experiment_3_dimension_impact( + *, + outdir: Path, + budget: int = 20, + runs: int = 5, + base_seed: int = 101, +) -> dict: + """ + Mirrors Section 6.5 narrative: + Compare best scores for restricted spaces: + - implementation-only + - parameter-only + - joint + """ + _ensure_outdir(outdir) + + families = [PipelineFamily.RDF, PipelineFamily.TEXT] + modes = [ + SearchSpaceMode.IMPLEMENTATION_ONLY, + SearchSpaceMode.PARAMETER_ONLY, + SearchSpaceMode.JOINT, + ] + + out: Dict[str, dict] = {"budget": budget, "runs": runs, "by_family": {}} + + for fam in families: + fam_out: Dict[str, dict] = {} + for mode in modes: + bests: List[float] = [] + for i in range(runs): + seed = base_seed + i * 17 + recs = run_search( + seed=20_000 * (i + 1) + seed, + family=fam, + method=SearchMethod.QUALITY_AWARE, + budget=budget, + mode=mode, + ) + curve = best_so_far_curve(recs) + bests.append(curve[-1]) + + fam_out[mode.value] = {"best_mean": mean(bests), "best_std": stdev(bests)} + + out["by_family"][fam.value] = fam_out + + (outdir / "exp3_dimension_impact.json").write_text(json.dumps(out, indent=2)) + return out + diff --git a/experiments/param-opti/src/qap_mock/models.py b/experiments/param-opti/src/qap_mock/models.py new file mode 100644 index 0000000..5809be5 --- /dev/null +++ b/experiments/param-opti/src/qap_mock/models.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Mapping + + +class PipelineFamily(str, Enum): + RDF = "rdf" + TEXT = "text" + + +class SearchMethod(str, Enum): + DEFAULT = "default" + RANDOM = "random" + QUALITY_AWARE = "quality_aware" + + +class SearchSpaceMode(str, Enum): + JOINT = "joint" + IMPLEMENTATION_ONLY = "implementation_only" + PARAMETER_ONLY = "parameter_only" + + +@dataclass(frozen=True) +class PipelineConfig: + family: PipelineFamily + implementations: Mapping[str, str] + params: Mapping[str, float] + + def as_dict(self) -> dict: + return { + "family": self.family.value, + "implementations": dict(self.implementations), + "params": dict(self.params), + } + diff --git a/experiments/param-opti/src/qap_mock/objectives.py b/experiments/param-opti/src/qap_mock/objectives.py new file mode 100644 index 0000000..8691ebe --- /dev/null +++ b/experiments/param-opti/src/qap_mock/objectives.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +import math +import random +from dataclasses import dataclass + +from .models import PipelineConfig, PipelineFamily +from .pipeline_util import ( + compute_rdf_metrics, + compute_te_metrics, + default_base_workdir, + run_pipeline_for_config, + TEST_DATA_ONTOLOGY_PATH, + # _test_data_path, +) + + +@dataclass(frozen=True) +class QualityBreakdown: + accuracy: float + coverage: float + consistency: float + total: float + + +def _sigmoid(x: float) -> float: + return 1.0 / (1.0 + math.exp(-x)) + + +def _base_quality_components(cfg: PipelineConfig) -> tuple[float, float, float]: + """ + Deterministic (noise-free) quality components for a configuration. + + This is used both for the simulated "true" evaluation (with added noise) + and for the approximate estimator (with different noise). + """ + if cfg.family == PipelineFamily.RDF: + impl_acc = 0.0 + impl_cov = 0.0 + impl_con = 0.0 + + om = cfg.implementations["ontology_matching"] + if om == "string_sim": + impl_con += 0.01 + elif om == "embedding_sim": + impl_acc += 0.05 + impl_cov += 0.02 + elif om == "hybrid": + impl_acc += 0.06 + impl_cov += 0.03 + impl_con += 0.01 + elif om == "llm_alignment": + impl_cov += 0.05 + impl_acc += 0.04 + impl_con -= 0.01 + + em = cfg.implementations["entity_matching"] + if em == "rule_based": + impl_con += 0.02 + elif em == "blocking_sim": + impl_acc += 0.04 + impl_cov += 0.02 + elif em == "embedding_er": + impl_acc += 0.06 + impl_cov += 0.03 + elif em == "llm_er": + impl_cov += 0.05 + impl_acc += 0.05 + impl_con -= 0.01 + + fu = cfg.implementations["fusion"] + if fu == "union": + impl_cov += 0.03 + elif fu == "majority_vote": + impl_con += 0.03 + impl_acc += 0.01 + elif fu == "quality_weighted": + impl_con += 0.06 + impl_acc += 0.02 + + s_thr = float(cfg.params["schema_sim_threshold"]) + e_thr = float(cfg.params["entity_sim_threshold"]) + f_thr = float(cfg.params["fusion_confidence_threshold"]) + bk = float(cfg.params.get("blocking_key_strength", 0.5)) + + acc = 0.55 + 0.18 * _sigmoid((s_thr - 0.65) * 8) + 0.18 * _sigmoid((e_thr - 0.65) * 8) + cov = 0.65 - 0.25 * _sigmoid((s_thr - 0.6) * 7) - 0.25 * _sigmoid((e_thr - 0.6) * 7) + con = 0.55 + 0.20 * _sigmoid((f_thr - 0.45) * 6) + + strict = (s_thr + e_thr) / 2.0 + con -= 0.05 * _sigmoid((strict - 0.85) * 10) + + cov += 0.03 * _sigmoid((bk - 0.3) * 6) + acc -= 0.02 * _sigmoid((bk - 0.8) * 10) + + acc += impl_acc + cov += impl_cov + con += impl_con + + return acc, cov, con + + if cfg.family == PipelineFamily.TEXT: + impl_acc = 0.0 + impl_cov = 0.0 + impl_con = 0.0 + + ie = cfg.implementations["information_extraction"] + if ie == "pattern_ie": + impl_con += 0.01 + elif ie == "openie": + impl_cov += 0.04 + impl_acc += 0.01 + elif ie == "hybrid_ie": + impl_cov += 0.06 + impl_acc += 0.02 + impl_con += 0.01 + elif ie == "llm_ie": + impl_cov += 0.08 + impl_acc += 0.03 + impl_con -= 0.01 + + el = cfg.implementations["entity_linking"] + if el == "dictionary_linking": + impl_cov += 0.02 + elif el == "embedding_linking": + impl_acc += 0.06 + elif el == "llm_linking": + impl_acc += 0.07 + impl_cov += 0.02 + impl_con -= 0.01 + + fu = cfg.implementations["fusion"] + if fu == "union": + impl_cov += 0.03 + elif fu == "majority_vote": + impl_con += 0.03 + impl_acc += 0.01 + elif fu == "quality_weighted": + impl_con += 0.07 + impl_acc += 0.02 + + ie_thr = float(cfg.params["ie_conf_threshold"]) + link_thr = float(cfg.params["link_sim_threshold"]) + f_thr = float(cfg.params["fusion_confidence_threshold"]) + cw = float(cfg.params.get("context_window", 256.0)) + + acc = 0.40 + 0.22 * _sigmoid((link_thr - 0.6) * 7) + 0.10 * _sigmoid((ie_thr - 0.55) * 6) + cov = 0.55 - 0.28 * _sigmoid((ie_thr - 0.55) * 7) - 0.18 * _sigmoid((link_thr - 0.6) * 6) + con = 0.45 + 0.22 * _sigmoid((f_thr - 0.45) * 6) + + noisy = (0.6 - ie_thr) + (0.6 - link_thr) + con -= 0.10 * _sigmoid(noisy * 6) + + cov += 0.03 * _sigmoid((cw - 160.0) / 60.0) + con -= 0.02 * _sigmoid((cw - 420.0) / 70.0) + + acc += impl_acc + cov += impl_cov + con += impl_con + + return acc, cov, con + + raise ValueError(f"Unknown family: {cfg.family}") + + +def evaluate_true_quality(rng: random.Random, cfg: PipelineConfig) -> QualityBreakdown: + """ + Real(ish) end-to-end objective: run a KGpipe pipeline for this config and + compute measurable proxy metrics from its outputs. + + Notes: + - This intentionally uses bundled `kgpipe_tasks/test/test_data` inputs so + the experiments are runnable out of the box. + - Metrics are proxy/reference-independent signals (no gold labels yet). + """ + base = default_base_workdir() + run = run_pipeline_for_config(cfg=cfg, base_workdir=base, stable_files=False) + + if cfg.family == PipelineFamily.RDF: + ontology = TEST_DATA_ONTOLOGY_PATH + m = compute_rdf_metrics(output_nt=run.final_output.path, ontology_ttl=ontology) + else: + m = compute_te_metrics(te_json_path=run.final_output.path) + + acc = min(1.0, max(0.0, float(m["accuracy"]))) + cov = min(1.0, max(0.0, float(m["coverage"]))) + con = min(1.0, max(0.0, float(m["consistency"]))) + + total = 0.45 * acc + 0.30 * cov + 0.25 * con + total = min(1.0, max(0.0, total)) + return QualityBreakdown(accuracy=acc, coverage=cov, consistency=con, total=total) + + +def estimate_quality_from_config(rng: random.Random, cfg: PipelineConfig) -> float: + """ + Approximate estimator Q-hat used by the quality-aware search to rank candidates + without executing the full pipeline. + + For now this remains a cheap heuristic over the config (so the search is not + dominated by expensive runs). The "true" objective is produced by actually + executing the pipeline in `evaluate_true_quality`. + """ + acc, cov, con = _base_quality_components(cfg) + # Estimator has its own noise and slight systematic distortion. + if cfg.family == PipelineFamily.RDF: + acc += rng.gauss(0.0, 0.015) + cov += rng.gauss(0.0, 0.015) + con += rng.gauss(0.0, 0.015) + else: + acc += rng.gauss(0.0, 0.020) + cov += rng.gauss(0.0, 0.020) + con += rng.gauss(0.0, 0.020) + + acc = min(1.0, max(0.0, acc)) + cov = min(1.0, max(0.0, cov)) + con = min(1.0, max(0.0, con)) + est = 0.45 * acc + 0.30 * cov + 0.25 * con + return min(1.0, max(0.0, est)) + + +def estimate_quality(rng: random.Random, true_total: float, family: PipelineFamily) -> float: + raise RuntimeError( + "estimate_quality(true_total, family) is deprecated; " + "use estimate_quality_from_config(rng, cfg) instead." + ) + diff --git a/experiments/param-opti/src/qap_mock/pipeline_util.py b/experiments/param-opti/src/qap_mock/pipeline_util.py new file mode 100644 index 0000000..e5f0890 --- /dev/null +++ b/experiments/param-opti/src/qap_mock/pipeline_util.py @@ -0,0 +1,414 @@ +from __future__ import annotations + +import hashlib +import json +import os +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any, Iterable, Optional + +if TYPE_CHECKING: + from kgpipe.common import Data, DataFormat, KgPipe, KgTask # pragma: no cover + from kgpipe.common.model.task import KgTaskReport # pragma: no cover + +from .models import PipelineConfig, PipelineFamily + + +@dataclass(frozen=True) +class PipelineRunResult: + family: PipelineFamily + cfg: PipelineConfig + workdir: Path + final_output: Any # Data + task_reports: Any # list[KgTaskReport] + aux: dict + + +TEST_DATA_SEED_KG_PATH = Path("/home/marvin/project/data/final/film_1k/split_0/kg/seed/data.nt") +TEST_DATA_ONTOLOGY_PATH = Path("/home/marvin/project/data/final/film_1k/movie-ontology.ttl") +TEST_DATA_RDF_PATH = Path("/home/marvin/project/data/final/film_1k/split_1/sources/rdf/data.nt") +TEST_DATA_TEXT_PATH = Path("/home/marvin/project/data/final/film_1k/split_1/sources/text/data/") + +def _import_tasks_for_family(family: PipelineFamily) -> None: + """ + Import task modules so their @Registry.task decorators execute. + + This keeps the rest of qap_mock independent from kgpipe_tasks import side effects. + """ + # RDF: PARIS matcher + exchange + fusion tasks. + if family == PipelineFamily.RDF: + # Entity matching (docker) + exchange (python) + import kgpipe_tasks.entity_resolution.matcher.paris_rdf_matcher # noqa: F401 + import kgpipe_tasks.entity_resolution.entity_match # noqa: F401 + + # Fusion (python) + import kgpipe_tasks.entity_resolution.fusion.union # noqa: F401 + import kgpipe_tasks.entity_resolution.fusion.preference # noqa: F401 + + return + + if family == PipelineFamily.TEXT: + # CoreNLP OpenIE extraction (docker) + exchange (python) + import kgpipe_tasks.text_processing.text_extraction.corenlp_extraction # noqa: F401 + + return + + raise ValueError(f"Unknown family: {family}") + + +def _cfg_hash(cfg: PipelineConfig) -> str: + payload = json.dumps(cfg.as_dict(), sort_keys=True, separators=(",", ":")).encode("utf-8") + return hashlib.sha256(payload).hexdigest()[:16] + + +def _ensure_dir(p: Path) -> None: + p.mkdir(parents=True, exist_ok=True) + + +# def _test_data_path(relative_path: str) -> Path: +# """ +# Use kgpipe_tasks' bundled test data as default inputs so qap_mock is runnable. +# """ +# base = Path(__file__).resolve().parents[3] / "src" / "kgpipe_tasks" / "test" / "test_data" +# path = (base / relative_path).resolve() +# if not path.exists(): +# raise FileNotFoundError(f"Missing test data file: {path}") +# return path + + +def _set_env_from_params(params: dict[str, float]) -> dict[str, Optional[str]]: + """ + Apply a minimal mapping from qap_mock params to the env-var based configuration + convention used by many kgpipe tasks. + + Returns a dict of previous env values so callers can restore them. + """ + # Only set variables that are known to be read by the tasks we use. + mapping: dict[str, tuple[str, float]] = { + # RDF fusion/preference tasks + "ENTITY_MATCHING_THRESHOLD": ("entity_sim_threshold", 0.7), + "RELATION_MATCHING_THRESHOLD": ("schema_sim_threshold", 0.7), + # Text: no stable env knobs used by CoreNLP task today + } + + prev: dict[str, Optional[str]] = {} + for env_key, (p_key, default) in mapping.items(): + prev[env_key] = os.environ.get(env_key) + val = float(params.get(p_key, default)) + os.environ[env_key] = str(val) + return prev + + +def _restore_env(prev: dict[str, Optional[str]]) -> None: + for k, v in prev.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + +def build_pipeline_for_config(*, cfg: PipelineConfig, workdir: Path) -> tuple[KgPipe, Data, Data]: + """ + Build a runnable KgPipe for the given configuration. + + We intentionally keep the mapping small and explicit: + - RDF: (optional) PARIS entity matching -> exchange -> fusion + - TEXT: CoreNLP OpenIE extraction (docker) -> exchange + + Returns (pipe, source, final_result_data). + """ + from kgpipe.common import Data, DataFormat, KgPipe, KgTask, Registry + + _import_tasks_for_family(cfg.family) + _ensure_dir(workdir) + + if cfg.family == PipelineFamily.RDF: + # Inputs: source + target (as seed) are bundled test fixtures. + source = Data(path=TEST_DATA_RDF_PATH, format=DataFormat.RDF_NTRIPLES) + target = Data(path=TEST_DATA_SEED_KG_PATH, format=DataFormat.RDF_NTRIPLES) + + # Ensure ontology env is set for fusion tasks that need it. + ontology_path = TEST_DATA_ONTOLOGY_PATH + os.environ.setdefault("ONTOLOGY_PATH", str(ontology_path)) + + # Decide whether to run entity matching. If we don't, we can still + # compute a meaningful output via simple union. + entity_impl = cfg.implementations.get("entity_matching", "rule_based") + fusion_impl = cfg.implementations.get("fusion", "union") + use_docker = os.environ.get("QAP_MOCK_USE_DOCKER", "0") == "1" + + tasks: list[KgTask] = [] + final_format = DataFormat.RDF_NTRIPLES + + def _empty_er(inputs: dict[str, Data], outputs: dict[str, Data]) -> None: + out_path = Path(outputs["output"].path) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps({"matches": [], "blocks": [], "clusters": []}, indent=2), encoding="utf-8") + + # dummy_entity_matching = KgTask( + # name="dummy_entity_matching", + # input_spec={"source": DataFormat.RDF_NTRIPLES, "target": DataFormat.RDF_NTRIPLES}, + # output_spec={"output": DataFormat.ER_JSON}, + # function=_empty_er, + # description="Dummy matcher emitting empty ER_JSON (no docker)", + # ) + + # if entity_impl != "rule_based": + # if use_docker: + tasks.extend( + [ + Registry.get_task("paris_entity_matching"), + Registry.get_task("paris_exchange"), + ] + ) + # When matches exist, prefer a fusion strategy that uses them. + if fusion_impl in ("quality_weighted", "majority_vote"): + tasks.append(Registry.get_task("fusion_first_value")) + else: + tasks.append(Registry.get_task("union_matched_rdf")) + # else: + # # Non-docker mode: skip PARIS and run a deterministic empty matcher. + # tasks.extend([dummy_entity_matching, Registry.get_task("union_matched_rdf")]) + # else: + # # No matching step: just union the two graphs. + # tasks.append(Registry.get_task("fusion_union_rdf")) + + # seed is the "kg"/target, which KgPipe.build will use when a task + # declares an input named "kg". + pipe = KgPipe(tasks=tasks, seed=target, data_dir=str(workdir), name=f"qap_mock_{cfg.family.value}") + + final = Data(path=workdir / "final.nt", format=final_format) + return pipe, source, final + + if cfg.family == PipelineFamily.TEXT: + text = Data(path=TEST_DATA_TEXT_PATH, format=DataFormat.TEXT) + + ie_impl = cfg.implementations.get("information_extraction", "pattern_ie") + + def _pattern_ie(inputs: dict[str, Data], outputs: dict[str, Data]) -> None: + import re + + in_path = Path(inputs["input"].path) + out_path = Path(outputs["output"].path) + out_path.mkdir(parents=True, exist_ok=True) + + txt = _read_text(in_path) + # Tiny, deterministic pattern extractor: "X is a Y" / "X is an Y". + triples = [] + for m in re.finditer(r"([A-Z][A-Za-z0-9_ ]{2,40}) is an? ([A-Za-z][A-Za-z0-9_ -]{2,40})", txt): + subj = m.group(1).strip() + obj = m.group(2).strip() + triples.append( + { + "subject": {"surface_form": subj}, + "predicate": {"surface_form": "is_a"}, + "object": {"surface_form": obj}, + } + ) + + doc = {"text": txt[:10_000], "triples": triples, "chains": [], "links": []} + (out_path / "pattern_ie.te.json").write_text(json.dumps(doc), encoding="utf-8") + + pattern_ie_task = KgTask( + name="pattern_ie_extraction", + input_spec={"input": DataFormat.TEXT}, + output_spec={"output": DataFormat.TE_JSON}, + function=_pattern_ie, + description="Lightweight pattern IE (no docker)", + ) + + if ie_impl == "pattern_ie": + tasks = [pattern_ie_task] + else: + use_docker = os.environ.get("QAP_MOCK_USE_DOCKER", "0") == "1" + if use_docker: + # Use CoreNLP OpenIE path for openie/hybrid/llm variants (docker-backed). + tasks = [ + Registry.get_task("corenlp_openie_extraction"), + Registry.get_task("corenlp_exchange"), + ] + else: + # Default to the lightweight extractor when docker isn't enabled. + tasks = [pattern_ie_task] + + pipe = KgPipe(tasks=tasks, seed=text, data_dir=str(workdir), name=f"qap_mock_{cfg.family.value}") + # Many TE_JSON-producing tasks treat the output as a directory of documents. + final = Data(path=workdir / "final_te", format=DataFormat.TE_JSON) + return pipe, text, final + + raise ValueError(f"Unknown family: {cfg.family}") + + +def run_pipeline_for_config( + *, cfg: PipelineConfig, base_workdir: Path, stable_files: bool = True +) -> PipelineRunResult: + """ + Execute a real KGpipe pipeline for this config and return its artifacts. + + Results are cached by (family, cfg-hash) under base_workdir to avoid repeating + expensive docker/service calls during search. + """ + run_id = f"{cfg.family.value}_{_cfg_hash(cfg)}" + workdir = base_workdir / run_id + _ensure_dir(workdir) + + try: + pipe, source, final = build_pipeline_for_config(cfg=cfg, workdir=workdir) + except ModuleNotFoundError as e: + raise RuntimeError( + "KGpipe dependencies are not installed in this environment. " + "To run the *real* (non-mock) execution path, install the project in editable mode:\n\n" + " python3 -m pip install -e .\n\n" + "This will also install the `kgcore` dependency declared in `pyproject.toml`.\n" + f"Original import error: {e}" + ) from e + + # Apply env-var config mapping used by tasks. + prev_env = _set_env_from_params(dict(cfg.params)) + try: + # If final exists and stable_files=True, KgTask.run will skip; still ok. + pipe.build(source=source, result=final, stable_files=stable_files) + reports = pipe.run(stable_files_override=stable_files) + finally: + _restore_env(prev_env) + + return PipelineRunResult( + family=cfg.family, + cfg=cfg, + workdir=workdir, + final_output=final, + task_reports=reports, + aux={"source": str(source.path), "seed": str(pipe.seed.path), "run_id": run_id}, + ) + + +def _read_text(path: Path, max_bytes: int = 4_000_000) -> str: + # Keep it simple and avoid huge reads in case a docker task goes wild. + data = path.read_bytes() + if len(data) > max_bytes: + data = data[:max_bytes] + return data.decode("utf-8", errors="replace") + + +def compute_rdf_metrics(*, output_nt: Path, ontology_ttl: Optional[Path] = None) -> dict[str, float]: + import importlib + + try: + rdflib = importlib.import_module("rdflib") + Graph = getattr(rdflib, "Graph") + URIRef = getattr(importlib.import_module("rdflib.term"), "URIRef") + g = Graph() + g.parse(output_nt, format="nt") + triples = len(g) + except Exception: + # Fallback without rdflib: approximate triples by counting lines. + txt = _read_text(output_nt) + triples = len([ln for ln in txt.splitlines() if ln.strip() and not ln.strip().startswith("#")]) + Graph = None # type: ignore[assignment] + URIRef = None # type: ignore[assignment] + g = None # type: ignore[assignment] + + # Consistency proxy: fraction of predicates that appear in ontology (or common RDF vocab). + allowed: set[str] = set() + if Graph is not None and URIRef is not None and ontology_ttl is not None and ontology_ttl.exists(): + try: + og = Graph() + og.parse(ontology_ttl) + # Allow all predicates defined as properties + rdfs:label/rdf:type. + for s, _, _ in og: + # cheap heuristic: treat all subjects that are URIRefs as "allowed" predicates + if isinstance(s, URIRef): + allowed.add(str(s)) + allowed.add("http://www.w3.org/2000/01/rdf-schema#label") + allowed.add("http://www.w3.org/1999/02/22-rdf-syntax-ns#type") + except Exception: + allowed = set() + + if allowed and g is not None and URIRef is not None: + ok = 0 + for _, p, _ in g: + if isinstance(p, URIRef) and str(p) in allowed: + ok += 1 + consistency = ok / max(1, triples) + else: + consistency = 0.5 + + # Coverage proxy: normalize by union of input graphs when using bundled test data. + try: + src = Graph().parse(TEST_DATA_RDF_PATH, format="nt") + tgt = Graph().parse(TEST_DATA_SEED_KG_PATH, format="nt") + union_triples = len(src) + len(tgt) + coverage = min(1.0, triples / max(1, union_triples)) + except Exception: + coverage = min(1.0, triples / 10_000.0) + + # Accuracy proxy: reward non-trivial graphs (very small outputs are likely bad). + accuracy = min(1.0, max(0.0, (triples / 2000.0))) + + return {"accuracy": float(accuracy), "coverage": float(coverage), "consistency": float(consistency)} + + +def compute_te_metrics(*, te_json_path: Path) -> dict[str, float]: + """ + Compute lightweight metrics from TE_JSON outputs. + + This intentionally avoids requiring a gold standard. It's a pragmatic proxy: + - coverage ~ extracted triples count + - consistency ~ fraction of triples that have all 3 spans populated + - accuracy ~ average link score if links exist, else a baseline + """ + # TE_JSON may be a directory (many files) or a single file. + triples = 0 + complete = 0 + link_scores: list[float] = [] + + paths: Iterable[Path] + if te_json_path.is_dir(): + paths = [p for p in te_json_path.iterdir() if p.is_file()] + else: + paths = [te_json_path] + + for p in paths: + try: + doc = json.loads(_read_text(p)) + except Exception: + continue + for t in doc.get("triples", []) or []: + triples += 1 + s = (t.get("subject") or {}).get("surface_form") + r = (t.get("predicate") or {}).get("surface_form") + o = (t.get("object") or {}).get("surface_form") + if s and r and o: + complete += 1 + for l in doc.get("links", []) or []: + try: + link_scores.append(float(l.get("score", 0.0))) + except Exception: + pass + + # Normalize coverage against a rough scale for the bundled Hobbit text. + coverage = min(1.0, triples / 5000.0) + consistency = complete / max(1, triples) if triples else 0.0 + accuracy = (sum(link_scores) / len(link_scores)) if link_scores else 0.35 + accuracy = min(1.0, max(0.0, accuracy)) + + return {"accuracy": float(accuracy), "coverage": float(coverage), "consistency": float(consistency)} + + +def default_base_workdir() -> Path: + # Keep outputs inside the experiment folder by default. + return Path(__file__).resolve().parents[2] / "output_qap_mock" / "_real_runs" + + +def make_temp_base_workdir() -> Path: + return Path(tempfile.mkdtemp(prefix="qap_mock_real_")) + + +# - pipeline auto algo +# - cleaning +# normalization task +# - pipeline task aggregation +# aggregate multiple task sub (DAGs) into a single task +# example: paris matching and fusion are two sub tasks, we can aggregate them into a single task + diff --git a/experiments/param-opti/src/qap_mock/search.py b/experiments/param-opti/src/qap_mock/search.py new file mode 100644 index 0000000..1d6c38d --- /dev/null +++ b/experiments/param-opti/src/qap_mock/search.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import random +from dataclasses import dataclass +from typing import Dict, List, Optional, Tuple + +from .models import PipelineConfig, PipelineFamily, SearchMethod, SearchSpaceMode +from .objectives import QualityBreakdown, estimate_quality_from_config, evaluate_true_quality +from .search_space import get_family_space, mutate_config, sample_config + + +@dataclass +class EvaluationRecord: + cfg: PipelineConfig + true: QualityBreakdown + est_total: float + + def as_dict(self) -> dict: + return { + "config": self.cfg.as_dict(), + "true": { + "accuracy": self.true.accuracy, + "coverage": self.true.coverage, + "consistency": self.true.consistency, + "total": self.true.total, + }, + "estimated_total": self.est_total, + } + + +def _eval_once(rng: random.Random, cfg: PipelineConfig) -> EvaluationRecord: + true = evaluate_true_quality(rng, cfg) + est = estimate_quality_from_config(rng, cfg) + return EvaluationRecord(cfg=cfg, true=true, est_total=est) + + +def run_search( + *, + seed: int, + family: PipelineFamily, + method: SearchMethod, + budget: int, + mode: SearchSpaceMode = SearchSpaceMode.JOINT, +) -> List[EvaluationRecord]: + rng = random.Random(seed) + space = get_family_space(family) + default_cfg = PipelineConfig(family=family, implementations=space.default_impl, params=space.default_params) + + records: List[EvaluationRecord] = [] + + if method == SearchMethod.DEFAULT: + records.append(_eval_once(rng, default_cfg)) + return records + + if method == SearchMethod.RANDOM: + for _ in range(budget): + cfg = sample_config(rng, family, mode=mode, fixed_default=default_cfg) + records.append(_eval_once(rng, cfg)) + return records + + if method == SearchMethod.QUALITY_AWARE: + # Simple, explainable heuristic: + # - start from default + # - maintain incumbent based on estimated quality (Q-hat) + # - propose new configs by mutating incumbent (exploitation) + # - occasional random exploration + incumbent = default_cfg + incumbent_est: Optional[float] = None + + for t in range(budget): + # "Lookahead" using cheap quality estimates: generate a pool of + # candidates, pick the one with best estimated quality, then + # spend one "real" evaluation budget on it. + pool_size = 12 if t < 5 else 8 + candidates: List[PipelineConfig] = [] + for _ in range(pool_size): + explore = rng.random() < (0.35 if t < 3 else 0.20) + if explore: + candidates.append(sample_config(rng, family, mode=mode, fixed_default=default_cfg)) + else: + candidates.append( + mutate_config( + rng, + incumbent, + mode=mode, + p_change_impl=0.70, + p_change_param=0.85, + ) + ) + + best_est = None + best_cfg = None + for c in candidates: + est = estimate_quality_from_config(rng, c) + if best_est is None or est > best_est: + best_est = est + best_cfg = c + + assert best_cfg is not None + cfg = best_cfg + + rec = _eval_once(rng, cfg) + records.append(rec) + + if incumbent_est is None or rec.est_total > incumbent_est: + incumbent = cfg + incumbent_est = rec.est_total + + return records + + raise ValueError(f"Unknown method: {method}") + + +def best_so_far_curve(records: List[EvaluationRecord]) -> List[float]: + best = -1.0 + curve: List[float] = [] + for r in records: + best = max(best, r.true.total) + curve.append(best) + return curve + + +def evals_to_fraction_of_final_best(curve: List[float], fraction: float) -> Optional[int]: + if not curve: + return None + final_best = curve[-1] + target = fraction * final_best + for i, v in enumerate(curve, start=1): + if v >= target: + return i + return None + + +def summarize_best(records: List[EvaluationRecord]) -> Tuple[float, float]: + curve = best_so_far_curve(records) + best = curve[-1] if curve else float("nan") + return best, best + diff --git a/experiments/param-opti/src/qap_mock/search_space.py b/experiments/param-opti/src/qap_mock/search_space.py new file mode 100644 index 0000000..c69f031 --- /dev/null +++ b/experiments/param-opti/src/qap_mock/search_space.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import random +from dataclasses import dataclass +from typing import Dict, List, Tuple + +from .models import PipelineConfig, PipelineFamily, SearchSpaceMode + + +@dataclass(frozen=True) +class FamilySpace: + tasks: List[str] + impl_choices: Dict[str, List[str]] + param_ranges: Dict[str, Tuple[float, float]] + default_impl: Dict[str, str] + default_params: Dict[str, float] + + +def get_family_space(family: PipelineFamily) -> FamilySpace: + # Compact but expressive, mirroring the paper text: + # - discrete implementation choices per task + # - continuous thresholds + if family == PipelineFamily.RDF: + tasks = ["ontology_matching", "entity_matching", "fusion"] + impl_choices = { + "ontology_matching": ["string_sim", "embedding_sim", "hybrid", "llm_alignment"], + "entity_matching": ["rule_based", "blocking_sim", "embedding_er", "llm_er"], + "fusion": ["union", "quality_weighted", "majority_vote"], + } + param_ranges = { + "schema_sim_threshold": (0.3, 0.95), + "entity_sim_threshold": (0.3, 0.95), + "fusion_confidence_threshold": (0.1, 0.9), + "blocking_key_strength": (0.0, 1.0), + } + default_impl = { + "ontology_matching": "string_sim", + "entity_matching": "rule_based", + "fusion": "union", + } + default_params = { + "schema_sim_threshold": 0.7, + "entity_sim_threshold": 0.7, + "fusion_confidence_threshold": 0.5, + "blocking_key_strength": 0.5, + } + return FamilySpace(tasks, impl_choices, param_ranges, default_impl, default_params) + + if family == PipelineFamily.TEXT: + tasks = ["information_extraction", "entity_linking", "fusion"] + impl_choices = { + "information_extraction": ["pattern_ie", "openie", "hybrid_ie", "llm_ie"], + "entity_linking": ["dictionary_linking", "embedding_linking", "llm_linking"], + "fusion": ["union", "quality_weighted", "majority_vote"], + } + param_ranges = { + "ie_conf_threshold": (0.2, 0.95), + "link_sim_threshold": (0.2, 0.95), + "fusion_confidence_threshold": (0.1, 0.9), + "context_window": (64.0, 512.0), + } + default_impl = { + "information_extraction": "pattern_ie", + "entity_linking": "dictionary_linking", + "fusion": "union", + } + default_params = { + "ie_conf_threshold": 0.6, + "link_sim_threshold": 0.6, + "fusion_confidence_threshold": 0.5, + "context_window": 256.0, + } + return FamilySpace(tasks, impl_choices, param_ranges, default_impl, default_params) + + raise ValueError(f"Unknown family: {family}") + + +def sample_config( + rng: random.Random, + family: PipelineFamily, + mode: SearchSpaceMode = SearchSpaceMode.JOINT, + fixed_default: PipelineConfig | None = None, +) -> PipelineConfig: + space = get_family_space(family) + + impl: Dict[str, str] = {} + params: Dict[str, float] = {} + + if fixed_default is None: + fixed_default = PipelineConfig(family=family, implementations=space.default_impl, params=space.default_params) + + if mode in (SearchSpaceMode.JOINT, SearchSpaceMode.IMPLEMENTATION_ONLY): + for t in space.tasks: + impl[t] = rng.choice(space.impl_choices[t]) + else: + impl = dict(fixed_default.implementations) + + if mode in (SearchSpaceMode.JOINT, SearchSpaceMode.PARAMETER_ONLY): + for p, (lo, hi) in space.param_ranges.items(): + params[p] = rng.uniform(lo, hi) + else: + params = dict(fixed_default.params) + + return PipelineConfig(family=family, implementations=impl, params=params) + + +def mutate_config( + rng: random.Random, + cfg: PipelineConfig, + mode: SearchSpaceMode = SearchSpaceMode.JOINT, + p_change_impl: float = 0.35, + p_change_param: float = 0.8, +) -> PipelineConfig: + space = get_family_space(cfg.family) + impl = dict(cfg.implementations) + params = dict(cfg.params) + + if mode in (SearchSpaceMode.JOINT, SearchSpaceMode.IMPLEMENTATION_ONLY) and rng.random() < p_change_impl: + t = rng.choice(space.tasks) + choices = [c for c in space.impl_choices[t] if c != impl[t]] + if choices: + impl[t] = rng.choice(choices) + # Occasionally flip a second task implementation to escape local optima. + if rng.random() < 0.25: + t2 = rng.choice([x for x in space.tasks if x != t]) + choices2 = [c for c in space.impl_choices[t2] if c != impl[t2]] + if choices2: + impl[t2] = rng.choice(choices2) + + if mode in (SearchSpaceMode.JOINT, SearchSpaceMode.PARAMETER_ONLY) and rng.random() < p_change_param: + p = rng.choice(list(space.param_ranges.keys())) + lo, hi = space.param_ranges[p] + # Gaussian step with clipping keeps changes local. + step = rng.gauss(0.0, (hi - lo) * 0.08) + params[p] = min(hi, max(lo, params[p] + step)) + if rng.random() < 0.25: + p2 = rng.choice([x for x in space.param_ranges.keys() if x != p]) + lo2, hi2 = space.param_ranges[p2] + step2 = rng.gauss(0.0, (hi2 - lo2) * 0.06) + params[p2] = min(hi2, max(lo2, params[p2] + step2)) + + return PipelineConfig(family=cfg.family, implementations=impl, params=params) + diff --git a/experiments/param-opti/src/qap_mock/stats.py b/experiments/param-opti/src/qap_mock/stats.py new file mode 100644 index 0000000..9232ff1 --- /dev/null +++ b/experiments/param-opti/src/qap_mock/stats.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import math +from typing import Iterable, List, Sequence, Tuple + + +def mean(xs: Sequence[float]) -> float: + return sum(xs) / len(xs) if xs else float("nan") + + +def stdev(xs: Sequence[float]) -> float: + if len(xs) < 2: + return float("nan") + m = mean(xs) + return math.sqrt(sum((x - m) ** 2 for x in xs) / (len(xs) - 1)) + + +def rankdata(xs: Sequence[float]) -> List[int]: + # Simple dense ranking (ties get same rank). + sorted_unique = sorted(set(xs)) + rank = {v: i + 1 for i, v in enumerate(sorted_unique)} + return [rank[v] for v in xs] + + +def pearsonr(x: Sequence[float], y: Sequence[float]) -> float: + if len(x) != len(y) or len(x) < 2: + return float("nan") + mx = mean(x) + my = mean(y) + num = sum((a - mx) * (b - my) for a, b in zip(x, y)) + denx = math.sqrt(sum((a - mx) ** 2 for a in x)) + deny = math.sqrt(sum((b - my) ** 2 for b in y)) + if denx == 0.0 or deny == 0.0: + return float("nan") + return num / (denx * deny) + + +def spearmanr(x: Sequence[float], y: Sequence[float]) -> float: + rx = rankdata(x) + ry = rankdata(y) + return pearsonr(rx, ry) + + +def mae(x: Sequence[float], y: Sequence[float]) -> float: + if len(x) != len(y) or not x: + return float("nan") + return sum(abs(a - b) for a, b in zip(x, y)) / len(x) + + +def topk_agreement(x: Sequence[float], y: Sequence[float], k: int) -> float: + if len(x) != len(y) or not x: + return float("nan") + n = len(x) + k = max(1, min(k, n)) + topx = set(sorted(range(n), key=lambda i: x[i], reverse=True)[:k]) + topy = set(sorted(range(n), key=lambda i: y[i], reverse=True)[:k]) + return len(topx & topy) / k + From ec08d89d727ad5cf482ef945492498bf552a75db Mon Sep 17 00:00:00 2001 From: Marvin Date: Fri, 10 Apr 2026 15:15:48 +0200 Subject: [PATCH 54/96] fix: fusion task imports --- src/kgpipe_tasks/entity_resolution/fusion/union.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/kgpipe_tasks/entity_resolution/fusion/union.py b/src/kgpipe_tasks/entity_resolution/fusion/union.py index 3af3e01..f31f49d 100644 --- a/src/kgpipe_tasks/entity_resolution/fusion/union.py +++ b/src/kgpipe_tasks/entity_resolution/fusion/union.py @@ -7,8 +7,9 @@ import json from kgpipe.common.registry import Registry import os -from kgcore.model.ontology import OntologyUtil -from kgpipe.execution.config import SOURCE_NAMESPACE, TARGET_ONTOLOGY_NAMESPACE, TARGET_RESOURCE_NAMESPACE + +from kgcore.api.ontology import OntologyUtil +from kgpipe.common.config import SOURCE_NAMESPACE, TARGET_ONTOLOGY_NAMESPACE, TARGET_RESOURCE_NAMESPACE def fuse_rdf_files(f1,f2,er): From 52072acae316439d2808ba65ac286640490e85b5 Mon Sep 17 00:00:00 2001 From: Marvin Date: Tue, 14 Apr 2026 17:20:07 +0200 Subject: [PATCH 55/96] feat(eval): add ignored-entity filtering and intersecting-type alignment Extend entity alignment to support an ignored-entities set, add a new label-embedding + intersecting-type method, and update MovieKG eval integration tests (including multi-source pipeline permutations). --- .../src/moviekg/datasets/tmp_remove_seeds.py | 2 +- .../moviekg/evaluation/test_eval_refactor.py | 56 +++++++++++++++++-- src/kgpipe/datasets/multipart_multisource.py | 10 +++- src/kgpipe_eval/metrics/entity_alignment.py | 54 +++++++++++++++++- src/kgpipe_eval/utils/alignment_utils.py | 28 +++++++++- 5 files changed, 138 insertions(+), 12 deletions(-) diff --git a/experiments/moviekg/src/moviekg/datasets/tmp_remove_seeds.py b/experiments/moviekg/src/moviekg/datasets/tmp_remove_seeds.py index b29c0b7..ea3dded 100644 --- a/experiments/moviekg/src/moviekg/datasets/tmp_remove_seeds.py +++ b/experiments/moviekg/src/moviekg/datasets/tmp_remove_seeds.py @@ -7,7 +7,7 @@ import pandas as pd from pathlib import Path -bench_data = KgBenchData.from_path(Path("/home/marvin/phd/data/moviekg/datasets/film_10k")) +bench_data = KgBenchData.from_path(Path("/home/marvin/phd/data/moviekg/datasets/film_1k")) for i in range(1, 4): seed = bench_data.dataset.splits[f"split_{0}"].kg_reference.meta.entities.file diff --git a/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py b/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py index 3fdee2f..f7b739e 100644 --- a/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py +++ b/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py @@ -18,11 +18,17 @@ from kgpipe.common.model.data import DataFormat import json from dataclasses import asdict +from itertools import permutations +from typing import Set +from kgpipe_eval.utils.kg_utils import Term try: from moviekg import config as moviekg_config + from moviekg.pipelines.test_inc_msp import ssp, idfn except Exception as e: # These are integration-style tests that depend on local env/config files. + import traceback + traceback.print_exc() pytest.skip(f"MovieKG config not available for eval integration test: {e}", allow_module_level=True) # TODO # [ ] Dataset Reader (split,ref,source,metadata) @@ -33,7 +39,7 @@ # substract seed from kg_1 and kg_1 from kg_2, or only seed from kg_1 and kg_2 -EX_BENCH_DATA_PATH = Path("/home/marvin/phd/data/moviekg/datasets/film_10k") +EX_BENCH_DATA_PATH = Path("/home/marvin/phd/data/moviekg/datasets/film_10k") # TODO read from env # EX_INC_PIPE_DATA_PATH = Path("/home/marvin/phd/data/moviekg/output/large/rdf_a") # TODO is a wrapper interface for now, Dataset needs refactor later @@ -52,6 +58,12 @@ def get_verified_entities_path(self, i: int, source_type: str) -> Path: current_new = current_path.with_name(f"{current_path.stem}_no_seed{current_path.suffix}") return current_new + def get_ignored_entities(self, i: int, source_type: str) -> Set[Term]: + seed_entities = self.dataset.splits[f"split_{0}"].kg_seed.meta.entities.read_csv() + # source_seed_entities = self.dataset.splits[f"split_{i-1}"].sources[source_type].meta.entities.read_csv() + return set([entity.entity_id for entity in seed_entities]) # + [entity.entity_id for entity in source_seed_entities]) + + class KgPipeData(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) result_kg: KgLike # name=rdf_a_1 @@ -76,17 +88,18 @@ def build_config_dict(i: int, pipe_data: KgPipeData, bench_data: KgBenchData) -> dup_cfg = DuplicateConfig( entity_alignment_config=EntityAlignmentConfig( method="label_embedding", - verified_entities_path=bench_data.get_verified_entities_path(i=i, source_type="todo"), + verified_entities_path=bench_data.get_verified_entities_path(i=i, source_type="rdf"), # TODO type needs to be derived from pipe_data verified_entities_delimiter="\t", entity_sim_threshold=0.95, ) ) ent_cfg = EntityAlignmentConfig( - method="label_embedding_and_type", + method="label_embedding_and_intersecting_type", verified_entities_path=bench_data.get_verified_entities_path(i=i, source_type="rdf"), # TODO type needs to be derived from pipe_data verified_entities_delimiter="\t", - entity_sim_threshold=0.95 + entity_sim_threshold=0.95, + ignored_entities=bench_data.get_ignored_entities(i=i, source_type="rdf") # TODO type needs to be derived from pipe_data ) return { @@ -190,5 +203,40 @@ def test_evaluate_new(pipeline_name: str): print(f"Wrote results to {stage_dir / 'eval_results.json'}") # Smoke checks: we got metric results back for this stage. + assert isinstance(results, list) + assert results + + +@pytest.mark.parametrize( + "source_1, source_2, source_3", + permutations(list[str](ssp.keys()), 3), + ids=idfn, +) +def test_evaluate_new_multisource_pipeline(source_1: str, source_2: str, source_3: str): + """ + Integration test for the *multi-source* incremental pipelines where the selected + source changes per iteration/stage (e.g. `a_b_c/stage_1`, `a_b_c/stage_2`, ...). + """ + pipeline_name = f"{source_1}_{source_2}_{source_3}" + output_dir = moviekg_config.OUTPUT_ROOT / pipeline_name + + if not output_dir.exists(): + pytest.skip(f"Pipeline output directory {output_dir} not found") + + stage_dirs = _stage_dirs(output_dir) + if not stage_dirs: + pytest.skip(f"No stage directories found under {output_dir}") + + bench_data = KgBenchData.from_path(EX_BENCH_DATA_PATH) + + for stage_dir in stage_dirs: + i = int(stage_dir.name.split("_", 1)[1]) + pipe_data = KgPipeData.from_path(stage_dir) + results = evaluate_stage(i=i, pipe_data=pipe_data, bench_data=bench_data) + + eval_results = _metric_results_to_jsonable(results) + with open(stage_dir / "eval_results.json", "w") as f: + json.dump(eval_results, f, indent=2) + assert isinstance(results, list) assert results \ No newline at end of file diff --git a/src/kgpipe/datasets/multipart_multisource.py b/src/kgpipe/datasets/multipart_multisource.py index 22f1d93..6653b06 100644 --- a/src/kgpipe/datasets/multipart_multisource.py +++ b/src/kgpipe/datasets/multipart_multisource.py @@ -215,13 +215,15 @@ class SplitIndex(BaseModel): # raise ValueError(f"{self.entities_csv} must contain an 'entity_id' column; got {header}") # return self +# SourceType = Literal["rdf", "json", "text"] + class Split(BaseModel): split_id: str root: Path index: SplitIndex kg_reference: Optional[KGBundle] = None kg_seed: Optional[KGBundle] = None - sources: Dict[str, SourceBundle] + sources: Dict[str, SourceBundle] # TODO SourceType def set_index(self, entities: List[EntitiesRow]): self.index.dir.mkdir(parents=True, exist_ok=True) @@ -548,12 +550,16 @@ def load_dataset(root: Path) -> Dataset: if seed_dir.exists(): seed_data_dir = seed_dir / "data" seed_meta_dir = seed_dir / "meta" + seed_meta = SourceMeta(root=seed_meta_dir) + ve = seed_meta_dir / "verified_entities.csv" + if ve.exists(): + seed_meta.entities = VerifiedEntities(file=ve) seed_parts = list_parts(seed_data_dir, (".nt", ".ttl", ".nq")) kg_seed = KGBundle( kind="seed", root=seed_dir, data=SourceData(dir=seed_data_dir, parts=seed_parts), - meta=SourceMeta(root=seed_meta_dir) + meta=seed_meta ) # sources diff --git a/src/kgpipe_eval/metrics/entity_alignment.py b/src/kgpipe_eval/metrics/entity_alignment.py index 3d9c0b6..d6237cc 100644 --- a/src/kgpipe_eval/metrics/entity_alignment.py +++ b/src/kgpipe_eval/metrics/entity_alignment.py @@ -3,7 +3,7 @@ from kgpipe_eval.api import Metric, Measurement, MetricResult from kgpipe_eval.utils.measurement_utils import BCMeasurement -from kgpipe_eval.utils.alignment_utils import align_entities_by_label_embedding, EntityAlignmentConfig, load_entity_uri_label_type_pairs, get_entity_uri_label_type_pairs +from kgpipe_eval.utils.alignment_utils import align_entities_by_label_embedding, EntityAlignmentConfig, load_entity_uri_label_type_pairs, get_entity_uri_label_typeset_pairs, get_entity_uri_label_type_pairs # Core Interface @@ -14,6 +14,8 @@ def eval_entity_alignment(kg: KG, config: EntityAlignmentConfig): alignments = eval_entity_alignment_by_label_alias_embedding(kg, config) elif config.method == "label_embedding_and_type": alignments = eval_entity_alignment_by_label_embedding_and_type(kg, config) + elif config.method == "label_embedding_and_intersecting_type": + alignments = eval_entity_alignment_by_label_embedding_and_intersecting_type(kg, config) else: raise ValueError(f"Invalid method: {config.method}") return alignments @@ -24,7 +26,7 @@ def eval_entity_alignment_by_label_embedding_and_type(kg: KG, config: EntityAlig alignments = align_entities_by_label_embedding(kg, config) ref_entity_uri_label_type_pairs = load_entity_uri_label_type_pairs(config) - gen_entity_uri_label_type_pairs = list(get_entity_uri_label_type_pairs(kg)) + gen_entity_uri_label_type_pairs = list(get_entity_uri_label_type_pairs(kg, config.ignored_entities)) # print ref and gen pairs for testing # print("--------------------------------") @@ -67,6 +69,54 @@ def eval_entity_alignment_by_label_embedding_and_type(kg: KG, config: EntityAlig fn=fn ) +def eval_entity_alignment_by_label_embedding_and_intersecting_type(kg: KG, config: EntityAlignmentConfig): + # Debugging: print some information about the config + print("--------------------------------") + print("ignored_entities") + print(len(config.ignored_entities)) + print("--------------------------------") + + alignments = align_entities_by_label_embedding(kg, config) + + ref_entity_uri_label_type_pairs = load_entity_uri_label_type_pairs(config) + gen_entity_uri_label_type_pairs = list(get_entity_uri_label_typeset_pairs(kg, config.ignored_entities)) + + ref_types = {pair.uri: set([pair.type]) for pair in ref_entity_uri_label_type_pairs if pair.type is not None} + # TODO gen_types can be multiple types, we need to handle this + gen_types = {pair.uri: pair.type_set for pair in gen_entity_uri_label_type_pairs if pair.type_set is not None} + + filtered_alignments = [] + for alignment in alignments: + if alignment.target in ref_types and alignment.source in gen_types: + # Debugging: print the intersection of the reference and generated types + # print("---") + # print("alignment.target", alignment.target) + # print("alignment.source", alignment.source) + # print("ref_types[alignment.target]", ref_types[alignment.target]) + # print("gen_types[alignment.source]", gen_types[alignment.source]) + # print("intersection", ref_types[alignment.target] & gen_types[alignment.source]) + # print("---") + if len(ref_types[alignment.target] & gen_types[alignment.source]) > 0: + filtered_alignments.append(alignment) + + ref_uris = set(pair.uri for pair in ref_entity_uri_label_type_pairs) + gen_uris = set(pair.uri for pair in gen_entity_uri_label_type_pairs) + aligned_gen_uris = set(alignment.target for alignment in filtered_alignments) + aligned_ref_uris = set(alignment.source for alignment in filtered_alignments) + + tp = len(ref_uris & aligned_gen_uris) # generated entities that are also in the reference + fp = len(gen_uris - aligned_ref_uris) # generated entities that are not in the reference + tn = 0 + fn = len(ref_uris - aligned_gen_uris) # missing generated entities that are in the reference + + return BCMeasurement( + tp=tp, + fp=fp, + tn=tn, + fn=fn + ) + + def eval_entity_alignment_by_label_embedding(kg: KG, config: EntityAlignmentConfig): alignments = align_entities_by_label_embedding(kg, config) diff --git a/src/kgpipe_eval/utils/alignment_utils.py b/src/kgpipe_eval/utils/alignment_utils.py index 80546a4..be87a96 100644 --- a/src/kgpipe_eval/utils/alignment_utils.py +++ b/src/kgpipe_eval/utils/alignment_utils.py @@ -10,16 +10,18 @@ from kgpipe.datasets.multipart_multisource import read_entities_csv, EntitiesRow import numpy as np from pathlib import Path +from typing import Set # TODO source entities csv to label only graph class EntityAlignmentConfig(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) - method: Literal["label_embedding", "label_alias_embedding", "label_embedding_and_type"] = "label_embedding" + method: Literal["label_embedding", "label_alias_embedding", "label_embedding_and_type", "label_embedding_and_intersecting_type"] = "label_embedding" reference_kg: Optional[KgLike] = None verified_entities_path: Optional[Path] = None verified_entities_delimiter: str = "\t" entity_sim_threshold: float = 0.95 + ignored_entities: Optional[Set[Term]] = None # value_sim_threshold: float = 0.5 @@ -48,8 +50,9 @@ def get_aligned_triples(kg: KG, reference_kg: KG, method: Literal["exact", "fuzz # return [(s, label) for s, _, label in triple_graph.triples((None, RDFS.label, None))] UriLabelTypePair = NamedTuple("UriLabelTypePair", [("uri", Term), ("label", Term), ("type", Term)]) +UriLabelTypeSetPair = NamedTuple("UriLabelTypeSetPair", [("uri", Term), ("label", Term), ("type_set", set[Term])]) -def get_entity_uri_label_type_pairs(kg: KG) -> list[UriLabelTypePair]: +def get_entity_uri_label_type_pairs(kg: KG, ignored_entities: Optional[Set[Term]] = None) -> list[UriLabelTypePair]: label_by_uri = {} type_by_uri = {} for s, p, o in kg.triples((None, RDFS.label, None)): @@ -57,11 +60,30 @@ def get_entity_uri_label_type_pairs(kg: KG) -> list[UriLabelTypePair]: for s, p, o in kg.triples((None, RDF.type, None)): type_by_uri[str(s)] = str(o) for uri in label_by_uri: + if ignored_entities and str(uri) in ignored_entities: + continue if uri in type_by_uri: yield UriLabelTypePair(uri=uri, label=label_by_uri[uri], type=type_by_uri[uri]) else: yield UriLabelTypePair(uri=uri, label=label_by_uri[uri], type=None) +def get_entity_uri_label_typeset_pairs(kg: KG, ignored_entities: Optional[Set[Term]] = None) -> list[UriLabelTypeSetPair]: + label_by_uri = {} + types_by_uri = {} + for s, p, o in kg.triples((None, RDFS.label, None)): + label_by_uri[str(s)] = str(o) + for s, p, o in kg.triples((None, RDF.type, None)): + if str(s) not in types_by_uri: + types_by_uri[str(s)] = set() + types_by_uri[str(s)].add(str(o)) + for uri in label_by_uri: + if ignored_entities and str(uri) in ignored_entities: + continue + if uri in types_by_uri: + yield UriLabelTypeSetPair(uri=uri, label=label_by_uri[uri], type_set=types_by_uri[uri]) + else: + yield UriLabelTypeSetPair(uri=uri, label=label_by_uri[uri], type_set=set()) + def load_verified_entities(path: Path, delimiter: str = "\t") -> list[UriLabelTypePair]: """ """ @@ -89,7 +111,7 @@ def align_entities_by_label_embedding(tg: TripleGraph, config: EntityAlignmentCo ref_labels = [pair.label for pair in ref_entity_uri_label_type_pairs] ref_labels_embeddings = model.encode(ref_labels, convert_to_numpy=True, show_progress_bar=False) - gen_entity_uri_label_type_pairs = list(get_entity_uri_label_type_pairs(tg)) + gen_entity_uri_label_type_pairs = list(get_entity_uri_label_type_pairs(tg, config.ignored_entities)) gen_labels = [pair.label for pair in gen_entity_uri_label_type_pairs] gen_labels_embeddings = model.encode(gen_labels, convert_to_numpy=True, show_progress_bar=False) From ea02c1525ea2baccf03ae6e907399881cfe8e36e Mon Sep 17 00:00:00 2001 From: marvin Date: Thu, 16 Apr 2026 18:44:55 +0200 Subject: [PATCH 56/96] stash --- .../src/param_opti/pipeline_util.py | 5 ++ .../param-opti/src/param_opti/search.py | 17 ++++++ .../src/param_opti/tasks/base_linker.py | 22 ++++++++ .../src/param_opti/tasks/base_matcher.py | 46 ++++++++++++++++ .../src/param_opti/tasks/formats.py | 8 +++ .../param-opti/src/param_opti/tasks/fusion.py | 0 .../param-opti/src/param_opti/tasks/paris.py | 0 .../src/param_opti/tasks/spotlight.py | 0 pyproject.toml | 52 ++++++++++++++++++- 9 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 experiments/param-opti/src/param_opti/pipeline_util.py create mode 100644 experiments/param-opti/src/param_opti/search.py create mode 100644 experiments/param-opti/src/param_opti/tasks/base_linker.py create mode 100644 experiments/param-opti/src/param_opti/tasks/base_matcher.py create mode 100644 experiments/param-opti/src/param_opti/tasks/formats.py create mode 100644 experiments/param-opti/src/param_opti/tasks/fusion.py create mode 100644 experiments/param-opti/src/param_opti/tasks/paris.py create mode 100644 experiments/param-opti/src/param_opti/tasks/spotlight.py diff --git a/experiments/param-opti/src/param_opti/pipeline_util.py b/experiments/param-opti/src/param_opti/pipeline_util.py new file mode 100644 index 0000000..eb0acf3 --- /dev/null +++ b/experiments/param-opti/src/param_opti/pipeline_util.py @@ -0,0 +1,5 @@ + + + +# check current implementation state + diff --git a/experiments/param-opti/src/param_opti/search.py b/experiments/param-opti/src/param_opti/search.py new file mode 100644 index 0000000..82658f7 --- /dev/null +++ b/experiments/param-opti/src/param_opti/search.py @@ -0,0 +1,17 @@ + + +def sample_random_valid(task_impls: List[str]): + pass + +class SearchSpace: + def __init__(self, task_impls: List[str]): + self.task_impls = task_impls + +class NeighborhoodSearch: + def __init__(self, search_space: SearchSpace): + self.search_space = search_space + + def search(self, budget: int): + pass + + diff --git a/experiments/param-opti/src/param_opti/tasks/base_linker.py b/experiments/param-opti/src/param_opti/tasks/base_linker.py new file mode 100644 index 0000000..7c8c10f --- /dev/null +++ b/experiments/param-opti/src/param_opti/tasks/base_linker.py @@ -0,0 +1,22 @@ +from kgpipe.common import TaskInput, TaskOutput, Data, DataFormat + +def relation_linker_label_alias_embedding_transformer(inputs: TaskInput, outputs: TaskOutput): + """ + Link relations using a base transformer model. + """ + pass + # relation_text = inputs["relation_text"] + # relation_linker = RelationLinkerBaseTransformer(relation_text) + # relation_linker.link() + # outputs["relation_link"] = relation_linker.relation_link + + +def entity_linker_label_alias_embedding_transformer(inputs: TaskInput, outputs: TaskOutput): + """ + Link entities using a base transformer model. + """ + pass + # entity_text = inputs["entity_text"] + # entity_linker = EntityLinkerBaseTransformer(entity_text) + # entity_linker.link() + # outputs["entity_link"] = entity_linker.entity_link \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/tasks/base_matcher.py b/experiments/param-opti/src/param_opti/tasks/base_matcher.py new file mode 100644 index 0000000..d70e7ac --- /dev/null +++ b/experiments/param-opti/src/param_opti/tasks/base_matcher.py @@ -0,0 +1,46 @@ +from kgpipe.common import TaskInput, TaskOutput, Data, DataFormat, Registry, BasicTaskCategoryCatalog +from kgpipe.common.model.configuration import ConfigurationDefinition, Parameter, ParameterType + +@Registry.task( + input_spec={"source": DataFormat.RDF, "target": DataFormat.RDF}, + output_spec={"output": DataFormat.AGREEMENTMAKER_RDF}, + description="Perform entity matching using AgreementMaker", + category=[BasicTaskCategoryCatalog.entity_matching], + config_spec=ConfigurationDefinition( + parameters=[ + Parameter(name="model_name", type=ParameterType.STRING, default="sentence-transformers/all-MiniLM-L6-v2"), + Parameter(name="similarity_threshold", type=ParameterType.NUMBER, default=0.5), + ] + ) +) +def relation_matcher_label_alias_embedding_transformer(inputs: TaskInput, outputs: TaskOutput): + """ + Match relations using a base transformer model. + """ + pass + # relation_text = inputs["relation_text"] + # relation_matcher = RelationMatcherBaseTransformer(relation_text) + # relation_matcher.match() + # outputs["relation_matcher"] = relation_matcher.relation_matcher + +@Registry.task( + input_spec={"source": DataFormat.RDF, "target": DataFormat.RDF}, + output_spec={"output": DataFormat.AGREEMENTMAKER_RDF}, + description="Perform entity matching using AgreementMaker", + category=[BasicTaskCategoryCatalog.entity_matching], + config_spec=ConfigurationDefinition( + parameters=[ + Parameter(name="model_name", type=ParameterType.STRING, default="sentence-transformers/all-MiniLM-L6-v2"), + Parameter(name="similarity_threshold", type=ParameterType.NUMBER, default=0.5), + ] + ) +) +def entity_matcher_label_alias_embedding_transformer(inputs: TaskInput, outputs: TaskOutput): + """ + Match entities using a base transformer model. + """ + pass + # entity_text = inputs["entity_text"] + # entity_matcher = EntityMatcherBaseTransformer(entity_text) + # entity_matcher.match() + # outputs["entity_matcher"] = entity_matcher.entity_matcher \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/tasks/formats.py b/experiments/param-opti/src/param_opti/tasks/formats.py new file mode 100644 index 0000000..97d1a3e --- /dev/null +++ b/experiments/param-opti/src/param_opti/tasks/formats.py @@ -0,0 +1,8 @@ + +# reimport and define of used formats + +from kgpipe.common import DataFormat +from kgpipe.common.model.default_catalog import BasicDataFormats, CustomDataFormats + +class ExtendedFormats(CustomDataFormats): + pass \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/tasks/fusion.py b/experiments/param-opti/src/param_opti/tasks/fusion.py new file mode 100644 index 0000000..e69de29 diff --git a/experiments/param-opti/src/param_opti/tasks/paris.py b/experiments/param-opti/src/param_opti/tasks/paris.py new file mode 100644 index 0000000..e69de29 diff --git a/experiments/param-opti/src/param_opti/tasks/spotlight.py b/experiments/param-opti/src/param_opti/tasks/spotlight.py new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml index 8c4e40b..57a7d1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,8 +21,8 @@ dependencies = [ "rdflib>=6.0.0", "matplotlib>=3.5.0", "networkx>=2.8.0", - "transformers>=4.50.0", - "sentence_transformers>=4.1.0", + # ML stack (torch/transformers) is intentionally NOT in base deps. + # Install via `pip/uv pip install ".[ml]"` plus the desired torch index (CPU/CUDA). "pulp>=3.3.0", "pytest>=8.4.2", "dotenv>=0.9.9", @@ -39,6 +39,54 @@ dependencies = [ [project.optional-dependencies] dev = ["pytest", "pytest-mock", "pytest-cov", "ruff", "black"] +cpu = [ + "torch", + "torchvision", + "torchaudio", +] +cuda = [ + "torch", + "torchvision", + "torchaudio", +] +ml = [ + "transformers>=4.50.0", + "sentence_transformers>=4.1.0", +] + +[tool.uv] +conflicts = [ + [ + { extra = "cpu" }, + { extra = "cuda" }, + ], +] + +[tool.uv.sources] +torch = [ + { index = "pytorch-cpu", extra = "cpu" }, + { index = "pytorch-cuda", extra = "cuda" }, +] +torchvision = [ + { index = "pytorch-cpu", extra = "cpu" }, + { index = "pytorch-cuda", extra = "cuda" }, +] +torchaudio = [ + { index = "pytorch-cpu", extra = "cpu" }, + { index = "pytorch-cuda", extra = "cuda" }, +] + +[[tool.uv.index]] +name = "pytorch-cpu" +url = "https://download.pytorch.org/whl/cpu" +explicit = true + +# CUDA wheels live on a separate PyTorch index. +# If you need a different CUDA version, change the URL (e.g. `cu128`, `cu126`, `cu121`). +[[tool.uv.index]] +name = "pytorch-cuda" +url = "https://download.pytorch.org/whl/cu130" +explicit = true [tool.setuptools.packages.find] where = ["src"] From 1ff7cbd461909b121d378eaaf1a11a9171079cb9 Mon Sep 17 00:00:00 2001 From: marvin Date: Thu, 16 Apr 2026 18:45:08 +0200 Subject: [PATCH 57/96] stash --- README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/README.md b/README.md index 54fcba9..2900b0a 100644 --- a/README.md +++ b/README.md @@ -51,5 +51,29 @@ KGpipe provides Single-Source Pipelines (SSPs) and Multi-Source Pipelines (MSPs) For documentation see the [docs](docs/reproduce.md) +## Installation notes (CPU vs CUDA) + +Some optional ML dependencies (e.g. `sentence_transformers`) pull in PyTorch (`torch`). Depending on which PyTorch wheel gets selected, you may see large downloads like `nvidia-*` and `triton`. + +KGpipe keeps the ML stack out of the default install; install it explicitly when needed. For `uv`, PyTorch is pinned to the official PyTorch wheel indexes to avoid accidentally pulling CUDA wheels from PyPI. + +### Base install (fast, no torch) + +```bash +uv pip install . +``` + +### ML install with CPU-only PyTorch (no `nvidia-*`) + +```bash +uv pip install ".[ml,cpu]" +``` + +### ML install with CUDA-enabled PyTorch (will download `nvidia-*`) + +```bash +uv pip install ".[ml,cuda]" +``` + ## Experiments - **[moviekg](experiments/moviekg/README.md)** evalaution of a pipelines, building a Movie KG from three sources (rdf,json,text). From 5ff6c956e6d0651a9512c82c33e03c81855c254a Mon Sep 17 00:00:00 2001 From: marvin Date: Tue, 21 Apr 2026 11:55:14 +0200 Subject: [PATCH 58/96] exp(params): draft config experiments; KgPipe consume configs --- .../src/param_opti/tasks/__init__.py | 4 + .../src/param_opti/tasks/base_linker.py | 37 ++- .../param-opti/src/param_opti/tasks/fusion.py | 24 ++ .../param-opti/src/param_opti/tasks/jedai.py | 0 .../param-opti/src/param_opti/tasks/paris.py | 46 ++++ experiments/param-opti/src/qap/__init__.py | 0 .../src/qap/test_pipeline_config.py | 232 ++++++++++++++++++ .../param-opti/src/qap/test_ref_based.py | 28 +++ src/kgpipe/common/model/pipeline.py | 102 +++++++- src/kgpipe/common/registry.py | 4 + 10 files changed, 462 insertions(+), 15 deletions(-) create mode 100644 experiments/param-opti/src/param_opti/tasks/jedai.py create mode 100644 experiments/param-opti/src/qap/__init__.py create mode 100644 experiments/param-opti/src/qap/test_pipeline_config.py create mode 100644 experiments/param-opti/src/qap/test_ref_based.py diff --git a/experiments/param-opti/src/param_opti/tasks/__init__.py b/experiments/param-opti/src/param_opti/tasks/__init__.py index e69de29..bb421c4 100644 --- a/experiments/param-opti/src/param_opti/tasks/__init__.py +++ b/experiments/param-opti/src/param_opti/tasks/__init__.py @@ -0,0 +1,4 @@ +from .paris import paris_entity_alignment_task, paris_graph_alignment_task +from .fusion import fusion_first_value_task, fusion_union_task + +__all__ = ["paris_entity_matching_task", "paris_exchange_task", "fusion_first_value_task", "fusion_union_task"] \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/tasks/base_linker.py b/experiments/param-opti/src/param_opti/tasks/base_linker.py index 7c8c10f..5af3df4 100644 --- a/experiments/param-opti/src/param_opti/tasks/base_linker.py +++ b/experiments/param-opti/src/param_opti/tasks/base_linker.py @@ -1,6 +1,7 @@ -from kgpipe.common import TaskInput, TaskOutput, Data, DataFormat +from kgpipe.common import TaskInput, TaskOutput, Data, DataFormat, KgTask +from kgpipe.common.model.configuration import ConfigurationProfile, ConfigurationDefinition, Parameter, ParameterType -def relation_linker_label_alias_embedding_transformer(inputs: TaskInput, outputs: TaskOutput): +def relation_linker_label_alias_embedding_transformer_function(inputs: TaskInput, outputs: TaskOutput, config: ConfigurationProfile): """ Link relations using a base transformer model. """ @@ -10,8 +11,21 @@ def relation_linker_label_alias_embedding_transformer(inputs: TaskInput, outputs # relation_linker.link() # outputs["relation_link"] = relation_linker.relation_link +relation_linker_label_alias_embedding_transformer_task = KgTask( + name="relation_linker_label_alias_embedding_transformer", + function=relation_linker_label_alias_embedding_transformer_function, + input_spec={"source": DataFormat.RDF, "target": DataFormat.RDF}, + output_spec={"output": DataFormat.RDF}, + config_spec=ConfigurationDefinition( + name="relation_linker_label_alias_embedding_transformer", + parameters=[ + Parameter(name="model_name", native_keys=["--model-name"], datatype=ParameterType.string, default_value="sentence-transformers/all-MiniLM-L6-v2", required=True, allowed_values=["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2"]), + Parameter(name="similarity_threshold", native_keys=["--similarity-threshold"], datatype=ParameterType.number, default_value=0.5, required=True, allowed_values=[0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]), + ] + ) +) -def entity_linker_label_alias_embedding_transformer(inputs: TaskInput, outputs: TaskOutput): +def entity_linker_label_alias_embedding_transformer_function(inputs: TaskInput, outputs: TaskOutput, config: ConfigurationProfile): """ Link entities using a base transformer model. """ @@ -19,4 +33,19 @@ def entity_linker_label_alias_embedding_transformer(inputs: TaskInput, outputs: # entity_text = inputs["entity_text"] # entity_linker = EntityLinkerBaseTransformer(entity_text) # entity_linker.link() - # outputs["entity_link"] = entity_linker.entity_link \ No newline at end of file + # outputs["entity_link"] = entity_linker.entity_link + +entity_linker_label_alias_embedding_transformer_task = KgTask( + name="entity_linker_label_alias_embedding_transformer", + function=entity_linker_label_alias_embedding_transformer_function, + input_spec={"source": DataFormat.RDF, "target": DataFormat.RDF}, + output_spec={"output": DataFormat.RDF}, + config_spec=ConfigurationDefinition( + name="entity_linker_label_alias_embedding_transformer", + parameters=[ + Parameter(name="model_name", native_keys=["--model-name"], datatype=ParameterType.string, default_value="sentence-transformers/all-MiniLM-L6-v2", required=True, allowed_values=["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2"]), + Parameter(name="similarity_threshold", native_keys=["--similarity-threshold"], datatype=ParameterType.number, default_value=0.5, required=True, allowed_values=[0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]), + ] + ) +) + diff --git a/experiments/param-opti/src/param_opti/tasks/fusion.py b/experiments/param-opti/src/param_opti/tasks/fusion.py index e69de29..ee73e29 100644 --- a/experiments/param-opti/src/param_opti/tasks/fusion.py +++ b/experiments/param-opti/src/param_opti/tasks/fusion.py @@ -0,0 +1,24 @@ +from kgpipe.common.model.configuration import ConfigurationProfile +from kgpipe.common.models import TaskInput, TaskOutput, KgTask, DataFormat + +def fusion_first_value_function(inputs: TaskInput, outputs: TaskOutput): + # touch output file + outputs["output"].path.touch() + +fusion_first_value_task = KgTask( + name="fusion_first_value", + function=fusion_first_value_function, + input_spec={"source": DataFormat.RDF_NTRIPLES, "target": DataFormat.RDF_NTRIPLES, "matches": DataFormat.ER_JSON}, + output_spec={"output": DataFormat.RDF_NTRIPLES}, +) + +def fusion_union_function(inputs: TaskInput, outputs: TaskOutput): + # touch output file + outputs["output"].path.touch() + +fusion_union_task = KgTask( + name="fusion_union", + function=fusion_union_function, + input_spec={"source": DataFormat.RDF_NTRIPLES, "target": DataFormat.RDF_NTRIPLES, "matches": DataFormat.ER_JSON}, + output_spec={"output": DataFormat.RDF_NTRIPLES}, +) \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/tasks/jedai.py b/experiments/param-opti/src/param_opti/tasks/jedai.py new file mode 100644 index 0000000..e69de29 diff --git a/experiments/param-opti/src/param_opti/tasks/paris.py b/experiments/param-opti/src/param_opti/tasks/paris.py index e69de29..21dbce9 100644 --- a/experiments/param-opti/src/param_opti/tasks/paris.py +++ b/experiments/param-opti/src/param_opti/tasks/paris.py @@ -0,0 +1,46 @@ +from kgpipe.common import TaskInput, TaskOutput, KgTask, DataFormat +from kgpipe.common.model.configuration import ConfigurationProfile, ConfigurationDefinition, Parameter, ParameterType + + + +def paris_entity_alignment_function(inputs: TaskInput, outputs: TaskOutput, config: ConfigurationProfile): + """ + matches entities between two RDF graphs + """ + # touch output file + print(f"paris_entity_alignment_function: {outputs['output'].path}") + outputs["output"].path.touch() + +paris_entity_alignment_task = KgTask( + name="paris_entity_alignment", + function=paris_entity_alignment_function, + input_spec={"source": DataFormat.RDF_NTRIPLES, "target": DataFormat.RDF_NTRIPLES}, + output_spec={"output": DataFormat.ER_JSON}, + config_spec=ConfigurationDefinition( + name="paris_entity_alignment", + parameters=[ + Parameter(name="entity_matching_threshold", native_keys=["--entity-matching-threshold"], datatype=ParameterType.number, default_value=0.5, required=True, allowed_values=[0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]), + ] + ) +) + +def paris_graph_alignment_function(inputs: TaskInput, outputs: TaskOutput, config: ConfigurationProfile): + """ + matches both entities and relations between two RDF graphs + """ + # touch output file + outputs["output"].path.touch() + +paris_graph_alignment_task = KgTask( + name="paris_graph_alignment", + function=paris_graph_alignment_function, + input_spec={"source": DataFormat.RDF_NTRIPLES, "target": DataFormat.RDF_NTRIPLES}, + output_spec={"output": DataFormat.ER_JSON}, + config_spec=ConfigurationDefinition( + name="paris_graph_alignment", + parameters=[ + Parameter(name="entity_matching_threshold", native_keys=["--entity-matching-threshold"], datatype=ParameterType.number, default_value=0.5, required=True, allowed_values=[0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]), + Parameter(name="relation_matching_threshold", native_keys=["--relation-matching-threshold"], datatype=ParameterType.number, default_value=0.5, required=True, allowed_values=[0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]), + ] + ) +) \ No newline at end of file diff --git a/experiments/param-opti/src/qap/__init__.py b/experiments/param-opti/src/qap/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/experiments/param-opti/src/qap/test_pipeline_config.py b/experiments/param-opti/src/qap/test_pipeline_config.py new file mode 100644 index 0000000..e1e7c79 --- /dev/null +++ b/experiments/param-opti/src/qap/test_pipeline_config.py @@ -0,0 +1,232 @@ +from typing import List, Dict, Any, Optional +import random +from kgpipe.common import KgPipe, Data, DataFormat, Registry +from kgpipe.common.model.configuration import ConfigurationProfile, ParameterBinding +from kgpipe.common.model.task import KgTask +from pydantic import BaseModel +from param_opti.tasks.paris import paris_graph_alignment_task +from param_opti.tasks.fusion import fusion_first_value_task +from param_opti.tasks.base_linker import relation_linker_label_alias_embedding_transformer_task, entity_linker_label_alias_embedding_transformer_task +from kgpipe.generation.loaders import build_from_conf +from pathlib import Path +# for given tasks and config parameters, generate a pipeline (KGpipe) + +tmp_base_dir = Path("tmp") +if not tmp_base_dir.exists(): + tmp_base_dir.mkdir(parents=True, exist_ok=True) + + +class PipelineConfig(BaseModel): + tasks: List[KgTask] + config_catalog: Dict[str, ConfigurationProfile] + +SEARCH_SPACE = { + "paris_graph_alignment_task": { + "category": "entity_matching", + "entity_matching_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], + "relation_matching_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], + }, + "fusion_first_value_task": { + "category": "fusion", + # "fusion_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], + }, + "relation_linker_label_alias_embedding_transformer_task": { + "category": "entity_linking", + "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2"], + "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], + }, + "entity_linker_label_alias_embedding_transformer_task": { + "category": "entity_linking", + "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2"], + "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], + }, +} + +task_dict = { + "paris_graph_alignment_task": paris_graph_alignment_task, + "fusion_first_value_task": fusion_first_value_task, + "relation_linker_label_alias_embedding_transformer_task": relation_linker_label_alias_embedding_transformer_task, + "entity_linker_label_alias_embedding_transformer_task": entity_linker_label_alias_embedding_transformer_task, +} + +for task_name, task in task_dict.items(): + Registry.add_task(task_name, task) + +class PipelineLayout(BaseModel): + """ + allowed task categories in the pipeline + """ + allowed_task_categories: List[str] + +def _get_param(definition: Any, param_name: str): + params = getattr(definition, "parameters", None) + if params is None: + raise KeyError(f"Task config_spec has no parameters field (missing {param_name})") + + # common shapes: dict-like or list of Parameter + if hasattr(params, "get"): + p = params.get(param_name) + if p is None: + raise KeyError(f"Parameter {param_name} not found in config_spec.parameters") + return p + + for p in params: + if getattr(p, "name", None) == param_name: + return p + raise KeyError(f"Parameter {param_name} not found in config_spec.parameters") + +def get_default_rdf_pipeline_config() -> PipelineConfig: + return PipelineConfig( + tasks=[ + paris_graph_alignment_task, + fusion_first_value_task, + ], + config_catalog={ + # Key must match KgTask.name because KgPipe delegates by task.name + "paris_graph_alignment": ConfigurationProfile( + name="paris_graph_alignment", + definition=paris_graph_alignment_task.config_spec, + bindings=[ + ParameterBinding(parameter=_get_param(paris_graph_alignment_task.config_spec, "entity_matching_threshold"), value=0.5), + ParameterBinding(parameter=_get_param(paris_graph_alignment_task.config_spec, "relation_matching_threshold"), value=0.5), + ], + ) + }, + ) + +# TODO rules for valid pipeline config: +def sample_valid_pipeline_config( + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, +) -> PipelineConfig: + """ + Randomly sample a valid pipeline config from the search space, + respecting the order of categories in the pipeline layout. + """ + tasks: List[KgTask] = [] + config_catalog: Dict[str, ConfigurationProfile] = {} + + for category in pipeline_layout.allowed_task_categories: + eligible_task_names = [ + tn for tn, space in search_space.items() if space.get("category") == category + ] + if not eligible_task_names: + continue + + task_key = random.choice(eligible_task_names) + task = task_dict[task_key] + tasks.append(task) + + # metadata only or task has no config spec + if getattr(task, "config_spec", None) is None: + continue + + bindings: List[ParameterBinding] = [] + name_parts: List[str] = [] + for config_name, config_values in search_space[task_key].items(): + if config_name == "category": + continue + if not isinstance(config_values, list): + raise TypeError( + f"Search space values must be lists; got {task_key}.{config_name}={type(config_values)}" + ) + if not config_values: + raise ValueError(f"Empty search space for {task_key}.{config_name}") + + config_value = random.choice(config_values) + name_parts.append(f"{config_name}={config_value}") + bindings.append( + ParameterBinding( + parameter=_get_param(task.config_spec, config_name), + value=config_value, + ) + ) + + if bindings: + config_catalog[task.name] = ConfigurationProfile( + name=f"{task.name}_" + ",".join(name_parts), + definition=task.config_spec, + bindings=bindings, + ) + + return PipelineConfig(tasks=tasks, config_catalog=config_catalog) + +def print_pipeline_config_short(pipeline_config: PipelineConfig): + """ + print the pipeline config in a short format + """ + print() + print("================") + for task in pipeline_config.tasks: + task_name = task.name + profile: Optional[ConfigurationProfile] = pipeline_config.config_catalog.get(task_name) + if profile is None: + print(f"- {task_name}") + continue + + parts: List[str] = [] + for binding in profile.bindings: + parts.append(f"{binding.parameter.name}={binding.value}") + params = ", ".join(parts) + print(f"- {task_name}({params})") + + + +def test_sample_valid_rdf_pipeline_config(): + pipeline_layout = PipelineLayout( + allowed_task_categories=["ontology_matching", "entity_matching", "fusion"] + ) + pipeline_config = sample_valid_pipeline_config(SEARCH_SPACE, pipeline_layout) + print_pipeline_config_short(pipeline_config) + +def test_sample_valid_text_pipeline_config(): + pipeline_layout = PipelineLayout( + allowed_task_categories=["information_extraction", "entity_linking", "fusion"] + ) + pipeline_config = sample_valid_pipeline_config(SEARCH_SPACE, pipeline_layout) + print_pipeline_config_short(pipeline_config) + + +def test_rdf_pipeline_from_default_config(): + pipeline_config = get_default_rdf_pipeline_config() + + seed_path = tmp_base_dir / "seed.nt" + source_path = tmp_base_dir / "source.nt" + result_path = tmp_base_dir / "result.nt" + + + pipeline = KgPipe( + tasks=pipeline_config.tasks, + seed=Data(path=seed_path, format=DataFormat.RDF_NTRIPLES), + data_dir=tmp_base_dir / "tasks_tmp", + name="test_pipeline") + + pipeline.build( + stable_files=True, + configCatalog=pipeline_config.config_catalog, + source=Data(path=source_path, format=DataFormat.RDF_NTRIPLES), + result=Data(path=result_path, format=DataFormat.RDF_NTRIPLES)) + + pipeline.run(configCatalog=pipeline_config.config_catalog, stable_files_override=True) + +def test_rdf_pipeline_from_config(): + pipeline_config = sample_valid_pipeline_config(SEARCH_SPACE, PipelineLayout(allowed_task_categories=["entity_matching", "fusion"])) + + seed_path = tmp_base_dir / "seed.nt" + source_path = tmp_base_dir / "source.nt" + result_path = tmp_base_dir / "result.nt" + + + pipeline = KgPipe( + tasks=pipeline_config.tasks, + seed=Data(path=seed_path, format=DataFormat.RDF_NTRIPLES), + data_dir=tmp_base_dir / "tasks_tmp", + name="test_pipeline") + + pipeline.build( + stable_files=True, + configCatalog=pipeline_config.config_catalog, + source=Data(path=source_path, format=DataFormat.RDF_NTRIPLES), + result=Data(path=result_path, format=DataFormat.RDF_NTRIPLES)) + + pipeline.run(configCatalog=pipeline_config.config_catalog, stable_files_override=True) \ No newline at end of file diff --git a/experiments/param-opti/src/qap/test_ref_based.py b/experiments/param-opti/src/qap/test_ref_based.py new file mode 100644 index 0000000..b4fb3c3 --- /dev/null +++ b/experiments/param-opti/src/qap/test_ref_based.py @@ -0,0 +1,28 @@ +from kgpipe.common import KgPipe, Data, DataFormat +from param_opti.tasks.paris import paris_entity_alignment_task, paris_graph_alignment_task +from param_opti.tasks.fusion import fusion_first_value_task, fusion_union_task +from pathlib import Path + +# Using ground truth + +# 1. execute PARIS pipeline, with different thresholds +# 2. evaluate the quality of the pipeline, with different thresholds + + +# - [ ] impl paris wrapper with exchange and threshold filter + +seed_path = Path("data/seed.nt") +pipe_result_dir_path = Path("data/pipe_result") + +def get_paris_pipeline(threshold: float): + name = f"paris_graph_alignment_task={threshold}_fusion_first_value_task" + + return KgPipe( + name=name, + tasks=[paris_graph_alignment_task, fusion_first_value_task], + seed=Data(path=seed_path, format=DataFormat.RDF_NTRIPLES), + data_dir=pipe_result_dir_path / "tmp" + ) + +def test_paris_pipelines(): + pass \ No newline at end of file diff --git a/src/kgpipe/common/model/pipeline.py b/src/kgpipe/common/model/pipeline.py index 8420c02..3755519 100644 --- a/src/kgpipe/common/model/pipeline.py +++ b/src/kgpipe/common/model/pipeline.py @@ -1,6 +1,7 @@ import os import time import uuid +import hashlib from abc import ABC, abstractmethod from dataclasses import dataclass, field from datetime import datetime @@ -18,6 +19,7 @@ from .data import Data, DataFormat, DataSet, Format from .task import KgTask, KgTaskReport +from .configuration import ConfigurationProfile # from .kg import KG from kgpipe.common.annotations import kg_class from kgpipe.common.graph.systemgraph import PipeKG @@ -125,19 +127,71 @@ def add_data(self, data: Data) -> None: self.data.append(data) - def build(self, source: Data, result: Optional[Data] = None, stable_files: bool = False) -> KgPipePlan: + def build( + self, + source: Data, + result: Optional[Data] = None, + stable_files: bool = False, + configCatalog: Optional[Mapping[str, ConfigurationProfile]] = None, + ) -> KgPipePlan: """Generate the execution plan as a list of dictionaries.""" catalog = [source] + self.data calls: List[KgPipePlanStep] = [] - def gen_file_path(task: KgTask, format_spec: Format, prefix: str = "", suffix: str = ""): - if stable_files: + def _profile_fingerprint(profile: Optional[ConfigurationProfile]) -> str: + if profile is None: + return "" + # Make it stable regardless of binding order. + bindings = [] + for b in getattr(profile, "bindings", []) or []: + param = getattr(b, "parameter", None) + pname = getattr(param, "name", None) + if pname is None: + pname = str(param) + bindings.append((str(pname), b.value)) + bindings.sort(key=lambda kv: kv[0]) + payload = json.dumps( + {"definition": getattr(getattr(profile, "definition", None), "name", None), "bindings": bindings}, + sort_keys=True, + default=str, + ) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + def _chain_hash(prev_hash: str, task_name: str, profile: Optional[ConfigurationProfile]) -> str: + fp = _profile_fingerprint(profile) + payload = json.dumps({"prev": prev_hash, "task": task_name, "profile": fp}, sort_keys=True) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + prev_hash = "0" * 64 + + def gen_file_path( + *, + task: KgTask, + format_spec: Format, + prefix: str = "", + suffix: str = "", + task_hash: Optional[str] = None, + ) -> Path: + # Backwards-compatible: stable_files without configCatalog keeps the old deterministic names. + if stable_files and configCatalog is None: return Path(self.data_dir) / f"{prefix}{task.name}{suffix}.{format_spec.extension}" - else: - return Path(self.data_dir) / f"{prefix}{task.name}.{uuid4().hex}.{format_spec.extension}" + + # If configCatalog is provided, filenames must be deterministic based on the hash chain. + if configCatalog is not None and task_hash is not None: + short = task_hash[:12] + return Path(self.data_dir) / f"{prefix}{task.name}.{short}{suffix}.{format_spec.extension}" + + # Default behavior: unique filenames. + return Path(self.data_dir) / f"{prefix}{task.name}.{uuid4().hex}.{format_spec.extension}" for idx, task in enumerate(self.tasks): + task_hash: Optional[str] = None + if configCatalog is not None: + profile = configCatalog.get(task.name) + task_hash = _chain_hash(prev_hash, task.name, profile) + prev_hash = task_hash + # Match inputs inputs = [] for input_name, format_spec in task.input_spec.items(): @@ -159,7 +213,13 @@ def gen_file_path(task: KgTask, format_spec: Format, prefix: str = "", suffix: s break else: suffix = f"_{len(outputs)}" - output_path = gen_file_path(task, format_spec, prefix=f"{idx}_", suffix=suffix) + output_path = gen_file_path( + task=task, + format_spec=format_spec, + prefix=f"{idx}_", + suffix=suffix, + task_hash=task_hash, + ) output_data = Data(path=output_path, format=format_spec) outputs.append(output_data) @@ -167,17 +227,19 @@ def gen_file_path(task: KgTask, format_spec: Format, prefix: str = "", suffix: s if len(inputs) != len(task.input_spec): missing_inputs = len(task.input_spec) - len(inputs) + catalog_str = "\n".join([str(i) for i in catalog]) raise ValueError( f"For task {task.name}: expected {task.input_spec} inputs, got {inputs}. " f"Missing {missing_inputs} inputs." - f"catalog: {"\n".join([str(i) for i in catalog])}" + f"catalog: {catalog_str}" ) elif len(outputs) != len(task.output_spec): missing_outputs = len(task.output_spec) - len(outputs) + catalog_str = "\n".join([str(i) for i in catalog]) raise ValueError( f"\nFor task {task.name}: expected {task.output_spec} outputs, got {outputs}. " f"\nMissing {missing_outputs} outputs." - f"\nCatalog: {"\n".join([str(i) for i in catalog])}" + f"\nCatalog: {catalog_str}" ) else: print(f"Adding task '{task.name}' to plan with\n\t inputs: {[str(i.path) for i in inputs]} and \n\t outputs: {[str(o.path) for o in outputs]}") @@ -211,7 +273,11 @@ def plot(self) -> None: """Plot the pipeline.""" pass - def run(self, stable_files_override: bool = False) -> List[KgTaskReport]: + def run( + self, + stable_files_override: bool = False, + configCatalog: Optional[Mapping[str, ConfigurationProfile]] = None, + ) -> List[KgTaskReport]: """Execute each task defined in the plan and collect the reports.""" if not self.plan: raise ValueError("Pipeline plan is empty. Call build() first.") @@ -232,10 +298,24 @@ def run(self, stable_files_override: bool = False) -> List[KgTaskReport]: if not input_data.exists(): raise FileNotFoundError(f"Input file {input_data.path} does not exist") + configProfile = None + if configCatalog is not None: + configProfile = configCatalog.get(task.name) + if self.previous_was_skipped: - report = task.run(task_spec.input, task_spec.output, stable_files_override=stable_files_override) + report = task.run( + task_spec.input, + task_spec.output, + stable_files_override=stable_files_override, + configProfile=configProfile, + ) else: - report = task.run(task_spec.input, task_spec.output, stable_files_override=True) + report = task.run( + task_spec.input, + task_spec.output, + stable_files_override=True, + configProfile=configProfile, + ) if report.status != "skipped": self.previous_was_skipped = False diff --git a/src/kgpipe/common/registry.py b/src/kgpipe/common/registry.py index f9d24e0..18bdcbf 100644 --- a/src/kgpipe/common/registry.py +++ b/src/kgpipe/common/registry.py @@ -59,6 +59,10 @@ def decorator(t): # Task # + @classmethod + def add_task(cls, name: str, task: KgTask): + cls._registry[f"task:{task.name}"] = task + @classmethod def task( cls, From 719afd0bec51921ad8a6c737fbef8dc6afa1873d Mon Sep 17 00:00:00 2001 From: Marvin Date: Sat, 25 Apr 2026 17:32:37 +0200 Subject: [PATCH 59/96] feat(llm): added first version of any_extract llm task --- src/kgpipe_llm/any_extraction.py | 198 +++++++++++++++++++++ src/kgpipe_llm/test/test_any_extraction.py | 24 +++ 2 files changed, 222 insertions(+) create mode 100644 src/kgpipe_llm/any_extraction.py create mode 100644 src/kgpipe_llm/test/test_any_extraction.py diff --git a/src/kgpipe_llm/any_extraction.py b/src/kgpipe_llm/any_extraction.py new file mode 100644 index 0000000..9509030 --- /dev/null +++ b/src/kgpipe_llm/any_extraction.py @@ -0,0 +1,198 @@ +# Generalized variant of RDF triple generation + +from kgpipe.common import Registry, DataFormat, Data, TaskInput, TaskOutput +from kgpipe.common.model.configuration import ConfigurationDefinition, Parameter, ParameterType, ConfigurationProfile +from kgpipe_llm.common.snippets import generate_ontology_snippet_v3 +from kgcore.api.ontology import OntologyUtil +from pathlib import Path +from kgpipe_llm.common.core import LLMClient + +from shutil import RegistryError +from pydantic import BaseModel, AnyUrl + +# class OntologyGroundedSurfaceTriple(BaseModel): +# subject_label: str +# predicate_uri: AnyUrl +# object_label: str + +from pydantic import BaseModel, Field, AnyUrl + + +class SurfaceTriple(BaseModel): + subject: str = Field( + description="Surface-form subject label. Not a URI." + ) + predicate_uri: AnyUrl = Field( + description="Ontology property URI." + ) + object: str = Field( + description="Surface-form object label or literal. Not a URI." + ) + + +class SurfaceTripleExtractionResult(BaseModel): + triples: list[SurfaceTriple] + +# ontology-guided semantic triple extraction. +# surface semantic triples +# ontology-grounded surface triples + + +def get_ontology_grounded_surface_triples_prompt_template(ontology: str, input_data: str) -> str: + return """ +You are an ontology-guided semantic triple extraction system. + +Your task is to extract ontology-grounded surface triples from the provided input data. + +A valid triple has the form: + + + +Where: +- subject is a surface-form string, label, name, or textual identifier. +- predicate_uri is a URI from the provided ontology vocabulary. +- object is a surface-form string, label, value, literal, or textual identifier. +- subject and object MUST NOT be converted into URIs. +- predicate_uri MUST be selected only from the ontology vocabulary. +- Do not invent ontology properties. +- Do not invent facts not supported by the input. +- Prefer the most specific ontology property that correctly matches the input. +- If a relation or attribute is present in the input but cannot be mapped to the ontology, place it in unmapped_candidates. +- Preserve meaningful entity names as they appear in the input, normalizing only whitespace and obvious formatting artifacts. +- Extract both attributes and relations when they can be represented with an ontology property. +- Return only valid structured output matching the provided schema. + +Ontology vocabulary: + +{ontology} + +Input data: + +{input_data} + +Extraction guidance: +1. Identify named entities, records, rows, objects, or document subjects. +2. Identify attributes and relations expressed in the input. +3. Map each attribute or relation to the best matching ontology property URI. +4. Emit triples using string labels for subject and object. +5. Include evidence when possible. +6. Include confidence between 0.0 and 1.0. +7. Report unmapped relation or attribute candidates. +""".format(ontology=ontology, input_data=input_data) + +def extract_ontology_surface_triples(data: str, ontology: Path, client: LLMClient) -> SurfaceTripleExtractionResult: + + ontology_snippet = generate_ontology_snippet_v3(OntologyUtil.load_ontology_from_file(ontology)) + + prompt = get_ontology_grounded_surface_triples_prompt_template(ontology_snippet, data) + response = client.send_prompt(prompt, SurfaceTripleExtractionResult) + + return response + +@Registry.task( + input_spec={"input": DataFormat.ANY}, + output_spec={"output": DataFormat.RDF_NTRIPLES}, + description="Generate RDF triples for a schema", + config_spec=ConfigurationDefinition( + name="extract_ontology_surface_triples", + parameters=[ + Parameter( + name="ontology", + datatype=ParameterType.string, + description="The schema to generate RDF triples for" + ), + Parameter( + name="prompt_template", + datatype=ParameterType.string, + description="The prompt template to use for the LLM" + ), + ] + ) +) +def extract_ontology_surface_triples_task(input: TaskInput, output: TaskOutput, config: ConfigurationProfile): + pass + + + +# from typing import Any, Literal +# from pydantic import BaseModel, Field, AnyUrl + + +# class OntologyTerm(BaseModel): +# uri: AnyUrl = Field( +# description="The ontology URI identifying a class, attribute, or relation." +# ) +# label: str | None = Field( +# default=None, +# description="Optional human-readable label for the ontology term." +# ) +# description: str | None = Field( +# default=None, +# description="Optional description or definition of the ontology term." +# ) + + +# class OntologyGroundedSurfaceTriple(BaseModel): +# subject: str = Field( +# description="Surface-form name or label of the subject entity. This is not a URI." +# ) + +# predicate_uri: AnyUrl = Field( +# description="URI of the ontology property, attribute, or relation used as the predicate." +# ) + +# object: str = Field( +# description="Surface-form value, entity name, label, literal, or textual object. This is not a URI." +# ) + +# subject_type_uri: AnyUrl | None = Field( +# default=None, +# description="Optional ontology class URI for the subject, if inferable from the input and ontology." +# ) + +# object_type_uri: AnyUrl | None = Field( +# default=None, +# description="Optional ontology class URI for the object, if inferable from the input and ontology." +# ) + +# evidence: str | None = Field( +# default=None, +# description="Short quote or compact excerpt from the input that supports this triple." +# ) + +# confidence: float = Field( +# ge=0.0, +# le=1.0, +# description="Model confidence that the triple is correct and uses the appropriate ontology predicate." +# ) + + +# class TripleExtractionIssue(BaseModel): +# message: str = Field( +# description="Description of an ambiguity, missing ontology term, or extraction problem." +# ) + +# severity: Literal["info", "warning", "error"] = Field( +# description="Severity of the issue." +# ) + +# related_text: str | None = Field( +# default=None, +# description="Optional source text related to the issue." +# ) + + +# class OntologySurfaceTripleExtractionResult(BaseModel): +# triples: list[OntologyGroundedSurfaceTriple] = Field( +# description="Extracted ontology-grounded surface triples." +# ) + +# unmapped_candidates: list[str] = Field( +# default_factory=list, +# description="Candidate relations or attributes found in the input that could not be mapped to the ontology." +# ) + +# issues: list[TripleExtractionIssue] = Field( +# default_factory=list, +# description="Warnings or errors encountered during extraction." +# ) \ No newline at end of file diff --git a/src/kgpipe_llm/test/test_any_extraction.py b/src/kgpipe_llm/test/test_any_extraction.py new file mode 100644 index 0000000..ab7ded6 --- /dev/null +++ b/src/kgpipe_llm/test/test_any_extraction.py @@ -0,0 +1,24 @@ +from kgpipe_llm.any_extraction import extract_ontology_surface_triples +from pathlib import Path +from kgpipe_llm.common.core import LLMClient +import os + +TEXT=""" +Titanic is a 1997 American epic historical romance film written and directed by James Cameron. Incorporating both historical and fictional aspects, it is based on accounts of the sinking of RMS Titanic in 1912. Leonardo DiCaprio and Kate Winslet star as members of different social classes who fall in love during the ship's ill-fated maiden voyage. The ensemble cast includes Billy Zane, Kathy Bates, Frances Fisher, Bernard Hill, Jonathan Hyde, Danny Nucci, David Warner and Bill Paxton. Cameron's inspiration came from his fascination with shipwrecks. He felt a love story interspersed with human loss would be essential to convey the emotional impact of the disaster. Production began on September 1, 1995, when Cameron shot footage of the Titanic wreck. The modern scenes were shot on board the Shirshov Institute of Oceanology research vessel Akademik Mstislav Keldysh, which Cameron had used as a base when filming the wreck. Scale models, computer-generated imagery (CGI), and a reconstruction of the Titanic built at Baja Studios were used to recreate the sinking. Titanic was initially in development at 20th Century Fox, but delays and a mounting budget resulted in Fox partnering with Paramount Pictures for financial help. It was the most expensive film ever made at the time, with a production budget of $200 million. Filming took place from July 1996 to March 1997. Titanic premiered at the Tokyo International Film Festival on November 1, 1997, and was released in the United States on December 19. It was distributed by Paramount Pictures in the United States and Canada and by 20th Century Fox in other territories. It was praised for its visual effects, performances (particularly those of DiCaprio, Winslet, and Gloria Stuart), production values, direction, score, cinematography, story, and emotional depth. Among other awards, the film received fourteen nominations at the 70th Academy Awards and won eleven, including Best Picture and Best Director. In doing so, it tied both All About Eve (1950) for the record for the most Academy Award nominations, and Ben-Hur (1959) for the most Academy Awards won by a film, making Titanic the most successful individual film in Academy Award history (these records would be matched by 2016's La La Land and 2003's The Lord of the Rings: The Return of the King respectively, although the nomination record was surpassed by 2025's Sinners in 2026). With an initial worldwide gross of over $1.84 billion, Titanic was the first film to reach the billion-dollar mark (1993's Jurassic Park would later become the earliest-released film to achieve this feat, via subsequent re-releases), and was the highest-grossing film of all time until Cameron's next film, Avatar (2009), surpassed it in 2010. Income from the initial theatrical release, retail video, and soundtrack sales and US broadcast rights exceeded $3.2 billion. Releases pushed the worldwide theatrical total to $2.264 billion, making Titanic the second film to gross more than $2 billion worldwide after Avatar; as of 2023, it is the fourth-highest-grossing film. In 2017, the Library of Congress selected it for preservation in the United States National Film Registry as "culturally, historically, or aesthetically significant +""" + +API_KEY = os.getenv("OPENAI_API_KEY") +if not API_KEY: + raise ValueError("OPENAI_API_KEY is not set") + +model_name="o4-mini" +ontology_path = Path("/home/marvin/phd/data/moviekg/datasets/film_10k/ontology.ttl") + +def test_extract_ontology_surface_triples(): + client = LLMClient( + model_name=model_name, + token=API_KEY, + api_type="openai", + ) + result = extract_ontology_surface_triples(TEXT, ontology_path, client) + print(result.model_dump_json(indent=2)) \ No newline at end of file From 72860dd0505f6fb26b06aaa9fd5672905e051438 Mon Sep 17 00:00:00 2001 From: Marvin Date: Tue, 12 May 2026 16:26:40 +0200 Subject: [PATCH 60/96] changes to kgi-bench mov eval --- .../moviekg/evaluation/test_eval_refactor.py | 16 +++++- .../src/moviekg/paper/helpers/helpers.py | 50 ++++++++++++++++--- 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py b/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py index f7b739e..1dacc8f 100644 --- a/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py +++ b/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py @@ -4,6 +4,7 @@ from kgpipe_eval.metrics.statistics import CountMetric from kgpipe_eval.metrics.duplicates import DuplicateConfig, DuplicateMetric from kgpipe_eval.metrics.entity_alignment import EntityAlignmentMetric +from kgpipe_eval.metrics.triple_alignment import TripleAlignmentConfig, ReferenceTripleAlignmentMetric from kgpipe_eval.utils.alignment_utils import EntityAlignmentConfig from kgpipe_eval.utils.kg_utils import KgLike, KgManager from kgpipe_eval.evaluator import Evaluator @@ -94,6 +95,17 @@ def build_config_dict(i: int, pipe_data: KgPipeData, bench_data: KgBenchData) -> ) ) + tri_cfg = TripleAlignmentConfig( + reference_kg=bench_data.dataset.splits[f"split_{i}"].kg_reference, + entity_alignment_config=EntityAlignmentConfig( + method="label_embedding", + verified_entities_path=bench_data.get_verified_entities_path(i=i, source_type="rdf"), # TODO type needs to be derived from pipe_data + verified_entities_delimiter="\t", + entity_sim_threshold=0.95, + ), + value_sim_threshold=0.5, + ) + ent_cfg = EntityAlignmentConfig( method="label_embedding_and_intersecting_type", verified_entities_path=bench_data.get_verified_entities_path(i=i, source_type="rdf"), # TODO type needs to be derived from pipe_data @@ -105,6 +117,7 @@ def build_config_dict(i: int, pipe_data: KgPipeData, bench_data: KgBenchData) -> return { "DuplicateMetric": dup_cfg, "EntityAlignmentMetric": ent_cfg, + "TripleAlignmentMetric": tri_cfg, } @@ -113,7 +126,8 @@ def evaluate_stage(i: int, pipe_data: KgPipeData, bench_data: KgBenchData) -> Li metrics = [ CountMetric(), EntityAlignmentMetric(), - DuplicateMetric() + DuplicateMetric(), + ReferenceTripleAlignmentMetric(), ] config_dict = build_config_dict(i, pipe_data, bench_data) return Evaluator().run(tg, metrics, config_dict) diff --git a/experiments/moviekg/src/moviekg/paper/helpers/helpers.py b/experiments/moviekg/src/moviekg/paper/helpers/helpers.py index 161f55e..2dd3200 100644 --- a/experiments/moviekg/src/moviekg/paper/helpers/helpers.py +++ b/experiments/moviekg/src/moviekg/paper/helpers/helpers.py @@ -130,11 +130,34 @@ def plot_growth_v1(df, metrics): "text_json_rdf": "#d95f0e", "text_rdf_json": "#d95f0e", } +PALETTE_2 = { + # JSON solo + "JSON_base": "#9ecae1", + # "json_baseA": "#9ecae1", + # RDF solo + "RDF_base": "#a1d99b", "RDF_llm": "#2ca02c", + # TEXT solo + "TEXT_base": "#fdd0a2", + + # JSON mixed → violet + "json_rdf_text": "#756bb1", "json_text_rdf": "#756bb1", + # RDF mixed → teal + "rdf_json_text": "#1c9099", "rdf_text_json": "#1c9099", + # TEXT mixed → red-brown + "text_json_rdf": "#d95f0e", "text_rdf_json": "#d95f0e", +} + + HUE_ORDER = [ "json_a","json_b","json_rdf_text","json_text_rdf", "rdf_a","rdf_b","rdf_json_text","rdf_text_json", "text_a","text_b","text_json_rdf","text_rdf_json" ] +HUE_ORDER_2 = [ + "RDF_base","RDF_llm","rdf_json_text","rdf_text_json", + "JSON_base","json_rdf_text","json_text_rdf", + "TEXT_base","text_json_rdf","text_rdf_json" +] def plot_growth(df, metrics, kind="bar", references={}): """ @@ -636,11 +659,23 @@ def plot_class_occurence_new(df, reference_stage_class_count, classes): class_name = "Other" pipeline_stage_class_count[pipeline][stage][class_name] += count + pretty_pipeline_names = { + "json_a": "JSON_base", + "json_c": "JSON_llm", + "rdf_a": "RDF_base", + "rdf_c": "RDF_llm", + "text_a": "TEXT_base", + "text_c": "TEXT_llm", + "json_llm_mapping_v1": "JSON_llm", + "rdf_llm_schema_align_v1": "RDF_llm", + "text_llm_triple_extract_v1": "TEXT_llm", + } + # convert dict of dict to rows for pipeline, stage_class_count in pipeline_stage_class_count.items(): for stage, class_count in stage_class_count.items(): for class_name, count in class_count.items(): - rows.append({"pipeline": pipeline, "stage": stage, "class": class_name.split("/")[-1], "count": count}) + rows.append({"pipeline": pretty_pipeline_names.get(pipeline, pipeline), "stage": stage, "class": class_name.split("/")[-1], "count": count}) # df: pipeline, stage, class, count df = pd.DataFrame(rows) @@ -655,8 +690,8 @@ def plot_class_occurence_new(df, reference_stage_class_count, classes): df, col="class", col_wrap=3, - height=4, - aspect=1.5, + height=3, + aspect=1.2, sharey=False, col_order=classes_short #+["Other"], # preserve requested order ) @@ -665,8 +700,8 @@ def plot_class_occurence_new(df, reference_stage_class_count, classes): x="stage", y="count", hue="pipeline", - hue_order=HUE_ORDER, - palette=PALETTE, + hue_order=HUE_ORDER_2, + palette=PALETTE_2, order=stage_order, dodge=True, errorbar=None @@ -699,7 +734,7 @@ def plot_class_occurence_new(df, reference_stage_class_count, classes): handles, labels, loc="lower center", ncol=min(6, len(labels)), # split across columns - bbox_to_anchor=(0.5, -0.02) # push below the grid + bbox_to_anchor=(0.5, -0.15) # push below the grid ) # make space at bottom so legend isn’t cut off @@ -729,6 +764,9 @@ def plot_class_occ_4_bar_chart(df): # remove seed and reference pipeline df = df[df["pipeline"] != "seed"] + df = df[df["pipeline"] != "json_b"] + df = df[df["pipeline"] != "rdf_b"] + df = df[df["pipeline"] != "text_b"] df = df[df["pipeline"] != "reference"] # subplot_source_entity_integration(df) From a9cef343bbe91ed5b48c0ffeb8419dcec14e00c9 Mon Sep 17 00:00:00 2001 From: Marvin Date: Mon, 27 Apr 2026 22:19:13 +0200 Subject: [PATCH 61/96] stash --- .../src/param_opti/tasks/base_linker.py | 22 +- .../src/param_opti/tasks/base_linker_lib.py | 214 ++++++++ .../src/param_opti/tasks/base_matcher.py | 146 ++++-- .../src/param_opti/tasks/base_matcher_lib.py | 234 +++++++++ .../src/param_opti/tasks/corenlp.py | 36 ++ .../src/param_opti/tasks/corenlp_lip.py | 148 ++++++ .../param-opti/src/param_opti/tasks/fusion.py | 38 +- .../src/param_opti/tasks/fusion_lib.py | 205 ++++++++ .../param-opti/src/param_opti/tasks/genie.py | 14 + .../src/param_opti/tasks/genie_lib.py | 0 .../param-opti/src/param_opti/tasks/jedai.py | 1 + .../src/param_opti/tasks/matching_helpers.py | 30 ++ .../param-opti/src/param_opti/tasks/paris.py | 45 +- .../src/param_opti/tasks/paris_lib.py | 152 ++++++ .../src/param_opti/tasks/select_lib.py | 119 +++++ .../src/param_opti/tasks/spotlight.py | 23 + .../src/param_opti/tasks/spotlight_lib.py | 187 +++++++ .../src/param_opti/tasks/text_helpers.py | 348 +++++++++++++ .../rdf_sampled_pipeline_configs.json | 175 +++++++ experiments/param-opti/src/qap/sge_metrics.py | 17 + .../param-opti/src/qap/test_exec_pipelines.py | 170 +++++++ .../src/qap/test_pipeline_config.py | 463 +++++++++++++++--- .../param-opti/src/qap/test_ref_based.py | 199 +++++++- .../param-opti/src/qap/test_sge_based.py | 36 ++ src/kgpipe/common/model/configuration.py | 8 +- src/kgpipe_eval/metrics/triple_alignment.py | 60 ++- src/kgpipe_eval/test/examples.py | 84 +++- src/kgpipe_eval/test/test_alignment_eval.py | 34 +- src/kgpipe_eval/test/utils.py | 10 + src/kgpipe_eval/utils/alignment_utils.py | 245 ++++++++- src/kgpipe_eval/utils/kg_utils.py | 20 + src/kgpipe_llm/common/core.py | 16 +- 32 files changed, 3341 insertions(+), 158 deletions(-) create mode 100644 experiments/param-opti/src/param_opti/tasks/base_linker_lib.py create mode 100644 experiments/param-opti/src/param_opti/tasks/base_matcher_lib.py create mode 100644 experiments/param-opti/src/param_opti/tasks/corenlp.py create mode 100644 experiments/param-opti/src/param_opti/tasks/corenlp_lip.py create mode 100644 experiments/param-opti/src/param_opti/tasks/fusion_lib.py create mode 100644 experiments/param-opti/src/param_opti/tasks/genie.py create mode 100644 experiments/param-opti/src/param_opti/tasks/genie_lib.py create mode 100644 experiments/param-opti/src/param_opti/tasks/matching_helpers.py create mode 100644 experiments/param-opti/src/param_opti/tasks/paris_lib.py create mode 100644 experiments/param-opti/src/param_opti/tasks/select_lib.py create mode 100644 experiments/param-opti/src/param_opti/tasks/spotlight_lib.py create mode 100644 experiments/param-opti/src/param_opti/tasks/text_helpers.py create mode 100644 experiments/param-opti/src/qap/fixtures/rdf_sampled_pipeline_configs.json create mode 100644 experiments/param-opti/src/qap/sge_metrics.py create mode 100644 experiments/param-opti/src/qap/test_exec_pipelines.py create mode 100644 experiments/param-opti/src/qap/test_sge_based.py diff --git a/experiments/param-opti/src/param_opti/tasks/base_linker.py b/experiments/param-opti/src/param_opti/tasks/base_linker.py index 5af3df4..1d09432 100644 --- a/experiments/param-opti/src/param_opti/tasks/base_linker.py +++ b/experiments/param-opti/src/param_opti/tasks/base_linker.py @@ -5,17 +5,14 @@ def relation_linker_label_alias_embedding_transformer_function(inputs: TaskInput """ Link relations using a base transformer model. """ - pass - # relation_text = inputs["relation_text"] - # relation_linker = RelationLinkerBaseTransformer(relation_text) - # relation_linker.link() - # outputs["relation_link"] = relation_linker.relation_link + from param_opti.tasks.base_linker_lib import label_alias_embedding_rl + label_alias_embedding_rl(inputs, outputs, model_name=config.get_parameter_value("model_name"), threshold=config.get_parameter_value("similarity_threshold")) relation_linker_label_alias_embedding_transformer_task = KgTask( name="relation_linker_label_alias_embedding_transformer", function=relation_linker_label_alias_embedding_transformer_function, - input_spec={"source": DataFormat.RDF, "target": DataFormat.RDF}, - output_spec={"output": DataFormat.RDF}, + input_spec={"source": DataFormat.TE_JSON, "target": DataFormat.RDF_NTRIPLES}, + output_spec={"output": DataFormat.TE_JSON}, config_spec=ConfigurationDefinition( name="relation_linker_label_alias_embedding_transformer", parameters=[ @@ -29,17 +26,14 @@ def entity_linker_label_alias_embedding_transformer_function(inputs: TaskInput, """ Link entities using a base transformer model. """ - pass - # entity_text = inputs["entity_text"] - # entity_linker = EntityLinkerBaseTransformer(entity_text) - # entity_linker.link() - # outputs["entity_link"] = entity_linker.entity_link + from param_opti.tasks.base_linker_lib import label_alias_embedding_el + label_alias_embedding_el(inputs, outputs, model_name=config.get_parameter_value("model_name"), threshold=config.get_parameter_value("similarity_threshold")) entity_linker_label_alias_embedding_transformer_task = KgTask( name="entity_linker_label_alias_embedding_transformer", function=entity_linker_label_alias_embedding_transformer_function, - input_spec={"source": DataFormat.RDF, "target": DataFormat.RDF}, - output_spec={"output": DataFormat.RDF}, + input_spec={"source": DataFormat.TE_JSON, "target": DataFormat.RDF_NTRIPLES}, + output_spec={"output": DataFormat.TE_JSON}, config_spec=ConfigurationDefinition( name="entity_linker_label_alias_embedding_transformer", parameters=[ diff --git a/experiments/param-opti/src/param_opti/tasks/base_linker_lib.py b/experiments/param-opti/src/param_opti/tasks/base_linker_lib.py new file mode 100644 index 0000000..680a575 --- /dev/null +++ b/experiments/param-opti/src/param_opti/tasks/base_linker_lib.py @@ -0,0 +1,214 @@ +import json +import os +from abc import ABC, abstractmethod +from typing import Dict, List + +import numpy as np +import torch +from kgcore.api.ontology import OntologyUtil, OwlProperty +from kgpipe.common import Data, DataFormat, Registry +from kgpipe_tasks.transform_interop.exchange.text_extraction import TE_Document, TE_Pair +from rdflib import Graph, RDFS +from sentence_transformers import SentenceTransformer, util +from tqdm import tqdm + +_models: Dict[str, SentenceTransformer] = {} + +class Embedder(ABC): + def __init__(self, embedder_name: str): + self.embedder_name = embedder_name + + @abstractmethod + def encode_as_dict(self, texts: List[str]) -> Dict[str, np.ndarray]: + pass + + @abstractmethod + def encode(self, texts: List[str]) -> np.ndarray: + pass + + +def get_model(model_name: str) -> SentenceTransformer: + if model_name not in _models: + model = SentenceTransformer(model_name) + if torch.cuda.is_available(): + model.to(torch.cuda.current_device()) + _models[model_name] = model + return _models[model_name] + +class SentenceTransformerEmbedder(Embedder): + def __init__(self, model_name: str): + super().__init__("sentence-transformer") + self.model_name = model_name + + def encode_as_dict(self, text_list: List[str]) -> Dict[str, np.ndarray]: + embeddings = self.encode(text_list) + return {text: embedding for text, embedding in zip(text_list, embeddings)} + + def encode(self, text_list: List[str]) -> np.ndarray: + embeddings = get_model(self.model_name).encode(text_list, show_progress_bar=False) + return embeddings + +class EntityMatch: + def __init__(self, entity: str, label: str, score: float): + self.entity = entity + self.label = label + self.score = score + + +def _validate_embedding_dimensions(query_embeddings: np.ndarray, target_embeddings: np.ndarray) -> None: + if query_embeddings.shape[1] != target_embeddings.shape[1]: + raise ValueError( + "Embedding dimension mismatch: " + f"{query_embeddings.shape[1]} vs {target_embeddings.shape[1]}" + ) + + +class AliasAndLabelBasedEntityLinker: + """ + Link extracted entity mentions to graph resources using label embeddings. + """ + + def __init__(self, graph: Graph, model_name: str = "all-MiniLM-L6-v2", threshold: float = 0.0): + self.graph = graph + self.embedder = SentenceTransformerEmbedder(model_name=model_name) + self.threshold = float(threshold) + self.entity_uri_label_tuples = [ + (entity_uri, str(label)) + for entity_uri, _, label in self.graph.triples((None, RDFS.label, None)) + ] + entity_texts = [label for _, label in self.entity_uri_label_tuples] + self.entity_embeddings = self.embedder.encode(entity_texts) + + def link_entities(self, extracted_entities: List[str]) -> List[EntityMatch]: + if not extracted_entities: + return [] + + best_matches = [] + key_embeddings = self.embedder.encode(extracted_entities) + _validate_embedding_dimensions(key_embeddings, self.entity_embeddings) + similarities = util.cos_sim(key_embeddings, self.entity_embeddings) + + for i, entity in enumerate(extracted_entities): + best_idx = int(similarities[i].argmax()) + best_score = float(similarities[i][best_idx]) + if best_score < self.threshold: + continue + entity_uri, _ = self.entity_uri_label_tuples[best_idx] + best_matches.append(EntityMatch(entity, entity_uri, best_score)) + + return best_matches + + + +def label_alias_embedding_el(inputs: Dict[str, Data], outputs: Dict[str, Data], model_name: str = "all-MiniLM-L6-v2", threshold: float = 0.5): + graph = Graph() + graph.parse(inputs["target"].path, format="nt") + linker = AliasAndLabelBasedEntityLinker(graph, model_name=model_name, threshold=threshold) + + if os.path.isdir(inputs["source"].path): + os.makedirs(outputs["output"].path, exist_ok=True) + for file in tqdm(os.listdir(inputs["source"].path), desc="Linking entities"): + te_doc_in = TE_Document(**json.load(open(os.path.join(inputs["source"].path, file)))) + entity_texts = list({triple.subject.surface_form for triple in te_doc_in.triples if triple.subject.surface_form}) + entity_texts += list({triple.object.surface_form for triple in te_doc_in.triples if triple.object.surface_form}) + entity_matches = linker.link_entities(entity_texts) + te_links = [TE_Pair(span=match.entity, mapping=match.label, link_type="entity", score=match.score) for match in entity_matches] + te_doc_out = te_doc_in.model_copy(deep=True) + te_doc_out.links += te_links + with open(os.path.join(outputs["output"].path, file), "w") as f: + f.write(te_doc_out.model_dump_json()) + else: + te_doc_in = TE_Document(**json.load(open(inputs["source"].path))) + entity_matches = linker.link_entities(list({triple.subject.surface_form for triple in te_doc_in.triples if triple.subject.surface_form})) + te_links = [TE_Pair(span=match.entity, mapping=match.label, link_type="entity", score=match.score) for match in entity_matches] + te_doc_out = te_doc_in.model_copy(deep=True) + te_doc_out.links += te_links + with open(outputs["output"].path, "w") as f: + f.write(te_doc_out.model_dump_json()) + + + +class RelationMatch: + def __init__(self, relation: str, predicate: OwlProperty, score: float): + self.relation = relation + self.predicate = predicate + self.score = score + + def __str__(self): + return f"RelationMatch(relation={self.relation}, predicate={self.predicate.uri}, score={self.score})" + + +def normalize(text): + return text.replace('_', ' ').replace('-', ' ').strip().lower() + +def build_property_text(prop: OwlProperty): + text_parts = [ + f"label: {normalize(prop.label)}", + f"altLabels: {', '.join(normalize(lbl) for lbl in prop.alias)}" + # f"domain: {normalize(prop.get('domain', ''))}", + # f"comment: {normalize(prop.get('comment', ''))}" + ] + return "; ".join(text_parts) + +class AliasAndTransformerBasedRelationLinker: + """ + Link extracted relation phrases to ontology predicates using label and alias embeddings. + """ + + def __init__(self, ontology_file, model_name: str = "all-MiniLM-L6-v2", threshold: float = 0.0): + self.ontology = OntologyUtil.load_ontology_from_file(ontology_file) + self.embedder = SentenceTransformerEmbedder(model_name=model_name) + self.threshold = float(threshold) + property_texts = [build_property_text(p) for p in self.ontology.properties] + self.property_embeddings = self.embedder.encode(property_texts) + + def link_relations(self, extracted_relations: List[str]) -> List[RelationMatch]: + if not extracted_relations: + return [] + + best_matches = [] + key_texts = [normalize(relation) for relation in extracted_relations] + key_embeddings = self.embedder.encode(key_texts) + _validate_embedding_dimensions(key_embeddings, self.property_embeddings) + similarities = util.cos_sim(key_embeddings, self.property_embeddings) + + for i, relation in enumerate(extracted_relations): + best_idx = int(similarities[i].argmax()) + best_score = float(similarities[i][best_idx]) + if best_score < self.threshold: + continue + match = self.ontology.properties[best_idx] + best_matches.append(RelationMatch(relation, match, best_score)) + + return best_matches + + +def label_alias_embedding_rl(inputs: Dict[str, Data], outputs: Dict[str, Data], model_name: str = "all-MiniLM-L6-v2", threshold: float = 0.5): + + ontology_path = os.environ.get("ONTOLOGY_PATH", "false") + if ontology_path == "false": + raise ValueError("ONTOLOGY_PATH is not set") + else: + ontology_path = ontology_path + + linker = AliasAndTransformerBasedRelationLinker(ontology_path, model_name=model_name, threshold=threshold) + + if os.path.isdir(inputs["source"].path): + os.makedirs(outputs["output"].path, exist_ok=True) + for file in tqdm(os.listdir(inputs["source"].path), desc="Linking relations"): + te_doc_in = TE_Document(**json.load(open(os.path.join(inputs["source"].path, file)))) + relation_texts = list({triple.predicate.surface_form for triple in te_doc_in.triples if triple.predicate.surface_form}) + relation_matches = linker.link_relations(relation_texts) + te_links = [TE_Pair(span=match.relation, mapping=match.predicate.uri, link_type="predicate", score=match.score) for match in relation_matches] + te_doc_out = te_doc_in.model_copy(deep=True) + te_doc_out.links += te_links + with open(os.path.join(outputs["output"].path, file), "w") as f: + f.write(te_doc_out.model_dump_json()) + else: + te_doc_in = TE_Document(**json.load(open(inputs["source"].path))) # TODO: check if this is correct + relation_matches = linker.link_relations(list({triple.predicate.surface_form for triple in te_doc_in.triples if triple.predicate.surface_form})) + te_links = [TE_Pair(span=match.relation, mapping=match.predicate.uri, link_type="predicate", score=match.score) for match in relation_matches] + te_doc_out = te_doc_in.model_copy(deep=True) + te_doc_out.links += te_links + with open(outputs["output"].path, "w") as f: + f.write(te_doc_out.model_dump_json()) \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/tasks/base_matcher.py b/experiments/param-opti/src/param_opti/tasks/base_matcher.py index d70e7ac..a43bc45 100644 --- a/experiments/param-opti/src/param_opti/tasks/base_matcher.py +++ b/experiments/param-opti/src/param_opti/tasks/base_matcher.py @@ -1,46 +1,110 @@ -from kgpipe.common import TaskInput, TaskOutput, Data, DataFormat, Registry, BasicTaskCategoryCatalog -from kgpipe.common.model.configuration import ConfigurationDefinition, Parameter, ParameterType - -@Registry.task( - input_spec={"source": DataFormat.RDF, "target": DataFormat.RDF}, - output_spec={"output": DataFormat.AGREEMENTMAKER_RDF}, - description="Perform entity matching using AgreementMaker", - category=[BasicTaskCategoryCatalog.entity_matching], - config_spec=ConfigurationDefinition( - parameters=[ - Parameter(name="model_name", type=ParameterType.STRING, default="sentence-transformers/all-MiniLM-L6-v2"), - Parameter(name="similarity_threshold", type=ParameterType.NUMBER, default=0.5), - ] +from kgpipe.common import TaskInput, TaskOutput, DataFormat +from kgpipe.common.model.configuration import ConfigurationDefinition, Parameter, ParameterType, ConfigurationProfile +from kgpipe.common.model.task import KgTask + +# Same as paris_graph_alignment_task / paris_entity_alignment_task: +# input_spec + output_spec as in experiments/param-opti/src/param_opti/tasks/paris.py (e.g. lines 58–59). +_ALIGNMENT_TWO_GRAPH_INPUT_SPEC = {"source": DataFormat.RDF_NTRIPLES, "target": DataFormat.RDF_NTRIPLES} +_ALIGNMENT_ER_JSON_OUTPUT_SPEC = {"output": DataFormat.ER_JSON} + + +def _embedding_config_params(): + return [ + Parameter( + name="model_name", + native_keys=["--model-name"], + datatype=ParameterType.string, + default_value="sentence-transformers/all-MiniLM-L6-v2", + required=True, + allowed_values=[ + "sentence-transformers/all-MiniLM-L6-v2", + "sentence-transformers/all-mpnet-base-v2", + ], + ), + Parameter( + name="similarity_threshold", + native_keys=["--similarity-threshold"], + datatype=ParameterType.number, + default_value=0.5, + required=True, + allowed_values=[0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0], + ), + ] + + +def graph_alignment_label_alias_embedding_transformer_function( + inputs: TaskInput, outputs: TaskOutput, config: ConfigurationProfile +): + """Match entities and relations between two RDF graphs (full graph alignment).""" + from param_opti.tasks.base_matcher_lib import label_embedding_graph_alignment_match + + label_embedding_graph_alignment_match( + inputs, + outputs, + model_name=config.get_parameter_value("model_name"), + threshold=float(config.get_parameter_value("similarity_threshold")), ) + + +graph_alignment_label_alias_embedding_transformer_task = KgTask( + name="graph_alignment_label_alias_embedding_transformer", + function=graph_alignment_label_alias_embedding_transformer_function, + input_spec=dict(_ALIGNMENT_TWO_GRAPH_INPUT_SPEC), + output_spec=dict(_ALIGNMENT_ER_JSON_OUTPUT_SPEC), + config_spec=ConfigurationDefinition( + name="graph_alignment_label_alias_embedding_transformer", + parameters=_embedding_config_params(), + ), ) -def relation_matcher_label_alias_embedding_transformer(inputs: TaskInput, outputs: TaskOutput): - """ - Match relations using a base transformer model. - """ - pass - # relation_text = inputs["relation_text"] - # relation_matcher = RelationMatcherBaseTransformer(relation_text) - # relation_matcher.match() - # outputs["relation_matcher"] = relation_matcher.relation_matcher - -@Registry.task( - input_spec={"source": DataFormat.RDF, "target": DataFormat.RDF}, - output_spec={"output": DataFormat.AGREEMENTMAKER_RDF}, - description="Perform entity matching using AgreementMaker", - category=[BasicTaskCategoryCatalog.entity_matching], + + +def entity_matcher_label_alias_embedding_transformer_function( + inputs: TaskInput, outputs: TaskOutput, config: ConfigurationProfile +): + """Entity alignment only (subject/object URIs with rdfs:label).""" + from param_opti.tasks.base_matcher_lib import label_embedding_entity_alignment_match + + label_embedding_entity_alignment_match( + inputs, + outputs, + model_name=config.get_parameter_value("model_name"), + threshold=float(config.get_parameter_value("similarity_threshold")), + ) + + +entity_matcher_label_alias_embedding_transformer_task = KgTask( + name="entity_matcher_label_alias_embedding_transformer", + function=entity_matcher_label_alias_embedding_transformer_function, + input_spec=dict(_ALIGNMENT_TWO_GRAPH_INPUT_SPEC), + output_spec=dict(_ALIGNMENT_ER_JSON_OUTPUT_SPEC), config_spec=ConfigurationDefinition( - parameters=[ - Parameter(name="model_name", type=ParameterType.STRING, default="sentence-transformers/all-MiniLM-L6-v2"), - Parameter(name="similarity_threshold", type=ParameterType.NUMBER, default=0.5), - ] + name="entity_matcher_label_alias_embedding_transformer", + parameters=_embedding_config_params(), + ), +) + + +def relation_matcher_label_alias_embedding_transformer_function( + inputs: TaskInput, outputs: TaskOutput, config: ConfigurationProfile +): + """Relation / predicate alignment only.""" + from param_opti.tasks.base_matcher_lib import label_embedding_relation_alignment_match + + label_embedding_relation_alignment_match( + inputs, + outputs, + model_name=config.get_parameter_value("model_name"), + threshold=float(config.get_parameter_value("similarity_threshold")), ) + + +relation_matcher_label_alias_embedding_transformer_task = KgTask( + name="relation_matcher_label_alias_embedding_transformer", + function=relation_matcher_label_alias_embedding_transformer_function, + input_spec=dict(_ALIGNMENT_TWO_GRAPH_INPUT_SPEC), + output_spec=dict(_ALIGNMENT_ER_JSON_OUTPUT_SPEC), + config_spec=ConfigurationDefinition( + name="relation_matcher_label_alias_embedding_transformer", + parameters=_embedding_config_params(), + ), ) -def entity_matcher_label_alias_embedding_transformer(inputs: TaskInput, outputs: TaskOutput): - """ - Match entities using a base transformer model. - """ - pass - # entity_text = inputs["entity_text"] - # entity_matcher = EntityMatcherBaseTransformer(entity_text) - # entity_matcher.match() - # outputs["entity_matcher"] = entity_matcher.entity_matcher \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/tasks/base_matcher_lib.py b/experiments/param-opti/src/param_opti/tasks/base_matcher_lib.py new file mode 100644 index 0000000..6ca3f41 --- /dev/null +++ b/experiments/param-opti/src/param_opti/tasks/base_matcher_lib.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Sequence + +from rdflib import Graph, Literal, RDFS, URIRef +from sentence_transformers import util + +from kgpipe.common import Data +from kgpipe_tasks.transform_interop.exchange.entity_matching import ER_Document, ER_Match + +# Reuse the shared embedder/model cache from the linker implementation. +from param_opti.tasks.base_linker_lib import SentenceTransformerEmbedder, _validate_embedding_dimensions + + +def _normalize_label(text: str) -> str: + return " ".join(text.replace("_", " ").replace("-", " ").strip().lower().split()) + + +def _safe_first_literal(values: Iterable[object]) -> Optional[str]: + for v in values: + if isinstance(v, Literal): + s = str(v).strip() + if s: + return s + return None + + +def _fallback_label_from_uri(uri: URIRef) -> str: + s = str(uri) + if "#" in s: + return s.rsplit("#", 1)[-1] + return s.rsplit("/", 1)[-1] + + +@dataclass(frozen=True) +class _LabeledUri: + uri: URIRef + label: str + + +def _extract_labeled_entities(graph: Graph) -> List[_LabeledUri]: + """ + Extract subject/object URIRefs that have an rdfs:label. + """ + uris: set[URIRef] = set() + for s, _, o in graph: + if isinstance(s, URIRef): + uris.add(s) + if isinstance(o, URIRef): + uris.add(o) + + labeled: List[_LabeledUri] = [] + for u in uris: + label = _safe_first_literal(graph.objects(u, RDFS.label)) + if label: + labeled.append(_LabeledUri(u, label)) + return labeled + + +def _extract_labeled_predicates(graph: Graph) -> List[_LabeledUri]: + """ + Extract predicate URIRefs and use rdfs:label if present, otherwise fall back to local-name. + """ + preds: set[URIRef] = {p for _, p, _ in graph if isinstance(p, URIRef)} + labeled: List[_LabeledUri] = [] + for p in preds: + label = _safe_first_literal(graph.objects(p, RDFS.label)) or _fallback_label_from_uri(p) + labeled.append(_LabeledUri(p, label)) + return labeled + + +def _best_matches( + source: Sequence[_LabeledUri], + target: Sequence[_LabeledUri], + *, + model_name: str, + threshold: float, + id_type: str, +) -> List[ER_Match]: + if not source or not target: + return [] + + embedder = SentenceTransformerEmbedder(model_name=model_name) + src_texts = [_normalize_label(x.label) for x in source] + tgt_texts = [_normalize_label(x.label) for x in target] + + src_emb = embedder.encode(src_texts) + tgt_emb = embedder.encode(tgt_texts) + _validate_embedding_dimensions(src_emb, tgt_emb) + + sims = util.cos_sim(src_emb, tgt_emb) + matches: List[ER_Match] = [] + + for i, src in enumerate(source): + best_idx = int(sims[i].argmax()) + best_score = float(sims[i][best_idx]) + if best_score < float(threshold): + continue + tgt = target[best_idx] + matches.append( + ER_Match( + id_1=str(src.uri), + id_2=str(tgt.uri), + score=best_score, + id_type=id_type, + ) + ) + return matches + + +def _write_er_document(output_path: Path, matches: List[ER_Match]) -> None: + output_path.parent.mkdir(parents=True, exist_ok=True) + doc = ER_Document(matches=matches) + output_path.write_text(doc.model_dump_json(), encoding="utf-8") + + +def _label_embedding_match_two_graphs( + inputs: Dict[str, Data], + outputs: Dict[str, Data], + *, + model_name: str, + threshold: float, + include_entities: bool, + include_relations: bool, +) -> None: + source_graph = Graph() + source_graph.parse(inputs["source"].path, format="nt") + + target_graph = Graph() + target_graph.parse(inputs["target"].path, format="nt") + + matches: List[ER_Match] = [] + + if include_entities: + source_entities = _extract_labeled_entities(source_graph) + target_entities = _extract_labeled_entities(target_graph) + matches.extend( + _best_matches( + source_entities, + target_entities, + model_name=model_name, + threshold=threshold, + id_type="entity", + ) + ) + + if include_relations: + source_preds = _extract_labeled_predicates(source_graph) + target_preds = _extract_labeled_predicates(target_graph) + matches.extend( + _best_matches( + source_preds, + target_preds, + model_name=model_name, + threshold=threshold, + id_type="relation", + ) + ) + + _write_er_document(outputs["output"].path, matches) + + +def label_embedding_graph_alignment_match( + inputs: Dict[str, Data], + outputs: Dict[str, Data], + *, + model_name: str = "sentence-transformers/all-MiniLM-L6-v2", + threshold: float = 0.5, +) -> None: + """ + Align two RDF graphs: match subject/object entities by rdfs:label and predicates by label. + + Writes `ER_Document` JSON with both entity and relation matches (same shape as `paris_lib`). + """ + _label_embedding_match_two_graphs( + inputs, + outputs, + model_name=model_name, + threshold=threshold, + include_entities=True, + include_relations=True, + ) + + +def label_embedding_entity_alignment_match( + inputs: Dict[str, Data], + outputs: Dict[str, Data], + *, + model_name: str = "sentence-transformers/all-MiniLM-L6-v2", + threshold: float = 0.5, +) -> None: + """Entity alignment only: matches with id_type \"entity\".""" + _label_embedding_match_two_graphs( + inputs, + outputs, + model_name=model_name, + threshold=threshold, + include_entities=True, + include_relations=False, + ) + + +def label_embedding_relation_alignment_match( + inputs: Dict[str, Data], + outputs: Dict[str, Data], + *, + model_name: str = "sentence-transformers/all-MiniLM-L6-v2", + threshold: float = 0.5, +) -> None: + """Relation / predicate alignment only: matches with id_type \"relation\".""" + _label_embedding_match_two_graphs( + inputs, + outputs, + model_name=model_name, + threshold=threshold, + include_entities=False, + include_relations=True, + ) + + +def label_embedding_graph_match( + inputs: Dict[str, Data], + outputs: Dict[str, Data], + *, + model_name: str = "sentence-transformers/all-MiniLM-L6-v2", + threshold: float = 0.5, +) -> None: + """Backward-compatible alias for full graph alignment (entities + relations).""" + label_embedding_graph_alignment_match( + inputs, outputs, model_name=model_name, threshold=threshold + ) + diff --git a/experiments/param-opti/src/param_opti/tasks/corenlp.py b/experiments/param-opti/src/param_opti/tasks/corenlp.py new file mode 100644 index 0000000..e45299b --- /dev/null +++ b/experiments/param-opti/src/param_opti/tasks/corenlp.py @@ -0,0 +1,36 @@ +from typing import Dict + +from pathlib import Path +from kgpipe.common import Data, DataFormat, Registry, KgTask +from kgpipe.common.model.configuration import ConfigurationDefinition + + +def corenlp_text_extraction_function(inputs: Dict[str, Data], outputs: Dict[str, Data]): + from param_opti.tasks.corenlp_lip import corenlp_openie_extraction, corenlp_exchange + + # Ensure parent directory exists for the TE JSON output path + outputs["output"].path.parent.mkdir(parents=True, exist_ok=True) + + input_path: Path = inputs["input"].path + final_te_output: Data = outputs["output"] + + # 1) Produce intermediate OpenIE JSON (file or directory) + if input_path.is_dir(): + openie_out_path = final_te_output.path.parent / f"{final_te_output.path.stem}_corenlp_openie_out" + else: + openie_out_path = final_te_output.path.parent / f"{final_te_output.path.stem}_corenlp_openie.json" + + openie_output = {"output": Data(openie_out_path, DataFormat.OPENIE_JSON)} + corenlp_openie_extraction({"input": inputs["input"]}, openie_output) + + # 2) Convert OpenIE JSON → TE JSON (final output) + corenlp_exchange({"input": openie_output["output"]}, {"output": final_te_output}) + + +corenlp_text_extraction_task = KgTask( + name="corenlp_text_extraction", + input_spec={"input": DataFormat.TEXT}, + output_spec={"output": DataFormat.TE_JSON}, + function=corenlp_text_extraction_function, + description="Extract text using CoreNLP" +) \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/tasks/corenlp_lip.py b/experiments/param-opti/src/param_opti/tasks/corenlp_lip.py new file mode 100644 index 0000000..4ef7559 --- /dev/null +++ b/experiments/param-opti/src/param_opti/tasks/corenlp_lip.py @@ -0,0 +1,148 @@ +from kgpipe.common import TaskInput, TaskOutput, Data, DataFormat, Registry, BasicTaskCategoryCatalog +from kgpipe.common.model.configuration import ConfigurationDefinition, Parameter, ParameterType, ConfigurationProfile +from kgpipe.common.model.task import KgTask + +def openie_pipeline_task_function(inputs: TaskInput, outputs: TaskOutput, config: ConfigurationProfile): + """ + Run the openie pipeline + """ + pass + +@Registry.task( + input_spec={"input": DataFormat.TEXT}, + output_spec={"output": DataFormat.OPENIE_JSON}, + description="Extract OpenIE triples using Stanford CoreNLP", + category=["TextProcessing", "TextExtraction"] +) +def openie_pipeline_task(inputs: TaskInput, outputs: TaskOutput, config: ConfigurationProfile): + """ + Run the openie pipeline + """ + pass + +""" +Stanford CoreNLP Information Extraction + +This module provides information extraction using Stanford CoreNLP. +""" + +import json +import os +from pathlib import Path +from typing import Dict, Any, List + +from kgpipe.common import KgTask, Data, DataFormat, Registry +from kgpipe.common.io import get_docker_volume_bindings, remap_data_path_for_container +from kgpipe.execution import docker_client + + +CORENLP_ENTRYPOINT = ["java", "-cp", "*", "edu.stanford.nlp.pipeline.StanfordCoreNLP"] + + +def corenlp_openie_extraction(inputs: Dict[str, Data], outputs: Dict[str, Data]): + """Extract OpenIE triples using Stanford CoreNLP.""" + # input_data = inputs["input"] + # output_data = outputs["output"] + + # Setup Docker + all_data = list(inputs.values()) + list(outputs.values()) + volumes, host_to_container = get_docker_volume_bindings(all_data) + + print(inputs["input"]) + print(outputs["output"]) + # Remap paths for container + input_path = remap_data_path_for_container(inputs["input"], host_to_container) + output_path = remap_data_path_for_container(outputs["output"], host_to_container) + + # Create command + command = ["bash", "openie.sh", str(input_path.path), str(output_path.path)] + # CORENLP_ENTRYPOINT + [ + # "-annotators", "tokenize,pos,lemma,ner,parse,coref,openie", + # "-file", str(input_path.path), + # "-outputFormat", "json", + # "-outputDirectory", str(output_path.path) + # ] + + # Run container + client = docker_client( + image="kgt/corenlp:latest", + command=command, + volumes=volumes + ) + client() + + +def corenlp_exchange(inputs: Dict[str, Data], outputs: Dict[str, Data]): + """Convert OpenIE JSON to IE JSON format.""" + input_path = inputs["input"].path + output_path = outputs["output"].path + + # create output folder + os.makedirs(os.path.dirname(output_path), exist_ok=True) + + def __openiejson2tejson(openiedata) -> Dict[str, Any]: + """Convert OpenIE JSON to TE Document format.""" + doc = {"triples": [], "chains": []} + + # Convert to triples + triplets = [] + for sentence in openiedata.get('sentences', []): + for triple_span in sentence.get('openie', []): + triplet = { + "subject": {"surface_form": triple_span.get('subject', '')}, + "predicate": {"surface_form": triple_span.get('relation', '')}, + "object": {"surface_form": triple_span.get('object', '')} + } + triplets.append(triplet) + + # Get chains (simplified) + chains = get_coreference_chains(openiedata) + + doc["triples"] = triplets + doc["chains"] = chains + return doc + + if os.path.isdir(input_path): + os.makedirs(output_path, exist_ok=True) + for file in os.listdir(input_path): + # Read input json + with open(os.path.join(input_path, file), 'r') as f: + data = json.load(f) + te_doc = __openiejson2tejson(data) + outfile = os.path.join(output_path, file) + + with open(outfile, 'w') as of: + json.dump(te_doc, of) + # print(f"Converted {input_path} to {outfile}") + + else: + # Read input json + with open(input_path, 'r') as f: + data = json.load(f) + te_doc = __openiejson2tejson(data) + with open(output_path, 'w') as of: + json.dump(te_doc, of) + # print(f"Converted {input_path} to {output_path}") + + +def get_coreference_chains(response: dict) -> List[Dict[str, Any]]: + """Extract coreference chains from CoreNLP response.""" + result = [] + for _, coref in response.get('corefs', {}).items(): + if len(coref) > 1: + chain = {"main": coref[0].get('text', '')} + alias = [] + for chunk in coref[1:]: + sentence = response.get('sentences', [])[chunk.get('sentNum', 1) - 1] + start = sentence.get('tokens', [])[chunk.get('startIndex', 1) - 1].get('characterOffsetBegin', 0) + end = sentence.get('tokens', [])[chunk.get('endIndex', 2) - 2].get('characterOffsetEnd', 0) + alias.append({ + "surface_form": chunk.get('text', ''), + "text": chunk.get('text', ''), + "start": start, + "end": end + }) + chain["aliases"] = alias + result.append(chain) + return result + diff --git a/experiments/param-opti/src/param_opti/tasks/fusion.py b/experiments/param-opti/src/param_opti/tasks/fusion.py index ee73e29..0c6eec3 100644 --- a/experiments/param-opti/src/param_opti/tasks/fusion.py +++ b/experiments/param-opti/src/param_opti/tasks/fusion.py @@ -1,15 +1,39 @@ +import os + from kgpipe.common.model.configuration import ConfigurationProfile +from kgpipe.common.model.configuration import ConfigurationDefinition, Parameter, ParameterType from kgpipe.common.models import TaskInput, TaskOutput, KgTask, DataFormat -def fusion_first_value_function(inputs: TaskInput, outputs: TaskOutput): - # touch output file - outputs["output"].path.touch() +def fusion_first_value_function( + inputs: TaskInput, outputs: TaskOutput, config: ConfigurationProfile | None = None +): + from param_opti.tasks.fusion_lib import fusion_first_value + + if config is not None: + ontology_path = config.get_parameter_value("ontology_path") + else: + ontology_path = os.environ.get("ONTOLOGY_PATH", "") + # TODO remove thresholds as they are applied by the matchers + fusion_first_value( + inputs, + outputs, + entity_matching_threshold=0.0, + relation_matching_threshold=0.0, + ontology_path=ontology_path, + ) fusion_first_value_task = KgTask( - name="fusion_first_value", + name="fusion_first_value_task", function=fusion_first_value_function, - input_spec={"source": DataFormat.RDF_NTRIPLES, "target": DataFormat.RDF_NTRIPLES, "matches": DataFormat.ER_JSON}, - output_spec={"output": DataFormat.RDF_NTRIPLES}, + input_spec={"source": DataFormat.RDF_NTRIPLES, "kg": DataFormat.RDF_NTRIPLES, "matches1": DataFormat.ER_JSON}, + output_spec={"output": DataFormat.RDF_NTRIPLES} + # config_spec=ConfigurationDefinition( + # name="fusion_first_value", + # parameters=[ + # # ontology path + # Parameter(name="ontology_path", native_keys=["--ontology-path"], datatype=ParameterType.string, default_value="", required=True), + # ] + # ) ) def fusion_union_function(inputs: TaskInput, outputs: TaskOutput): @@ -17,7 +41,7 @@ def fusion_union_function(inputs: TaskInput, outputs: TaskOutput): outputs["output"].path.touch() fusion_union_task = KgTask( - name="fusion_union", + name="fusion_union_task", function=fusion_union_function, input_spec={"source": DataFormat.RDF_NTRIPLES, "target": DataFormat.RDF_NTRIPLES, "matches": DataFormat.ER_JSON}, output_spec={"output": DataFormat.RDF_NTRIPLES}, diff --git a/experiments/param-opti/src/param_opti/tasks/fusion_lib.py b/experiments/param-opti/src/param_opti/tasks/fusion_lib.py new file mode 100644 index 0000000..0581ad7 --- /dev/null +++ b/experiments/param-opti/src/param_opti/tasks/fusion_lib.py @@ -0,0 +1,205 @@ + +from kgpipe.common.models import KgTask, DataFormat, Data +from logging import getLogger + +from pydantic import BaseModel +from rdflib import OWL, Graph, URIRef, RDFS, RDF, SKOS +from pathlib import Path +import json +import os +from kgcore.api.ontology import OntologyUtil +from kgpipe.common.config import TARGET_ONTOLOGY_NAMESPACE +from typing import Dict, List +from kgpipe_tasks.entity_resolution.fusion.util import load_matches_from_file + +SINGLE_CANDIDATE_CHECK: bool=False + +logger = getLogger(__name__) + +class TrackRecord(BaseModel): + original_subject: str + subject: str + original_predicate: str + predicate: str + original_object: str + object: str + +def select_first_value(inputs: Dict[str, Data], outputs: Dict[str, Data]): + """ + For two KGs A and B, merge A into B where for each s_p and + 1) p is fusable and B does not have any s_p_o or + 2) p is not fusable erge all s_p_o + """ + ontology_path = os.environ.get("ONTOLOGY_PATH", "false") + if ontology_path == "false": + raise ValueError("ONTOLOGY_PATH is not set") + + ontology = OntologyUtil.load_ontology_from_file(Path(ontology_path)) + allowed_predicates = set[str]([str(p.uri) for p in ontology.properties]+[str(RDFS.label), str(RDF.type), str(SKOS.altLabel)]) + fusable_properties = set[str]([str(p.uri) for p in ontology.properties if p.max_cardinality == 1]+[str(RDFS.label), str(RDF.type)]) + + def is_fusable(p): + return str(p) in fusable_properties + + source_graph = Graph() + source_graph.parse(inputs["source"].path, format="nt") + seed_graph = Graph() # seed graph + seed_graph.parse(inputs["target"].path, format="nt") + + current_subjects = set[str]([str(s) for s in seed_graph.subjects(unique=True)]) + + selected: List[TrackRecord] = [] + discarded: List[TrackRecord] = [] + + for s, p, o in source_graph: + s_can = s + p_can = p + o_can = o + + if not isinstance(p_can, URIRef) or str(p_can) not in allowed_predicates: + continue + + if p_can == RDF.type and not str(o_can).startswith(TARGET_ONTOLOGY_NAMESPACE): + continue + + if is_fusable(p_can): + # Add exactly one value if none exists yet + if not any(seed_graph.objects(s_can, p_can)): + seed_graph.add((s_can, p_can, o_can)) + selected.append( + TrackRecord(subject=s_can,predicate=p_can,object=o,original_subject=s,original_predicate=p,original_object=o)) + # keep subjects set fresh for subsequent matches + if isinstance(s_can, URIRef): + current_subjects.add(str(s_can)) + else: + discarded.append( + TrackRecord(subject=s_can,predicate=p_can,object=o,original_subject=s,original_predicate=p,original_object=o)) + else: + # Non-fusable: copy if not already present (avoid dupes) + if (s_can, p_can, o_can) not in seed_graph: + seed_graph.add((s_can, p_can, o_can)) + if isinstance(s_can, URIRef): + current_subjects.add(str(s_can)) + + # sel(ected) + selected_file_path = outputs["output"].path.parent / (outputs["output"].path.stem + ".selected.json") + with open(selected_file_path, "w") as f: + json.dump(selected, f, default=lambda x: x.model_dump()) + # dis(carded) + discarded_file_path = outputs["output"].path.parent / (outputs["output"].path.stem + ".discarded.json") + with open(discarded_file_path, "w") as f: + json.dump(discarded, f, default=lambda x: x.model_dump()) + + # prov graph is skipped here as no uris are replaced (is done in previouse steps) + seed_graph.serialize(outputs["output"].path, format="nt") + +def fusion_first_value(inputs: Dict[str, Data], outputs: Dict[str, Data], entity_matching_threshold: float, relation_matching_threshold: float, ontology_path: str): + """ + Fuse RDF entities + - replacing ids of target graph with ids of source graph based on matches + - only fusable properties are fused + - selects the first value from source graph if no target value exists (does not add values from target graph) + - also if target graph has multiple values for a property, the first value is selected (for new entities) + """ + ontology = OntologyUtil.load_ontology_from_file(Path(ontology_path)) + allowed_predicates = set[str]([str(p.uri) for p in ontology.properties]+[str(RDFS.label), str(RDF.type), str(SKOS.altLabel)]) + fusable_properties = set[str]([str(p.uri) for p in ontology.properties if p.max_cardinality == 1]+[str(RDFS.label), str(RDF.type)]) + + def is_fusable(p): + return str(p) in fusable_properties + + entity_matches = load_matches_from_file(inputs["matches1"].path, entity_matching_threshold, "entity") + relation_matches = load_matches_from_file(inputs["matches1"].path, relation_matching_threshold, "relation") + + source_graph = Graph() + source_graph.parse(inputs["source"].path, format="nt") + seed_graph = Graph() # seed graph + seed_graph.parse(inputs["kg"].path, format="nt") + + current_subjects = set[str]([str(s) for s in seed_graph.subjects(unique=True)]) + + sameAsProv = {} + + def canonicalize_entity_term(term): + """Map a URI from the target graph to the matching source URI, if any.""" + if isinstance(term, URIRef): + t_str = str(term) + cluster = entity_matches.get_cluster(t_str) + if cluster: + right_candidates = [c for c in cluster if not c == t_str] + if len(right_candidates) > 2 and SINGLE_CANDIDATE_CHECK: + raise ValueError(f"Multiple matches found for {t_str}") + else: + for m in right_candidates: + # if not m == t_str: + sameAsProv[str(term)] = str(m) + return URIRef(m) + return term + else: + return term + return term + + def canonicalize_property_term(term): + """Map a URI from the target graph to the matching source URI, if any.""" + if isinstance(term, URIRef): + t_str = str(term) + mapped = relation_matches.has_match_to_namespace(t_str, TARGET_ONTOLOGY_NAMESPACE) + if mapped: + return URIRef(mapped) + else: # TODO this is a workaround for the base pipelines... + mapped = relation_matches.has_match_to_namespace(t_str, str(RDFS)) + if mapped: + return URIRef(mapped) + return term + + selected: List[TrackRecord] = [] + discarded: List[TrackRecord] = [] + + for s, p, o in source_graph: + # Canonicalize + logger.debug(f"Canonicalizing {s}, {p}, {o}") + s_can = canonicalize_entity_term(s) + p_can = canonicalize_property_term(p) + o_can = canonicalize_entity_term(o) if isinstance(o, URIRef) else o # keep literals/bnodes as-is + + # Only work with properties that are in our ontology (after canonicalization) + if not isinstance(p_can, URIRef) or str(p_can) not in allowed_predicates: + logger.debug(f"Skipping {s}, {p}, {o} because it is not in the allowed predicates") + continue + + if p_can == RDF.type and not str(o_can).startswith(TARGET_ONTOLOGY_NAMESPACE): + continue + + if is_fusable(p_can): + # Add exactly one value if none exists yet + if not any(seed_graph.objects(s_can, p_can)): + seed_graph.add((s_can, p_can, o_can)) + selected.append( + TrackRecord(subject=s_can,predicate=p_can,object=o,original_subject=s,original_predicate=p,original_object=o)) + # keep subjects set fresh for subsequent matches + if isinstance(s_can, URIRef): + current_subjects.add(str(s_can)) + else: + discarded.append( + TrackRecord(subject=s_can,predicate=p_can,object=o,original_subject=s,original_predicate=p,original_object=o)) + else: + # Non-fusable: copy if not already present (avoid dupes) + if (s_can, p_can, o_can) not in seed_graph: + seed_graph.add((s_can, p_can, o_can)) + if isinstance(s_can, URIRef): + current_subjects.add(str(s_can)) + + # sel(ected) + selected_file_path = outputs["output"].path.parent / (outputs["output"].path.stem + ".selected.json") + with open(selected_file_path, "w") as f: + json.dump(selected, f, default=lambda x: x.model_dump()) + # dis(carded) + discarded_file_path = outputs["output"].path.parent / (outputs["output"].path.stem + ".discarded.json") + with open(discarded_file_path, "w") as f: + json.dump(discarded, f, default=lambda x: x.model_dump()) + + prov_graph = Graph() + for sid,gid in sameAsProv.items(): + prov_graph.add((URIRef(gid), OWL.sameAs, URIRef(sid))) + prov_graph.serialize(outputs["output"].path.as_posix() + ".prov", format="nt") + seed_graph.serialize(outputs["output"].path, format="nt") \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/tasks/genie.py b/experiments/param-opti/src/param_opti/tasks/genie.py new file mode 100644 index 0000000..1abf7c6 --- /dev/null +++ b/experiments/param-opti/src/param_opti/tasks/genie.py @@ -0,0 +1,14 @@ +from typing import Dict, Any +from kgpipe.common import Data, DataFormat, Registry, KgTask + + +def genie_text_extraction_function(inputs: Dict[str, Data], outputs: Dict[str, Data]): + pass + +genie_text_extraction_task = KgTask( + name="genie_text_extraction", + input_spec={"input": DataFormat.TEXT}, + output_spec={"output": DataFormat.TE_JSON}, + function=genie_text_extraction_function, + description="Extract text using Genie" +) \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/tasks/genie_lib.py b/experiments/param-opti/src/param_opti/tasks/genie_lib.py new file mode 100644 index 0000000..e69de29 diff --git a/experiments/param-opti/src/param_opti/tasks/jedai.py b/experiments/param-opti/src/param_opti/tasks/jedai.py index e69de29..062fe48 100644 --- a/experiments/param-opti/src/param_opti/tasks/jedai.py +++ b/experiments/param-opti/src/param_opti/tasks/jedai.py @@ -0,0 +1 @@ +# Skipped for now \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/tasks/matching_helpers.py b/experiments/param-opti/src/param_opti/tasks/matching_helpers.py new file mode 100644 index 0000000..fd301a0 --- /dev/null +++ b/experiments/param-opti/src/param_opti/tasks/matching_helpers.py @@ -0,0 +1,30 @@ +from pathlib import Path + +from kgpipe.common import Data, DataFormat, KgTask +from typing import Dict +from kgpipe_tasks.transform_interop.exchange.entity_matching import ER_Document +import json + + +def _load_er_document(path: Path) -> ER_Document: + """Parse ER JSON; empty or whitespace-only files yield an empty document (stub tasks may touch-only outputs).""" + raw = path.read_text(encoding="utf-8") + if not raw.strip(): + return ER_Document() + return ER_Document(**json.loads(raw)) + + +def aggregate_matching_results_function(inputs: Dict[str, Data], outputs: Dict[str, Data]): + er1 = _load_er_document(Path(inputs["json1"].path)) + er2 = _load_er_document(Path(inputs["json2"].path)) + er_comb = ER_Document(matches=er1.matches + er2.matches) + with open(outputs["output"].path, "w") as f: + json.dump(er_comb.model_dump(), f, indent=4) + + +aggregate_matching_results_task = KgTask( + name="aggregate_matching_results", + input_spec=dict({"json1": DataFormat.ER_JSON, "json2": DataFormat.ER_JSON}), + output_spec=dict({"output": DataFormat.ER_JSON}), + function=aggregate_matching_results_function +) \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/tasks/paris.py b/experiments/param-opti/src/param_opti/tasks/paris.py index 21dbce9..4fb5772 100644 --- a/experiments/param-opti/src/param_opti/tasks/paris.py +++ b/experiments/param-opti/src/param_opti/tasks/paris.py @@ -1,5 +1,6 @@ -from kgpipe.common import TaskInput, TaskOutput, KgTask, DataFormat +from kgpipe.common import TaskInput, TaskOutput, KgTask, DataFormat, Data from kgpipe.common.model.configuration import ConfigurationProfile, ConfigurationDefinition, Parameter, ParameterType +from pathlib import Path @@ -29,7 +30,27 @@ def paris_graph_alignment_function(inputs: TaskInput, outputs: TaskOutput, confi matches both entities and relations between two RDF graphs """ # touch output file - outputs["output"].path.touch() + from param_opti.tasks.paris_lib import paris_exchange, paris_entity_matching + entity_matching_threshold = float(config.get_parameter_value("entity_matching_threshold")) + relation_matching_threshold = float(config.get_parameter_value("relation_matching_threshold")) + + # Ensure parent directory exists for the ER JSON output file + outputs["output"].path.parent.mkdir(parents=True, exist_ok=True) + + # 1 produce matches in paris csv format + matching_dir = outputs["output"].path.parent / f"{outputs['output'].path.stem}_paris_out" + matching_output = {"output": Data(matching_dir, DataFormat.PARIS_CSV)} + + # paris_entity_matching expects {"source": ..., "kg": ...} + paris_entity_matching({"source": inputs["source"], "kg": inputs["target"]}, matching_output) + + # 2 convert paris output dir to er.json format (file) + paris_exchange( + matching_output["output"].path, + outputs["output"].path, + entity_matching_threshold, + relation_matching_threshold, + ) paris_graph_alignment_task = KgTask( name="paris_graph_alignment", @@ -43,4 +64,24 @@ def paris_graph_alignment_function(inputs: TaskInput, outputs: TaskOutput, confi Parameter(name="relation_matching_threshold", native_keys=["--relation-matching-threshold"], datatype=ParameterType.number, default_value=0.5, required=True, allowed_values=[0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]), ] ) +) + +def paris_ontology_matching_function(inputs: TaskInput, outputs: TaskOutput, config: ConfigurationProfile): + """ + matches ontologies between two RDF graphs + """ + # touch output file + outputs["output"].path.touch() + +paris_ontology_matching_task = KgTask( + name="paris_ontology_matching", + function=paris_ontology_matching_function, + input_spec={"source": DataFormat.RDF_NTRIPLES, "target": DataFormat.RDF_NTRIPLES}, + output_spec={"output": DataFormat.ER_JSON}, + config_spec=ConfigurationDefinition( + name="paris_ontology_matching", + parameters=[ + Parameter(name="ontology_matching_threshold", native_keys=["--ontology-matching-threshold"], datatype=ParameterType.number, default_value=0.5, required=True, allowed_values=[0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]), + ] + ) ) \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/tasks/paris_lib.py b/experiments/param-opti/src/param_opti/tasks/paris_lib.py new file mode 100644 index 0000000..2aa5fd5 --- /dev/null +++ b/experiments/param-opti/src/param_opti/tasks/paris_lib.py @@ -0,0 +1,152 @@ +""" +Paris RDF Matcher task implementation. +""" + +from pathlib import Path +from typing import Dict, Any +import pandas as pd +import os +import csv +from typing import List + +from kgpipe.common import KgTask, DataFormat, Data, Registry +from kgpipe.common.io import get_docker_volume_bindings, remap_data_path_for_container +from kgpipe.execution import docker_client +from kgpipe_tasks.transform_interop.exchange.entity_matching import ER_Match, ER_Document + + +def paris_entity_matching(inputs: Dict[str, Data], outputs: Dict[str, Data]): + """ + Paris entity matching task that runs in a Docker container. + + Args: + inputs: Dictionary mapping input names to Data objects + outputs: Dictionary mapping output names to Data objects + """ + # print(f"Running Paris entity matching with inputs: {inputs}") + + all_data = list(inputs.values()) + list(outputs.values()) + volumes, host_to_container = get_docker_volume_bindings(all_data) + + # Extract input paths + source_path = remap_data_path_for_container(inputs["source"], host_to_container) + target_path = remap_data_path_for_container(inputs["kg"], host_to_container) + output_path = remap_data_path_for_container(outputs["output"], host_to_container) + + # Ensure output directory exists + outputs["output"].path.parent.mkdir(parents=True, exist_ok=True) + + # Get all data for Docker volume bindings + + # Create Docker client with proper volume bindings + client = docker_client( + image="kgt/paris:latest", + # command=["ls", "-la"], + command=["bash", "paris.sh", + str(source_path.path), + str(target_path.path), + str(output_path.path)], + volumes=volumes, + ) + + # Execute the container + result = client() + print(f"Paris entity matching completed: {result}") + + +PREFIX_MAP = { + "dbp": "http://dbpedia.org/", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "rdf" : "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "xsd" : "http://www.w3.org/2001/XMLSchema#", + "schema" : "http://schema.org/", + "dbo": "http://dbpedia.org/ontology/", + "foaf": "http://xmlns.com/foaf/0.1/", + "skos": "http://www.w3.org/2004/02/skos/core#", +} + +def resolvePrefixedUri(uri): + if not uri.startswith("http://") and not uri.startswith("https://"): + prefix, suffix = uri.split(":", 1) + # try: + prefix = PREFIX_MAP[prefix] + # except Exception as e: + # print(f"Unknown prefix: {prefix} for {uri}") + # raise Exception(f"Unknown prefix: {prefix} for {uri}") + return prefix + suffix + else: + return uri + + + +def paris_exchange(input_path: Path, output_path: Path, entity_matching_threshold: float, relation_matching_threshold: float): + """ + Convert Paris CSV output to standard RDF matching format. + + Args: + inputs: Dictionary mapping input names to Data objects (Paris CSV) + outputs: Dictionary mapping output names to Data objects (RDF) + """ + print(f"Converting Paris CSV to matching format with input_path: {input_path} and output_path: {output_path}") + + files = [str(f) for f in os.listdir(input_path)] + + iteration_ids = [ int(f.split("_")[0]) for f in files if f.endswith(".tsv") ] + + iteration_ids.sort() + + last_eqv_it = iteration_ids[-1] + + def getEqvFileName(id): return f"{id}_eqv.tsv" + def getRelFileNames(id): return [f"{id}_superrelations1.tsv",f"{id}_superrelations2.tsv"] + + def check_file_exists(last_eqv_it): + try: + return os.stat(os.path.join(input_path, getEqvFileName(last_eqv_it))).st_size > 0 + except FileNotFoundError: + return -1 + + while 0 == check_file_exists(last_eqv_it) : + last_eqv_it -= 1 + + last_relation_it = last_eqv_it - 1 + + matches : List[ER_Match] = [] + + def extract_matches(file,id_type): + with open(file, newline='', encoding='utf-8') as csvfile: + reader = csv.reader(csvfile, delimiter='\t') + for row in reader: + if len(row) == 3: + er_match = ER_Match( + id_1=resolvePrefixedUri(row[0]), + id_2=resolvePrefixedUri(row[1]), + score=float(row[2]), + id_type=id_type + ) + matches.append(er_match) + + def filter_matches(matches: List[ER_Match]): + + for match in matches: + if match.id_type == "entity" and match.score > entity_matching_threshold: + yield match + if match.id_type == "relation" and match.score > relation_matching_threshold: + yield match + + if last_eqv_it == -1: + doc = ER_Document(matches=list(filter_matches([]))) + with open(output_path, 'w', encoding='utf-8') as jsonfile: + jsonfile.write(doc.model_dump_json()) + else: + eqv_file = getEqvFileName(last_eqv_it) + rel_files = getRelFileNames(last_relation_it) + + extract_matches(os.path.join(input_path,eqv_file),"entity") + [ extract_matches(os.path.join(input_path,f), "relation") for f in rel_files ] + + + doc = ER_Document(matches=list(filter_matches(matches))) + + with open(output_path, 'w', encoding='utf-8') as jsonfile: + jsonfile.write(doc.model_dump_json()) \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/tasks/select_lib.py b/experiments/param-opti/src/param_opti/tasks/select_lib.py new file mode 100644 index 0000000..7dd390f --- /dev/null +++ b/experiments/param-opti/src/param_opti/tasks/select_lib.py @@ -0,0 +1,119 @@ +import json +import os +from logging import getLogger +from pathlib import Path +from typing import Dict, List + +from kgcore.api.ontology import OntologyUtil +from kgpipe.common.config import TARGET_ONTOLOGY_NAMESPACE +from kgpipe.common.model.configuration import ConfigurationDefinition +from kgpipe.common.models import Data, DataFormat, KgTask +from pydantic import BaseModel +from rdflib import Graph, RDF, RDFS, SKOS, URIRef + +logger = getLogger(__name__) + +class TrackRecord(BaseModel): + original_subject: str + subject: str + original_predicate: str + predicate: str + original_object: str + object: str + + +def select_first_value_function(inputs: Dict[str, Data], outputs: Dict[str, Data]): + """ + For two KGs A and B, merge A into B where for each s_p and + 1) p is fusable and B does not have any s_p_o or + 2) p is not fusable erge all s_p_o + """ + ontology_path = os.environ.get("ONTOLOGY_PATH", "false") + if ontology_path == "false": + raise ValueError("ONTOLOGY_PATH is not set") + + ontology = OntologyUtil.load_ontology_from_file(Path(ontology_path)) + allowed_predicates = set[str]([str(p.uri) for p in ontology.properties]+[str(RDFS.label), str(RDF.type), str(SKOS.altLabel)]) + fusable_properties = set[str]([str(p.uri) for p in ontology.properties if p.max_cardinality == 1]+[str(RDFS.label), str(RDF.type)]) + + def is_fusable(p): + return str(p) in fusable_properties + + source_graph = Graph() + source_graph.parse(inputs["source"].path, format="nt") + seed_graph = Graph() # seed graph + seed_graph.parse(inputs["target"].path, format="nt") + + current_subjects = set[str]([str(s) for s in seed_graph.subjects(unique=True)]) + + selected: List[TrackRecord] = [] + discarded: List[TrackRecord] = [] + + for s, p, o in source_graph: + s_can = s + p_can = p + o_can = o + + if not isinstance(p_can, URIRef) or str(p_can) not in allowed_predicates: + continue + + if p_can == RDF.type and not str(o_can).startswith(TARGET_ONTOLOGY_NAMESPACE): + continue + + if is_fusable(p_can): + # Add exactly one value if none exists yet + if not any(seed_graph.objects(s_can, p_can)): + seed_graph.add((s_can, p_can, o_can)) + selected.append( + TrackRecord( + subject=str(s_can), + predicate=str(p_can), + object=str(o_can), + original_subject=str(s), + original_predicate=str(p), + original_object=str(o), + ) + ) + # keep subjects set fresh for subsequent matches + if isinstance(s_can, URIRef): + current_subjects.add(str(s_can)) + else: + discarded.append( + TrackRecord( + subject=str(s_can), + predicate=str(p_can), + object=str(o_can), + original_subject=str(s), + original_predicate=str(p), + original_object=str(o), + ) + ) + else: + # Non-fusable: copy if not already present (avoid dupes) + if (s_can, p_can, o_can) not in seed_graph: + seed_graph.add((s_can, p_can, o_can)) + if isinstance(s_can, URIRef): + current_subjects.add(str(s_can)) + + # sel(ected) + selected_file_path = outputs["output"].path.parent / (outputs["output"].path.stem + ".selected.json") + with open(selected_file_path, "w") as f: + json.dump(selected, f, default=lambda x: x.model_dump()) + # dis(carded) + discarded_file_path = outputs["output"].path.parent / (outputs["output"].path.stem + ".discarded.json") + with open(discarded_file_path, "w") as f: + json.dump(discarded, f, default=lambda x: x.model_dump()) + + # prov graph is skipped here as no uris are replaced (is done in previouse steps) + seed_graph.serialize(outputs["output"].path, format="nt") + +select_first_value_task = KgTask( + name="select_first_value", + input_spec={"source": DataFormat.RDF_NTRIPLES, "target": DataFormat.RDF_NTRIPLES}, + output_spec={"output": DataFormat.RDF_NTRIPLES}, + function=select_first_value_function, + config_spec=ConfigurationDefinition( + name="select_first_value", + parameters=[] + ) +) \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/tasks/spotlight.py b/experiments/param-opti/src/param_opti/tasks/spotlight.py index e69de29..213cef4 100644 --- a/experiments/param-opti/src/param_opti/tasks/spotlight.py +++ b/experiments/param-opti/src/param_opti/tasks/spotlight.py @@ -0,0 +1,23 @@ +from typing import Dict, Any +from kgpipe.common import Data, DataFormat, Registry, KgTask +from kgpipe.common.model.configuration import ConfigurationDefinition, Parameter, ParameterType + +def spotlight_entity_linking_function(inputs: Dict[str, Data], outputs: Dict[str, Data]): + from param_opti.tasks.spotlight_lib import dbpedia_spotlight_ner_nel, dbpedia_spotlight_exchange_filtered + + dbpedia_spotlight_ner_nel({"input": inputs["input"]}, {"output": outputs["output"]}) + dbpedia_spotlight_exchange_filtered({"source": outputs["output"]}, {"output": outputs["output"]}) + +spotlight_entity_linking_task = KgTask( + name="spotlight_entity_linking", + input_spec={"input": DataFormat.TEXT}, + output_spec={"output": DataFormat.TE_JSON}, + function=spotlight_entity_linking_function, + description="Link entities using Spotlight", + config_spec=ConfigurationDefinition( + name="spotlight_entity_linking", + parameters=[ + Parameter(name="similarity_threshold", native_keys=["--similarity-threshold"], datatype=ParameterType.number, default_value=0.5, required=True, allowed_values=[0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]), + ] + ) +) \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/tasks/spotlight_lib.py b/experiments/param-opti/src/param_opti/tasks/spotlight_lib.py new file mode 100644 index 0000000..277de80 --- /dev/null +++ b/experiments/param-opti/src/param_opti/tasks/spotlight_lib.py @@ -0,0 +1,187 @@ +""" +DBpedia Spotlight Entity Linking + +This module provides entity linking using DBpedia Spotlight. +""" + +import json +import os +import requests +from pathlib import Path +from typing import Dict, Any + +from kgpipe.common import KgTask, Data, DataFormat, Registry +from kgpipe.common.io import get_docker_volume_bindings +from kgpipe.execution import docker_client +from tqdm import tqdm + +import os + + +CONFIDENCE = 0.35 +HEADERS = { + "Accept": "application/json" +} + + +def api_request(url: str, text: str) -> Dict[str, Any]: + """Make API request to DBpedia Spotlight.""" + data = { + "text": text, + "confidence": str(CONFIDENCE) + } + response = requests.post(url, data=data, headers=HEADERS, verify=False) + + if response.status_code == 200: + result = response.json() + else: + result = { + "error": f"Request failed with status code {response.status_code}", + "text": text + } + return result + + +@Registry.task( + input_spec={"input": DataFormat.TEXT}, + output_spec={"output": DataFormat.SPOTLIGHT_JSON}, + description="Link entities using DBpedia Spotlight API", + category=["TextProcessing", "EntityLinking"] +) +def dbpedia_spotlight_ner_nel(inputs: Dict[str, Data], outputs: Dict[str, Data]): + """Link entities using DBpedia Spotlight API.""" + input_data = inputs["input"] + output_data = outputs["output"] + + DBPEDIA_ANNOTATE_URL = os.getenv("DBPEDIA_ANNOTATE_URL") + if not DBPEDIA_ANNOTATE_URL: + raise ValueError("Missing DBpedia ANnotate URL") + + dir_or_file = input_data.path + if os.path.isdir(dir_or_file): + os.makedirs(output_data.path, exist_ok=True) + for file in tqdm(os.listdir(dir_or_file)): + with open(os.path.join(dir_or_file, file), encoding='utf-8') as f: + input_text = f.read() + + results = api_request(DBPEDIA_ANNOTATE_URL, input_text) + + with open(os.path.join(output_data.path, file+".json"), 'w', encoding='utf-8') as f: + f.write(json.dumps(results)) + # print(f"Converted {file} to {os.path.join(output_data.path, file)}") + else: + with open(input_data.path, encoding='utf-8') as f: + input_text = f.read() + + results = api_request(DBPEDIA_ANNOTATE_URL, input_text) + + with open(output_data.path, 'w', encoding='utf-8') as f: + f.write(json.dumps(results)) + + +@Registry.task( + input_spec={"source": DataFormat.SPOTLIGHT_JSON}, + output_spec={"output": DataFormat.TE_JSON}, + description="Convert Spotlight JSON to TE JSON format", + category=["TextProcessing", "EntityLinking"] +) +def dbpedia_spotlight_exchange_filtered(inputs: Dict[str, Data], outputs: Dict[str, Data]): + """Convert Spotlight JSON to TE JSON format.""" + input_path = inputs["source"].path + output_path = outputs["output"].path + + + + # create output folder + os.makedirs(os.path.normpath(output_path), exist_ok=True) + + def __spotlightjson2tejson(data) -> Dict[str, Any]: + """Convert Spotlight JSON to TE Document format.""" + links = [] + + for result in data.get('Resources', []): + link = { + "span": result.get('@surfaceForm', ''), + "mapping": result.get('@URI', ''), + "score": float(result.get('@similarityScore', 0.0)), + "link_type": "entity" + } + links.append(link) + + text = data.get('@text', '') + return {"text": text, "links": links} + + if os.path.isdir(input_path): + for file in os.listdir(input_path): + # Read input json + with open(os.path.join(input_path, file), 'r') as f: + data = json.load(f) + te_doc = __spotlightjson2tejson(data) + outfile = os.path.join(output_path, file) + + with open(outfile, 'w') as of: + json.dump(te_doc, of) + # print(f"Converted {file} to {outfile}") + + else: + # Read input json + with open(input_path, 'r') as f: + data = json.load(f) + te_doc = __spotlightjson2tejson(data) + outfile = os.path.join(output_path, 'output.te.json') + with open(outfile, 'w') as of: + json.dump(te_doc, of) + # print(f"Converted {input_path} to {output_path}") + + +@Registry.task( + input_spec={"source": DataFormat.SPOTLIGHT_JSON}, + output_spec={"output": DataFormat.TE_JSON}, + description="Convert Spotlight JSON to TE JSON format, with seed filter", + category=["TextProcessing", "EntityLinking"] +) +def dbpedia_spotlight_exchange(inputs: Dict[str, Data], outputs: Dict[str, Data]): + """Convert Spotlight JSON to TE JSON format.""" + input_path = inputs["source"].path + output_path = outputs["output"].path + + # create output folder + os.makedirs(os.path.normpath(output_path), exist_ok=True) + + def __spotlightjson2tejson(data) -> Dict[str, Any]: + """Convert Spotlight JSON to TE Document format.""" + links = [] + + for result in data.get('Resources', []): + link = { + "span": result.get('@surfaceForm', ''), + "mapping": result.get('@URI', ''), + "score": float(result.get('@similarityScore', 0.0)), + "link_type": "entity" + } + links.append(link) + + text = data.get('@text', '') + return {"text": text, "links": links} + + if os.path.isdir(input_path): + for file in os.listdir(input_path): + # Read input json + with open(os.path.join(input_path, file), 'r') as f: + data = json.load(f) + te_doc = __spotlightjson2tejson(data) + outfile = os.path.join(output_path, file) + + with open(outfile, 'w') as of: + json.dump(te_doc, of) + # print(f"Converted {file} to {outfile}") + + else: + # Read input json + with open(input_path, 'r') as f: + data = json.load(f) + te_doc = __spotlightjson2tejson(data) + outfile = os.path.join(output_path, 'output.te.json') + with open(outfile, 'w') as of: + json.dump(te_doc, of) + \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/tasks/text_helpers.py b/experiments/param-opti/src/param_opti/tasks/text_helpers.py new file mode 100644 index 0000000..4f5d776 --- /dev/null +++ b/experiments/param-opti/src/param_opti/tasks/text_helpers.py @@ -0,0 +1,348 @@ +import json +import logging +import os +from pathlib import Path +from typing import Dict, List + +from kgcore.api.ontology import Ontology, OntologyUtil +from kgpipe.common import Data, DataFormat, KgTask +from kgpipe.common.model.configuration import ConfigurationDefinition +from kgpipe_tasks.common.benchutils import hash_uri +from kgpipe_tasks.transform_interop.exchange.text_extraction import ( + TE_Chains, + TE_Document, + TE_Pair, + TE_Triple, +) +from rdflib import Graph, Literal, RDF, RDFS, URIRef, XSD + +logger = logging.getLogger(__name__) + +def __aggregate_x_te_json(input_paths: List[Path], output_path: Path): + + if len(input_paths) == 0: + raise Exception("No input paths provided") + if not all(os.path.exists(path) for path in input_paths): + raise Exception("All input paths must exist") + + path_is_dir_list = [os.path.isdir(path) for path in input_paths] + if all(path_is_dir_list): + os.makedirs(output_path, exist_ok=True) + for file in os.listdir(input_paths[0]): + sub_file_paths = [Path(os.path.join(path, file)) for path in input_paths] + file_exists = [os.path.exists(path) for path in sub_file_paths] + if all(file_exists): + __aggregate_x_te_json(sub_file_paths, Path(os.path.join(output_path, file))) + else: + logger.warning(f"File {file} does not exist in all input paths") + filtered_sub_file_paths = [path for path in sub_file_paths if os.path.exists(path)] + __aggregate_x_te_json(filtered_sub_file_paths, Path(os.path.join(output_path, file))) + elif not all(path_is_dir_list): + merged_doc = TE_Document() + for file in input_paths: + doc = TE_Document(**json.load(open(file))) + merged_doc.chains += doc.chains + merged_doc.links += doc.links + merged_doc.triples += doc.triples + with open(output_path, "w") as f: + f.write(merged_doc.model_dump_json()) + logger.info(f"Aggregated {", ".join([str(path) for path in input_paths])} to {output_path}") + else: + raise Exception("All inputs must be either directories or files") + + +# @Registry.task( +# input_spec={"json1": DataFormat.TE_JSON, "json2": DataFormat.TE_JSON}, +# output_spec={"output": DataFormat.TE_JSON}, +# description="Aggregate 2 TE_Document JSON files", +# category=["Aggregation"] +# ) +# def aggregate2_te_json(inputs: Dict[str, Data], outputs: Dict[str, Data]): +# __aggregate_x_te_json([inputs["json1"].path, inputs["json2"].path], outputs["output"].path) + + +def aggregate3_text_tasks_task_function(inputs: Dict[str, Data], outputs: Dict[str, Data]): + __aggregate_x_te_json([inputs["json1"].path, inputs["json2"].path, inputs["json3"].path], outputs["output"].path) + +aggregate_text_tasks_task = KgTask( + name="aggregate3_text_tasks_task", + input_spec={"json1": DataFormat.TE_JSON, "json2": DataFormat.TE_JSON, "json3": DataFormat.TE_JSON}, + output_spec={"output": DataFormat.TE_JSON}, + function=aggregate3_text_tasks_task_function +) + + +def generatePredicate(surface_form, namespace): + return URIRef(namespace + surface_form.replace(" ", "_")) + +def __generateRDF(doc: TE_Document, ontology: Ontology, newP: bool = False, newE: bool = False, namespace: str = "http://kg.org/text/"): + """ + A processing node, part of a pipeline + collects information from extractors, linkers, and resolvers and then it produces the final triples + """ + + + def process_chains(triples, chains: List[TE_Chains]): + new_triples = triples + chain_dict = {} + for chain in chains: + for alias in chain.aliases: + chain_dict[alias.surface_form] = chain.main + if len(chain_dict) > 0: + # TODO check if chain_dict should be a dict of TE_SPANS to avoid merging + for triple in new_triples: + if triple.subject.surface_form in chain_dict: + triple.subject.surface_form = chain_dict[triple.subject.surface_form] + if triple.object.surface_form in chain_dict: + triple.object.surface_form = chain_dict[triple.object.surface_form] + return new_triples + + def process_links(triples, links: List[TE_Pair]): + new_triples = triples + try: + if len(links) > 0: + so_spans = {} + p_spans = {} + for triple in triples: + # Add Subject spans + if triple.subject.surface_form.lower().startswith("http://"): + pass + elif triple.subject.surface_form.lower() not in so_spans: + so_spans[triple.subject.surface_form.lower()] = [triple.subject] + else: + so_spans[triple.subject.surface_form.lower()].append(triple.subject) + # Add object spans + if triple.object.surface_form.lower().startswith("http://"): + pass + elif triple.object.surface_form.lower() not in so_spans: + so_spans[triple.object.surface_form.lower()] = [triple.object] + else: + so_spans[triple.object.surface_form.lower()].append(triple.object) + # add predicate spans + if triple.predicate.surface_form.lower().startswith("http://"): + pass + elif triple.predicate.surface_form.lower() not in p_spans: + p_spans[triple.predicate.surface_form.lower()] = [triple.predicate] + else: + p_spans[triple.predicate.surface_form.lower()].append(triple.predicate) + for link in links: + if link.link_type == 'entity': + spans = so_spans + else: + spans = p_spans + if link.span and link.span.lower() in spans: + for span in spans[link.span.lower()]: + span.mapping = link.mapping + # span.surface_form = link.mapping + except Exception as exp: + raise exp + finally: + return new_triples + + + triples: List[TE_Triple] = doc.triples + links: List[TE_Pair] = doc.links + chains: List[TE_Chains] = doc.chains + + dereferenced_tiples = process_chains(triples, chains) + linked_triples: List[TE_Triple] = process_links(dereferenced_tiples, links) + finalGraph = Graph() + + for triple in linked_triples: + subject = None + if triple.subject.mapping: + subject = URIRef(triple.subject.mapping) + # else: + # subject = triple.subject.surface_form + + predicate = None + if triple.predicate.mapping: + predicate = URIRef(triple.predicate.mapping) + elif newP: + predicate = generatePredicate(triple.predicate.surface_form, namespace) + + object = None + # TODO if predicate is a datatype or object property + if triple.object.mapping: + object = URIRef(triple.object.mapping) + # else: + # object = Literal(triple.object.surface_form) + # if(subject and predicate and object): + # finalGraph.add((subject, predicate, object)) + + # new entities + if(predicate): + # print(f"new subject: {subject} {triple.subject.surface_form}") + + domain, range = ontology.get_domain_range(str(predicate)) + isObjectProperty = True if range and range.startswith("http://kg.org") else False + # print(f"predicate: {predicate}, domain: {domain}, range: {range}, isObjectProperty: {isObjectProperty}") + # print(f"predicate: {predicate}, domain: {domain}, range: {range}") + + if subject and subject.startswith("http://dbpedia.org"): # TODO workaround for dbpedia... + finalGraph.add((subject, RDFS.label, Literal(triple.subject.surface_form))) + + + if not subject and triple.subject.surface_form and newE: + subject = URIRef(namespace+hash_uri(triple.subject.surface_form)) + finalGraph.add((subject, RDFS.label, Literal(triple.subject.surface_form))) + print(f"new subject: {subject} {triple.subject.surface_form}") + else: + print(f"subject: {subject} {triple.subject.surface_form}") + + if domain and subject: + finalGraph.add((subject, RDF.type, URIRef(domain))) + + if not object and triple.object.surface_form and newE: + if isObjectProperty: + object = URIRef(namespace+hash_uri(triple.object.surface_form)) + finalGraph.add((object, RDFS.label, Literal(triple.object.surface_form))) + if range: + finalGraph.add((object, RDF.type, URIRef(range))) + else: + datatype = range if range else str(XSD.string) + object = Literal(triple.object.surface_form, datatype=datatype) + else: + if not isObjectProperty: + datatype = range if range else str(XSD.string) + object = Literal(triple.object.surface_form, datatype=datatype) + + if(subject and predicate and object): + finalGraph.add((subject, predicate, object)) + + return finalGraph + + +def generate_rdf(inputs: Dict[str, Data], outputs: Dict[str, Data], ontology: Ontology, newP: bool, newE: bool): + dir_or_file = inputs["source"].path + graph = Graph() + if os.path.isdir(dir_or_file): + for file in os.listdir(dir_or_file): + json_data = json.load(open(os.path.join(dir_or_file, file))) + doc = TE_Document(**json_data) + for s, p, o in __generateRDF(doc, ontology, newP=newP, newE=newE): + graph.add(triple=(s, p, o)) + else: + doc = TE_Document(**json.load(open(dir_or_file))) + graph = __generateRDF(doc, ontology, newP=newP, newE=newE) + + graph.serialize(outputs["output"].path, format="nt") + print(f"RDF written to {outputs['output'].path}") + + +def generate_rdf_from_text_results_function(inputs: Dict[str, Data], outputs: Dict[str, Data]): + + ontology_path = os.environ.get("ONTOLOGY_PATH", "false") + if ontology_path == "false": + raise ValueError("ONTOLOGY_PATH is not set") + + ontology = OntologyUtil.load_ontology_from_file(Path(ontology_path)) + + generate_rdf(inputs, outputs, ontology, newP=False, newE=True) + + +generate_rdf_from_text_results_task = KgTask( + name="construct_rdf_from_text_tasks_task", + input_spec={"source": DataFormat.TE_JSON}, + output_spec={"output": DataFormat.RDF_NTRIPLES}, + function=generate_rdf_from_text_results_function +) + + +# ------------------------------------------------------------ + + +# def aggregate_3iejson_with_filter(inputs: Dict[str, Data], outputs: Dict[str, Data]): +# json1_path = inputs["json1"].path +# json2_path = inputs["json2"].path +# json3_path = inputs["json3"].path + +# def load_kg_uris_from_shades(): +# """ +# Loads the URIs of the entities in the current KG. +# """ +# shade_file = "/home/marvin/project/data/current/shade_seed.json" +# with open(shade_file, "r") as f: +# return json.load(f) + +# shade_dict = load_kg_uris_from_shades() +# reverse_shade_dict = {v: k for k, v in shade_dict.items()} +# kg_uris = set(shade_dict.values()) + + +# def filter_ie_doc(doc: TE_Document): +# """ +# Removes links to entities that are not in the current KG. +# """ + +# # for uri in kg_uris: +# # print(uri) + +# # Create a new list instead of modifying while iterating +# filtered_links = [] +# for link in doc.links: +# if link.link_type == "entity": +# if link.mapping not in kg_uris: +# # print(f"Removing entity link to {link.mapping} because it is not in the current KG") +# continue # Skip this link +# else: +# tmp = link.mapping +# try: +# link.mapping = reverse_shade_dict[tmp] +# # print(f"Replacing entity link {tmp} with {link.mapping}") +# except KeyError: +# print(f"KeyError: {tmp} not found in reverse_shade_dict, skipping") +# continue # Skip this link +# # elif link.link_type == "relation": +# # if link.mapping not in kg_uris: +# # print(f"Removing relation link to {link.mapping} because it is not in the current KG") +# # continue # Skip this link + +# # Add the link to the filtered list (either it passed all checks or it's not an entity link) +# filtered_links.append(link) + +# doc.links = filtered_links +# return doc + + +# if os.path.isdir(json1_path) and os.path.isdir(json2_path) and os.path.isdir(json3_path): +# # list files in each directory +# json1_files = set(os.listdir(json1_path)) +# json2_files = set(os.listdir(json2_path)) +# json3_files = set(os.listdir(json3_path)) + +# # check for mismatches +# if json1_files == json2_files == json3_files: +# os.makedirs(outputs["output"].path, exist_ok=True) +# for file in json1_files: +# json1_doc = TE_Document(**json.load(open(os.path.join(json1_path, file)))) +# json2_doc = TE_Document(**json.load(open(os.path.join(json2_path, file)))) +# json3_doc = TE_Document(**json.load(open(os.path.join(json3_path, file)))) + +# merged_doc = TE_Document() +# merged_doc.chains = json1_doc.chains + json2_doc.chains + json3_doc.chains +# merged_doc.links = json1_doc.links + json2_doc.links + json3_doc.links +# merged_doc.triples = json1_doc.triples + json2_doc.triples + json3_doc.triples + +# merged_doc = filter_ie_doc(merged_doc) + +# with open(os.path.join(outputs["output"].path, file), "w") as f: +# f.write(merged_doc.model_dump_json()) +# # print(f"Converted {file} to {os.path.join(outputs['output'].path, file)}") +# else: +# print("File mismatch detected:") +# print("Files only in json1:", json1_files - json2_files - json3_files) +# print("Files only in json2:", json2_files - json1_files - json3_files) +# print("Files only in json3:", json3_files - json1_files - json2_files) +# print("Common files in all:", json1_files & json2_files & json3_files) +# raise Exception("All input directories must contain the same file names") +# else: +# raise Exception("All inputs must be directories") + +# aggregate_3iejson_with_filter_task = KgTask( +# name="aggregate_iejson_with_filter_task", +# input_spec={"json1": DataFormat.TE_JSON, "json2": DataFormat.TE_JSON, "json3": DataFormat.TE_JSON}, +# output_spec={"output": DataFormat.TE_JSON}, +# function=aggregate_3iejson_with_filter +# ) + diff --git a/experiments/param-opti/src/qap/fixtures/rdf_sampled_pipeline_configs.json b/experiments/param-opti/src/qap/fixtures/rdf_sampled_pipeline_configs.json new file mode 100644 index 0000000..a4e16a7 --- /dev/null +++ b/experiments/param-opti/src/qap/fixtures/rdf_sampled_pipeline_configs.json @@ -0,0 +1,175 @@ +{ + "samples": [ + { + "profiles": { + "graph_alignment_label_alias_embedding_transformer": { + "bindings": [ + { + "parameter": "model_name", + "value": "sentence-transformers/all-mpnet-base-v2" + }, + { + "parameter": "similarity_threshold", + "value": 0.8 + } + ], + "profile_name": "graph_alignment_label_alias_embedding_transformer_model_name=sentence-transformers/all-mpnet-base-v2,similarity_threshold=0.8" + } + }, + "task_keys": [ + "graph_alignment_label_alias_embedding_transformer_task", + "fusion_first_value_task" + ] + }, + { + "profiles": { + "entity_matcher_label_alias_embedding_transformer": { + "bindings": [ + { + "parameter": "model_name", + "value": "sentence-transformers/all-mpnet-base-v2" + }, + { + "parameter": "similarity_threshold", + "value": 0.8 + } + ], + "profile_name": "entity_matcher_label_alias_embedding_transformer_model_name=sentence-transformers/all-mpnet-base-v2,similarity_threshold=0.8" + }, + "relation_matcher_label_alias_embedding_transformer": { + "bindings": [ + { + "parameter": "model_name", + "value": "sentence-transformers/all-MiniLM-L6-v2" + }, + { + "parameter": "similarity_threshold", + "value": 0.7 + } + ], + "profile_name": "relation_matcher_label_alias_embedding_transformer_model_name=sentence-transformers/all-MiniLM-L6-v2,similarity_threshold=0.7" + } + }, + "task_keys": [ + "relation_matcher_label_alias_embedding_transformer_task", + "entity_matcher_label_alias_embedding_transformer_task", + "aggregate_matching_results_task", + "fusion_first_value_task" + ] + }, + { + "profiles": { + "paris_entity_alignment": { + "bindings": [ + { + "parameter": "entity_matching_threshold", + "value": 0.7 + } + ], + "profile_name": "paris_entity_alignment_entity_matching_threshold=0.7" + }, + "relation_matcher_label_alias_embedding_transformer": { + "bindings": [ + { + "parameter": "model_name", + "value": "sentence-transformers/all-mpnet-base-v2" + }, + { + "parameter": "similarity_threshold", + "value": 0.8 + } + ], + "profile_name": "relation_matcher_label_alias_embedding_transformer_model_name=sentence-transformers/all-mpnet-base-v2,similarity_threshold=0.8" + } + }, + "task_keys": [ + "relation_matcher_label_alias_embedding_transformer_task", + "paris_entity_alignment_task", + "aggregate_matching_results_task", + "fusion_first_value_task" + ] + }, + { + "profiles": { + "entity_matcher_label_alias_embedding_transformer": { + "bindings": [ + { + "parameter": "model_name", + "value": "sentence-transformers/all-MiniLM-L6-v2" + }, + { + "parameter": "similarity_threshold", + "value": 0.9 + } + ], + "profile_name": "entity_matcher_label_alias_embedding_transformer_model_name=sentence-transformers/all-MiniLM-L6-v2,similarity_threshold=0.9" + }, + "paris_ontology_matching": { + "bindings": [ + { + "parameter": "ontology_matching_threshold", + "value": 0.9 + } + ], + "profile_name": "paris_ontology_matching_ontology_matching_threshold=0.9" + } + }, + "task_keys": [ + "paris_ontology_matching_task", + "entity_matcher_label_alias_embedding_transformer_task", + "aggregate_matching_results_task", + "fusion_first_value_task" + ] + }, + { + "profiles": { + "paris_entity_alignment": { + "bindings": [ + { + "parameter": "entity_matching_threshold", + "value": 0.7 + } + ], + "profile_name": "paris_entity_alignment_entity_matching_threshold=0.7" + }, + "paris_ontology_matching": { + "bindings": [ + { + "parameter": "ontology_matching_threshold", + "value": 0.6 + } + ], + "profile_name": "paris_ontology_matching_ontology_matching_threshold=0.6" + } + }, + "task_keys": [ + "paris_ontology_matching_task", + "paris_entity_alignment_task", + "aggregate_matching_results_task", + "fusion_first_value_task" + ] + }, + { + "profiles": { + "paris_graph_alignment": { + "bindings": [ + { + "parameter": "entity_matching_threshold", + "value": 0.6 + }, + { + "parameter": "relation_matching_threshold", + "value": 0.5 + } + ], + "profile_name": "paris_graph_alignment_entity_matching_threshold=0.6,relation_matching_threshold=0.5" + } + }, + "task_keys": [ + "paris_graph_alignment_task", + "fusion_first_value_task" + ] + } + ], + "version": 1 +} diff --git a/experiments/param-opti/src/qap/sge_metrics.py b/experiments/param-opti/src/qap/sge_metrics.py new file mode 100644 index 0000000..b4e0e33 --- /dev/null +++ b/experiments/param-opti/src/qap/sge_metrics.py @@ -0,0 +1,17 @@ +from kg_sge.api.correctness import SourceGroundCorrectenss, SourceGroundCorrectnessConfig +from kg_sge.api.coverage import SourceGroundedCoverage, SourceGroundedCoverageConfig +from kgpipe_eval.utils.kg_utils import KgManager, KG, KgLike + +class SourceGroundedCorrectnessMetric: + def __init__(self): + self.correctness = SourceGroundCorrectenss() + + def compute(self, kg: KG, config: SourceGroundCorrectnessConfig): + pass + +class SourceGroundedCoverageMetric: + def __init__(self): + self.coverage = SourceGroundedCoverage() + + def compute(self, kg: KG, config: SourceGroundedCoverageConfig): + pass \ No newline at end of file diff --git a/experiments/param-opti/src/qap/test_exec_pipelines.py b/experiments/param-opti/src/qap/test_exec_pipelines.py new file mode 100644 index 0000000..2ae5626 --- /dev/null +++ b/experiments/param-opti/src/qap/test_exec_pipelines.py @@ -0,0 +1,170 @@ +from kgpipe.common import KgPipe, Data, DataFormat +from kgpipe.common.model.configuration import ConfigurationProfile, ParameterBinding, ConfigurationDefinition +from param_opti.tasks.paris import paris_graph_alignment_task, paris_entity_alignment_task, paris_ontology_matching_task +from param_opti.tasks.fusion import fusion_first_value_task +from param_opti.tasks.base_linker import relation_linker_label_alias_embedding_transformer_task, entity_linker_label_alias_embedding_transformer_task +from param_opti.tasks.corenlp import corenlp_text_extraction_task +from param_opti.tasks.genie import genie_text_extraction_task +from param_opti.tasks.spotlight import spotlight_entity_linking_task +from param_opti.tasks.text_helpers import aggregate_text_tasks_task, generate_rdf_from_text_results_task +from param_opti.tasks.select_lib import select_first_value_task +from qap.test_pipeline_config import ( + PipelineConfig, + _get_param, + load_rdf_sampled_pipeline_configs, +) +from pathlib import Path +import pytest +import os + +from dotenv import load_dotenv +load_dotenv() + +tmp_base_dir = Path("tmp") +if not tmp_base_dir.exists(): + tmp_base_dir.mkdir(parents=True, exist_ok=True) + + +ontology_path = "data/input_final/target_kg/ontology.ttl" +os.environ["ONTOLOGY_PATH"] = ontology_path + + +def get_default_rdf_pipeline_config() -> PipelineConfig: + return PipelineConfig( + tasks=[ + paris_graph_alignment_task, + fusion_first_value_task, + ], + config_catalog={ + # Key must match KgTask.name because KgPipe delegates by task.name + "paris_graph_alignment": ConfigurationProfile( + name="paris_graph_alignment", + definition=paris_graph_alignment_task.config_spec, + bindings=[ + ParameterBinding(parameter=_get_param(paris_graph_alignment_task.config_spec, "entity_matching_threshold"), value=0.5), + ParameterBinding(parameter=_get_param(paris_graph_alignment_task.config_spec, "relation_matching_threshold"), value=0.5), + ], + ) + }, + ) + +def test_rdf_pipeline_from_default_config(): + pipeline_config = get_default_rdf_pipeline_config() + + seed_path = tmp_base_dir / "seed.nt" + source_path = tmp_base_dir / "source.nt" + result_path = tmp_base_dir / "result.nt" + tasks_tmp_dir = tmp_base_dir / "tasks_tmp" + tasks_tmp_dir.mkdir(parents=True, exist_ok=True) + + # Ensure inputs exist for pipeline execution. + seed_path.write_text(" .\n") + source_path.write_text(" .\n") + + pipeline = KgPipe( + tasks=pipeline_config.tasks, + seed=Data(path=seed_path, format=DataFormat.RDF_NTRIPLES), + data_dir=tasks_tmp_dir, + name="test_pipeline") + + pipeline.build( + stable_files=True, + configCatalog=pipeline_config.config_catalog, + source=Data(path=source_path, format=DataFormat.RDF_NTRIPLES), + result=Data(path=result_path, format=DataFormat.RDF_NTRIPLES)) + + pipeline.run(configCatalog=pipeline_config.config_catalog, stable_files_override=True) + + +@pytest.mark.parametrize("config_idx", range(len(load_rdf_sampled_pipeline_configs()))) +def test_rdf_pipeline_from_saved_sampled_configs(config_idx): + """Runs KGpipe using PipelineConfigs materialized from the JSON fixture written by test_pipeline_config.""" + configs = load_rdf_sampled_pipeline_configs() + assert configs, "fixtures/rdf_sampled_pipeline_configs.json is missing or empty; run test_enumerate_all_valid_rdf_task_combinations_with_config_sampling" + + pipeline_config = configs[config_idx] + + seed_path = tmp_base_dir / "seed_saved_sample.nt" + source_path = tmp_base_dir / "source_saved_sample.nt" + result_path = tmp_base_dir / f"result_saved_sample_config_idx_{config_idx}.nt" + tasks_tmp_dir = tmp_base_dir / f"tasks_tmp_saved_sample_config_idx_{config_idx}" + tasks_tmp_dir.mkdir(parents=True, exist_ok=True) + + seed_path.write_text(" .\n") + source_path.write_text(" .\n") + + pipeline = KgPipe( + tasks=pipeline_config.tasks, + seed=Data(path=seed_path, format=DataFormat.RDF_NTRIPLES), + data_dir=tasks_tmp_dir, + name="test_pipeline_saved_sample", + ) + + pipeline.build( + stable_files=True, + configCatalog=pipeline_config.config_catalog, + source=Data(path=source_path, format=DataFormat.RDF_NTRIPLES), + result=Data(path=result_path, format=DataFormat.RDF_NTRIPLES), + ) + + pipeline.run(configCatalog=pipeline_config.config_catalog, stable_files_override=True) + + + +def get_default_text_pipeline_config() -> PipelineConfig: + return PipelineConfig( + tasks=[ + corenlp_text_extraction_task, + entity_linker_label_alias_embedding_transformer_task, + relation_linker_label_alias_embedding_transformer_task, + aggregate_text_tasks_task, + generate_rdf_from_text_results_task, + select_first_value_task, + ], + config_catalog={ + "entity_linker_label_alias_embedding_transformer": ConfigurationProfile( + name="entity_linker_label_alias_embedding_transformer", + definition=entity_linker_label_alias_embedding_transformer_task.config_spec, + bindings=[ + ParameterBinding(parameter=_get_param(entity_linker_label_alias_embedding_transformer_task.config_spec, "model_name"), value="sentence-transformers/all-MiniLM-L6-v2"), + ParameterBinding(parameter=_get_param(entity_linker_label_alias_embedding_transformer_task.config_spec, "similarity_threshold"), value=0.5), + ], + ), + "relation_linker_label_alias_embedding_transformer": ConfigurationProfile( + name="relation_linker_label_alias_embedding_transformer", + definition=relation_linker_label_alias_embedding_transformer_task.config_spec, + bindings=[ + ParameterBinding(parameter=_get_param(relation_linker_label_alias_embedding_transformer_task.config_spec, "model_name"), value="sentence-transformers/all-MiniLM-L6-v2"), + ParameterBinding(parameter=_get_param(relation_linker_label_alias_embedding_transformer_task.config_spec, "similarity_threshold"), value=0.5), + ], + ), + }, + ) + +def test_text_pipeline_from_default_config(): + pipeline_config = get_default_text_pipeline_config() + + import os + os.environ["ONTOLOGY_PATH"] = "data/input_final/target_kg/ontology.ttl" + + seed_path = Path("data/input_final/target_kg/graph.nt") + source_path = Path("data/input_final/txt_source/docs") + result_path = Path("data/tmp/text_pipelines/result.nt") + tasks_tmp_dir = Path("data/tmp/text_pipelines/tasks_tmp") + tasks_tmp_dir.mkdir(parents=True, exist_ok=True) + + pipeline = KgPipe( + tasks=pipeline_config.tasks, + seed=Data(path=seed_path, format=DataFormat.RDF_NTRIPLES), + data_dir=tasks_tmp_dir, + name="test_text_pipeline") + + pipeline.build( + stable_files=True, + configCatalog=pipeline_config.config_catalog, + source=Data(path=source_path, format=DataFormat.TEXT), + result=Data(path=result_path, format=DataFormat.RDF_NTRIPLES)) + + pipeline.run(configCatalog=pipeline_config.config_catalog, stable_files_override=False) + + diff --git a/experiments/param-opti/src/qap/test_pipeline_config.py b/experiments/param-opti/src/qap/test_pipeline_config.py index e1e7c79..ee9c14f 100644 --- a/experiments/param-opti/src/qap/test_pipeline_config.py +++ b/experiments/param-opti/src/qap/test_pipeline_config.py @@ -1,12 +1,24 @@ from typing import List, Dict, Any, Optional +import json import random from kgpipe.common import KgPipe, Data, DataFormat, Registry from kgpipe.common.model.configuration import ConfigurationProfile, ParameterBinding from kgpipe.common.model.task import KgTask from pydantic import BaseModel -from param_opti.tasks.paris import paris_graph_alignment_task + +from param_opti.tasks.paris import paris_graph_alignment_task, paris_entity_alignment_task, paris_ontology_matching_task from param_opti.tasks.fusion import fusion_first_value_task from param_opti.tasks.base_linker import relation_linker_label_alias_embedding_transformer_task, entity_linker_label_alias_embedding_transformer_task +from param_opti.tasks.base_matcher import ( + graph_alignment_label_alias_embedding_transformer_task, + relation_matcher_label_alias_embedding_transformer_task, + entity_matcher_label_alias_embedding_transformer_task, +) +from param_opti.tasks.corenlp import corenlp_text_extraction_task +from param_opti.tasks.genie import genie_text_extraction_task +from param_opti.tasks.spotlight import spotlight_entity_linking_task +from param_opti.tasks.matching_helpers import aggregate_matching_results_task + from kgpipe.generation.loaders import build_from_conf from pathlib import Path # for given tasks and config parameters, generate a pipeline (KGpipe) @@ -15,23 +27,52 @@ if not tmp_base_dir.exists(): tmp_base_dir.mkdir(parents=True, exist_ok=True) +RDF_SAMPLED_PIPELINE_CONFIGS_FIXTURE = Path(__file__).resolve().parent / "fixtures" / "rdf_sampled_pipeline_configs.json" +_RDF_PIPELINE_CONFIG_SNAPSHOT_VERSION = 1 + class PipelineConfig(BaseModel): tasks: List[KgTask] config_catalog: Dict[str, ConfigurationProfile] -SEARCH_SPACE = { +RDF_SEARCH_SPACE = { + "graph_alignment_label_alias_embedding_transformer_task": { + "category": ["ontology_matching", "entity_matching", "aggregate_matching_results"], + "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2"], + "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], + }, + "relation_matcher_label_alias_embedding_transformer_task": { + "category": ["ontology_matching"], + "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2"], + "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], + }, + "entity_matcher_label_alias_embedding_transformer_task": { + "category": ["entity_matching"], + "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2"], + "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], + }, + "paris_ontology_matching_task": { + "category": ["ontology_matching"], + "ontology_matching_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], + }, + "paris_entity_alignment_task": { + "category": ["entity_matching"], + "entity_matching_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], + }, "paris_graph_alignment_task": { - "category": "entity_matching", + "category": ["ontology_matching", "entity_matching", "aggregate_matching_results"], "entity_matching_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], "relation_matching_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], }, + "aggregate_matching_results_task": { + "category": ["aggregate_matching_results"], + }, "fusion_first_value_task": { - "category": "fusion", + "category": ["fusion"], # "fusion_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], }, "relation_linker_label_alias_embedding_transformer_task": { - "category": "entity_linking", + "category": ["entity_linking"], "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2"], "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], }, @@ -42,14 +83,60 @@ class PipelineConfig(BaseModel): }, } -task_dict = { +TEXT_SEARCH_SPACE = { + "corenlp_text_extraction_task": { + "category": ["information_extraction"], + # does not have config parameters + }, + "genie_text_extraction_task": { + "category": ["information_extraction"], + # does not have config parameters + }, + "spotlight_entity_linking_task": { + "category": ["entity_linking"], + "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], + }, + "relation_linker_label_alias_embedding_transformer_task": { + "category": ["relation_linking"], + "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2"], + "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], + }, + "entity_linker_label_alias_embedding_transformer_task": { + "category": ["entity_linking"], + "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2"], + "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], + }, + "fusion_first_value_task": { + "category": ["fusion"], + }, +} + +TEXT_TASK_DICT = { + "corenlp_text_extraction_task": corenlp_text_extraction_task, + "genie_text_extraction_task": genie_text_extraction_task, + "spotlight_entity_linking_task": spotlight_entity_linking_task, + "relation_linker_label_alias_embedding_transformer_task": relation_linker_label_alias_embedding_transformer_task, + "entity_linker_label_alias_embedding_transformer_task": entity_linker_label_alias_embedding_transformer_task, + "fusion_first_value_task": fusion_first_value_task, +} + +RDF_TASK_DICT = { + "graph_alignment_label_alias_embedding_transformer_task": graph_alignment_label_alias_embedding_transformer_task, + "relation_matcher_label_alias_embedding_transformer_task": relation_matcher_label_alias_embedding_transformer_task, + "entity_matcher_label_alias_embedding_transformer_task": entity_matcher_label_alias_embedding_transformer_task, + "paris_ontology_matching_task": paris_ontology_matching_task, + "paris_entity_alignment_task": paris_entity_alignment_task, "paris_graph_alignment_task": paris_graph_alignment_task, "fusion_first_value_task": fusion_first_value_task, "relation_linker_label_alias_embedding_transformer_task": relation_linker_label_alias_embedding_transformer_task, "entity_linker_label_alias_embedding_transformer_task": entity_linker_label_alias_embedding_transformer_task, + "aggregate_matching_results_task": aggregate_matching_results_task, + # "fusion_union_task": fusion_union_task, } -for task_name, task in task_dict.items(): +task_dict = {**TEXT_TASK_DICT, **RDF_TASK_DICT} + +for task_name, task in RDF_TASK_DICT.items(): Registry.add_task(task_name, task) class PipelineLayout(BaseModel): @@ -58,6 +145,72 @@ class PipelineLayout(BaseModel): """ allowed_task_categories: List[str] + +def _task_categories_list(search_space: Dict[str, Dict[str, Any]], task_name: str) -> List[str]: + raw = search_space.get(task_name, {}).get("category") + if isinstance(raw, list): + return [c for c in raw if isinstance(c, str)] + if isinstance(raw, str): + return [raw] + return [] + + +def enumerate_valid_task_combinations( + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, +) -> List[List[str]]: + """ + Enumerate all possible task-name combinations for the given pipeline layout, + respecting category order and multi-category coverage, without sampling config options. + + A task is only eligible for the current category if its declared categories are + disjoint from categories already covered by earlier tasks. That avoids pairing e.g. + Paris ontology matching with a dual-category embedding matcher that would repeat + ontology coverage when only entity matching is still needed. + """ + all_task_names = list(search_space.keys()) + + combos: List[List[str]] = [[]] + covered_sets: List[set[str]] = [set()] + + for category in pipeline_layout.allowed_task_categories: + next_combos: List[List[str]] = [] + next_covered_sets: List[set[str]] = [] + + for combo, covered in zip(combos, covered_sets): + if category in covered: + next_combos.append(combo) + next_covered_sets.append(covered) + continue + + eligible: List[str] = [] + for tn in all_task_names: + cats = _task_categories_list(search_space, tn) + if category not in cats: + continue + if set(cats) & covered: + continue + eligible.append(tn) + for tn in eligible: + new_combo = combo + [tn] + new_covered = set(covered) + new_covered.update(_task_categories_list(search_space, tn)) + next_combos.append(new_combo) + next_covered_sets.append(new_covered) + + combos, covered_sets = next_combos, next_covered_sets + + # De-duplicate while keeping stable order. + seen: set[tuple[str, ...]] = set() + unique: List[List[str]] = [] + for c in combos: + t = tuple(c) + if t in seen: + continue + seen.add(t) + unique.append(c) + return unique + def _get_param(definition: Any, param_name: str): params = getattr(definition, "parameters", None) if params is None: @@ -75,24 +228,63 @@ def _get_param(definition: Any, param_name: str): return p raise KeyError(f"Parameter {param_name} not found in config_spec.parameters") -def get_default_rdf_pipeline_config() -> PipelineConfig: - return PipelineConfig( - tasks=[ - paris_graph_alignment_task, - fusion_first_value_task, - ], - config_catalog={ - # Key must match KgTask.name because KgPipe delegates by task.name - "paris_graph_alignment": ConfigurationProfile( - name="paris_graph_alignment", - definition=paris_graph_alignment_task.config_spec, - bindings=[ - ParameterBinding(parameter=_get_param(paris_graph_alignment_task.config_spec, "entity_matching_threshold"), value=0.5), - ParameterBinding(parameter=_get_param(paris_graph_alignment_task.config_spec, "relation_matching_threshold"), value=0.5), - ], + +def pipeline_config_to_snapshot(task_keys: List[str], pipeline_config: PipelineConfig) -> Dict[str, Any]: + profiles: Dict[str, Any] = {} + for task in pipeline_config.tasks: + prof = pipeline_config.config_catalog.get(task.name) + if prof is None: + continue + profiles[task.name] = { + "profile_name": prof.name, + "bindings": [ + {"parameter": binding.parameter.name, "value": binding.value} + for binding in prof.bindings + ], + } + return {"task_keys": task_keys, "profiles": profiles} + + +def pipeline_config_from_snapshot(snapshot: Dict[str, Any]) -> PipelineConfig: + task_keys: List[str] = snapshot["task_keys"] + profiles: Dict[str, Any] = snapshot.get("profiles") or {} + tasks: List[KgTask] = [] + config_catalog: Dict[str, ConfigurationProfile] = {} + + for task_key in task_keys: + task = task_dict[task_key] + tasks.append(task) + prof_data = profiles.get(task.name) + if prof_data is None: + continue + if getattr(task, "config_spec", None) is None: + continue + bindings = [ + ParameterBinding( + parameter=_get_param(task.config_spec, b["parameter"]), + value=b["value"], ) - }, - ) + for b in prof_data["bindings"] + ] + config_catalog[task.name] = ConfigurationProfile( + name=prof_data["profile_name"], + definition=task.config_spec, + bindings=bindings, + ) + + return PipelineConfig(tasks=tasks, config_catalog=config_catalog) + + +def load_rdf_sampled_pipeline_configs(path: Optional[Path] = None) -> List[PipelineConfig]: + fixture_path = path or RDF_SAMPLED_PIPELINE_CONFIGS_FIXTURE + raw = json.loads(fixture_path.read_text(encoding="utf-8")) + if raw.get("version") != _RDF_PIPELINE_CONFIG_SNAPSHOT_VERSION: + raise ValueError( + f"Unsupported rdf sampled configs snapshot version {raw.get('version')!r}; " + f"expected {_RDF_PIPELINE_CONFIG_SNAPSHOT_VERSION}" + ) + return [pipeline_config_from_snapshot(item) for item in raw["samples"]] + # TODO rules for valid pipeline config: def sample_valid_pipeline_config( @@ -105,16 +297,40 @@ def sample_valid_pipeline_config( """ tasks: List[KgTask] = [] config_catalog: Dict[str, ConfigurationProfile] = {} + covered_categories: set[str] = set() for category in pipeline_layout.allowed_task_categories: + if category in covered_categories: + continue + eligible_task_names = [ - tn for tn, space in search_space.items() if space.get("category") == category + tn + for tn, space in search_space.items() + if ( + space.get("category") == category + or ( + isinstance(space.get("category"), list) + and category in (space.get("category") or []) + ) + ) ] if not eligible_task_names: continue + eligible_task_names = [ + tn + for tn in eligible_task_names + if not (set(_task_categories_list(search_space, tn)) & covered_categories) + ] + if not eligible_task_names: + raise ValueError( + f"No task can cover category {category!r} without overlapping already covered " + f"categories {sorted(covered_categories)}. Adjust search_space or pipeline_layout." + ) + task_key = random.choice(eligible_task_names) task = task_dict[task_key] + covered_categories.update(_task_categories_list(search_space, task_key)) tasks.append(task) # metadata only or task has no config spec @@ -170,63 +386,186 @@ def print_pipeline_config_short(pipeline_config: PipelineConfig): params = ", ".join(parts) print(f"- {task_name}({params})") +def sample_config_catalog_for_task_combo( + search_space: Dict[str, Dict[str, Any]], + task_name_combo: List[str], + *, + rng: random.Random, +) -> PipelineConfig: + tasks: List[KgTask] = [] + config_catalog: Dict[str, ConfigurationProfile] = {} + + for task_key in task_name_combo: + task = task_dict[task_key] + tasks.append(task) + + if getattr(task, "config_spec", None) is None: + continue + + bindings: List[ParameterBinding] = [] + name_parts: List[str] = [] + for config_name, config_values in search_space[task_key].items(): + if config_name == "category": + continue + if not isinstance(config_values, list): + raise TypeError( + f"Search space values must be lists; got {task_key}.{config_name}={type(config_values)}" + ) + if not config_values: + raise ValueError(f"Empty search space for {task_key}.{config_name}") + + config_value = rng.choice(config_values) + name_parts.append(f"{config_name}={config_value}") + bindings.append( + ParameterBinding( + parameter=_get_param(task.config_spec, config_name), + value=config_value, + ) + ) + + if bindings: + config_catalog[task.name] = ConfigurationProfile( + name=f"{task.name}_" + ",".join(name_parts), + definition=task.config_spec, + bindings=bindings, + ) + + return PipelineConfig(tasks=tasks, config_catalog=config_catalog) + def test_sample_valid_rdf_pipeline_config(): pipeline_layout = PipelineLayout( - allowed_task_categories=["ontology_matching", "entity_matching", "fusion"] + allowed_task_categories=["ontology_matching", "entity_matching", "aggregate_matching_results", "fusion"] ) - pipeline_config = sample_valid_pipeline_config(SEARCH_SPACE, pipeline_layout) + pipeline_config = sample_valid_pipeline_config(RDF_SEARCH_SPACE, pipeline_layout) print_pipeline_config_short(pipeline_config) +def test_enumerate_all_valid_rdf_task_combinations_no_config_sampling(): + print("enumerate_all_valid_rdf_task_combinations_no_config_sampling") + pipeline_layout = PipelineLayout( + allowed_task_categories=["ontology_matching", "entity_matching", "aggregate_matching_results", "fusion"] + ) + combos = enumerate_valid_task_combinations(RDF_SEARCH_SPACE, pipeline_layout) + + for combo in combos: + print(combo) + + # With current SEARCH_SPACE: + # - ontology_matching can be satisfied by paris_ontology_matching_task, paris_entity_alignment_task, paris_graph_alignment_task + # - entity_matching can be satisfied by paris_entity_alignment_task, paris_graph_alignment_task (and may be skipped if already covered) + # - fusion must be satisfied by fusion_first_value_task + # expected = { + # ("paris_ontology_matching_task", "paris_entity_alignment_task", "fusion_first_value_task"), + # ("paris_ontology_matching_task", "paris_graph_alignment_task", "fusion_first_value_task"), + # ("paris_graph_alignment_task", "fusion_first_value_task"), + # } + + # assert set(tuple(c) for c in combos) == expected + +def test_enumerate_all_valid_rdf_task_combinations_with_config_sampling(): + print("enumerate_all_valid_rdf_task_combinations_with_config_sampling") + n = 1 + rng = random.Random(0) + + pipeline_layout = PipelineLayout( + allowed_task_categories=["ontology_matching", "entity_matching", "aggregate_matching_results", "fusion"] + ) + combos = enumerate_valid_task_combinations(RDF_SEARCH_SPACE, pipeline_layout) + + total_config_count = 0 + snapshots: List[Dict[str, Any]] = [] + + for combo in combos: + print() + print("combo:", combo) + for i in range(n): + total_config_count += 1 + print(f"sample {total_config_count}/{len(combos) * n}") + pipeline_config = sample_config_catalog_for_task_combo( + RDF_SEARCH_SPACE, combo, rng=rng + ) + + print_pipeline_config_short(pipeline_config) + snapshots.append(pipeline_config_to_snapshot(combo, pipeline_config)) + + RDF_SAMPLED_PIPELINE_CONFIGS_FIXTURE.parent.mkdir(parents=True, exist_ok=True) + RDF_SAMPLED_PIPELINE_CONFIGS_FIXTURE.write_text( + json.dumps( + {"version": _RDF_PIPELINE_CONFIG_SNAPSHOT_VERSION, "samples": snapshots}, + indent=2, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + + def test_sample_valid_text_pipeline_config(): pipeline_layout = PipelineLayout( - allowed_task_categories=["information_extraction", "entity_linking", "fusion"] + allowed_task_categories=["information_extraction", "entity_linking", "relation_linking", "fusion"] ) - pipeline_config = sample_valid_pipeline_config(SEARCH_SPACE, pipeline_layout) + pipeline_config = sample_valid_pipeline_config(TEXT_SEARCH_SPACE, pipeline_layout) print_pipeline_config_short(pipeline_config) -def test_rdf_pipeline_from_default_config(): - pipeline_config = get_default_rdf_pipeline_config() - - seed_path = tmp_base_dir / "seed.nt" - source_path = tmp_base_dir / "source.nt" - result_path = tmp_base_dir / "result.nt" +def test_enumerate_all_valid_text_task_combinations_no_config_sampling(): + print("enumerate_all_valid_text_task_combinations_no_config_sampling") + pipeline_layout = PipelineLayout( + allowed_task_categories=["information_extraction", "entity_linking", "relation_linking", "fusion"] + ) + combos = enumerate_valid_task_combinations(TEXT_SEARCH_SPACE, pipeline_layout) + for combo in combos: + print(combo) +def test_enumerate_all_valid_text_task_combinations_with_config_sampling(): + print("enumerate_all_valid_text_task_combinations_with_config_sampling") + n = 5 + rng = random.Random(0) - pipeline = KgPipe( - tasks=pipeline_config.tasks, - seed=Data(path=seed_path, format=DataFormat.RDF_NTRIPLES), - data_dir=tmp_base_dir / "tasks_tmp", - name="test_pipeline") + pipeline_layout = PipelineLayout( + allowed_task_categories=["information_extraction", "entity_linking", "relation_linking", "fusion"] + ) + combos = enumerate_valid_task_combinations(TEXT_SEARCH_SPACE, pipeline_layout) + + total_config_count = 0 + + for combo in combos: + print() + print("combo:", combo) + for i in range(n): + total_config_count += 1 + print(f"sample {total_config_count}/{len(combos) * n}") + pipeline_config = sample_config_catalog_for_task_combo( + TEXT_SEARCH_SPACE, combo, rng=rng + ) + print_pipeline_config_short(pipeline_config) - pipeline.build( - stable_files=True, - configCatalog=pipeline_config.config_catalog, - source=Data(path=source_path, format=DataFormat.RDF_NTRIPLES), - result=Data(path=result_path, format=DataFormat.RDF_NTRIPLES)) - pipeline.run(configCatalog=pipeline_config.config_catalog, stable_files_override=True) -def test_rdf_pipeline_from_config(): - pipeline_config = sample_valid_pipeline_config(SEARCH_SPACE, PipelineLayout(allowed_task_categories=["entity_matching", "fusion"])) +# def test_rdf_pipeline_from_config(): +# pipeline_config = sample_valid_pipeline_config(RDF_SEARCH_SPACE, PipelineLayout(allowed_task_categories=["entity_matching", "fusion"])) - seed_path = tmp_base_dir / "seed.nt" - source_path = tmp_base_dir / "source.nt" - result_path = tmp_base_dir / "result.nt" +# seed_path = tmp_base_dir / "seed.nt" +# source_path = tmp_base_dir / "source.nt" +# result_path = tmp_base_dir / "result.nt" +# tasks_tmp_dir = tmp_base_dir / "tasks_tmp" +# tasks_tmp_dir.mkdir(parents=True, exist_ok=True) +# # Ensure inputs exist for pipeline execution. +# seed_path.write_text(" .\n") +# source_path.write_text(" .\n") - pipeline = KgPipe( - tasks=pipeline_config.tasks, - seed=Data(path=seed_path, format=DataFormat.RDF_NTRIPLES), - data_dir=tmp_base_dir / "tasks_tmp", - name="test_pipeline") +# pipeline = KgPipe( +# tasks=pipeline_config.tasks, +# seed=Data(path=seed_path, format=DataFormat.RDF_NTRIPLES), +# data_dir=tasks_tmp_dir, +# name="test_pipeline") - pipeline.build( - stable_files=True, - configCatalog=pipeline_config.config_catalog, - source=Data(path=source_path, format=DataFormat.RDF_NTRIPLES), - result=Data(path=result_path, format=DataFormat.RDF_NTRIPLES)) +# pipeline.build( +# stable_files=True, +# configCatalog=pipeline_config.config_catalog, +# source=Data(path=source_path, format=DataFormat.RDF_NTRIPLES), +# result=Data(path=result_path, format=DataFormat.RDF_NTRIPLES)) - pipeline.run(configCatalog=pipeline_config.config_catalog, stable_files_override=True) \ No newline at end of file +# pipeline.run(configCatalog=pipeline_config.config_catalog, stable_files_override=True) \ No newline at end of file diff --git a/experiments/param-opti/src/qap/test_ref_based.py b/experiments/param-opti/src/qap/test_ref_based.py index b4fb3c3..fca309f 100644 --- a/experiments/param-opti/src/qap/test_ref_based.py +++ b/experiments/param-opti/src/qap/test_ref_based.py @@ -1,8 +1,12 @@ from kgpipe.common import KgPipe, Data, DataFormat -from param_opti.tasks.paris import paris_entity_alignment_task, paris_graph_alignment_task -from param_opti.tasks.fusion import fusion_first_value_task, fusion_union_task +from kgpipe.common.model.configuration import ConfigurationProfile, ParameterBinding, ConfigurationDefinition +from param_opti.tasks.paris import paris_graph_alignment_task +from param_opti.tasks.fusion import fusion_first_value_task +from param_opti.tasks.openie import openie_pipeline_task +from param_opti.tasks.base_linker import relation_linker_label_alias_embedding_transformer_task, entity_linker_label_alias_embedding_transformer_task from pathlib import Path - +from typing import List +import pytest # Using ground truth # 1. execute PARIS pipeline, with different thresholds @@ -11,18 +15,191 @@ # - [ ] impl paris wrapper with exchange and threshold filter -seed_path = Path("data/seed.nt") -pipe_result_dir_path = Path("data/pipe_result") +ontology_path = "tmp/ontology.ttl" + +tmp_base_dir = Path("data/tmp/rdf_pipelines") +tmp_base_dir.mkdir(parents=True, exist_ok=True) + + +def _write_to_file(string: str, path: Path): + with open(path, "w") as f: + f.write(string) + +def _get_param(definition: ConfigurationDefinition, param_name: str): + params = getattr(definition, "parameters", None) + if params is None: + raise KeyError(f"Task config_spec has no parameters field (missing {param_name})") + + if hasattr(params, "get"): + p = params.get(param_name) + if p is None: + raise KeyError(f"Parameter {param_name} not found in config_spec.parameters") + return p + + for p in params: + if getattr(p, "name", None) == param_name: + return p + raise KeyError(f"Parameter {param_name} not found in config_spec.parameters") + + +def get_paris_pipeline(entity_matching_threshold: float, relation_matching_threshold: float): + name = ( + f"paris_graph_alignment(entity={entity_matching_threshold},rel={relation_matching_threshold})" + "_fusion_first_value" + ) -def get_paris_pipeline(threshold: float): - name = f"paris_graph_alignment_task={threshold}_fusion_first_value_task" + seed_path = Path("data/inputs/target_kg/data.nt") + source_path = Path("data/inputs/rdf_source/data.nt") + result_path = Path(f"data/tmp/rdf_pipelines/result_{entity_matching_threshold}_{relation_matching_threshold}.nt") + tasks_tmp_dir = Path(f"data/tmp/rdf_pipelines/tasks_tmp_{entity_matching_threshold}_{relation_matching_threshold}") + tasks_tmp_dir.mkdir(parents=True, exist_ok=True) - return KgPipe( + config_catalog = { + "paris_graph_alignment": ConfigurationProfile( + name=f"paris_graph_alignment_entity={entity_matching_threshold},relation={relation_matching_threshold}", + definition=paris_graph_alignment_task.config_spec, + bindings=[ + ParameterBinding( + parameter=_get_param(paris_graph_alignment_task.config_spec, "entity_matching_threshold"), + value=entity_matching_threshold, + ), + ParameterBinding( + parameter=_get_param(paris_graph_alignment_task.config_spec, "relation_matching_threshold"), + value=relation_matching_threshold, + ), + ], + ), + "fusion_first_value": ConfigurationProfile( + name="fusion_first_value", + definition=fusion_first_value_task.config_spec, + bindings=[ + ParameterBinding( + parameter=_get_param(fusion_first_value_task.config_spec, "ontology_path"), + value=ontology_path, + ), + ], + ) + } + + pipeline = KgPipe( name=name, tasks=[paris_graph_alignment_task, fusion_first_value_task], seed=Data(path=seed_path, format=DataFormat.RDF_NTRIPLES), - data_dir=pipe_result_dir_path / "tmp" + data_dir=tasks_tmp_dir, + ) + + pipeline.build( + stable_files=True, + configCatalog=config_catalog, + source=Data(path=source_path, format=DataFormat.RDF_NTRIPLES), + result=Data(path=result_path, format=DataFormat.RDF_NTRIPLES), ) -def test_paris_pipelines(): - pass \ No newline at end of file + return pipeline, config_catalog + +def get_openie_pipeline(entity_linking_threshold: float, relation_linking_threshold: float): + name = ( + f"openie_pipeline(entity={entity_linking_threshold},rel={relation_linking_threshold})" + ) + + seed_path = Path("data/inputs/target_kg/data.nt") + source_path = Path("data/inputs/text_source/docs") + result_path = Path(f"data/tmp/rdf_pipelines/result_{entity_linking_threshold}_{relation_linking_threshold}.nt") + tasks_tmp_dir = Path(f"data/tmp/rdf_pipelines/tasks_tmp_{entity_linking_threshold}_{relation_linking_threshold}") + tasks_tmp_dir.mkdir(parents=True, exist_ok=True) + + config_catalog = { + "openie_pipeline": ConfigurationProfile( + name=name, + definition=openie_pipeline_task.config_spec, + bindings=[ + ParameterBinding(parameter=_get_param(openie_pipeline_task.config_spec, "entity_linking_threshold"), value=entity_linking_threshold), + ParameterBinding(parameter=_get_param(openie_pipeline_task.config_spec, "relation_linking_threshold"), value=relation_linking_threshold), + ], + ), + } + + pipeline = KgPipe( + name=name, + tasks=[openie_pipeline_task, relation_linker_label_alias_embedding_transformer_task, entity_linker_label_alias_embedding_transformer_task], + seed=Data(path=seed_path, format=DataFormat.TEXT), + data_dir=tasks_tmp_dir, + ) + + pipeline.build( + stable_files=True, + configCatalog=config_catalog, + source=Data(path=source_path, format=DataFormat.TEXT), + result=Data(path=result_path, format=DataFormat.RDF_NTRIPLES), + ) + + return pipeline, config_catalog + +# parameterize the test with different thresholds for entity matching and relation matching +@pytest.mark.parametrize("entity_matching_threshold", [0.5, 0.6, 0.7, 0.8, 0.9]) +@pytest.mark.parametrize("relation_matching_threshold", [0.5, 0.6, 0.7, 0.8, 0.9]) +def test_paris_pipelines(entity_matching_threshold, relation_matching_threshold): + """ + test a paris pipeline with different thresholds for entity matching and relation matching + """ + pipeline, config_catalog = get_paris_pipeline( + entity_matching_threshold, relation_matching_threshold + ) + pipeline.run(configCatalog=config_catalog, stable_files_override=False) + print(f"Pipeline run with entity_matching_threshold={entity_matching_threshold} and relation_matching_threshold={relation_matching_threshold}") + + +@pytest.mark.parametrize("entity_matching_threshold", [0.5, 0.6, 0.7, 0.8, 0.9]) +@pytest.mark.parametrize("relation_matching_threshold", [0.5, 0.6, 0.7, 0.8, 0.9]) +def test_eval_paris_pipeline(entity_matching_threshold, relation_matching_threshold): + """ + evaluate a paris pipeline with different thresholds for entity matching and relation matching + current best "entity_alignment_0.9_0.7" with f1 score 0.971 + """ + + print(f"Evaluating triple alignment with entity_matching_threshold={entity_matching_threshold} and relation_matching_threshold={relation_matching_threshold}...") + from kgpipe_eval.utils.kg_utils import KgManager + from kgpipe_eval.metrics.triple_alignment import TripleAlignmentMetric, TripleAlignmentConfig + from kgpipe_eval.metrics.entity_alignment import EntityAlignmentMetric, EntityAlignmentConfig + from kgpipe_eval.api import MetricResult + from kgpipe_eval.test.utils import render_metric_result + + ref_kg_path = Path("data/inputs/reference_kg/data_agg.nt") + gen_kg_path = Path(f"data/tmp/rdf_pipelines/result_{entity_matching_threshold}_{relation_matching_threshold}.nt") + + entity_alignment_config = EntityAlignmentConfig( + method="label_embedding", + reference_kg=ref_kg_path, + verified_entities_path=None, + verified_entities_delimiter="\t", + entity_sim_threshold=0.95 + ) + + tg = KgManager.load_kg(gen_kg_path) + metric_result : MetricResult = EntityAlignmentMetric().compute(tg, entity_alignment_config) + result_string = render_metric_result(metric_result) + _write_to_file(result_string, Path(f"data/tmp/rdf_pipelines/entity_alignment_{entity_matching_threshold}_{relation_matching_threshold}.txt")) + + + triple_alignment_config = TripleAlignmentConfig( + reference_kg=ref_kg_path, + entity_alignment_config=entity_alignment_config, + value_sim_threshold=0.5, + cache_literal_embeddings=True + ) + + tg = KgManager.load_kg(gen_kg_path) + metric_result : MetricResult = TripleAlignmentMetric().compute(tg, triple_alignment_config) + result_string = render_metric_result(metric_result) + _write_to_file(result_string, Path(f"data/tmp/rdf_pipelines/triple_alignment_{entity_matching_threshold}_{relation_matching_threshold}.txt")) + + +@pytest.mark.parametrize("entity_linking_threshold", [0.5, 0.6, 0.7, 0.8, 0.9]) +@pytest.mark.parametrize("relation_linking_threshold", [0.5, 0.6, 0.7, 0.8, 0.9]) +def test_openie_pipeline(entity_linking_threshold, relation_linking_threshold): + """ + test the openie pipeline + """ + pipeline, config_catalog = get_openie_pipeline(entity_linking_threshold, relation_linking_threshold) + + print(pipeline.plan()) \ No newline at end of file diff --git a/experiments/param-opti/src/qap/test_sge_based.py b/experiments/param-opti/src/qap/test_sge_based.py new file mode 100644 index 0000000..b6674bd --- /dev/null +++ b/experiments/param-opti/src/qap/test_sge_based.py @@ -0,0 +1,36 @@ +def eval_paris_pipeline(entity_matching_threshold: float, relation_matching_threshold: float): + """ + evaluate a paris pipeline with different thresholds for entity matching and relation matching + """ + pass + + # ref_kg_path = Path("data/inputs/reference_kg/data_agg.nt") + # gen_kg_path = Path(f"data/tmp/rdf_pipelines/result_{entity_matching_threshold}_{relation_matching_threshold}.nt") + + # source_grounded_correctness_config = SourceGroundedCorrectnessConfig( + # kg_graph=ref_kg_path, + # source_corpus=gen_kg_path, + # index_dir=Path("data/tmp/source_grounded_correctness"), + # verbalize_method="natural", + # verifier="nli", + # nli_model="facebook/bart-large-mnli", + # nli_device="cpu", + # llm_model="gpt-4.1-mini", + # llm_device="cpu" + # ) + + # source_grounded_correctness_metric = SourceGroundedCorrectnessMetric() + # source_grounded_correctness_metric.compute(KgManager.load_kg(gen_kg_path), source_grounded_correctness_config) + +def eval_openie_pipeline(): + """ + evaluate an openie pipeline + """ + pass + + # ref_kg_path = Path("data/inputs/reference_kg/data_agg.nt") + # gen_kg_path = Path(f"data/tmp/rdf_pipelines/result_{entity_matching_threshold}_{relation_matching_threshold}.nt") + + # source_grounded_coverage_config = SourceGroundedCoverageConfig( + # kg_graph=ref_kg_path, + # source_corpus=gen_kg_path, \ No newline at end of file diff --git a/src/kgpipe/common/model/configuration.py b/src/kgpipe/common/model/configuration.py index 4ebacb9..0c52bec 100644 --- a/src/kgpipe/common/model/configuration.py +++ b/src/kgpipe/common/model/configuration.py @@ -33,13 +33,13 @@ class Parameter(BaseModel): # +allowed_values: any[*]? # +min/max/unit: number?/number?/string? name: str - native_keys: List[str] datatype: ParameterType - default_value: str | int | float | bool - required: bool + default_value: str | int | float | bool = field(default_factory=lambda: None) + required: bool = False + native_keys: List[str] = field(default_factory=list) # scope: Scope # (training/inference/io/resources) # constraints - allowed_values: List[str | int | float | bool] + allowed_values: List[str | int | float | bool] = field(default_factory=list) minimum: Optional[float] = None maximum: Optional[float] = None unit: Optional[str] = None diff --git a/src/kgpipe_eval/metrics/triple_alignment.py b/src/kgpipe_eval/metrics/triple_alignment.py index 7934ff6..516f18d 100644 --- a/src/kgpipe_eval/metrics/triple_alignment.py +++ b/src/kgpipe_eval/metrics/triple_alignment.py @@ -3,29 +3,71 @@ from kgpipe.common import KG from kgpipe_eval.metrics.entity_alignment import EntityAlignmentConfig +from kgpipe_eval.utils.kg_utils import KgLike, KgManager, TripleGraph +from kgpipe_eval.utils.alignment_utils import align_triples_by_value_embedding from kgpipe_eval.utils.measurement_utils import BCMeasurement -from kgpipe_eval.api import Metric +from kgpipe_eval.api import Measurement, Metric, MetricResult # measures precision, recall, f1 score, etc. class TripleAlignmentConfig(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) - reference_kg: KG + reference_kg: KgLike + method: Literal["value_embedding", "exact"] = "value_embedding" entity_alignment_config: EntityAlignmentConfig value_sim_threshold: float = 0.5 + cache_literal_embeddings: bool = False + cache_ref_literal_embeddings: bool = True -# def eval_triple_alignment(method: Literal["exact", "fuzzy", "semantic"] = "exact"): -# pass +def eval_triple_alignment(tg: TripleGraph, config: TripleAlignmentConfig): + if config.method == "value_embedding": + alignments = align_triples_by_value_embedding(tg, config) + elif config.method == "exact": + pass + # alignments = align_triples_by_exact_match(tg, config) + else: + raise ValueError(f"Invalid method: {config.method}") + + print("Triple alignments: ", len(alignments)) + + ref_tg = KgManager.load_kg(config.reference_kg) + ref_triples = set(ref_tg.triples((None, None, None))) + gen_triples = set(tg.triples((None, None, None))) + + aligned_ref_triples = set(a.target for a in alignments) + aligned_gen_triples = set(a.source for a in alignments) -def eval_triple_alignment_by_label_embedding(method: Literal["exact", "fuzzy", "semantic"] = "exact"): - pass + tp = len(aligned_ref_triples) # aligned reference triples + fp = len(gen_triples - aligned_gen_triples) # generated triples not aligned to any reference triple + tn = 0 + fn = len(ref_triples - aligned_ref_triples) # reference triples missing in generation + return BCMeasurement(tp=tp, fp=fp, tn=tn, fn=fn) -def eval_triple_alignment_by_label_embedding_soft_literals(method: Literal["exact", "fuzzy", "semantic"] = "exact"): - pass +# def eval_triple_alignment_by_label_embedding(method: Literal["exact", "fuzzy", "semantic"] = "exact"): +# pass + + +# def eval_triple_alignment_by_label_embedding_soft_literals(method: Literal["exact", "fuzzy", "semantic"] = "exact"): +# pass class ReferenceTripleAlignmentMetric(Metric): - pass + + def compute(self, kg: KG, config: TripleAlignmentConfig): + m: BCMeasurement = eval_triple_alignment(kg, config) + return MetricResult( + metric=self, + measurements=[ + Measurement(name="tp", value=m.tp, unit="number"), + Measurement(name="fp", value=m.fp, unit="number"), + Measurement(name="tn", value=m.tn, unit="number"), + Measurement(name="fn", value=m.fn, unit="number"), + Measurement(name="precision", value=m.precision(), unit="percentage"), + Measurement(name="recall", value=m.recall(), unit="percentage"), + Measurement(name="f1_score", value=m.f1_score(), unit="percentage"), + ], + summary=f"Triple alignment by {config.method}", + ) # Backward-compatibility alias (imported by `kgpipe_eval.metrics.__init__`). diff --git a/src/kgpipe_eval/test/examples.py b/src/kgpipe_eval/test/examples.py index dc7aeb3..06ad14d 100644 --- a/src/kgpipe_eval/test/examples.py +++ b/src/kgpipe_eval/test/examples.py @@ -14,7 +14,6 @@ rdfs:label "HarperCollins" ; :countryCode "GB" . """ - TEST_TURTLE_TRIPLES = """ @prefix : . @prefix o: . @@ -96,8 +95,89 @@ rdfs:label "Unexpected Entity"@en . """ +GENERATED_TURTLE_TRIPLES = """ +@prefix : . +@prefix o: . +@prefix rdf: . +@prefix rdfs: . +@prefix xsd: . + +# Entities designed to exercise alignment corner-cases: +# - multiple entities per type (Book/Author/Publisher/Store) +# - missing / extra attributes across graphs +# - literal variations (lang tags, datatypes, different lexical forms) +# - ambiguous labels (near-duplicates, casing differences) +# - multi-valued properties + +:store1 rdf:type o:BookStore ; + rdfs:label "Example Books (Downtown)"@en ; + :countryCode "US" ; + :hasInventory :itemA, :itemB, :itemC . + +:publisherHC rdf:type o:Publisher ; + rdfs:label "HarperCollins" ; + :countryCode "GB" . + +# different wrong type +:publisherPenguin rdf:type o:Author ; + rdfs:label "Penguin Books"@en ; + :countryCode "GB" . + +:authorTolkien rdf:type o:Author ; + rdfs:label "J. R. R. Tolkien" ; + :born "1892-01-03"^^xsd:date ; + :died "1973-09-02"^^xsd:date ; + :sameAs . + +:authorRowling rdf:type o:Author ; + rdfs:label "J.K. Rowling" ; + :born "1965-07-31"^^xsd:date . + +:itemA rdf:type o:Book ; + rdfs:label "The Hobbit"@en ; + :bookTitle "The Hobbit, or There and Back Again"@en ; + :bookAuthor :authorTolkien ; + :publisher :publisherHC ; + :isbn13 "9780261102217" ; + :pageCount "310"^^xsd:integer ; + :tags "fantasy", "classic" ; + :inSeries :seriesMiddleEarth . + +:itemB rdf:type o:Book ; + rdfs:label "The Hobbit (Illustrated)"@en ; + :bookTitle "The Hobbit"@en ; + :bookAuthor :authorTolkien ; + :publisher :publisherHC ; + :isbn13 "978-0-261-10221-7" ; # lexical variation + :pageCount 320 ; # integer without explicit datatype + :publicationYear "1997"^^xsd:gYear . + +:itemC rdf:type o:Book ; + rdfs:label "Harry Potter and the Philosopher's Stone"@en ; + :bookTitle "Harry Potter and the Philosopher's Stone"@en ; + :bookAuthor :authorRowling ; + :publisher :publisherPenguin ; + :isbn13 "9780747532699" ; + :pageCount "223"^^xsd:integer . + +# Same label, different type (common edge case for label-only alignment) +:hobbit rdf:type o:Film ; + rdfs:label "The Hobbit"@en ; + :releaseYear "2012"^^xsd:gYear . + +# Missing rdf:type but has label (edge case for type-aware matching) +:unknownEntity rdfs:label "HarperCollins" . + +:seriesMiddleEarth rdf:type o:Series ; + rdfs:label "Middle-earth Legendarium"@en . + +# false positive unexpected entity +:unexpectedEntity rdf:type o:Book ; + rdfs:label "Unexpected Entity"@en . +""" + REFERENCE_TURTLE_TRIPLES = """ -@prefix : . +@prefix : . @prefix o: . @prefix rdf: . @prefix rdfs: . diff --git a/src/kgpipe_eval/test/test_alignment_eval.py b/src/kgpipe_eval/test/test_alignment_eval.py index b8ddc9e..9d10a93 100644 --- a/src/kgpipe_eval/test/test_alignment_eval.py +++ b/src/kgpipe_eval/test/test_alignment_eval.py @@ -2,7 +2,8 @@ from kgpipe_eval.utils.alignment_utils import EntityAlignmentConfig from kgpipe_eval.metrics.entity_alignment import EntityAlignmentMetric -from kgpipe_eval.test.utils import get_test_kg, get_verified_entities_path, render_metric_result +from kgpipe_eval.metrics.triple_alignment import TripleAlignmentMetric, TripleAlignmentConfig +from kgpipe_eval.test.utils import get_test_kg, get_verified_entities_path, render_metric_result, get_reference_kg, get_generated_kg from kgpipe_eval.utils.kg_utils import KgManager from kgpipe_eval.api import MetricResult @@ -29,4 +30,33 @@ def test_align_entities_by_label_embedding_and_type(): ) tg = KgManager.load_kg(get_test_kg()) metric_result : MetricResult = EntityAlignmentMetric().compute(tg, config) - print(render_metric_result(metric_result)) \ No newline at end of file + print(render_metric_result(metric_result)) + +def test_align_entities_by_label_embedding_and_type_ref_kg(): + config = EntityAlignmentConfig( + method="label_embedding", + reference_kg=get_reference_kg(), + verified_entities_path=None, + verified_entities_delimiter="\t", + entity_sim_threshold=0.95 + ) + tg = KgManager.load_kg(get_test_kg()) + metric_result : MetricResult = EntityAlignmentMetric().compute(tg, config) + print(render_metric_result(metric_result)) + +def test_align_triples_by_value_embedding(): + config = TripleAlignmentConfig( + reference_kg=get_reference_kg(), + entity_alignment_config=EntityAlignmentConfig( + method="label_embedding", + reference_kg=get_reference_kg(), + verified_entities_path=None, + verified_entities_delimiter="\t", + entity_sim_threshold=0.95 + ), + value_sim_threshold=0.5 + ) + tg = KgManager.load_kg(get_generated_kg()) + metric_result : MetricResult = TripleAlignmentMetric().compute(tg, config) + print(render_metric_result(metric_result)) + diff --git a/src/kgpipe_eval/test/utils.py b/src/kgpipe_eval/test/utils.py index afe8034..855b957 100644 --- a/src/kgpipe_eval/test/utils.py +++ b/src/kgpipe_eval/test/utils.py @@ -24,6 +24,16 @@ def get_test_kg(sample_size: int = -1) -> KG: g.serialize(destination=tmp_dir / "test.nt", format="ntriples") return KG("test", name="test", path=tmp_dir / "test.nt", format=DataFormat.RDF_NTRIPLES) +def get_generated_kg(sample_size: int = -1) -> KG: + generated_triples = GENERATED_TURTLE_TRIPLES + if sample_size > 0: + generated_triples = generated_triples[:sample_size] + # write generated_triples to a file + g = Graph() + g.parse(data=generated_triples, format="turtle") + g.serialize(destination=tmp_dir / "generated.nt", format="ntriples") + return KG("generated", name="generated", path=tmp_dir / "generated.nt", format=DataFormat.RDF_NTRIPLES) + def get_reference_kg(sample_size: int = -1) -> KG: reference_triples = REFERENCE_TURTLE_TRIPLES if sample_size > 0: diff --git a/src/kgpipe_eval/utils/alignment_utils.py b/src/kgpipe_eval/utils/alignment_utils.py index be87a96..1e4f16c 100644 --- a/src/kgpipe_eval/utils/alignment_utils.py +++ b/src/kgpipe_eval/utils/alignment_utils.py @@ -1,5 +1,6 @@ +from transformers.models.t5gemma2.modeling_t5gemma2 import T5Gemma2ClassificationHead from kgpipe.common import KG -from typing import Literal, NamedTuple, Optional +from typing import TYPE_CHECKING, Literal, NamedTuple, Optional from functools import lru_cache from pydantic import BaseModel, ConfigDict, model_validator @@ -7,13 +8,19 @@ from kgpipe.util.embeddings.st_emb import get_model from rdflib import RDFS, RDF +from rdflib.term import BNode +from rdflib.term import Literal as RdLiteral from kgpipe.datasets.multipart_multisource import read_entities_csv, EntitiesRow import numpy as np from pathlib import Path +from tqdm import tqdm +from tqdm import tqdm from typing import Set # TODO source entities csv to label only graph +DEBUG = True + class EntityAlignmentConfig(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) method: Literal["label_embedding", "label_alias_embedding", "label_embedding_and_type", "label_embedding_and_intersecting_type"] = "label_embedding" @@ -127,3 +134,239 @@ def align_entities_by_label_embedding(tg: TripleGraph, config: EntityAlignmentCo def align_by_label_alias_embedding(triple_graph: TripleGraph, model="", similarity="cosine", threshold=0.5): pass + + +if TYPE_CHECKING: # avoid circular import at runtime + from kgpipe_eval.metrics.triple_alignment import TripleAlignmentConfig + + +def _is_literal(term: Term) -> bool: + return isinstance(term, RdLiteral) + + +def _literal_text(lit: RdLiteral) -> str: + # Prefer lexical form; fall back to python value string. + try: + return str(lit) + except Exception: + return str(lit.toPython()) + + +def align_triples_by_value_embedding(tg: TripleGraph, config: "TripleAlignmentConfig") -> list[TripleAlignment]: + """ + Align generated triples in `tg` to reference triples using: + - entity alignment (for URI/BNode subjects/objects) + - embedding similarity for literal object values (for same subject+predicate) + """ + ref_tg = KgManager.load_kg(config.reference_kg) + + # 0) Blank node mapping. + # + # rdflib assigns fresh IDs to BNodes on parse/load, so loading the "same" KG + # twice will not preserve BNode identifiers. We map BNodes by an outgoing-edge + # signature (predicate + object lexical form) to make exact-equal graphs align. + def _term_key(t: Term) -> str: + return str(t) + + def _bnode_signature(g: TripleGraph, b: BNode) -> tuple[tuple[str, str], ...]: + pairs: list[tuple[str, str]] = [] + for _, p, o in g.triples((b, None, None)): + if _is_literal(o): + ok = _literal_text(o) + else: + ok = _term_key(o) + pairs.append((_term_key(p), ok)) + pairs.sort() + return tuple(pairs) + + def _build_bnode_map(gen_g: TripleGraph, ref_g: TripleGraph) -> dict[str, Term]: + ref_by_sig: dict[tuple[tuple[str, str], ...], list[BNode]] = {} + for s, _, _ in ref_g.triples((None, None, None)): + if isinstance(s, BNode): + print(f"Ref bnode: {s}") + sig = _bnode_signature(ref_g, s) + ref_by_sig.setdefault(sig, []).append(s) + + gen_by_sig: dict[tuple[tuple[str, str], ...], list[BNode]] = {} + for s, _, _ in gen_g.triples((None, None, None)): + if isinstance(s, BNode): + print(f"Gen bnode: {s}") + sig = _bnode_signature(gen_g, s) + gen_by_sig.setdefault(sig, []).append(s) + + # Accept signature matches. If a signature occurs multiple times in both graphs, + # map deterministically by sorting node IDs and zipping. This makes identical KGs + # align even when they contain repeated blank-node structures. + out: dict[str, Term] = {} + for sig, gen_nodes in gen_by_sig.items(): + ref_nodes = ref_by_sig.get(sig, []) + if not ref_nodes: + continue + if len(gen_nodes) != len(ref_nodes): + continue + for gnode, rnode in zip(sorted(gen_nodes, key=_term_key), sorted(ref_nodes, key=_term_key)): + out[_term_key(gnode)] = rnode + return out + + gen_bnode_to_ref: dict[str, Term] = _build_bnode_map(tg, ref_tg) + + # 1) Entity alignments (generated -> reference) + ent_cfg = config.entity_alignment_config + if getattr(ent_cfg, "reference_kg", None) is None and getattr(ent_cfg, "verified_entities_path", None) is None: + # Ensure validator requirements are met; default to using the reference KG. + ent_cfg = ent_cfg.model_copy(update={"reference_kg": config.reference_kg}) + + entity_alignments = align_entities_by_label_embedding(tg, ent_cfg) + + if DEBUG: print("Entity alignments: ", len(entity_alignments)) + + gen_to_ref_entity: dict[str, Term] = {} + best_score_by_gen: dict[str, float] = {} + for a in entity_alignments: + gen_key = str(a.source) + if gen_key not in best_score_by_gen or a.score > best_score_by_gen[gen_key]: + best_score_by_gen[gen_key] = float(a.score) + gen_to_ref_entity[gen_key] = a.target + + # 2) Index generated triples, both raw and entity-mapped + mapped_gen_triples: list[tuple[Triple, Triple]] = [] # (raw_gen, mapped_to_ref_space) + gen_by_sp_literal: dict[tuple[Term, Term], list[tuple[Triple, str]]] = {} + gen_by_sp_entity: dict[tuple[Term, Term], set[Triple]] = {} + + if DEBUG: print("Gen by sp literal: ", len(gen_by_sp_literal)) + if DEBUG: print("Gen by sp entity: ", len(gen_by_sp_entity)) + + sp_iter = getattr(tg, "iter_sp_groups", None) + if callable(sp_iter): + sp_groups = sp_iter() + for s, p, os in sp_groups: + for o in os: + mapped_s = gen_to_ref_entity.get(str(s), gen_bnode_to_ref.get(str(s), s)) + mapped_o = gen_to_ref_entity.get(str(o), gen_bnode_to_ref.get(str(o), o)) if not _is_literal(o) else o + mapped = (mapped_s, p, mapped_o) + raw = (s, p, o) + mapped_gen_triples.append((raw, mapped)) + + # Normalize keys to string form to avoid rdflib Term vs string mismatches. + sp = (_term_key(mapped_s), _term_key(p)) + if _is_literal(o): + gen_by_sp_literal.setdefault(sp, []).append((raw, _literal_text(o))) + else: + gen_by_sp_entity.setdefault(sp, set()).add(raw) + else: + for s, p, o in tg.triples((None, None, None)): + mapped_s = gen_to_ref_entity.get(str(s), gen_bnode_to_ref.get(str(s), s)) + mapped_o = gen_to_ref_entity.get(str(o), gen_bnode_to_ref.get(str(o), o)) if not _is_literal(o) else o + mapped = (mapped_s, p, mapped_o) + raw = (s, p, o) + mapped_gen_triples.append((raw, mapped)) + + # Normalize keys to string form to avoid rdflib Term vs string mismatches. + sp = (_term_key(mapped_s), _term_key(p)) + if _is_literal(o): + gen_by_sp_literal.setdefault(sp, []).append((raw, _literal_text(o))) + else: + gen_by_sp_entity.setdefault(sp, set()).add(raw) + + if DEBUG: print("Mapped gen triples: ", len(mapped_gen_triples)) + + # 3) Prepare literal embedding caches (optional) + model = get_model() + alignments: list[TripleAlignment] = [] + + # Encode generated literal texts once, cache by text. + cache_gen_literals = bool(getattr(config, "cache_literal_embeddings", True)) + gen_lit_emb_by_text: dict[str, np.ndarray] = {} + if cache_gen_literals and gen_by_sp_literal: + unique_texts = sorted({txt for candidates in gen_by_sp_literal.values() for _, txt in candidates}) + if unique_texts: + emb = model.encode(unique_texts, convert_to_numpy=True, show_progress_bar=True) + gen_lit_emb_by_text = {t: emb[i : i + 1] for i, t in enumerate(unique_texts)} + + # Reference literal embedding cache (by text). + cache_ref_literals = bool(getattr(config, "cache_ref_literal_embeddings", True)) + ref_lit_emb_by_text: dict[str, np.ndarray] = {} + if cache_ref_literals: + unique_texts = sorted({_literal_text(ro) for _, _, ro in ref_tg.triples((None, None, None))}) + if unique_texts: + emb = model.encode(unique_texts, convert_to_numpy=True, show_progress_bar=True) + ref_lit_emb_by_text = {t: emb[i : i + 1] for i, t in enumerate(unique_texts)} + + def get_ref_literal_embedding(texts: list[str]) -> np.ndarray: + if cache_ref_literals and ref_lit_emb_by_text: + return np.concatenate([ref_lit_emb_by_text[t] for t in texts], axis=0) + else: + return model.encode(texts, convert_to_numpy=True, show_progress_bar=True) + + + def get_gen_literal_embedding(texts: list[str]) -> np.ndarray: + if cache_gen_literals and gen_lit_emb_by_text: + return np.concatenate([gen_lit_emb_by_text[t] for t in texts], axis=0) + else: + return model.encode(texts, convert_to_numpy=True, show_progress_bar=True) + + if DEBUG: print("gen_lit_emb_by_text: ", len(gen_lit_emb_by_text)) + if DEBUG: print("ref_lit_emb_by_text: ", len(ref_lit_emb_by_text)) + + from rdflib import Graph, URIRef + gen_graph : Graph = tg._graph() + ref_graph : Graph = ref_tg._graph() + + test_objects = list(ref_graph.objects(URIRef("http://kg.org/resource/f4eb17c4ed78c87c29124018c9f180b5"), URIRef("http://kg.org/ontology/deathPlace"), unique=True)) + if DEBUG: print("Test objects: ", len(test_objects)) + + if DEBUG: print("Gen graph: ", len(list(gen_graph.triples((None, None, None))))) + if DEBUG: print("Ref graph: ", len(list(ref_graph.triples((None, None, None))))) + + for gs, gp in tqdm(gen_graph.subject_predicates(unique=True), desc="Aligning triples by value embedding"): + # sp = (_term_key(gs), _term_key(gp)) + + # check for s mapping in reference space + ref_s = gen_to_ref_entity.get(str(gs), gen_bnode_to_ref.get(str(gs), gs)) + if ref_s is None: + continue # s is not mapped to reference space + + gen_objects = list(gen_graph.objects(gs, gp)) + gen_literal_objs = [o for o in gen_objects if _is_literal(o)] + + # IMPORTANT: query reference objects in reference-space subject + ref_objects = list(ref_graph.objects(URIRef(str(ref_s)), URIRef(str(gp)))) + ref_literal_objs = [o for o in ref_objects if _is_literal(o)] + + # print("gs: ", gs, "gp: ", gp) + # print("ref_s: ", ref_s) + # print("gen_literal_objs: ", len(gen_literal_objs)) + # print("ref_literal_objs: ", len(ref_literal_objs)) + # print("gen_objects: ", len(gen_objects)) + # print("ref_objects: ", len(ref_objects)) + + if len(gen_literal_objs) > 0 and len(ref_literal_objs) > 0: + + gen_object_texts = [_literal_text(o) for o in gen_literal_objs] + ref_object_texts = [_literal_text(o) for o in ref_literal_objs] + + gen_object_embeddings = get_gen_literal_embedding(gen_object_texts) + ref_object_embeddings = get_ref_literal_embedding(ref_object_texts) + + sims = np.dot(gen_object_embeddings, ref_object_embeddings.T) # shape (n_gen, n_ref) + best_flat = int(np.argmax(sims)) + best_i, best_j = np.unravel_index(best_flat, sims.shape) + + if float(sims[best_i, best_j]) >= float(config.value_sim_threshold): + alignments.append( + TripleAlignment( + source=(gs, gp, gen_literal_objs[best_i]), + target=(ref_s, gp, ref_literal_objs[best_j]), + ) + ) + + # get all non-literal objects mapped to reference space + gen_object_non_literal = [o for o in gen_objects if not _is_literal(o)] + ref_object_non_literal = [o for o in ref_objects if not _is_literal(o)] + + # find if any of the non-literal objects in the generated graph are mapped to the same object in the reference graph + for gen_obj in gen_object_non_literal: + if gen_to_ref_entity.get(str(gen_obj), gen_bnode_to_ref.get(str(gen_obj), gen_obj)) in ref_object_non_literal: + alignments.append(TripleAlignment(source=(gs, gp, gen_obj), target=(gs, gp, ref_object_non_literal[ref_object_non_literal.index(gen_obj)]))) + + return alignments \ No newline at end of file diff --git a/src/kgpipe_eval/utils/kg_utils.py b/src/kgpipe_eval/utils/kg_utils.py index 09b3410..1032af2 100644 --- a/src/kgpipe_eval/utils/kg_utils.py +++ b/src/kgpipe_eval/utils/kg_utils.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from pathlib import Path from typing import Iterable, Protocol, Union, runtime_checkable, Optional, Tuple, Literal +from collections import defaultdict from rdflib import RDF, Graph, RDFS from rdflib.term import Identifier, Literal, URIRef @@ -121,6 +122,25 @@ def subjects(self) -> Iterable[Term]: g = self._graph() return g.subjects(unique=True) + def iter_sp_groups(self) -> Iterable[tuple[Term, Term, list[Term]]]: + """Yield (s, p, [o1, o2, ...]) for all subjects/predicates.""" + g = self._graph() + by_sp: dict[tuple[Term, Term], list[Term]] = defaultdict(list) + for s, p, o in g.triples((None, None, None)): + by_sp[(s, p)].append(o) + for (s, p), objs in by_sp.items(): + yield (s, p, objs) + + def subject_predicate_pairs(self) -> Iterable[tuple[Term, Term]]: + """Yield (s, p) for all subjects/predicates.""" + g = self._graph() + return g.subject_predicates(unique=True) + + def objects(self, subject: Term, predicate: Term) -> Iterable[Term]: + """Yield (o) for all objects of (s, p).""" + g = self._graph() + return g.objects(subject, predicate) + def entities(self) -> Iterable[Term]: return self.subjects() # TODO inlcude objects that are not subjects diff --git a/src/kgpipe_llm/common/core.py b/src/kgpipe_llm/common/core.py index d1ee620..7c3af9f 100644 --- a/src/kgpipe_llm/common/core.py +++ b/src/kgpipe_llm/common/core.py @@ -3,7 +3,7 @@ """ import os -from typing import Any, Dict, Generic, Optional, TypeVar +from typing import Any, Dict, Generic, Optional, TypeVar, cast from dotenv import load_dotenv from pydantic import BaseModel @@ -92,7 +92,7 @@ def send_prompt( ) print(f"INFO: {self.api_type}_call_with_tool {type(schema_class)}") - dict_val, _model_val = tool_call( + dict_val, model_val = tool_call( endpoint_url=endpoint, api_key=self.token, model_name=self.model_name, @@ -101,10 +101,16 @@ def send_prompt( system_prompt=system_prompt, seed=self.seed, ) + # Prefer returning the validated Pydantic instance when possible. + if isinstance(schema_class, type) and issubclass(schema_class, BaseModel): + if isinstance(model_val, BaseModel): + return model_val + if isinstance(dict_val, dict): + return cast(type[T], schema_class).model_validate(dict_val) return dict_val print(f"INFO: ollama_call with {type(schema_class)}") - return ollama_call( + raw_val = ollama_call( endpoint_url=self.endpoint_url, api_key=self.token, model_name=self.model_name, @@ -113,6 +119,10 @@ def send_prompt( system_prompt=system_prompt, seed=self.seed, ) + # Ollama path currently returns raw JSON; upgrade to a validated model when requested. + if isinstance(schema_class, type) and issubclass(schema_class, BaseModel) and isinstance(raw_val, dict): + return cast(type[T], schema_class).model_validate(raw_val) + return raw_val class BaseTask(Generic[T]): From 71b0db873127c0c6e8a8b875f06151923e1ac039 Mon Sep 17 00:00:00 2001 From: Marvin Date: Tue, 12 May 2026 16:30:32 +0200 Subject: [PATCH 62/96] exp(params): final selection of pipelines --- .../src/param_opti/tasks/base_linker.py | 4 +- .../src/param_opti/tasks/base_linker_lib.py | 2 + .../src/param_opti/tasks/base_matcher.py | 1 + .../param-opti/src/param_opti/tasks/genie.py | 23 +- .../src/param_opti/tasks/genie_lib.py | 89 ++++++++ .../param-opti/src/param_opti/tasks/paris.py | 45 +++- .../src/param_opti/tasks/spotlight.py | 29 ++- .../src/param_opti/tasks/spotlight_lib.py | 83 ++------ .../src/param_opti/tasks/text_helpers.py | 37 +++- .../rdf_sampled_pipeline_configs.json | 64 ++---- .../text_sampled_pipeline_configs.json | 153 +++++++++++++ ...eline_config.py => test_conf_pipelines.py} | 201 +++++++++++++++--- .../param-opti/src/qap/test_eval_pipelines.py | 122 +++++++++++ .../param-opti/src/qap/test_exec_pipelines.py | 52 ++++- src/kgpipe_eval/utils/alignment_utils.py | 32 ++- 15 files changed, 753 insertions(+), 184 deletions(-) create mode 100644 experiments/param-opti/src/qap/fixtures/text_sampled_pipeline_configs.json rename experiments/param-opti/src/qap/{test_pipeline_config.py => test_conf_pipelines.py} (74%) create mode 100644 experiments/param-opti/src/qap/test_eval_pipelines.py diff --git a/experiments/param-opti/src/param_opti/tasks/base_linker.py b/experiments/param-opti/src/param_opti/tasks/base_linker.py index 1d09432..aee7932 100644 --- a/experiments/param-opti/src/param_opti/tasks/base_linker.py +++ b/experiments/param-opti/src/param_opti/tasks/base_linker.py @@ -16,7 +16,7 @@ def relation_linker_label_alias_embedding_transformer_function(inputs: TaskInput config_spec=ConfigurationDefinition( name="relation_linker_label_alias_embedding_transformer", parameters=[ - Parameter(name="model_name", native_keys=["--model-name"], datatype=ParameterType.string, default_value="sentence-transformers/all-MiniLM-L6-v2", required=True, allowed_values=["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2"]), + Parameter(name="model_name", native_keys=["--model-name"], datatype=ParameterType.string, default_value="sentence-transformers/all-MiniLM-L6-v2", required=True, allowed_values=["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2", "intfloat/e5-base-v2"]), Parameter(name="similarity_threshold", native_keys=["--similarity-threshold"], datatype=ParameterType.number, default_value=0.5, required=True, allowed_values=[0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]), ] ) @@ -37,7 +37,7 @@ def entity_linker_label_alias_embedding_transformer_function(inputs: TaskInput, config_spec=ConfigurationDefinition( name="entity_linker_label_alias_embedding_transformer", parameters=[ - Parameter(name="model_name", native_keys=["--model-name"], datatype=ParameterType.string, default_value="sentence-transformers/all-MiniLM-L6-v2", required=True, allowed_values=["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2"]), + Parameter(name="model_name", native_keys=["--model-name"], datatype=ParameterType.string, default_value="sentence-transformers/all-MiniLM-L6-v2", required=True, allowed_values=["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2", "intfloat/e5-base-v2"]), Parameter(name="similarity_threshold", native_keys=["--similarity-threshold"], datatype=ParameterType.number, default_value=0.5, required=True, allowed_values=[0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]), ] ) diff --git a/experiments/param-opti/src/param_opti/tasks/base_linker_lib.py b/experiments/param-opti/src/param_opti/tasks/base_linker_lib.py index 680a575..1b0b12d 100644 --- a/experiments/param-opti/src/param_opti/tasks/base_linker_lib.py +++ b/experiments/param-opti/src/param_opti/tasks/base_linker_lib.py @@ -156,6 +156,7 @@ class AliasAndTransformerBasedRelationLinker: """ def __init__(self, ontology_file, model_name: str = "all-MiniLM-L6-v2", threshold: float = 0.0): + print(f"Init AliasAndTransformerBasedRelationLinker with ontology file: {ontology_file} and model name: {model_name} and threshold: {threshold}") self.ontology = OntologyUtil.load_ontology_from_file(ontology_file) self.embedder = SentenceTransformerEmbedder(model_name=model_name) self.threshold = float(threshold) @@ -175,6 +176,7 @@ def link_relations(self, extracted_relations: List[str]) -> List[RelationMatch]: for i, relation in enumerate(extracted_relations): best_idx = int(similarities[i].argmax()) best_score = float(similarities[i][best_idx]) + # print(f"Relation: {relation}, matched to: {self.ontology.properties[best_idx].uri}, label: {self.ontology.properties[best_idx].label}, Best Index: {best_idx}, Best Score: {best_score}") if best_score < self.threshold: continue match = self.ontology.properties[best_idx] diff --git a/experiments/param-opti/src/param_opti/tasks/base_matcher.py b/experiments/param-opti/src/param_opti/tasks/base_matcher.py index a43bc45..223fdc4 100644 --- a/experiments/param-opti/src/param_opti/tasks/base_matcher.py +++ b/experiments/param-opti/src/param_opti/tasks/base_matcher.py @@ -19,6 +19,7 @@ def _embedding_config_params(): allowed_values=[ "sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2", + "intfloat/e5-base-v2", ], ), Parameter( diff --git a/experiments/param-opti/src/param_opti/tasks/genie.py b/experiments/param-opti/src/param_opti/tasks/genie.py index 1abf7c6..a97b566 100644 --- a/experiments/param-opti/src/param_opti/tasks/genie.py +++ b/experiments/param-opti/src/param_opti/tasks/genie.py @@ -1,9 +1,28 @@ from typing import Dict, Any from kgpipe.common import Data, DataFormat, Registry, KgTask - +from pathlib import Path def genie_text_extraction_function(inputs: Dict[str, Data], outputs: Dict[str, Data]): - pass + from param_opti.tasks.genie_lib import genie_task_docker, genie_exchange + + # Ensure parent directory exists for the TE JSON output path + outputs["output"].path.parent.mkdir(parents=True, exist_ok=True) + + input_path: Path = inputs["input"].path + final_te_output: Data = outputs["output"] + + # 1) Produce intermediate OpenIE JSON (file or directory) + if input_path.is_dir(): + genie_out_path = final_te_output.path.parent / f"{final_te_output.path.stem}_corenlp_openie_out" + else: + genie_out_path = final_te_output.path.parent / f"{final_te_output.path.stem}_corenlp_openie.json" + + genie_outpit = {"output": Data(genie_out_path, DataFormat.OPENIE_JSON)} + genie_task_docker({"input": inputs["input"]}, genie_outpit) + + # 2) Convert OpenIE JSON → TE JSON (final output) + genie_exchange({"input": genie_outpit["output"]}, {"output": final_te_output}) + genie_text_extraction_task = KgTask( name="genie_text_extraction", diff --git a/experiments/param-opti/src/param_opti/tasks/genie_lib.py b/experiments/param-opti/src/param_opti/tasks/genie_lib.py index e69de29..0806f37 100644 --- a/experiments/param-opti/src/param_opti/tasks/genie_lib.py +++ b/experiments/param-opti/src/param_opti/tasks/genie_lib.py @@ -0,0 +1,89 @@ + +import re +import json +import os +from typing import Dict +from kgpipe.common import Data, TaskInput, TaskOutput +from kgpipe.common import KgTask, DataFormat, Data, Registry, TaskInput, TaskOutput +from kgpipe.common.io import get_docker_volume_bindings, remap_data_path_for_container +from kgpipe.execution import docker_client +from kgpipe_tasks.transform_interop.exchange.entity_matching import ER_Match, ER_Document + + +def genie_task_docker(inputs: TaskInput, outputs: TaskOutput): + """ + GenIE information extraction task that runs in a Docker container. + + Args: + inputs: Dictionary mapping input names to Data objects + outputs: Dictionary mapping output names to Data objects + """ + + all_data = list(inputs.values()) + list(outputs.values()) + volumes, host_to_container = get_docker_volume_bindings(all_data) + + source_path = remap_data_path_for_container(inputs["input"], host_to_container) + output_path = remap_data_path_for_container(outputs["output"], host_to_container) + + client = docker_client( + image="genie:latest", + command=["genie.sh", + str(source_path.path), + str(output_path.path)], + volumes=volumes, + ) + + result = client() + print(f"GenIE completed: {result}") + +def process_io(input_path, output_path, process_file_fn, extension): + if os.path.isdir(input_path): + os.makedirs(output_path, exist_ok=True) + + for filename in os.listdir(input_path): + input_file = os.path.join(input_path, filename) + + if not os.path.isfile(input_file): + continue + + output_file = os.path.join( + output_path, + os.path.splitext(filename)[0] + extension + ) + + process_file_fn(input_file, output_file) + + else: + process_file_fn(input_path, output_path) + +def genie_exchange(inputs: Dict[str, Data], outputs: Dict[str, Data]): + input_path = inputs["input"].path + output_path = outputs["output"].path + + triple_pattern = re.compile( + r"\s*(.*?)\s*\s*(.*?)\s*\s*(.*?)\s*" + ) + + def exchange_file(input_file, output_file): + triples = [] + chains = [] + + with open(input_file, "r", encoding="utf-8") as f: + genie_output = json.load(f) + + for sentence in genie_output: + for beam in sentence: + text = beam.get("text", "") + matches = triple_pattern.findall(text) + + for subj, pred, obj in matches: + triples.append({ + "subject": {"surface_form": subj.strip()}, + "predicate": {"surface_form": pred.strip()}, + "object": {"surface_form": obj.strip()} + }) + + with open(output_file, "w", encoding="utf-8") as f: + json.dump({"triples": triples, "chains": chains}, f, indent=2) + + process_io(input_path, output_path, exchange_file, ".te.json") \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/tasks/paris.py b/experiments/param-opti/src/param_opti/tasks/paris.py index 4fb5772..85e2b27 100644 --- a/experiments/param-opti/src/param_opti/tasks/paris.py +++ b/experiments/param-opti/src/param_opti/tasks/paris.py @@ -9,8 +9,27 @@ def paris_entity_alignment_function(inputs: TaskInput, outputs: TaskOutput, conf matches entities between two RDF graphs """ # touch output file - print(f"paris_entity_alignment_function: {outputs['output'].path}") - outputs["output"].path.touch() + from param_opti.tasks.paris_lib import paris_exchange, paris_entity_matching + entity_matching_threshold = float(config.get_parameter_value("entity_matching_threshold")) + relation_matching_threshold = float(2) # todo skip all matches + + # Ensure parent directory exists for the ER JSON output file + outputs["output"].path.parent.mkdir(parents=True, exist_ok=True) + + # 1 produce matches in paris csv format + matching_dir = outputs["output"].path.parent / f"{outputs['output'].path.stem}_paris_out" + matching_output = {"output": Data(matching_dir, DataFormat.PARIS_CSV)} + + # paris_entity_matching expects {"source": ..., "kg": ...} + paris_entity_matching({"source": inputs["source"], "kg": inputs["target"]}, matching_output) + + # 2 convert paris output dir to er.json format (file) + paris_exchange( + matching_output["output"].path, + outputs["output"].path, + entity_matching_threshold, + relation_matching_threshold, + ) paris_entity_alignment_task = KgTask( name="paris_entity_alignment", @@ -71,7 +90,27 @@ def paris_ontology_matching_function(inputs: TaskInput, outputs: TaskOutput, con matches ontologies between two RDF graphs """ # touch output file - outputs["output"].path.touch() + from param_opti.tasks.paris_lib import paris_exchange, paris_entity_matching + entity_matching_threshold = float(2) # todo skip all matches + ontology_matching_threshold = float(config.get_parameter_value("ontology_matching_threshold")) + + # Ensure parent directory exists for the ER JSON output file + outputs["output"].path.parent.mkdir(parents=True, exist_ok=True) + + # 1 produce matches in paris csv format + matching_dir = outputs["output"].path.parent / f"{outputs['output'].path.stem}_paris_out" + matching_output = {"output": Data(matching_dir, DataFormat.PARIS_CSV)} + + # paris_entity_matching expects {"source": ..., "kg": ...} + paris_entity_matching({"source": inputs["source"], "kg": inputs["target"]}, matching_output) + + # 2 convert paris output dir to er.json format (file) + paris_exchange( + matching_output["output"].path, + outputs["output"].path, + entity_matching_threshold, + ontology_matching_threshold, + ) paris_ontology_matching_task = KgTask( name="paris_ontology_matching", diff --git a/experiments/param-opti/src/param_opti/tasks/spotlight.py b/experiments/param-opti/src/param_opti/tasks/spotlight.py index 213cef4..3129ad0 100644 --- a/experiments/param-opti/src/param_opti/tasks/spotlight.py +++ b/experiments/param-opti/src/param_opti/tasks/spotlight.py @@ -1,12 +1,31 @@ from typing import Dict, Any from kgpipe.common import Data, DataFormat, Registry, KgTask -from kgpipe.common.model.configuration import ConfigurationDefinition, Parameter, ParameterType +from kgpipe.common.model.configuration import ConfigurationDefinition, ConfigurationProfile, Parameter, ParameterType +from pathlib import Path + +def spotlight_entity_linking_function(inputs: Dict[str, Data], outputs: Dict[str, Data], config: ConfigurationProfile ): + from param_opti.tasks.spotlight_lib import dbpedia_spotlight_ner_nel, dbpedia_spotlight_exchange + + # Ensure parent directory exists for the TE JSON output path + outputs["output"].path.parent.mkdir(parents=True, exist_ok=True) + + input_path: Path = inputs["input"].path + final_te_output: Data = outputs["output"] + + # 1) Produce intermediate OpenIE JSON (file or directory) + if input_path.is_dir(): + spotlight_out_path = final_te_output.path.parent / f"{final_te_output.path.stem}_corenlp_openie_out" + else: + spotlight_out_path = final_te_output.path.parent / f"{final_te_output.path.stem}_corenlp_openie.json" + + spotlight_out = {"output": Data(spotlight_out_path, DataFormat.OPENIE_JSON)} + if not spotlight_out_path.exists(): + dbpedia_spotlight_ner_nel({"input": inputs["input"]}, spotlight_out) + + # 2) Convert OpenIE JSON → TE JSON (final output) + dbpedia_spotlight_exchange({"input": spotlight_out["output"]}, {"output": final_te_output}, config.get_parameter_value("similarity_threshold")) -def spotlight_entity_linking_function(inputs: Dict[str, Data], outputs: Dict[str, Data]): - from param_opti.tasks.spotlight_lib import dbpedia_spotlight_ner_nel, dbpedia_spotlight_exchange_filtered - dbpedia_spotlight_ner_nel({"input": inputs["input"]}, {"output": outputs["output"]}) - dbpedia_spotlight_exchange_filtered({"source": outputs["output"]}, {"output": outputs["output"]}) spotlight_entity_linking_task = KgTask( name="spotlight_entity_linking", diff --git a/experiments/param-opti/src/param_opti/tasks/spotlight_lib.py b/experiments/param-opti/src/param_opti/tasks/spotlight_lib.py index 277de80..0bba12b 100644 --- a/experiments/param-opti/src/param_opti/tasks/spotlight_lib.py +++ b/experiments/param-opti/src/param_opti/tasks/spotlight_lib.py @@ -22,7 +22,7 @@ HEADERS = { "Accept": "application/json" } - +DEFAULT_API_URL = "http://localhost:2222/rest/annotate" def api_request(url: str, text: str) -> Dict[str, Any]: """Make API request to DBpedia Spotlight.""" @@ -42,18 +42,12 @@ def api_request(url: str, text: str) -> Dict[str, Any]: return result -@Registry.task( - input_spec={"input": DataFormat.TEXT}, - output_spec={"output": DataFormat.SPOTLIGHT_JSON}, - description="Link entities using DBpedia Spotlight API", - category=["TextProcessing", "EntityLinking"] -) def dbpedia_spotlight_ner_nel(inputs: Dict[str, Data], outputs: Dict[str, Data]): """Link entities using DBpedia Spotlight API.""" input_data = inputs["input"] output_data = outputs["output"] - DBPEDIA_ANNOTATE_URL = os.getenv("DBPEDIA_ANNOTATE_URL") + DBPEDIA_ANNOTATE_URL = os.getenv("DBPEDIA_ANNOTATE_URL", DEFAULT_API_URL) if not DBPEDIA_ANNOTATE_URL: raise ValueError("Missing DBpedia ANnotate URL") @@ -79,70 +73,15 @@ def dbpedia_spotlight_ner_nel(inputs: Dict[str, Data], outputs: Dict[str, Data]) f.write(json.dumps(results)) -@Registry.task( - input_spec={"source": DataFormat.SPOTLIGHT_JSON}, - output_spec={"output": DataFormat.TE_JSON}, - description="Convert Spotlight JSON to TE JSON format", - category=["TextProcessing", "EntityLinking"] -) -def dbpedia_spotlight_exchange_filtered(inputs: Dict[str, Data], outputs: Dict[str, Data]): - """Convert Spotlight JSON to TE JSON format.""" - input_path = inputs["source"].path - output_path = outputs["output"].path - - - - # create output folder - os.makedirs(os.path.normpath(output_path), exist_ok=True) - - def __spotlightjson2tejson(data) -> Dict[str, Any]: - """Convert Spotlight JSON to TE Document format.""" - links = [] - - for result in data.get('Resources', []): - link = { - "span": result.get('@surfaceForm', ''), - "mapping": result.get('@URI', ''), - "score": float(result.get('@similarityScore', 0.0)), - "link_type": "entity" - } - links.append(link) - - text = data.get('@text', '') - return {"text": text, "links": links} - - if os.path.isdir(input_path): - for file in os.listdir(input_path): - # Read input json - with open(os.path.join(input_path, file), 'r') as f: - data = json.load(f) - te_doc = __spotlightjson2tejson(data) - outfile = os.path.join(output_path, file) - - with open(outfile, 'w') as of: - json.dump(te_doc, of) - # print(f"Converted {file} to {outfile}") - - else: - # Read input json - with open(input_path, 'r') as f: - data = json.load(f) - te_doc = __spotlightjson2tejson(data) - outfile = os.path.join(output_path, 'output.te.json') - with open(outfile, 'w') as of: - json.dump(te_doc, of) - # print(f"Converted {input_path} to {output_path}") - - -@Registry.task( - input_spec={"source": DataFormat.SPOTLIGHT_JSON}, - output_spec={"output": DataFormat.TE_JSON}, - description="Convert Spotlight JSON to TE JSON format, with seed filter", - category=["TextProcessing", "EntityLinking"] -) -def dbpedia_spotlight_exchange(inputs: Dict[str, Data], outputs: Dict[str, Data]): +# @Registry.task( +# input_spec={"source": DataFormat.SPOTLIGHT_JSON}, +# output_spec={"output": DataFormat.TE_JSON}, +# description="Convert Spotlight JSON to TE JSON format, with seed filter", +# category=["TextProcessing", "EntityLinking"] +# ) +def dbpedia_spotlight_exchange(inputs: Dict[str, Data], outputs: Dict[str, Data], threshold: float = 0.5): """Convert Spotlight JSON to TE JSON format.""" - input_path = inputs["source"].path + input_path = inputs["input"].path output_path = outputs["output"].path # create output folder @@ -153,6 +92,8 @@ def __spotlightjson2tejson(data) -> Dict[str, Any]: links = [] for result in data.get('Resources', []): + if float(result.get('@similarityScore', 0.0)) < threshold: + continue link = { "span": result.get('@surfaceForm', ''), "mapping": result.get('@URI', ''), diff --git a/experiments/param-opti/src/param_opti/tasks/text_helpers.py b/experiments/param-opti/src/param_opti/tasks/text_helpers.py index 4f5d776..0bef3f4 100644 --- a/experiments/param-opti/src/param_opti/tasks/text_helpers.py +++ b/experiments/param-opti/src/param_opti/tasks/text_helpers.py @@ -18,6 +18,8 @@ logger = logging.getLogger(__name__) + + def __aggregate_x_te_json(input_paths: List[Path], output_path: Path): if len(input_paths) == 0: @@ -65,23 +67,44 @@ def aggregate3_text_tasks_task_function(inputs: Dict[str, Data], outputs: Dict[s __aggregate_x_te_json([inputs["json1"].path, inputs["json2"].path, inputs["json3"].path], outputs["output"].path) aggregate_text_tasks_task = KgTask( - name="aggregate3_text_tasks_task", + name="aggregate_text_tasks_task", input_spec={"json1": DataFormat.TE_JSON, "json2": DataFormat.TE_JSON, "json3": DataFormat.TE_JSON}, output_spec={"output": DataFormat.TE_JSON}, function=aggregate3_text_tasks_task_function ) +def aggregate2_text_tasks_task_function(inputs: Dict[str, Data], outputs: Dict[str, Data]): + __aggregate_x_te_json([inputs["json1"].path, inputs["json2"].path], outputs["output"].path) + +aggregate_entity_linking_task = KgTask( + name="aggregate_entity_linking_task", + input_spec={"json1": DataFormat.TE_JSON, "json2": DataFormat.TE_JSON}, + output_spec={"output": DataFormat.TE_JSON}, + function=aggregate2_text_tasks_task_function +) + +aggregate_relation_linking_task = KgTask( + name="aggregate_relation_linking_task", + input_spec={"json1": DataFormat.TE_JSON, "json2": DataFormat.TE_JSON}, + output_spec={"output": DataFormat.TE_JSON}, + function=aggregate2_text_tasks_task_function +) def generatePredicate(surface_form, namespace): return URIRef(namespace + surface_form.replace(" ", "_")) +def __hash_dbpedia_uri(uri: URIRef, namespace: str = "http://kg.org/resource/"): + if uri.startswith("http://dbpedia.org/"): + return URIRef(namespace + hash_uri(str(uri))) + else: + return uri + def __generateRDF(doc: TE_Document, ontology: Ontology, newP: bool = False, newE: bool = False, namespace: str = "http://kg.org/text/"): """ A processing node, part of a pipeline collects information from extractors, linkers, and resolvers and then it produces the final triples """ - def process_chains(triples, chains: List[TE_Chains]): new_triples = triples chain_dict = {} @@ -180,7 +203,7 @@ def process_links(triples, links: List[TE_Pair]): # print(f"predicate: {predicate}, domain: {domain}, range: {range}") if subject and subject.startswith("http://dbpedia.org"): # TODO workaround for dbpedia... - finalGraph.add((subject, RDFS.label, Literal(triple.subject.surface_form))) + finalGraph.add((__hash_dbpedia_uri(subject), RDFS.label, Literal(triple.subject.surface_form))) if not subject and triple.subject.surface_form and newE: @@ -191,7 +214,10 @@ def process_links(triples, links: List[TE_Pair]): print(f"subject: {subject} {triple.subject.surface_form}") if domain and subject: - finalGraph.add((subject, RDF.type, URIRef(domain))) + finalGraph.add((__hash_dbpedia_uri(subject), RDF.type, URIRef(domain))) + + if object and isObjectProperty and object.startswith("http://dbpedia.org"): # TODO workaround for dbpedia... + finalGraph.add((__hash_dbpedia_uri(object), RDFS.label, Literal(triple.object.surface_form))) if not object and triple.object.surface_form and newE: if isObjectProperty: @@ -208,7 +234,7 @@ def process_links(triples, links: List[TE_Pair]): object = Literal(triple.object.surface_form, datatype=datatype) if(subject and predicate and object): - finalGraph.add((subject, predicate, object)) + finalGraph.add((__hash_dbpedia_uri(subject), predicate, __hash_dbpedia_uri(object))) return finalGraph @@ -220,6 +246,7 @@ def generate_rdf(inputs: Dict[str, Data], outputs: Dict[str, Data], ontology: On for file in os.listdir(dir_or_file): json_data = json.load(open(os.path.join(dir_or_file, file))) doc = TE_Document(**json_data) + print(f"doc: {doc}") for s, p, o in __generateRDF(doc, ontology, newP=newP, newE=newE): graph.add(triple=(s, p, o)) else: diff --git a/experiments/param-opti/src/qap/fixtures/rdf_sampled_pipeline_configs.json b/experiments/param-opti/src/qap/fixtures/rdf_sampled_pipeline_configs.json index a4e16a7..5a745c1 100644 --- a/experiments/param-opti/src/qap/fixtures/rdf_sampled_pipeline_configs.json +++ b/experiments/param-opti/src/qap/fixtures/rdf_sampled_pipeline_configs.json @@ -6,14 +6,14 @@ "bindings": [ { "parameter": "model_name", - "value": "sentence-transformers/all-mpnet-base-v2" + "value": "intfloat/e5-base-v2" }, { "parameter": "similarity_threshold", "value": 0.8 } ], - "profile_name": "graph_alignment_label_alias_embedding_transformer_model_name=sentence-transformers/all-mpnet-base-v2,similarity_threshold=0.8" + "profile_name": "graph_alignment_label_alias_embedding_transformer_model_name=intfloat/e5-base-v2,similarity_threshold=0.8" } }, "task_keys": [ @@ -27,27 +27,27 @@ "bindings": [ { "parameter": "model_name", - "value": "sentence-transformers/all-mpnet-base-v2" + "value": "intfloat/e5-base-v2" }, { "parameter": "similarity_threshold", "value": 0.8 } ], - "profile_name": "entity_matcher_label_alias_embedding_transformer_model_name=sentence-transformers/all-mpnet-base-v2,similarity_threshold=0.8" + "profile_name": "entity_matcher_label_alias_embedding_transformer_model_name=intfloat/e5-base-v2,similarity_threshold=0.8" }, "relation_matcher_label_alias_embedding_transformer": { "bindings": [ { "parameter": "model_name", - "value": "sentence-transformers/all-MiniLM-L6-v2" + "value": "intfloat/e5-base-v2" }, { "parameter": "similarity_threshold", - "value": 0.7 + "value": 0.5 } ], - "profile_name": "relation_matcher_label_alias_embedding_transformer_model_name=sentence-transformers/all-MiniLM-L6-v2,similarity_threshold=0.7" + "profile_name": "relation_matcher_label_alias_embedding_transformer_model_name=intfloat/e5-base-v2,similarity_threshold=0.5" } }, "task_keys": [ @@ -63,23 +63,23 @@ "bindings": [ { "parameter": "entity_matching_threshold", - "value": 0.7 + "value": 0.9 } ], - "profile_name": "paris_entity_alignment_entity_matching_threshold=0.7" + "profile_name": "paris_entity_alignment_entity_matching_threshold=0.9" }, "relation_matcher_label_alias_embedding_transformer": { "bindings": [ { "parameter": "model_name", - "value": "sentence-transformers/all-mpnet-base-v2" + "value": "intfloat/e5-base-v2" }, { "parameter": "similarity_threshold", - "value": 0.8 + "value": 0.5 } ], - "profile_name": "relation_matcher_label_alias_embedding_transformer_model_name=sentence-transformers/all-mpnet-base-v2,similarity_threshold=0.8" + "profile_name": "relation_matcher_label_alias_embedding_transformer_model_name=intfloat/e5-base-v2,similarity_threshold=0.5" } }, "task_keys": [ @@ -95,23 +95,23 @@ "bindings": [ { "parameter": "model_name", - "value": "sentence-transformers/all-MiniLM-L6-v2" + "value": "intfloat/e5-base-v2" }, { "parameter": "similarity_threshold", "value": 0.9 } ], - "profile_name": "entity_matcher_label_alias_embedding_transformer_model_name=sentence-transformers/all-MiniLM-L6-v2,similarity_threshold=0.9" + "profile_name": "entity_matcher_label_alias_embedding_transformer_model_name=intfloat/e5-base-v2,similarity_threshold=0.9" }, "paris_ontology_matching": { "bindings": [ { "parameter": "ontology_matching_threshold", - "value": 0.9 + "value": 0.5 } ], - "profile_name": "paris_ontology_matching_ontology_matching_threshold=0.9" + "profile_name": "paris_ontology_matching_ontology_matching_threshold=0.5" } }, "task_keys": [ @@ -121,48 +121,20 @@ "fusion_first_value_task" ] }, - { - "profiles": { - "paris_entity_alignment": { - "bindings": [ - { - "parameter": "entity_matching_threshold", - "value": 0.7 - } - ], - "profile_name": "paris_entity_alignment_entity_matching_threshold=0.7" - }, - "paris_ontology_matching": { - "bindings": [ - { - "parameter": "ontology_matching_threshold", - "value": 0.6 - } - ], - "profile_name": "paris_ontology_matching_ontology_matching_threshold=0.6" - } - }, - "task_keys": [ - "paris_ontology_matching_task", - "paris_entity_alignment_task", - "aggregate_matching_results_task", - "fusion_first_value_task" - ] - }, { "profiles": { "paris_graph_alignment": { "bindings": [ { "parameter": "entity_matching_threshold", - "value": 0.6 + "value": 0.9 }, { "parameter": "relation_matching_threshold", "value": 0.5 } ], - "profile_name": "paris_graph_alignment_entity_matching_threshold=0.6,relation_matching_threshold=0.5" + "profile_name": "paris_graph_alignment_entity_matching_threshold=0.9,relation_matching_threshold=0.5" } }, "task_keys": [ diff --git a/experiments/param-opti/src/qap/fixtures/text_sampled_pipeline_configs.json b/experiments/param-opti/src/qap/fixtures/text_sampled_pipeline_configs.json new file mode 100644 index 0000000..e5336cc --- /dev/null +++ b/experiments/param-opti/src/qap/fixtures/text_sampled_pipeline_configs.json @@ -0,0 +1,153 @@ +{ + "samples": [ + { + "profiles": { + "relation_linker_label_alias_embedding_transformer": { + "bindings": [ + { + "parameter": "model_name", + "value": "intfloat/e5-base-v2" + }, + { + "parameter": "similarity_threshold", + "value": 0.5 + } + ], + "profile_name": "relation_linker_label_alias_embedding_transformer_model_name=intfloat/e5-base-v2,similarity_threshold=0.5" + }, + "spotlight_entity_linking": { + "bindings": [ + { + "parameter": "similarity_threshold", + "value": 0.8 + } + ], + "profile_name": "spotlight_entity_linking_similarity_threshold=0.8" + } + }, + "task_keys": [ + "corenlp_text_extraction_task", + "spotlight_entity_linking_task", + "aggregate_entity_linking_task", + "relation_linker_label_alias_embedding_transformer_task", + "aggregate_relation_linking_task", + "generate_rdf_from_text_results_task", + "select_first_value_task" + ] + }, + { + "profiles": { + "entity_linker_label_alias_embedding_transformer": { + "bindings": [ + { + "parameter": "model_name", + "value": "intfloat/e5-base-v2" + }, + { + "parameter": "similarity_threshold", + "value": 0.9 + } + ], + "profile_name": "entity_linker_label_alias_embedding_transformer_model_name=intfloat/e5-base-v2,similarity_threshold=0.9" + }, + "relation_linker_label_alias_embedding_transformer": { + "bindings": [ + { + "parameter": "model_name", + "value": "intfloat/e5-base-v2" + }, + { + "parameter": "similarity_threshold", + "value": 0.5 + } + ], + "profile_name": "relation_linker_label_alias_embedding_transformer_model_name=intfloat/e5-base-v2,similarity_threshold=0.5" + } + }, + "task_keys": [ + "corenlp_text_extraction_task", + "entity_linker_label_alias_embedding_transformer_task", + "aggregate_entity_linking_task", + "relation_linker_label_alias_embedding_transformer_task", + "aggregate_relation_linking_task", + "generate_rdf_from_text_results_task", + "select_first_value_task" + ] + }, + { + "profiles": { + "relation_linker_label_alias_embedding_transformer": { + "bindings": [ + { + "parameter": "model_name", + "value": "intfloat/e5-base-v2" + }, + { + "parameter": "similarity_threshold", + "value": 0.5 + } + ], + "profile_name": "relation_linker_label_alias_embedding_transformer_model_name=intfloat/e5-base-v2,similarity_threshold=0.5" + }, + "spotlight_entity_linking": { + "bindings": [ + { + "parameter": "similarity_threshold", + "value": 0.8 + } + ], + "profile_name": "spotlight_entity_linking_similarity_threshold=0.8" + } + }, + "task_keys": [ + "genie_text_extraction_task", + "spotlight_entity_linking_task", + "aggregate_entity_linking_task", + "relation_linker_label_alias_embedding_transformer_task", + "aggregate_relation_linking_task", + "generate_rdf_from_text_results_task", + "select_first_value_task" + ] + }, + { + "profiles": { + "entity_linker_label_alias_embedding_transformer": { + "bindings": [ + { + "parameter": "model_name", + "value": "intfloat/e5-base-v2" + }, + { + "parameter": "similarity_threshold", + "value": 0.9 + } + ], + "profile_name": "entity_linker_label_alias_embedding_transformer_model_name=intfloat/e5-base-v2,similarity_threshold=0.9" + }, + "relation_linker_label_alias_embedding_transformer": { + "bindings": [ + { + "parameter": "model_name", + "value": "intfloat/e5-base-v2" + }, + { + "parameter": "similarity_threshold", + "value": 0.5 + } + ], + "profile_name": "relation_linker_label_alias_embedding_transformer_model_name=intfloat/e5-base-v2,similarity_threshold=0.5" + } + }, + "task_keys": [ + "genie_text_extraction_task", + "entity_linker_label_alias_embedding_transformer_task", + "aggregate_entity_linking_task", + "relation_linker_label_alias_embedding_transformer_task", + "aggregate_relation_linking_task", + "generate_rdf_from_text_results_task", + "select_first_value_task" + ] + } + ], + "version": 1 +} diff --git a/experiments/param-opti/src/qap/test_pipeline_config.py b/experiments/param-opti/src/qap/test_conf_pipelines.py similarity index 74% rename from experiments/param-opti/src/qap/test_pipeline_config.py rename to experiments/param-opti/src/qap/test_conf_pipelines.py index ee9c14f..89fe3ee 100644 --- a/experiments/param-opti/src/qap/test_pipeline_config.py +++ b/experiments/param-opti/src/qap/test_conf_pipelines.py @@ -1,4 +1,5 @@ from typing import List, Dict, Any, Optional +import itertools import json import random from kgpipe.common import KgPipe, Data, DataFormat, Registry @@ -18,7 +19,9 @@ from param_opti.tasks.genie import genie_text_extraction_task from param_opti.tasks.spotlight import spotlight_entity_linking_task from param_opti.tasks.matching_helpers import aggregate_matching_results_task - +from param_opti.tasks.text_helpers import aggregate_entity_linking_task, aggregate_relation_linking_task +from param_opti.tasks.text_helpers import generate_rdf_from_text_results_task +from param_opti.tasks.select_lib import select_first_value_task from kgpipe.generation.loaders import build_from_conf from pathlib import Path # for given tasks and config parameters, generate a pipeline (KGpipe) @@ -30,6 +33,9 @@ RDF_SAMPLED_PIPELINE_CONFIGS_FIXTURE = Path(__file__).resolve().parent / "fixtures" / "rdf_sampled_pipeline_configs.json" _RDF_PIPELINE_CONFIG_SNAPSHOT_VERSION = 1 +TEXT_SAMPLED_PIPELINE_CONFIGS_FIXTURE = Path(__file__).resolve().parent / "fixtures" / "text_sampled_pipeline_configs.json" +_TEXT_PIPELINE_CONFIG_SNAPSHOT_VERSION = 1 + class PipelineConfig(BaseModel): tasks: List[KgTask] @@ -38,17 +44,17 @@ class PipelineConfig(BaseModel): RDF_SEARCH_SPACE = { "graph_alignment_label_alias_embedding_transformer_task": { "category": ["ontology_matching", "entity_matching", "aggregate_matching_results"], - "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2"], + "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2", "infloat/e5-base-v2"], "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], }, "relation_matcher_label_alias_embedding_transformer_task": { "category": ["ontology_matching"], - "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2"], + "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2", "infloat/e5-base-v2"], "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], }, "entity_matcher_label_alias_embedding_transformer_task": { "category": ["entity_matching"], - "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2"], + "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2", "infloat/e5-base-v2"], "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], }, "paris_ontology_matching_task": { @@ -73,12 +79,12 @@ class PipelineConfig(BaseModel): }, "relation_linker_label_alias_embedding_transformer_task": { "category": ["entity_linking"], - "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2"], + "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2", "infloat/e5-base-v2"], "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], }, "entity_linker_label_alias_embedding_transformer_task": { "category": "entity_linking", - "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2"], + "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2", "infloat/e5-base-v2"], "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], }, } @@ -98,15 +104,24 @@ class PipelineConfig(BaseModel): }, "relation_linker_label_alias_embedding_transformer_task": { "category": ["relation_linking"], - "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2"], + "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2", "infloat/e5-base-v2"], "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], }, "entity_linker_label_alias_embedding_transformer_task": { "category": ["entity_linking"], - "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2"], + "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2", "infloat/e5-base-v2"], "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], }, - "fusion_first_value_task": { + "aggregate_entity_linking_task": { + "category": ["aggregate_entity_linking"], + }, + "aggregate_relation_linking_task": { + "category": ["aggregate_relation_linking"], + }, + "generate_rdf_from_text_results_task": { + "category": ["construct_rdf"], + }, + "select_first_value_task": { "category": ["fusion"], }, } @@ -117,7 +132,10 @@ class PipelineConfig(BaseModel): "spotlight_entity_linking_task": spotlight_entity_linking_task, "relation_linker_label_alias_embedding_transformer_task": relation_linker_label_alias_embedding_transformer_task, "entity_linker_label_alias_embedding_transformer_task": entity_linker_label_alias_embedding_transformer_task, - "fusion_first_value_task": fusion_first_value_task, + "select_first_value_task": select_first_value_task, + "aggregate_entity_linking_task": aggregate_entity_linking_task, + "aggregate_relation_linking_task": aggregate_relation_linking_task, + "generate_rdf_from_text_results_task": generate_rdf_from_text_results_task, } RDF_TASK_DICT = { @@ -134,6 +152,8 @@ class PipelineConfig(BaseModel): # "fusion_union_task": fusion_union_task, } + + task_dict = {**TEXT_TASK_DICT, **RDF_TASK_DICT} for task_name, task in RDF_TASK_DICT.items(): @@ -145,6 +165,13 @@ class PipelineLayout(BaseModel): """ allowed_task_categories: List[str] +TEXT_PIPELINE_LAYOUT = PipelineLayout( + allowed_task_categories=["information_extraction", "entity_linking", "aggregate_entity_linking", "relation_linking", "aggregate_relation_linking", "construct_rdf", "fusion"] +) + +RDF_PIPELINE_LAYOUT = PipelineLayout( + allowed_task_categories=["ontology_matching", "entity_matching", "aggregate_matching_results", "fusion"] +) def _task_categories_list(search_space: Dict[str, Dict[str, Any]], task_name: str) -> List[str]: raw = search_space.get(task_name, {}).get("category") @@ -286,6 +313,17 @@ def load_rdf_sampled_pipeline_configs(path: Optional[Path] = None) -> List[Pipel return [pipeline_config_from_snapshot(item) for item in raw["samples"]] +def load_text_sampled_pipeline_configs(path: Optional[Path] = None) -> List[PipelineConfig]: + fixture_path = path or TEXT_SAMPLED_PIPELINE_CONFIGS_FIXTURE + raw = json.loads(fixture_path.read_text(encoding="utf-8")) + if raw.get("version") != _TEXT_PIPELINE_CONFIG_SNAPSHOT_VERSION: + raise ValueError( + f"Unsupported text sampled configs snapshot version {raw.get('version')!r}; " + f"expected {_TEXT_PIPELINE_CONFIG_SNAPSHOT_VERSION}" + ) + return [pipeline_config_from_snapshot(item) for item in raw["samples"]] + + # TODO rules for valid pipeline config: def sample_valid_pipeline_config( search_space: Dict[str, Dict[str, Any]], @@ -432,6 +470,88 @@ def sample_config_catalog_for_task_combo( return PipelineConfig(tasks=tasks, config_catalog=config_catalog) +def enumerate_exhaustive_pipeline_config_snapshots( + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, +) -> List[Dict[str, Any]]: + combos = enumerate_valid_task_combinations(search_space, pipeline_layout) + + def _task_param_assignments(task_key: str) -> List[Dict[str, Any]]: + space = search_space.get(task_key, {}) + param_space: Dict[str, List[Any]] = {k: v for k, v in space.items() if k != "category"} + if not param_space: + return [{}] + keys = list(param_space.keys()) + values_lists = [param_space[k] for k in keys] + return [dict(zip(keys, values)) for values in itertools.product(*values_lists)] + + all_snapshots: List[Dict[str, Any]] = [] + total_expected = 0 + + for combo in combos: + per_task_assignments = [_task_param_assignments(task_key) for task_key in combo] + + expected_for_combo = 1 + for assignments in per_task_assignments: + expected_for_combo *= len(assignments) + total_expected += expected_for_combo + + produced_for_combo = 0 + print() + print("combo:", combo) + print("expected configs:", expected_for_combo) + + for assignment_tuple in itertools.product(*per_task_assignments): + produced_for_combo += 1 + if produced_for_combo % 100 == 1 or produced_for_combo == expected_for_combo: + print(f"config {produced_for_combo}/{expected_for_combo}") + + tasks: List[KgTask] = [] + config_catalog: Dict[str, ConfigurationProfile] = {} + + for task_key, params in zip(combo, assignment_tuple): + task = task_dict[task_key] + tasks.append(task) + + if not params: + continue + if getattr(task, "config_spec", None) is None: + continue + + bindings: List[ParameterBinding] = [] + name_parts: List[str] = [] + + # Iterate in search_space order for stable snapshots. + for config_name, _config_values in search_space[task_key].items(): + if config_name == "category": + continue + if config_name not in params: + continue + config_value = params[config_name] + name_parts.append(f"{config_name}={config_value}") + bindings.append( + ParameterBinding( + parameter=_get_param(task.config_spec, config_name), + value=config_value, + ) + ) + + config_catalog[task.name] = ConfigurationProfile( + name=f"{task.name}_" + ",".join(name_parts), + definition=task.config_spec, + bindings=bindings, + ) + + pipeline_config = PipelineConfig(tasks=tasks, config_catalog=config_catalog) + all_snapshots.append(pipeline_config_to_snapshot(combo, pipeline_config)) + + assert produced_for_combo == expected_for_combo + + print() + print("TOTAL expected configs:", total_expected) + print("TOTAL generated snapshots:", len(all_snapshots)) + return all_snapshots + def test_sample_valid_rdf_pipeline_config(): @@ -443,10 +563,7 @@ def test_sample_valid_rdf_pipeline_config(): def test_enumerate_all_valid_rdf_task_combinations_no_config_sampling(): print("enumerate_all_valid_rdf_task_combinations_no_config_sampling") - pipeline_layout = PipelineLayout( - allowed_task_categories=["ontology_matching", "entity_matching", "aggregate_matching_results", "fusion"] - ) - combos = enumerate_valid_task_combinations(RDF_SEARCH_SPACE, pipeline_layout) + combos = enumerate_valid_task_combinations(RDF_SEARCH_SPACE, RDF_PIPELINE_LAYOUT) for combo in combos: print(combo) @@ -461,17 +578,16 @@ def test_enumerate_all_valid_rdf_task_combinations_no_config_sampling(): # ("paris_graph_alignment_task", "fusion_first_value_task"), # } - # assert set(tuple(c) for c in combos) == expected + # assert set(tuple(c) for c in combos) == expected + + def test_enumerate_all_valid_rdf_task_combinations_with_config_sampling(): print("enumerate_all_valid_rdf_task_combinations_with_config_sampling") n = 1 rng = random.Random(0) - pipeline_layout = PipelineLayout( - allowed_task_categories=["ontology_matching", "entity_matching", "aggregate_matching_results", "fusion"] - ) - combos = enumerate_valid_task_combinations(RDF_SEARCH_SPACE, pipeline_layout) + combos = enumerate_valid_task_combinations(RDF_SEARCH_SPACE, RDF_PIPELINE_LAYOUT) total_config_count = 0 snapshots: List[Dict[str, Any]] = [] @@ -502,33 +618,25 @@ def test_enumerate_all_valid_rdf_task_combinations_with_config_sampling(): def test_sample_valid_text_pipeline_config(): - pipeline_layout = PipelineLayout( - allowed_task_categories=["information_extraction", "entity_linking", "relation_linking", "fusion"] - ) - pipeline_config = sample_valid_pipeline_config(TEXT_SEARCH_SPACE, pipeline_layout) + pipeline_config = sample_valid_pipeline_config(TEXT_SEARCH_SPACE, TEXT_PIPELINE_LAYOUT) print_pipeline_config_short(pipeline_config) def test_enumerate_all_valid_text_task_combinations_no_config_sampling(): print("enumerate_all_valid_text_task_combinations_no_config_sampling") - pipeline_layout = PipelineLayout( - allowed_task_categories=["information_extraction", "entity_linking", "relation_linking", "fusion"] - ) - combos = enumerate_valid_task_combinations(TEXT_SEARCH_SPACE, pipeline_layout) + combos = enumerate_valid_task_combinations(TEXT_SEARCH_SPACE, TEXT_PIPELINE_LAYOUT) for combo in combos: print(combo) def test_enumerate_all_valid_text_task_combinations_with_config_sampling(): print("enumerate_all_valid_text_task_combinations_with_config_sampling") - n = 5 + n = 1 rng = random.Random(0) - pipeline_layout = PipelineLayout( - allowed_task_categories=["information_extraction", "entity_linking", "relation_linking", "fusion"] - ) - combos = enumerate_valid_task_combinations(TEXT_SEARCH_SPACE, pipeline_layout) + combos = enumerate_valid_task_combinations(TEXT_SEARCH_SPACE, TEXT_PIPELINE_LAYOUT) total_config_count = 0 + snapshots: List[Dict[str, Any]] = [] for combo in combos: print() @@ -540,6 +648,35 @@ def test_enumerate_all_valid_text_task_combinations_with_config_sampling(): TEXT_SEARCH_SPACE, combo, rng=rng ) print_pipeline_config_short(pipeline_config) + snapshots.append(pipeline_config_to_snapshot(combo, pipeline_config)) + + TEXT_SAMPLED_PIPELINE_CONFIGS_FIXTURE.parent.mkdir(parents=True, exist_ok=True) + TEXT_SAMPLED_PIPELINE_CONFIGS_FIXTURE.write_text( + json.dumps( + {"version": _TEXT_PIPELINE_CONFIG_SNAPSHOT_VERSION, "samples": snapshots}, + indent=2, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + +def test_enumerate_all_valid_text_task_combinations_with_config_sampling_exhaustive(): + print("enumerate_all_valid_text_task_combinations_with_config_sampling_exhaustive") + all_snapshots = enumerate_exhaustive_pipeline_config_snapshots( + TEXT_SEARCH_SPACE, TEXT_PIPELINE_LAYOUT + ) + serialized = [json.dumps(s, sort_keys=True) for s in all_snapshots] + assert len(set(serialized)) == len(serialized) + + +def test_enumerate_all_valid_rdf_task_combinations_with_config_sampling_exhaustive(): + print("enumerate_all_valid_rdf_task_combinations_with_config_sampling_exhaustive") + all_snapshots = enumerate_exhaustive_pipeline_config_snapshots( + RDF_SEARCH_SPACE, RDF_PIPELINE_LAYOUT + ) + serialized = [json.dumps(s, sort_keys=True) for s in all_snapshots] + assert len(set(serialized)) == len(serialized) diff --git a/experiments/param-opti/src/qap/test_eval_pipelines.py b/experiments/param-opti/src/qap/test_eval_pipelines.py new file mode 100644 index 0000000..bcc5012 --- /dev/null +++ b/experiments/param-opti/src/qap/test_eval_pipelines.py @@ -0,0 +1,122 @@ +from pathlib import Path +import json +import pytest + +from kgpipe_eval.utils.kg_utils import KgManager +from kgpipe_eval.metrics.triple_alignment import TripleAlignmentMetric, TripleAlignmentConfig +from kgpipe_eval.metrics.entity_alignment import EntityAlignmentMetric, EntityAlignmentConfig +from kgpipe_eval.api import MetricResult +from kgpipe_eval.test.utils import render_metric_result + +rdf_base_dir = Path("data/tmp/rdf_pipelines/") +text_base_dir = Path("data/tmp/text_pipelines/") +result_dir = Path("data/output/reference_eval") + +def get_rdf_final_kgs(): + """ + get all files matching rdf_result_saved_sample_config_idx_*.nt in dir + """ + BASE_DIR = rdf_base_dir + return [f for f in BASE_DIR.glob("*eval.nt")] + +def get_text_final_kgs(): + """ + get all files matching text_result_saved_sample_config_idx_*.nt in dir + """ + BASE_DIR = text_base_dir + return [f for f in BASE_DIR.glob("*eval.nt")] + +def test_get_rdf_final_kgs(): + """ + test the get_final_kgs function + """ + final_kgs = get_rdf_final_kgs() + for final_kg in final_kgs: + print(final_kg) + +def test_get_text_final_kgs(): + """ + test the get_final_kgs function + """ + final_kgs = get_text_final_kgs() + for final_kg in final_kgs: + print(final_kg) + +def _write_to_file(string: str, path: Path): + with open(path, "w") as f: + f.write(string) + print(f"wrote to {path}") + +def _metric_result_to_jsonable(metric_result: MetricResult) -> dict: + metric = metric_result.metric + metric_key = getattr(metric, "key", metric.__class__.__name__) + return { + "metric": metric_key, + "summary": metric_result.summary, + "measurements": [ + {"name": m.name, "value": m.value, "unit": m.unit} + for m in metric_result.measurements + ], + } + +def _write_json(obj: object, path: Path): + with open(path, "w") as f: + json.dump(obj, f, indent=2, sort_keys=True, default=str) + f.write("\n") + print(f"wrote to {path}") + +# seed_kg = KgManager.load_kg(Path("data/input_final/target_kg/graph.nt")) + +def eval_pipeline(final_kg, reference_kg_path): + + print(f"evaluating {final_kg}") + + ref_kg_path = reference_kg_path + gen_kg_path = final_kg + + entity_alignment_config = EntityAlignmentConfig( + method="label_embedding", + reference_kg=ref_kg_path, + verified_entities_path=None, + verified_entities_delimiter="\t", + entity_sim_threshold=0.95 + ) + + + gen_kg = KgManager.load_kg(gen_kg_path) + # test_kg = KgManager.substract_kg(gen_kg, seed_kg) # TODO add back labels and types + test_kg = gen_kg + + metric_result : MetricResult = EntityAlignmentMetric().compute(test_kg, entity_alignment_config) + result_string = render_metric_result(metric_result) + _write_to_file(result_string, result_dir / (final_kg.name + ".entity_alignment.txt")) + _write_json(_metric_result_to_jsonable(metric_result), result_dir / (final_kg.name + ".entity_alignment.json")) + + + triple_alignment_config = TripleAlignmentConfig( + reference_kg=ref_kg_path, + entity_alignment_config=entity_alignment_config, + value_sim_threshold=0.5, + cache_literal_embeddings=True + ) + + metric_result : MetricResult = TripleAlignmentMetric().compute(test_kg, triple_alignment_config) + result_string = render_metric_result(metric_result) + _write_to_file(result_string, result_dir / (final_kg.name + ".triple_alignment.txt")) + _write_json(_metric_result_to_jsonable(metric_result), result_dir / (final_kg.name + ".triple_alignment.json")) + +@pytest.mark.parametrize("final_kg", get_rdf_final_kgs()) +def test_eval_rdf_pipeline_runs(final_kg): + """ + evaluate all runs of the rdf pipelines + """ + eval_pipeline(final_kg, Path("data/input_final/reference_kg/data_no_seed.nt")) + +@pytest.mark.parametrize("final_kg", get_text_final_kgs()) +def test_eval_text_pipeline_runs(final_kg): + """ + evaluate all runs of the text pipelines + """ + # data/input_final/txt_source/ref + + eval_pipeline(final_kg, Path("/data/datasets/params_experiments/latest/input_final/txt_source/tmp_reference/reference_kg_noseed.nt")) \ No newline at end of file diff --git a/experiments/param-opti/src/qap/test_exec_pipelines.py b/experiments/param-opti/src/qap/test_exec_pipelines.py index 2ae5626..1804e86 100644 --- a/experiments/param-opti/src/qap/test_exec_pipelines.py +++ b/experiments/param-opti/src/qap/test_exec_pipelines.py @@ -8,10 +8,11 @@ from param_opti.tasks.spotlight import spotlight_entity_linking_task from param_opti.tasks.text_helpers import aggregate_text_tasks_task, generate_rdf_from_text_results_task from param_opti.tasks.select_lib import select_first_value_task -from qap.test_pipeline_config import ( +from qap.test_conf_pipelines import ( PipelineConfig, _get_param, load_rdf_sampled_pipeline_configs, + load_text_sampled_pipeline_configs, ) from pathlib import Path import pytest @@ -20,7 +21,7 @@ from dotenv import load_dotenv load_dotenv() -tmp_base_dir = Path("tmp") +tmp_base_dir = Path("data/tmp/text_pipelines") if not tmp_base_dir.exists(): tmp_base_dir.mkdir(parents=True, exist_ok=True) @@ -84,15 +85,12 @@ def test_rdf_pipeline_from_saved_sampled_configs(config_idx): pipeline_config = configs[config_idx] - seed_path = tmp_base_dir / "seed_saved_sample.nt" - source_path = tmp_base_dir / "source_saved_sample.nt" - result_path = tmp_base_dir / f"result_saved_sample_config_idx_{config_idx}.nt" - tasks_tmp_dir = tmp_base_dir / f"tasks_tmp_saved_sample_config_idx_{config_idx}" + seed_path = Path("data/input_final/target_kg/graph.nt") + source_path = Path("data/input_final/rdf_source/graph.nt") + result_path = tmp_base_dir / f"rdf_result_saved_sample_config_idx_{config_idx}.nt" + tasks_tmp_dir = tmp_base_dir / f"rdf_tasks_tmp_saved_sample_config_idx_{config_idx}" tasks_tmp_dir.mkdir(parents=True, exist_ok=True) - seed_path.write_text(" .\n") - source_path.write_text(" .\n") - pipeline = KgPipe( tasks=pipeline_config.tasks, seed=Data(path=seed_path, format=DataFormat.RDF_NTRIPLES), @@ -110,7 +108,6 @@ def test_rdf_pipeline_from_saved_sampled_configs(config_idx): pipeline.run(configCatalog=pipeline_config.config_catalog, stable_files_override=True) - def get_default_text_pipeline_config() -> PipelineConfig: return PipelineConfig( tasks=[ @@ -168,3 +165,38 @@ def test_text_pipeline_from_default_config(): pipeline.run(configCatalog=pipeline_config.config_catalog, stable_files_override=False) +@pytest.mark.parametrize("config_idx", range(len(load_text_sampled_pipeline_configs()))) +def test_text_pipeline_from_saved_sampled_configs(config_idx): + """Runs KGpipe using PipelineConfigs materialized from the JSON fixture written by test_pipeline_config.""" + configs = load_text_sampled_pipeline_configs() + assert configs, "fixtures/text_sampled_pipeline_configs.json is missing or empty; run test_enumerate_all_valid_text_task_combinations_with_config_sampling" + + pipeline_config = configs[config_idx] + + seed_path = Path("data/input_final/target_kg/graph.nt") + source_path = Path("data/input_final/txt_source/docs") + result_path = tmp_base_dir / f"text_result_saved_sample_config_idx_{config_idx}.nt" + tasks_tmp_dir = tmp_base_dir / f"text_tasks_tmp_saved_sample_config_idx_{config_idx}" + tasks_tmp_dir.mkdir(parents=True, exist_ok=True) + + pipeline = KgPipe( + tasks=pipeline_config.tasks, + seed=Data(path=seed_path, format=DataFormat.RDF_NTRIPLES), + data_dir=tasks_tmp_dir, + name="test_text_pipeline_saved_sample", + ) + + print(f"Building pipeline... {config_idx}") + print("#######################") + + pipeline.build( + stable_files=True, + configCatalog=pipeline_config.config_catalog, + source=Data(path=source_path, format=DataFormat.TEXT), + result=Data(path=result_path, format=DataFormat.RDF_NTRIPLES), + ) + + print(f"Running pipeline... {config_idx}") + print("#######################") + + pipeline.run(configCatalog=pipeline_config.config_catalog, stable_files_override=False) \ No newline at end of file diff --git a/src/kgpipe_eval/utils/alignment_utils.py b/src/kgpipe_eval/utils/alignment_utils.py index 1e4f16c..0f9359d 100644 --- a/src/kgpipe_eval/utils/alignment_utils.py +++ b/src/kgpipe_eval/utils/alignment_utils.py @@ -220,13 +220,26 @@ def _build_bnode_map(gen_g: TripleGraph, ref_g: TripleGraph) -> dict[str, Term]: if DEBUG: print("Entity alignments: ", len(entity_alignments)) + def _as_term(t: Term | str) -> Term: + # Entity alignment currently carries string IDs; convert to rdflib Terms so + # aligned triples are comparable to `ref_tg.triples(...)` output. + if isinstance(t, str): + try: + from rdflib import URIRef + return URIRef(t) + except Exception: + # Fall back to raw string (will likely not match ref triples, but + # avoids crashing on non-URI identifiers). + return t # type: ignore[return-value] + return t + gen_to_ref_entity: dict[str, Term] = {} best_score_by_gen: dict[str, float] = {} for a in entity_alignments: gen_key = str(a.source) if gen_key not in best_score_by_gen or a.score > best_score_by_gen[gen_key]: best_score_by_gen[gen_key] = float(a.score) - gen_to_ref_entity[gen_key] = a.target + gen_to_ref_entity[gen_key] = _as_term(a.target) # 2) Index generated triples, both raw and entity-mapped mapped_gen_triples: list[tuple[Triple, Triple]] = [] # (raw_gen, mapped_to_ref_space) @@ -308,13 +321,10 @@ def get_gen_literal_embedding(texts: list[str]) -> np.ndarray: if DEBUG: print("gen_lit_emb_by_text: ", len(gen_lit_emb_by_text)) if DEBUG: print("ref_lit_emb_by_text: ", len(ref_lit_emb_by_text)) - from rdflib import Graph, URIRef + from rdflib import Graph gen_graph : Graph = tg._graph() ref_graph : Graph = ref_tg._graph() - test_objects = list(ref_graph.objects(URIRef("http://kg.org/resource/f4eb17c4ed78c87c29124018c9f180b5"), URIRef("http://kg.org/ontology/deathPlace"), unique=True)) - if DEBUG: print("Test objects: ", len(test_objects)) - if DEBUG: print("Gen graph: ", len(list(gen_graph.triples((None, None, None))))) if DEBUG: print("Ref graph: ", len(list(ref_graph.triples((None, None, None))))) @@ -330,7 +340,7 @@ def get_gen_literal_embedding(texts: list[str]) -> np.ndarray: gen_literal_objs = [o for o in gen_objects if _is_literal(o)] # IMPORTANT: query reference objects in reference-space subject - ref_objects = list(ref_graph.objects(URIRef(str(ref_s)), URIRef(str(gp)))) + ref_objects = list(ref_graph.objects(ref_s, gp)) ref_literal_objs = [o for o in ref_objects if _is_literal(o)] # print("gs: ", gs, "gp: ", gp) @@ -366,7 +376,13 @@ def get_gen_literal_embedding(texts: list[str]) -> np.ndarray: # find if any of the non-literal objects in the generated graph are mapped to the same object in the reference graph for gen_obj in gen_object_non_literal: - if gen_to_ref_entity.get(str(gen_obj), gen_bnode_to_ref.get(str(gen_obj), gen_obj)) in ref_object_non_literal: - alignments.append(TripleAlignment(source=(gs, gp, gen_obj), target=(gs, gp, ref_object_non_literal[ref_object_non_literal.index(gen_obj)]))) + ref_o = gen_to_ref_entity.get(str(gen_obj), gen_bnode_to_ref.get(str(gen_obj), gen_obj)) + if ref_o in ref_object_non_literal: + alignments.append( + TripleAlignment( + source=(gs, gp, gen_obj), + target=(ref_s, gp, ref_o), + ) + ) return alignments \ No newline at end of file From 6dc3e4083a8ba42da09064002a528e970559c391 Mon Sep 17 00:00:00 2001 From: Marvin Date: Wed, 13 May 2026 12:14:41 +0200 Subject: [PATCH 63/96] fix(eval): renamed triple align metric --- src/kgpipe_eval/metrics/triple_alignment.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/kgpipe_eval/metrics/triple_alignment.py b/src/kgpipe_eval/metrics/triple_alignment.py index 516f18d..5114ddb 100644 --- a/src/kgpipe_eval/metrics/triple_alignment.py +++ b/src/kgpipe_eval/metrics/triple_alignment.py @@ -51,7 +51,7 @@ def eval_triple_alignment(tg: TripleGraph, config: TripleAlignmentConfig): # def eval_triple_alignment_by_label_embedding_soft_literals(method: Literal["exact", "fuzzy", "semantic"] = "exact"): # pass -class ReferenceTripleAlignmentMetric(Metric): +class TripleAlignmentMetric(Metric): def compute(self, kg: KG, config: TripleAlignmentConfig): m: BCMeasurement = eval_triple_alignment(kg, config) @@ -71,4 +71,4 @@ def compute(self, kg: KG, config: TripleAlignmentConfig): # Backward-compatibility alias (imported by `kgpipe_eval.metrics.__init__`). -TripleAlignmentMetric = ReferenceTripleAlignmentMetric \ No newline at end of file +# TripleAlignmentMetric = TripleAlignmentMetric \ No newline at end of file From 9987cb2a7a417c9222ecfb8eee12d5b5d43e479e Mon Sep 17 00:00:00 2001 From: Marvin Date: Wed, 13 May 2026 12:15:06 +0200 Subject: [PATCH 64/96] exp(moviekg): changed paths --- .../moviekg/evaluation/test_eval_refactor.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py b/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py index 1dacc8f..61621cb 100644 --- a/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py +++ b/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py @@ -4,7 +4,7 @@ from kgpipe_eval.metrics.statistics import CountMetric from kgpipe_eval.metrics.duplicates import DuplicateConfig, DuplicateMetric from kgpipe_eval.metrics.entity_alignment import EntityAlignmentMetric -from kgpipe_eval.metrics.triple_alignment import TripleAlignmentConfig, ReferenceTripleAlignmentMetric +from kgpipe_eval.metrics.triple_alignment import TripleAlignmentConfig, TripleAlignmentMetric from kgpipe_eval.utils.alignment_utils import EntityAlignmentConfig from kgpipe_eval.utils.kg_utils import KgLike, KgManager from kgpipe_eval.evaluator import Evaluator @@ -79,7 +79,7 @@ def from_path(path: Path | str) -> 'KgPipeData': report = KgPipeReport.from_path(path / "exec-report.json") tmp_dir = path / "tmp" return KgPipeData( - result_kg=KG(name=path.name, id=path.name, path=path / "result.nt", format=DataFormat.RDF_NTRIPLES), + result_kg=KG(name=path.name, id=path.name, path=path / "result_eval.nt", format=DataFormat.RDF_NTRIPLES), plan=plan, report=report, tmp_dir=tmp_dir @@ -96,14 +96,17 @@ def build_config_dict(i: int, pipe_data: KgPipeData, bench_data: KgBenchData) -> ) tri_cfg = TripleAlignmentConfig( - reference_kg=bench_data.dataset.splits[f"split_{i}"].kg_reference, + reference_kg=bench_data.dataset.splits[f"split_{i}"].kg_reference.root / "data_agg_eval.nt", entity_alignment_config=EntityAlignmentConfig( method="label_embedding", - verified_entities_path=bench_data.get_verified_entities_path(i=i, source_type="rdf"), # TODO type needs to be derived from pipe_data - verified_entities_delimiter="\t", + reference_kg=bench_data.dataset.splits[f"split_{i}"].kg_reference.root / "data_agg_eval.nt", + # verified_entities_path=bench_data.get_verified_entities_path(i=i, source_type="rdf"), # TODO type needs to be derived from pipe_data + # verified_entities_delimiter="\t", entity_sim_threshold=0.95, ), value_sim_threshold=0.5, + cache_literal_embeddings=True, + cache_ref_literal_embeddings=True, ) ent_cfg = EntityAlignmentConfig( @@ -127,7 +130,7 @@ def evaluate_stage(i: int, pipe_data: KgPipeData, bench_data: KgBenchData) -> Li CountMetric(), EntityAlignmentMetric(), DuplicateMetric(), - ReferenceTripleAlignmentMetric(), + TripleAlignmentMetric(), ] config_dict = build_config_dict(i, pipe_data, bench_data) return Evaluator().run(tg, metrics, config_dict) @@ -208,6 +211,8 @@ def test_evaluate_new(pipeline_name: str): for stage_dir in stage_dirs: i = int(stage_dir.name.split("_", 1)[1]) + if i != 3: + continue # only run for stage 3 pipe_data = KgPipeData.from_path(stage_dir) results = evaluate_stage(i=i, pipe_data=pipe_data, bench_data=bench_data) @@ -245,6 +250,8 @@ def test_evaluate_new_multisource_pipeline(source_1: str, source_2: str, source_ for stage_dir in stage_dirs: i = int(stage_dir.name.split("_", 1)[1]) + if i != 3: + continue # only run for stage 3 pipe_data = KgPipeData.from_path(stage_dir) results = evaluate_stage(i=i, pipe_data=pipe_data, bench_data=bench_data) From 36e0489709a2b80a6aef3f5fa72660e63c41ca07 Mon Sep 17 00:00:00 2001 From: Marvin Date: Wed, 13 May 2026 12:21:31 +0200 Subject: [PATCH 65/96] feat(genie-task) --- .../param-opti/wrappers/genie/Dockerfile | 30 +++++ .../param-opti/wrappers/genie/README.md | 79 +++++++++++ .../wrappers/genie/bin/genie_cli.py | 126 ++++++++++++++++++ .../param-opti/wrappers/genie/genie.sh | 43 ++++++ .../param-opti/wrappers/genie/output.json | 20 +++ .../param-opti/wrappers/genie/output_1.json | 26 ++++ .../param-opti/wrappers/genie/test.txt | 3 + .../param-opti/wrappers/genie/test_1.txt | 1 + .../wrappers/genie/test_docker_run.sh | 1 + 9 files changed, 329 insertions(+) create mode 100644 experiments/param-opti/wrappers/genie/Dockerfile create mode 100644 experiments/param-opti/wrappers/genie/README.md create mode 100644 experiments/param-opti/wrappers/genie/bin/genie_cli.py create mode 100644 experiments/param-opti/wrappers/genie/genie.sh create mode 100644 experiments/param-opti/wrappers/genie/output.json create mode 100644 experiments/param-opti/wrappers/genie/output_1.json create mode 100644 experiments/param-opti/wrappers/genie/test.txt create mode 100644 experiments/param-opti/wrappers/genie/test_1.txt create mode 100644 experiments/param-opti/wrappers/genie/test_docker_run.sh diff --git a/experiments/param-opti/wrappers/genie/Dockerfile b/experiments/param-opti/wrappers/genie/Dockerfile new file mode 100644 index 0000000..a66625e --- /dev/null +++ b/experiments/param-opti/wrappers/genie/Dockerfile @@ -0,0 +1,30 @@ +FROM python:3.8-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + git wget unzip\ + && rm -rf /var/lib/apt/lists/* + +RUN git clone https://github.com/epfl-dlab/GenIE.git + +WORKDIR /app/GenIE + +RUN pip install --upgrade pip + +RUN pip install -r pip_requirements.txt + +RUN mkdir -p data/models + +# Models initialized with a pretrained language model (GenIE - PLM) Trained on Rebel +RUN wget https://zenodo.org/record/6139236/files/genie_plm_r.ckpt \ + -O data/models/genie_plm_r.ckpt + +RUN wget https://zenodo.org/record/6139236/files/tries.zip \ + && unzip tries.zip -d data && rm tries.zip + +COPY bin/genie_cli.py /app/GenIE/genie_cli.py +COPY genie.sh /usr/local/bin/genie.sh +RUN chmod +x /usr/local/bin/genie.sh + + diff --git a/experiments/param-opti/wrappers/genie/README.md b/experiments/param-opti/wrappers/genie/README.md new file mode 100644 index 0000000..4b21480 --- /dev/null +++ b/experiments/param-opti/wrappers/genie/README.md @@ -0,0 +1,79 @@ +# README.md +## Build Docker +```bash +docker build -t genie . +``` + +## Run Docker +```bash +docker run --rm \ + -v /home/theo/Work/SCADS.AI/Projects/KGpipe/experiments/text-pipelines/test/Titanic.txt:/data/input.txt \ + -v /home/theo/Work/SCADS.AI/Projects/KGpipe/experiments/text-pipelines/wrappers/genie/output.json:/data/output.json \ + genie genie.sh /data/input.txt /data/output.json +``` + + +## Tool Parameters + +### Model Parameters +- `checkpoint` (pre trained model) **or** +- `hydra` + +--- + +### Constraint Parameters +- `entity_trie` (pickle) **or** string list +- `relation_trie` (pickle) **or** string list + +--- + +### Generate Parameters +Uses standard `Transformers generate()` function. + +#### Beam Search +- `num_beams` +- `num_return_sequences` +- `early_stopping` +- `length_penalty` + +#### Sampling +- `do_sample` +- `temperature` +- `top_k` +- `top_p` +- `typical_p` + +#### Output Length +- `max_length` +- `max_new_tokens` +- `min_length` +- `min_new_tokens` + +#### Scores & Debug +- `return_dict_in_generate` +- `output_scores` +- `output_attentions` +- `output_hidden_states` +- `output_logits` + +#### Seed +- `seed` + +#### Token-Control +- `bos_token_id` +- `eos_token_id` +- `pad_token_id` +- `decoder_start_token_id` +- `forced_bos_token_id` +- `forced_eos_token_id` + +#### Repetition / Constraints +- `repetition_penalty` +- `no_repeat_ngram_size` +- `bad_words_ids` +- `force_words_ids` +- `constraints` +- `prefix_allowed_tokens_fn` + + + diff --git a/experiments/param-opti/wrappers/genie/bin/genie_cli.py b/experiments/param-opti/wrappers/genie/bin/genie_cli.py new file mode 100644 index 0000000..3382e36 --- /dev/null +++ b/experiments/param-opti/wrappers/genie/bin/genie_cli.py @@ -0,0 +1,126 @@ +import sys +import os +import json +import re + +from genie.models import GeniePL +from genie.constrained_generation import Trie + +DATA_DIR = os.path.join(os.getcwd(), "data") + + +def load_model(): + ckpt_name = "genie_plm_r.ckpt" + path_to_checkpoint = os.path.join(DATA_DIR, "models", ckpt_name) + + model = GeniePL.load_from_checkpoint( + checkpoint_path=path_to_checkpoint + ) + + return model + + +def load_tries(): + entity_trie_path = os.path.join(DATA_DIR, "tries/large/entity_trie.pickle") + entity_trie = Trie.load(entity_trie_path) + + relation_trie_path = os.path.join(DATA_DIR, "tries/large/relation_trie.pickle") + relation_trie = Trie.load(relation_trie_path) + + return {"entity_trie": entity_trie, "relation_trie": relation_trie} + + +def split_into_sentences(text: str): + text = re.sub(r"\s+", " ", text).strip() + if not text: + return [] + + # Keep this lightweight so folder mode still benefits from a + # single long-lived Python process without extra tokenizer deps. + parts = re.split(r"(?<=[.!?])\s+(?=[A-Z0-9\"'(\[])", + text) + return [part.strip() for part in parts if part.strip()] + + +def extract_file(model, tries, input_path: str, output_path: str): + with open(input_path, "r", encoding="utf-8") as f: + text = f.read() + + sentences = split_into_sentences(text) + if not sentences: + with open(output_path, "w", encoding="utf-8") as f: + json.dump([], f, indent=2, ensure_ascii=False) + return + + generation_args = { + "num_beams": 5, + "num_return_sequences": 1, + "max_length": 128, + "early_stopping": True, + "no_repeat_ngram_size": 3, + "repetition_penalty": 1.2, + "length_penalty": 0.8, + "return_dict_in_generate": True, + "output_scores": True, + } + + outputs = model.sample( + sentences, + **tries, + **generation_args, + ) + + with open(output_path, "w", encoding="utf-8") as f: + json.dump(outputs, f, indent=2, ensure_ascii=False) + + +def main(): + if len(sys.argv) < 3: + print("Usage: genie.sh ") + sys.exit(1) + + input_path = sys.argv[1] + output_path = sys.argv[2] + + if os.path.isdir(input_path): + if os.path.isfile(output_path): + raise SystemExit("Error: output must be a folder when input is a folder") + + os.makedirs(output_path, exist_ok=True) + + model = load_model() + tries = load_tries() + + files = [ + os.path.join(input_path, name) + for name in os.listdir(input_path) + if os.path.isfile(os.path.join(input_path, name)) + ] + files.sort() + + for in_file in files: + filename = os.path.basename(in_file) + out_file = os.path.join(output_path, filename) + if os.path.exists(out_file): + continue + extract_file(model, tries, in_file, out_file) + print(f"Processed {in_file} → {out_file}") + + print(f"Extraction finished. Results written to folder {output_path}") + return + + if os.path.isfile(input_path): + if os.path.isdir(output_path): + raise SystemExit("Error: output must be a file when input is a file") + + model = load_model() + tries = load_tries() + extract_file(model, tries, input_path, output_path) + print(f"Extraction finished. Results written to {output_path}") + return + + raise SystemExit("Error: input must be a file or directory") + + +if __name__ == "__main__": + main() diff --git a/experiments/param-opti/wrappers/genie/genie.sh b/experiments/param-opti/wrappers/genie/genie.sh new file mode 100644 index 0000000..71fb229 --- /dev/null +++ b/experiments/param-opti/wrappers/genie/genie.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -e + +if [ "$#" -ne 2 ]; then + echo "Usage:" + echo " genie.sh " + echo " genie.sh " + exit 1 +fi + +INPUT="$1" +OUTPUT="$2" + +GRAPHENE_DIR="/app/GenIE" + +if [ -f "$INPUT" ]; then + if [ -d "$OUTPUT" ]; then + echo "Error: Output must be a file when input is a file" + exit 1 + fi + + echo "Processing single file..." + python /app/GenIE/genie_cli.py "$INPUT" "$OUTPUT" + echo "Done." + exit 0 +fi + +if [ -d "$INPUT" ]; then + if [ -f "$OUTPUT" ]; then + echo "Error: Output must be a folder when input is a folder" + exit 1 + fi + mkdir -p "$OUTPUT" + chmod 777 "$OUTPUT" + + echo "Processing folder..." + python /app/GenIE/genie_cli.py "$INPUT" "$OUTPUT" + echo "All files processed." + exit 0 +fi + +echo "Error: Input must be a file or directory" +exit 1 \ No newline at end of file diff --git a/experiments/param-opti/wrappers/genie/output.json b/experiments/param-opti/wrappers/genie/output.json new file mode 100644 index 0000000..aa99d3e --- /dev/null +++ b/experiments/param-opti/wrappers/genie/output.json @@ -0,0 +1,20 @@ +[ + [ + { + "text": " Captain America publisher Marvel Comics ", + "log_prob": -0.8582733273506165 + } + ], + [ + { + "text": " Marvel Studios parent organization Paramount Pictures ", + "log_prob": -0.5044617652893066 + } + ], + [ + { + "text": " El Capitan Theatre country United States ", + "log_prob": -0.5770944952964783 + } + ] +] \ No newline at end of file diff --git a/experiments/param-opti/wrappers/genie/output_1.json b/experiments/param-opti/wrappers/genie/output_1.json new file mode 100644 index 0000000..4972807 --- /dev/null +++ b/experiments/param-opti/wrappers/genie/output_1.json @@ -0,0 +1,26 @@ +[ + [ + { + "text": " Captain America publisher Marvel Comics ", + "log_prob": -0.4791736900806427 + } + ], + [ + { + "text": " Marvel Cinematic Universe production company Marvel Studios ", + "log_prob": -0.33403322100639343 + } + ], + [ + { + "text": " Captain America performer Chris Evans (actor) ", + "log_prob": -0.46612370014190674 + } + ], + [ + { + "text": " Captain America conflict World War II ", + "log_prob": -0.39299872517585754 + } + ] +] \ No newline at end of file diff --git a/experiments/param-opti/wrappers/genie/test.txt b/experiments/param-opti/wrappers/genie/test.txt new file mode 100644 index 0000000..4eb32be --- /dev/null +++ b/experiments/param-opti/wrappers/genie/test.txt @@ -0,0 +1,3 @@ +Captain America: The First Avenger is a 2011 American superhero film based on the Marvel Comics character Captain America. Produced by Marvel Studios and distributed by Paramount Pictures, it is the fifth film in the Marvel Cinematic Universe (MCU). The film was directed by Joe Johnston, written by Christopher Markus and Stephen McFeely, and stars Chris Evans as Steve Rogers / Captain America alongside Tommy Lee Jones, Hugo Weaving, Hayley Atwell, Sebastian Stan, Dominic Cooper, Toby Jones, Neal McDonough, Derek Luke, and Stanley Tucci. During World War II, Rogers, a frail man, is transformed into the super-soldier Captain America and must stop the Red Skull (Weaving) from using the Tesseract as an energy source for world domination. +The film began as a concept in 1997 and was scheduled for distribution by Artisan Entertainment. However, a lawsuit disrupted the project and was not settled until September 2003. In 2005, Marvel Studios received a loan from Merrill Lynch, and planned to finance and release the film through Paramount Pictures. Directors Jon Favreau and Louis Leterrier were interested in directing the project before Johnston was approached in 2008. The principal characters were cast between March and June 2010. Production began in June, and filming took place in London, Manchester, Caerwent, Liverpool, and Los Angeles. Several different techniques were used by the visual effects company Lola to create the physical appearance of the character before he becomes Captain America. +Captain America: The First Avenger premiered at the El Capitan Theatre in Los Angeles on July 19, 2011, and was released in the United States on July 22, as part of Phase One of the MCU. The film was commercially successful, grossing over $370 million worldwide, and received positive reviews from critics, who praised Evans' performance, the film's depiction of its 1940s time period, and Johnston's direction. Two direct sequels have been released: Captain America: The Winter Soldier (2014) and Captain America: Civil War (2016). diff --git a/experiments/param-opti/wrappers/genie/test_1.txt b/experiments/param-opti/wrappers/genie/test_1.txt new file mode 100644 index 0000000..ea6f33e --- /dev/null +++ b/experiments/param-opti/wrappers/genie/test_1.txt @@ -0,0 +1 @@ +Captain America: The First Avenger is a 2011 American superhero film based on the Marvel Comics character Captain America. Produced by Marvel Studios and distributed by Paramount Pictures, it is the fifth film in the Marvel Cinematic Universe (MCU). The film was directed by Joe Johnston, written by Christopher Markus and Stephen McFeely, and stars Chris Evans as Steve Rogers / Captain America alongside Tommy Lee Jones, Hugo Weaving, Hayley Atwell, Sebastian Stan, Dominic Cooper, Toby Jones, Neal McDonough, Derek Luke, and Stanley Tucci. During World War II, Rogers, a frail man, is transformed into the super-soldier Captain America and must stop the Red Skull (Weaving) from using the Tesseract as an energy source for world domination. diff --git a/experiments/param-opti/wrappers/genie/test_docker_run.sh b/experiments/param-opti/wrappers/genie/test_docker_run.sh new file mode 100644 index 0000000..9b00275 --- /dev/null +++ b/experiments/param-opti/wrappers/genie/test_docker_run.sh @@ -0,0 +1 @@ +docker run -v $(pwd):$(pwd) genie genie.sh $(pwd)/test_1.txt $(pwd)/output_1.json \ No newline at end of file From a32377f00bcf4e74c7b1c90cc6e2e7601008bb4d Mon Sep 17 00:00:00 2001 From: Marvin Date: Mon, 1 Jun 2026 14:02:29 +0200 Subject: [PATCH 66/96] exp(params): ignore test data, READE note to run sge pipelines --- experiments/param-opti/.gitignore | 6 +++++- experiments/param-opti/README.md | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/experiments/param-opti/.gitignore b/experiments/param-opti/.gitignore index a4d684a..a5521ea 100644 --- a/experiments/param-opti/.gitignore +++ b/experiments/param-opti/.gitignore @@ -1,3 +1,7 @@ output/ repos/ -output_qap_mock/ \ No newline at end of file +output_qap_mock/ +testdata/ +tmp/ +data/ +data \ No newline at end of file diff --git a/experiments/param-opti/README.md b/experiments/param-opti/README.md index 91e2f0f..edc5def 100644 --- a/experiments/param-opti/README.md +++ b/experiments/param-opti/README.md @@ -141,4 +141,9 @@ A `_summary.json` file is also generated with aggregate statistics. 1. Task Assignment: Selecting 2. Task Tunning -3. \ No newline at end of file +3. + + +# Notes + +../../.venv/bin/pytest -s --show-capture=no src/qap/test_exec_pipelines.py -k "test_rdf_pipeline_from_saved_sampled_configs" \ No newline at end of file From 5a5b022f02a9ed27793fe7b16229f4e4818c186d Mon Sep 17 00:00:00 2001 From: Marvin Date: Tue, 2 Jun 2026 13:38:50 +0200 Subject: [PATCH 67/96] docu: changed docs to mkdocs and workflow --- docs/index.md | 41 +++++- docs/metrics/metrics.md | 0 docs/metrics/reference_entity_alignment.md | 0 docs/metrics/reference_triple_alignment.md | 0 docs/metrics/stats_counts.md | 0 docs/migration.md | 0 docs/quickstart.md | 160 +++++++++++++++++++++ docs/reproduce.md | 2 +- mkdocs.yml | 52 +++++++ pyproject.toml | 4 + 10 files changed, 252 insertions(+), 7 deletions(-) create mode 100644 docs/metrics/metrics.md create mode 100644 docs/metrics/reference_entity_alignment.md create mode 100644 docs/metrics/reference_triple_alignment.md create mode 100644 docs/metrics/stats_counts.md create mode 100644 docs/migration.md create mode 100644 docs/quickstart.md create mode 100644 mkdocs.yml diff --git a/docs/index.md b/docs/index.md index 1a39c1b..8d9032a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,6 +4,37 @@ KGpipe is a framework to define pipelines for data integration into knowledge gr The framework is organized into three main subpackages: `kgpipe` contains the core framework functionality including CLI, common utilities, execution backends, and evaluation components. `kgpipe_tasks` provides task implementations for cleaning, construction, entity resolution, schema alignment, and text processing. `kgpipe_llm` includes LLM-based task implementations and utilities. +**Current version**: 0.7.0 +**Python**: >= 3.12 + +## Quickstart + +Start here: [Quickstart guide](quickstart.md) + +Minimal “happy path” (install + discover + inspect what’s available): + +```bash +pip install -e . +kgpipe discover --all --show-results +kgpipe list --type tasks +kgpipe list --type metrics +``` + +Create a new experiment project (recommended): + +```bash +cd experiments/examples +./init.sh +``` + +## How to use KGpipe (docs map) + +- Define tasks: [Task specification](tasks.md) +- Build and run pipelines: [Pipelines](pipelines.md) +- Configure runs and task parameters: [Configuration](configuration.md) and [Parameters](parameters.md) +- Evaluate generated KGs: [Evaluation](evaluation.md) and [Metrics](metrics/) +- Understand the internal “PipeKG”: [Meta KG](metakg.md) + ## Meta KG [link](metakg.md) @@ -39,11 +70,9 @@ Additional evaluation metrics are documented in the [metrics](metrics/) director ## Other Links - [Reproducing the movie kg experiments for 15 pipelines](reproduce.md) (rdf, json, text) +- [Migration notes](migration.md) +- [UI / viewer](view.md) -## Docu Backlog +## Docs backlog -- Explain different execution modes - - File Batches - - Streaming -- Explain advanced pipelines -- Ontology creation... \ No newline at end of file +Open items live in `TODO.md` (High/Medium/Low priority). Keep the landing page focused on user-facing docs. \ No newline at end of file diff --git a/docs/metrics/metrics.md b/docs/metrics/metrics.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/metrics/reference_entity_alignment.md b/docs/metrics/reference_entity_alignment.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/metrics/reference_triple_alignment.md b/docs/metrics/reference_triple_alignment.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/metrics/stats_counts.md b/docs/metrics/stats_counts.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 0000000..b50f1ec --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,160 @@ +# KGpipe Quickstart + +This quickstart shows the **current** workflow for defining and running: +- tasks (Python functions registered in the `Registry`) +- pipelines (a `KgPipe` connecting tasks via input/output `Data`) +- metrics/evaluations (evaluators + metrics run on a `KG`) + +## See also +- `experiments/examples/`: a minimal example project using KGpipe +- `docs/reproduce.md`: running the (deprecated but working) reproduction experiments + +## Install + +From the repo root: + +```bash +pip install -e . +``` + +If you need the optional ML stack (transformers / sentence-transformers), install extras: + +```bash +pip install -e ".[ml]" +``` + +## Create a new experiment (recommended starting point) + +The easiest way to get a working project layout is to copy the template in `experiments/examples/`. + +```bash +cd experiments/examples +./init.sh +``` + +The script creates a new directory containing a Python package with example tasks/pipelines. Then: + +```bash +cd "" +pip install -e . +``` + +## Define tasks + +Tasks are normal Python callables registered via `@Registry.task(...)`. See +`experiments/examples/src/kgpipe_examples/task_examples.py` for canonical examples. + +Key concepts: +- **`input_spec` / `output_spec`**: the expected formats for inputs/outputs +- **`TaskInput` / `TaskOutput`**: dict-like objects mapping names to `Data` +- **`trace_task_run`**: wraps the function to produce a run report + +Minimal pattern (simplified from the examples): + +```python +from kgpipe.common import TaskInput, TaskOutput, trace_task_run +from kgpipe.common.registry import Registry + +@trace_task_run +@Registry.task( + input_spec={"input": "some_format"}, + output_spec={"output": "some_other_format"}, + description="Example task", +) +def my_task(inputs: TaskInput, outputs: TaskOutput): + outputs["output"].path.touch() +``` + +## Define and run a pipeline (Python API) + +Pipelines connect tasks by passing `Data` (path + format) between them. A minimal example exists in +`experiments/examples/src/kgpipe_examples/pipe_examples.py`. + +The core pattern: + +```python +from kgpipe.common import KgPipe, Data + +# tasks = [task_a, task_b, ...] # registered task callables (from your package) +# seed = Data(path=..., format=...) +# result = Data(path=..., format=...) +pipe = KgPipe(tasks=tasks, seed=seed, data_dir="/tmp/my_run_dir") +pipe.build(source=seed, result=result) +pipe.run() +``` + +## Discover components and inspect what’s available (CLI) + +The CLI entrypoint is `kgpipe` (see `pyproject.toml`). + +To register tasks/pipelines/metrics from your local package, import it via discovery: + +```bash +# From inside your experiment venv / environment +kgpipe discover --package --show-results +``` + +You can also discover from a local module path (directory or file): + +```bash +kgpipe discover --module-path ./src/ --show-results +``` + +To list what KGpipe currently knows about (after discovery): + +```bash +kgpipe list --type tasks +kgpipe list --type metrics +``` + +To show details for a specific task: + +```bash +kgpipe show --type task +``` + +To print YAML templates for evaluation configs: + +```bash +kgpipe show metric-config-templates +``` + +## Run a single task (CLI) + +KGpipe can execute a registered task directly. The `--input/--output` syntax is: + +\[ +\texttt{|@} +\] + +(`@` is optional.) + +Example: + +```bash +kgpipe task \ + --input "/tmp/in.txt|txt@input" \ + --output "/tmp/out.txt|txt@output" +``` + +Tip: if you get “Task not found”, run `kgpipe discover ...` first. + +## Run a minimal evaluation (Python API) + +KG evaluation is done via evaluators + metric names. See +`experiments/examples/src/kgpipe_examples/eval_examples.py` for a working minimal example. + +Example sketch (statistical metrics): + +```python +from kgpipe.common.model.kg import KG +from kgpipe.common.model.default_catalog import BasicDataFormats +from kgpipe.evaluation.aspects.statistical import StatisticalEvaluator, StatisticalConfig, EntityCountMetric + +kg = KG(id="my_kg", name="My KG", path="my_kg.nt", format=BasicDataFormats.RDF_NTRIPLES) +results = StatisticalEvaluator().evaluate( + kg, + metrics=[EntityCountMetric().name], + config=StatisticalConfig(name="default"), +) +``` \ No newline at end of file diff --git a/docs/reproduce.md b/docs/reproduce.md index 40875e6..70b1d69 100644 --- a/docs/reproduce.md +++ b/docs/reproduce.md @@ -1,4 +1,4 @@ -# Rep Experiments +# Rep Experiments (Deprecated but working) Guidelines to run the [experiments](../experiments) - see also [moviekg](../experiments/moviekg/README.md) diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..7a41041 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,52 @@ +site_name: KGpipe +site_description: Knowledge Graph pipeline evaluation framework + +# For GitHub Pages under // +use_directory_urls: true + +theme: + name: material + features: + - navigation.instant + - navigation.tracking + - navigation.sections + - navigation.expand + - navigation.top + - toc.integrate + - search.suggest + - search.highlight + +markdown_extensions: + - admonition + - toc: + permalink: true + - pymdownx.superfences + - pymdownx.details + +plugins: + - search + +docs_dir: docs +site_dir: site + +nav: + - Home: index.md + - Quickstart: quickstart.md + - Concepts: + - Tasks: tasks.md + - Pipelines: pipelines.md + - Configuration: configuration.md + - Parameters: parameters.md + - Meta KG: metakg.md + - Evaluation: + - Overview: evaluation.md + - Metrics index: metrics/metrics.md + - Entity coverage: metrics/entity_coverage.md + - Reference entity alignment: metrics/reference_entity_alignment.md + - Reference triple alignment: metrics/reference_triple_alignment.md + - Stats counts: metrics/stats_counts.md + - Experiments: + - Reproduce MovieKG: reproduce.md + - Other: + - Migration: migration.md + - View/UI: view.md diff --git a/pyproject.toml b/pyproject.toml index 57a7d1b..13cbf96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,10 @@ dependencies = [ [project.optional-dependencies] dev = ["pytest", "pytest-mock", "pytest-cov", "ruff", "black"] +docs = [ + "mkdocs-material", + "mkdocstrings[python]", +] cpu = [ "torch", "torchvision", From ccdf436f2f1e903be3aede72631d1acd3fc0dd83 Mon Sep 17 00:00:00 2001 From: Marvin Date: Wed, 3 Jun 2026 16:32:06 +0200 Subject: [PATCH 68/96] cleanup: docu refactor; consitency metrics update --- README.md | 49 +- docs/README.md | 45 + docs/adoption.md | 85 ++ docs/evaluation.md | 235 ++---- docs/index.md | 36 +- docs/metrics/entity_coverage.md | 2 +- docs/migration.md | 7 + docs/quickstart.md | 37 +- docs/workflow.png | Bin 0 -> 581605 bytes experiments/moviekg/Makefile | 95 +-- experiments/moviekg/README.md | 91 +- experiments/moviekg/env | 13 +- experiments/moviekg/eval.sh | 10 - experiments/moviekg/pipeline.conf | 86 +- experiments/moviekg/src/moviekg/config.py | 24 +- .../src/moviekg/evaluation/__init__.py | 0 .../moviekg/src/moviekg/evaluation/helpers.py | 206 ----- .../moviekg/evaluation/test_eval_refactor.py | 263 ------ .../evaluation/test_inc_msp_evaluation.py | 61 -- .../evaluation/test_inc_ssp_evaluation.py | 58 -- .../src/moviekg/evaluation/test_ref_dev.py | 247 ------ .../moviekg/evaluation/test_sensitivity.py | 159 ---- .../moviekg/src/moviekg/paper/__init__.py | 0 .../moviekg/src/moviekg/paper/config.py | 135 --- .../src/moviekg/paper/helpers/__init__.py | 0 .../src/moviekg/paper/helpers/agggregate.py | 224 ----- .../src/moviekg/paper/helpers/getter.py | 557 ------------- .../src/moviekg/paper/helpers/helpers.py | 777 ----------------- .../src/moviekg/paper/helpers/ranking.py | 119 --- .../moviekg/src/moviekg/paper/test_figtab.py | 779 ------------------ .../src/moviekg/paper/test_ranksens.py | 162 ---- .../moviekg/src/moviekg/pipelines/helpers.py | 9 +- mkdocs.yml | 3 +- src/kgpipe/cli/eval_new.py | 46 ++ src/kgpipe/io/__init__.py | 2 + src/kgpipe/io/pipe_out.py | 122 +++ .../metrics/consistency_violations.py | 634 +++++++++++++- src/kgpipe_eval/utils/kg_utils.py | 9 + 38 files changed, 1246 insertions(+), 4141 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/adoption.md create mode 100644 docs/workflow.png delete mode 100644 experiments/moviekg/eval.sh delete mode 100644 experiments/moviekg/src/moviekg/evaluation/__init__.py delete mode 100644 experiments/moviekg/src/moviekg/evaluation/helpers.py delete mode 100644 experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py delete mode 100644 experiments/moviekg/src/moviekg/evaluation/test_inc_msp_evaluation.py delete mode 100644 experiments/moviekg/src/moviekg/evaluation/test_inc_ssp_evaluation.py delete mode 100644 experiments/moviekg/src/moviekg/evaluation/test_ref_dev.py delete mode 100644 experiments/moviekg/src/moviekg/evaluation/test_sensitivity.py delete mode 100644 experiments/moviekg/src/moviekg/paper/__init__.py delete mode 100644 experiments/moviekg/src/moviekg/paper/config.py delete mode 100644 experiments/moviekg/src/moviekg/paper/helpers/__init__.py delete mode 100644 experiments/moviekg/src/moviekg/paper/helpers/agggregate.py delete mode 100644 experiments/moviekg/src/moviekg/paper/helpers/getter.py delete mode 100644 experiments/moviekg/src/moviekg/paper/helpers/helpers.py delete mode 100644 experiments/moviekg/src/moviekg/paper/helpers/ranking.py delete mode 100644 experiments/moviekg/src/moviekg/paper/test_figtab.py delete mode 100644 experiments/moviekg/src/moviekg/paper/test_ranksens.py create mode 100644 src/kgpipe/io/__init__.py create mode 100644 src/kgpipe/io/pipe_out.py diff --git a/README.md b/README.md index 2900b0a..3c463c3 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,21 @@ # KGpipe: A Framework for Knowledge Graph Integration Pipelines -- 📊 [Benchmark Datasets](https://doi.org/10.5281/zenodo.17246357) +## Related benchmarks & datasets + +- **KGI-Bench**: benchmark specification + tooling for KG integration evaluation. See `https://github.com/ScaDS/KGI-Bench`. +- **KGI-Bench (Movies)**: Movie-domain benchmark dataset release (Zenodo). See `https://doi.org/10.5281/zenodo.17246357`. KGpipe is an open-source framework for defining, executing, and evaluating knowledge graph (KG) integration pipelines. It enables the reuse and composition of existing tools (e.g., OpenIE, PARIS, JedAI) and Large Language Models (LLMs) into modular pipelines that integrate heterogeneous data sources into a unified KG. +![KGpipe workflow](docs/workflow.png) + +**Who is this for?** +- You have multiple heterogeneous sources (RDF/JSON/text) and want a **reproducible, modular pipeline**. +- You want to **reuse existing tooling** (Python libs, Dockerized CLIs, remote APIs/LLMs) without rewriting everything. +- You want to **evaluate** generated KGs with a growing set of metrics (`kgpipe_eval`). + **Key features:** - Modular and extensible pipeline specification. - Support for multiple execution backends (Python, Docker, HTTP services). @@ -13,6 +23,28 @@ It enables the reuse and composition of existing tools (e.g., OpenIE, PARIS, Jed - Novel benchmark for systematic evaluation of pipelines across RDF, JSON, and text sources. - Metrics covering structural, semantic, and reference-based evaluation. +## Quickstart (5 minutes) + +Install from source (editable): + +```bash +pip install -e . +kgpipe --help +``` + +Bootstrap a minimal example project and discover its tasks: + +```bash +cd experiments/examples +./init.sh + +cd "" +pip install -e . + +kgpipe discover --package --show-results +kgpipe list --type tasks +``` + ## Architecture Each pipeline is a sequence of tasks with well-defined input/output contracts. @@ -49,7 +81,18 @@ KGpipe provides Single-Source Pipelines (SSPs) and Multi-Source Pipelines (MSPs) ## Usage -For documentation see the [docs](docs/reproduce.md) +Documentation lives in `docs/`: +- **Start here**: `docs/index.md` and `docs/quickstart.md` +- **Adopting KGpipe / wrapping existing tools**: `docs/adoption.md` +- **Evaluation (new API)**: `docs/evaluation.md` (uses `kgpipe_eval`) +- **MovieKG reproduction**: `docs/reproduce.md` + +### Documentation site (GitHub Pages) + +This repo is set up to build docs with **MkDocs + Material**: +- config: `mkdocs.yml` +- local build instructions: `docs/README.md` +- deploy workflow: `.github/workflows/docs.yml` (GitHub Pages via Actions) ## Installation notes (CPU vs CUDA) @@ -76,4 +119,4 @@ uv pip install ".[ml,cuda]" ``` ## Experiments -- **[moviekg](experiments/moviekg/README.md)** evalaution of a pipelines, building a Movie KG from three sources (rdf,json,text). +- **[moviekg](experiments/moviekg/README.md)** evaluation of pipelines, building a Movie KG from three sources (rdf, json, text). diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..b066c6a --- /dev/null +++ b/docs/README.md @@ -0,0 +1,45 @@ +# Docs (MkDocs) + +This repository uses **MkDocs + Material** to build the documentation site from the Markdown files in `docs/`. + +## Local preview + +### Option A: pip + +```bash +python -m pip install -e ".[docs]" +mkdocs serve +``` + +Then open the URL shown in the terminal (usually `http://127.0.0.1:8000/`). + +### Option B: uv (recommended if you use uv) + +```bash +uv pip install -e ".[docs]" +mkdocs serve +``` + +## Build + +```bash +mkdocs build --strict +``` + +The static site is written to `site/`. + +## Navigation / sidebar + +Edit `mkdocs.yml` (`nav:` section) to control: +- sidebar structure +- ordering +- page titles + +## Deployment (GitHub Pages) + +Deployment is handled by the GitHub Actions workflow: +- `.github/workflows/docs.yml` + +In your GitHub repo settings, set: +- **Settings → Pages → Source**: **GitHub Actions** + diff --git a/docs/adoption.md b/docs/adoption.md new file mode 100644 index 0000000..c1229a5 --- /dev/null +++ b/docs/adoption.md @@ -0,0 +1,85 @@ +# Adopting KGpipe (integrating existing pipelines/tools) + +This page explains how to **adopt KGpipe** when you already have: +- an existing KG pipeline (e.g., DBpedia-style multi-step workflows), and/or +- existing implementations you want to reuse (Python code, Dockerized tools, external APIs). + +The goal is to map “what you already have” onto KGpipe’s building blocks: +- **Tasks**: reusable steps with typed inputs/outputs (`input_spec` / `output_spec`) +- **Pipelines**: ordered task graphs (`KgPipe`) that transform `Data` from seed → result +- **Configuration**: parameters passed into tasks (often via env/config profiles) + +## 1) Convert an existing pipeline into a KGpipe pipeline + +When you have a pipeline described elsewhere (scripts, Airflow, Makefile, DBpedia extraction steps, etc.), do this: + +1. **List pipeline steps** (one row per step): name, inputs, outputs, and “how it runs” (Python/Docker/API). +2. **Define formats** for each boundary artifact (RDF formats, CSV, JSON, text). If needed, extend formats. +3. **Wrap each step as a KGpipe task** (see sections below). +4. **Compose tasks into a `KgPipe`** and verify the input/output formats connect. + +Practical tip: start by wrapping a *single* step and run it via `kgpipe task ...`, then grow into a pipeline. + +## 2) Wrap existing tasks (three common patterns) + +### A) Wrap a Dockerized CLI tool + +Use this when the tool is a command-line program and can run inside a container. + +Reference example: +- `src/kgpipe_tasks/entity_resolution/matcher/paris_rdf_matcher.py` + +What to document for each wrapper: +- Docker image name + how to build/pull it +- command template (mapping KGpipe input/output keys to CLI args) +- volume mounts / working dir assumptions +- required environment variables + +### B) Wrap existing Python code + +Use this when you have Python functions/classes you want to call directly. + +Reference example: +- `experiments/param-opti/src/param_opti/tasks/base_linker.py` + +What to document for each wrapper: +- the function/class you call +- how you read from `inputs[...]` and write to `outputs[...]` +- how you map configuration parameters into function args (or config objects) + +### C) Wrap an external API (HTTP service) + +Use this when the implementation is “some service endpoint” (DBpedia Spotlight, LLM providers, etc.). + +Reference examples: +- `experiments/param-opti/src/param_opti/tasks/spotlight_lib.py` +- `experiments/param-opti/src/param_opti/tasks/spotlight.py` + +What to document for each wrapper: +- endpoint URL + auth +- request/response format +- retry/timeouts and caching +- how you handle rate limits and partial failures + +## 3) Discovery (making your tasks available) + +Once tasks exist in a Python package, KGpipe can discover them (they register when imported). + +```bash +kgpipe discover --package --show-results +kgpipe list --type tasks +``` + +## 4) Recommended structure for “adopted” pipelines + +A maintainable layout usually separates: +- `tasks/`: wrappers (Python/Docker/API) +- `pipelines/`: composition (KgPipe builders or pipeline configs) +- `configs/`: pipeline/task configuration profiles +- `docker/`: Dockerfiles and wrapper scripts (if needed) + +## Status + +This page is the intended replacement for `migration.md` (which was a misleading name). It will be expanded with +copy-pastable code snippets for each wrapper type using the referenced files above as canonical examples. + diff --git a/docs/evaluation.md b/docs/evaluation.md index fc27bda..613eb33 100644 --- a/docs/evaluation.md +++ b/docs/evaluation.md @@ -1,181 +1,114 @@ -# KG Evaluation +# KG Evaluation (new API) -The framework provides several approaches to evaluate the quality of a generated knowledge graph. Evaluation is organized into different aspects, each focusing on specific quality dimensions. +KGpipe currently contains **two** evaluation implementations: -## Evaluation Aspects +- **New** (recommended): `kgpipe_eval` (package: `src/kgpipe_eval/`) +- **Old** (deprecated soon): `kgpipe.evaluation` (package: `src/kgpipe/evaluation/`) -The framework supports evaluation across multiple aspects: +This page documents the **new** `kgpipe_eval` API. -- **Statistical**: Basic metrics like triple count, entity count, graph density, and other structural properties -- **Semantic**: Validation of ontology consistency, type errors, relation direction, and semantic correctness -- **Reference**: Comparison against curated gold-standard knowledge graphs using precision, recall, and F1 scores -- **Efficiency**: Resource consumption metrics including runtime, memory usage, and cost +## Mental model -## Using the Evaluator +In `kgpipe_eval`, evaluation is composed from: +- **KG loader / adapter**: turns a `KgLike` (e.g. a `kgpipe.common.model.kg.KG`) into an in-memory `TripleGraph` +- **Metric instances**: objects implementing `Metric.compute(...) -> MetricResult` +- **Metric configs** (optional): typed config objects passed to metrics that require parameters +- **Evaluator**: runs multiple metrics against a loaded graph -The main entry point for evaluation is the `Evaluator` class. You configure which aspects to evaluate and then run evaluation on a knowledge graph: +Key types: +- `kgpipe_eval.api.Metric`: metric interface (`key`, `description`, `compute`) +- `kgpipe_eval.api.MetricResult`: dataclass with `measurements` + optional `summary` +- `kgpipe_eval.evaluator.Evaluator`: runs a list of metrics with an optional `confs` dict + +## Minimal example (statistics) ```python -from kgpipe.evaluation import Evaluator, EvaluationConfig, EvaluationAspect -from kgpipe.common.models import KG, DataFormat from pathlib import Path -# Create evaluation configuration -config = EvaluationConfig( - aspects=[EvaluationAspect.STATISTICAL, EvaluationAspect.SEMANTIC, EvaluationAspect.REFERENCE], - metrics=None # None means use all available metrics for each aspect -) +from kgpipe.common.model.data import DataFormat +from kgpipe.common.model.kg import KG -# Create evaluator -evaluator = Evaluator(config) +from kgpipe_eval.evaluator import Evaluator +from kgpipe_eval.metrics.statistics import CountMetric +from kgpipe_eval.utils.kg_utils import KgManager -# Load the knowledge graph to evaluate kg = KG( id="my_kg", - name="My Knowledge Graph", - path=Path("result.nt"), - format=DataFormat.RDF_NTRIPLES + name="My KG", + path=Path("my_kg.nt"), + format=DataFormat.RDF_NTRIPLES, ) -# For reference-based evaluation, provide reference data -references = { - "gold_standard": Data(path=Path("gold_standard.nt"), format=DataFormat.RDF_NTRIPLES) -} - -# Run evaluation -report = evaluator.evaluate(kg, references=references) +tg = KgManager.load_kg(kg) +results = Evaluator().run(tg, metrics=[CountMetric()]) -# Access results -print(f"Overall score: {report.overall_score}") -for aspect_result in report.aspect_results: - print(f"{aspect_result.aspect.value}: {len(aspect_result.metrics)} metrics") - for metric in aspect_result.metrics: - print(f" {metric.name}: {metric.value} (normalized: {metric.normalized_score})") +for r in results: + print(r.metric.key, r.summary) + for m in r.measurements: + print(" ", m.name, m.value) ``` -## Evaluation via CLI - -You can also evaluate knowledge graphs using the command-line interface: - -```bash -kgpipe eval target.nt --ground-truth gold.nt --aspects statistical semantic reference --output results.json -``` - -The CLI supports: -- `--aspects`: Specify which aspects to evaluate (statistical, semantic, reference, efficiency) -- `--metrics`: Filter to specific metrics by name -- `--ground-truth`: Path to reference knowledge graph for reference-based evaluation -- `--output`: Save evaluation results to a JSON file - -## Statistical Evaluation - -Statistical evaluation provides basic metrics about the knowledge graph structure: - -- Triple count -- Entity count -- Relation count -- Graph density -- Average degree -- Connected components - -These metrics help understand the scale and structure of the generated knowledge graph. - -## Semantic Evaluation +## Metrics that need configuration -Semantic evaluation validates the knowledge graph against its ontology: +Some metrics require a config object. The `Evaluator` detects this by introspecting the metric’s +`compute(...)` signature: +- `compute(self, kg)` → no config needed +- `compute(self, kg, config)` → config required and must be provided -- Disjoint domain violations -- Incorrect relation direction -- Incorrect relation cardinality -- Incorrect relation domain/range -- Incorrect datatypes -- Ontology class coverage -- Ontology relation coverage -- Namespace coverage +You pass configs via a dict keyed by the metric key/class name. -These metrics ensure the knowledge graph conforms to its schema and maintains semantic consistency. +Example (triple alignment + duplicates): -## Reference-based Evaluation - -Reference-based evaluation compares the generated knowledge graph against a gold standard: - -- Entity matching (precision, recall, F1) -- Relation matching (precision, recall, F1) -- Triple alignment -- Source typed entity coverage -- Reference class coverage - -This type of evaluation requires a curated reference knowledge graph that serves as ground truth. - -## Evaluation Reports - -Evaluation results are returned as `EvaluationReport` objects that contain: +```python +from kgpipe_eval.evaluator import Evaluator +from kgpipe_eval.utils.kg_utils import KgManager + +from kgpipe_eval.metrics.duplicates import DuplicateMetric, DuplicateConfig +from kgpipe_eval.metrics.triple_alignment import TripleAlignmentMetric, TripleAlignmentConfig +from kgpipe_eval.utils.alignment_utils import EntityAlignmentConfig + +tg = KgManager.load_kg("path/to/result_eval.nt") # KgLike: path, KG object, ... + +metrics = [DuplicateMetric(), TripleAlignmentMetric()] +confs = { + "DuplicateMetric": DuplicateConfig( + entity_alignment_config=EntityAlignmentConfig( + method="label_embedding", + verified_entities_path="path/to/verified_entities.tsv", + verified_entities_delimiter="\\t", + entity_sim_threshold=0.95, + ) + ), + "TripleAlignmentMetric": TripleAlignmentConfig( + reference_kg="path/to/reference.nt", + entity_alignment_config=EntityAlignmentConfig( + method="label_embedding", + reference_kg="path/to/reference.nt", + entity_sim_threshold=0.95, + ), + value_sim_threshold=0.5, + cache_literal_embeddings=True, + cache_ref_literal_embeddings=True, + ), +} -- The evaluated knowledge graph -- Reference data used (if any) -- Aspect results for each evaluated aspect -- Individual metric results with values and normalized scores -- Overall score (average of normalized scores across all metrics) +results = Evaluator().run(tg, metrics, confs) +``` -Reports can be serialized to JSON for storage and later analysis: +## Canonical reference example (MovieKG) -```python -report.to_json("evaluation_results.json") -``` +For a realistic end-to-end usage example (loading pipeline stage outputs, wiring configs, running multiple metrics), +see: -# Hierarchy +- `experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py` -``` -QualityEvaluationOntology - -QualityDimension - ├─ Accuracy - ├─ Coverage - ├─ Consistency - └─ Uniqueness - -Metric - ├─ BaseMetric - │ ├─ Precision - │ └─ Recall - ├─ CompositeMetric - │ └─ F1Score - └─ AggregatedMetric - ├─ MacroAverage - └─ MicroAverage - -QualityIssue - ├─ DuplicateEntities (false positives for EM) - ├─ DisjointDomainIssue - └─ MissingEntities (false positives for OM, or true positives for EM) - -EvaluationArtifact - ├─ ReferenceDataset - └─ QualityRulePattern -``` +That file shows how to: +- build per-metric configs (duplicates/entity alignment/triple alignment) +- load the KG from a pipeline output directory +- serialize `MetricResult` to JSON (because it contains metric objects) -Example Instance: Entity Matching +## CLI note -``` -ReferenceDataset - │ - ▼ -Precision / Recall - │ - ▼ -F1Score - │ - ▼ -Evaluation of Matching Quality - │ -False Negatives - │ - ▼ -DuplicateEntities - │ - ▼ -RedundancyIssue - │ - ▼ -Violates Uniqueness Dimension -``` \ No newline at end of file +There is a “new eval” CLI command path intended to run these metrics (see `kgpipe_eval.api` docstring mentioning +`kgpipe eval-new`). If you want the docs to include the CLI, we should first confirm the exact CLI flags and expected +inputs in `src/kgpipe/cli/eval_new.py` and align this page with that implementation. \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index 8d9032a..b506b88 100644 --- a/docs/index.md +++ b/docs/index.md @@ -7,6 +7,8 @@ The framework is organized into three main subpackages: `kgpipe` contains the co **Current version**: 0.7.0 **Python**: >= 3.12 +![KGpipe workflow](workflow.png) + ## Quickstart Start here: [Quickstart guide](quickstart.md) @@ -35,42 +37,10 @@ cd experiments/examples - Evaluate generated KGs: [Evaluation](evaluation.md) and [Metrics](metrics/) - Understand the internal “PipeKG”: [Meta KG](metakg.md) -## Meta KG - -[link](metakg.md) - -KGpipe uses an internally maintained Meta KG (PipeKG) to maintain tasks, tool implementations, their components, pipelines, and metrics. This knowledge base enables automatic pipeline generation and tracking of execution results. - -## Task Specification - -[link](tasks.md) - -The framework enables the description and integration of integration tasks. You can describe tasks with Python, interface existing implementations with Python, Docker, or remote API requests. Tasks are discovered and registered through the framework's discovery mechanism. - -## Pipeline Generation and Execution - -[link](pipelines.md) - -KGpipe allows you to define pipelines manually or using an automatic search algorithm that operates on the PipeKG knowledge base and a set of given constraints. You can swap subpipelines or single tasks with other components to experiment with different approaches. - -## Configuration - -[link](configuration.md) - -The framework supports configuration at multiple levels. The main configuration is specified in `kgpipe.yml`, and individual tasks can define their own configuration parameters that will be passed by the framework when executing pipelines. - -## Evaluation - -[link](evaluation.md) - -The framework provides several approaches to evaluate the quality of a generated knowledge graph, including accuracy, coverage, consistency, statistics, and efficiency measurements. Evaluation metrics are tracked in the Meta KG alongside pipeline results. - -Additional evaluation metrics are documented in the [metrics](metrics/) directory, such as [entity coverage](metrics/entity_coverage.md). - ## Other Links - [Reproducing the movie kg experiments for 15 pipelines](reproduce.md) (rdf, json, text) -- [Migration notes](migration.md) +- [Adopting KGpipe (integrating existing pipelines/tools)](adoption.md) - [UI / viewer](view.md) ## Docs backlog diff --git a/docs/metrics/entity_coverage.md b/docs/metrics/entity_coverage.md index 84b4ff1..1aa3ea5 100644 --- a/docs/metrics/entity_coverage.md +++ b/docs/metrics/entity_coverage.md @@ -1,4 +1,4 @@ -# Entity Coverage Metric +# Entity Coverage Metric (OLD) The Entity Coverage metric evaluates how well source entities are integrated into the target knowledge graph. It measures the overlap between expected source entities and the entities actually present in the generated knowledge graph. diff --git a/docs/migration.md b/docs/migration.md index e69de29..1ca2964 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -0,0 +1,7 @@ +# Migration (renamed) + +This page was renamed to better reflect its intent. + +Use: +- [`adoption.md`](adoption.md): **Adopting KGpipe (integrating existing pipelines/tools)** + diff --git a/docs/quickstart.md b/docs/quickstart.md index b50f1ec..a2b08f4 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -139,22 +139,35 @@ kgpipe task \ Tip: if you get “Task not found”, run `kgpipe discover ...` first. -## Run a minimal evaluation (Python API) +## Run a minimal evaluation / metrics (Python API, new `kgpipe_eval`) -KG evaluation is done via evaluators + metric names. See -`experiments/examples/src/kgpipe_examples/eval_examples.py` for a working minimal example. +KG evaluation is being migrated to the **new** `kgpipe_eval` package (recommended). A realistic integration-style +example exists in: -Example sketch (statistical metrics): +- `experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py` + +Minimal example (basic statistics): ```python +from pathlib import Path + +from kgpipe.common.model.data import DataFormat from kgpipe.common.model.kg import KG -from kgpipe.common.model.default_catalog import BasicDataFormats -from kgpipe.evaluation.aspects.statistical import StatisticalEvaluator, StatisticalConfig, EntityCountMetric - -kg = KG(id="my_kg", name="My KG", path="my_kg.nt", format=BasicDataFormats.RDF_NTRIPLES) -results = StatisticalEvaluator().evaluate( - kg, - metrics=[EntityCountMetric().name], - config=StatisticalConfig(name="default"), + +from kgpipe_eval.evaluator import Evaluator +from kgpipe_eval.metrics.statistics import CountMetric +from kgpipe_eval.utils.kg_utils import KgManager + +kg = KG( + id="my_kg", + name="My KG", + path=Path("my_kg.nt"), + format=DataFormat.RDF_NTRIPLES, ) + +tg = KgManager.load_kg(kg) +results = Evaluator().run(tg, metrics=[CountMetric()]) + +for r in results: + print(r.metric.key, r.summary) ``` \ No newline at end of file diff --git a/docs/workflow.png b/docs/workflow.png new file mode 100644 index 0000000000000000000000000000000000000000..41239b06697dbbaad6485f3db1a7f2ebeae3d13f GIT binary patch literal 581605 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ν zmCKAcY9!1R{23ZXFMi|*A59WD#;WE)Gn|GFdV zSE8Qr_hMa9z19ey!xa8p`8AEkN9(k20s zBzjy_lHPF=Vk78iMrflHH&+8yy3-v#1>}-b?6B8s!cAxqidNs@(4)v7In#HsjZskS zU0K*+;ZxElh-G$I$_`uY{K=7EWyV=Qy%xmn0+Aal;?Cb@=yr|!&>AlW13oft1|g#-}}cI4}?t1?M7^3U`pKIR8#yhZk05$8hBgU#~Fs z=Fv4Z7mQ~_#P~c#i|Xq8?1^9yQDrYy?)g)^6<#LY`INDKm{$H49k+EvPTQ4*cETE5HX(4aMard z%tM6EhLx83J-5j_-)HX+eG4KyY1x^khAVS}-s=?ZoO54X*6h4fPbIoztGc?ndgmwO zg4oLudGOOhV`o&evUpbYv#+$$8aHkt?$o;3$|@N@O5A7>^AW7@ZjqOp1c>d z(3d{zMX}ttuVdmrEP(BIaY(OK(Y#JDYwoegv@GIx)6H4l0FpD&eI|j(iR5Y8KAF)9 z1@4-}QzQN8dzL99(Nb4OnZb`e$ly(=d9_L~T4-$b4F&vVb^P0B8*gl_v-Lc`j|f}e z2H;C~#l}_FZ)|Y3VRQBvEf7j8PX=zolR9q5CX@Jj9In9dFU6T{1#>Ngo+kgKm-^{2 zHpkuyAGUL79WJpI8;GnpdB(iuDsAD^B7qtK=KsjS^0{`*z>s(hpRV4cjnMjcHNs)ay`h`RY@@jn#hLJZaX9 z>&N~fIDzF5=+*2QQwO8pb)?r8h)|Uv6CJqt_|i;%KWpKf#U_-d{Z(*Prmw|#B=5H% z%qrLuw7_w8n4VEI-wl*>B&2&-6v+u%7p679pI7lGSnVsNYEfZqCi2 ztM;z)HVSEiE^9eMw@+i~mEvJ~to~3(oqk>(s$i>z$5uh?_sk}1-tbILI_cbh1=m{@ zxu@r=(Y{SFAG#o!25s=0h?kbjmLzL&La7unVwT+sug;}yUN61N@$(8WTtLc#R6M<& zC}z<%Q&QGUf-vSpE@i;G)C8|AAPp5!7&aC7?{90rzok5TP!J};P7xkCIl1fDSX!+L zXzRCLg|G(ajoIh9(aZ)V9#3SHlvIU%f2gSbzGv*%U@>toQPne!UX!`hCOdGe$*3N^ z%O|qcdu)HW*!a3HA-+)7GTohAXf=<+GX3Md)v?z@Qroi}g&D7>RpPV1D@EPUbBB=b zKV##06s@Q85DO16v8xsq?aw25V=&BO+<3P1nkSG)?Dd6$<%3M$?vT-zgL{YplA)`E z*bb0+Z`%sra?f;JIp;^FAnVZ}c#$Z#xs`Q`xo(ToyB0UeM1RfhNF zHYS((g@PBd2#IzoJ-jf3YnkRdemwD$Fmxa?GO}ZB`ZnijSA(T~D6MlR1dUV#1HU(b zrf-NBW*5@d%AL1{;nM8jYZW@_-95pVCHXwCLd5sY2(7ET$7=fHJReP6E=Hkk9|*ZnPRGtpWw(ZGu2xA%ODj7cKnMncPHH(x+82AJWIj+MDOM zn)q$hkvIRudF!=_iMmygGYGjc_R!tydXU1PZ5LLh>Fc^Nglf{Tr-sEY=O08Uj;%Y{ z|Jc%y-)AJGt2p_F2_tsWdl^Nd#vj&58$yG|kF8377SyxR+>P=&L=M{t+r;~_tM;eB zHfvM}Cd9^T>wICzCR_Cn%LFuYLJ$e-14P{3eb#gd4<~|vS*F^r>c5v&6*j8;+tD9w zmcy%JJj^U5(;9nek?~yB{ z^}9&e*QwSJv*}Z@Ob@9*o>dM}`JGG^rU@Qb9S)A0UXE&tuOFGmO6xjWbR*etttB>_y5R93#9Y7}jHTXVkVLxrmV9LO!tLqyp*k5o^lwD^)D))uBkcK2% zT~GL#P|nz56eK!q(X;|@#^8y6?Yj#s?!_m;(SC<}HmxLzNVGxr0~ zBB1A`=Cl=hUr)3+0*Y?iWWlurDp!XH(CAEDB{ZJ049Y}IH{CxR-9T&*Ccc`c8N`V) zz27|Zm;&Xm7J$Na)?UTUz-dtJQFwt>rDpbz8!$Z>W)QqB{&gIHeP{qK7~AQ9h`5F} zJQu*?AwqY>0m_G_Gm{T8>_|f6Q$H)|HB9{~F!uNRo`)l+CSOjC312LU218ASd{(;~ zMEp(ho;ybg%aYb&9`&t^;J1=}!>Qp5Zb zcS!bq#3>FgcfA%aHtl+gs)nU-|It&sy4nwq0NulKFeT)(Wt!CL$W7f3Vr-c)q?dHmu>OaigMPY#G#rN6$Z76MR|y_}6cl^cx9c%=GeC@}Lng z6+TS3P27H0$~j`Fpng3h_L+cR*K!LA?=NeBgjgiKai`Nm3 zzOb|uSq&!yqy=CJA$Xxw8C+f!92^}d z5cdwQ%u&n|>=D*<5TBt7Pj*C)rj^nwqO`~O&`1Y9vUIC>92NbrG2-`YvCGXtDDrXE%-e zH$E>X-)d#1!8g8GD?=7VW1N|>_3s|ncvq^Y-&Yk=Z53-f^)H0BqP*$KaUE4XSELNp z5Y){KL5*ja&~d&%Yq#hO{jAwIQg|&qN}eLZ%Exhh9C&oP62^Z}Ids2qKTfg+h+qt7 zVY2sVv_CiQV3O^ z1F*(P`QTj0B5^Qr*=hVG0B<01OVP)oK-a|Gz@>;fPA09+2-$~hA66}7>Oap0rWt9O z$`FMTu+vaRcSFG&wrz&HTx4G+R)67#XuMZtcQYrWAfS>m3;wx~6XJ3j^9tuMu5GF~ z#m&Pb#7FZQ&^A@IHS;yo>Uei?DIF)n`OOu>8V)u%-{^eayCY~}mg8`O5t3?Ok7auH z%jL%py@J@gJak9#;=Wf)YD4pvt*5{=E+Y7Iaj>yv(ZIWYfUE?!e$q?XU)#KHh_0@5 z`pHpzZPD|CX*f#=J$)bPKl}>T4+!=1PP(!Xs(9Eb1E0%m!j#*9C&H`sgVZ{l2os!4 zxOhU?+PX6#@*I~TOqiP^4-+ofT``&t<%;f_BoE#`^>f{{rg!3M%oB1q|IwMtYH_3m zuy7}X&z>RGPwO2YixLLgng`M30%jrjH#pQAd{c}rxC_Wh__N~Va{tEbe`~kEo2>vC z$wTWqHM!7`O|kl^dMz%hc-D1j({Q$pDK6CtACd4B+qV!J-vz*B9E8uYV}Sdj>kAD- zeq0}`X{L_mkgd}4vza4Kx~T4UJ%y*U-f$jgVKk8xa0hdoQ0_ zU7f=~zBifrm0T$QfuP`Sw^gYF)$ zhSyN*Hr{LSg6;hy6ssC^4?(o=Gq6ebNddru|ED(qbfc8%C*kcaE%ZCH`!^Z__8`Iq zSwJ&v;6)9Nt`1IkNzC5gkPx3JnRf~i~Xac2isT&)LCm}>r-ThDMof2*3A54&^STg(X zHQ5?l8L3s6dF%xbbdFsf+U{Pj06I2tXdK{4n}V)LNE{mlhu~1`3~?!ItsUyXy~>mwtpi)8y(hyNP*<^a25&4!iaKKA_Uqk0l)yD?-DGX>hz%d zMPq$OTv|v|4)TeTwEQ&{Xe*!~8lVNY(R*Dx8rY3WJbT_v7WhD? z(!9Ec^C}4Bb$Jh6oaQ`bF&DK}^f(c{%AXH(0-0fQm);(JPcn>xG;MrgFFP)(BE^>y?(8GI(CE6G-h;}{9#(B!6gHT;rF%lS8s=f@Ls zx0VFhl_uh*JSe{Ex<93=F5$c7V%fbv4J67>U?icR zM$>j~Dir;=vRr;yy-x;2+Z3Okk2AdEyAmj6M>2rO4`8X~X`xRDV{`J0KgNhZh7Q12 zaUWG5C;@GWqara*$AT6(c)$3LXby^ty|-W!{~%abLAjA=Kp3IOz~Zf}x>`s4E~d(9 zpz^F`x65XExOY*B?lRvz_I!ljSj-AMR;%|mSyhF1{XCVx!qgukT(Wm&A3axGFdr_cc0QawtXXc4cl|>OjuSYZ$?o zZS}$+k55s6TSVb|_IgE!VsN_}*psMHq3WKG0uDJ>6QHhlB;oKj@Nwn(r$~CKCa2!1E8w<-`kn0t0UQda7q4JFd z>?}^q*9qP$87g#k#LsI{+ws=e!)4FRFCkSMfrkO3kSdP7XO8+rcsf9s7{XB3xsO|JXOFfE?qN8hC zEfzgCLZvL9uA}l${4`71WPUg5G*&cgr*xd5Hp)EJ`a$!d$@rG|_4Og=F4nv^%BAZW zF`7m8f1v%FR%I!GtpMwB1tv(WVol7>)XturGih6qQOi1RBtSYPkJ%8FBfCkSm}<;^ zSf&e;PH4|xMgc9mY3rG@Ov{0bvQbe&X0!Mu_P#H{L%$(S+4|2-@GV9wL>yq+^u6wW z7Y#IMynK<9l%{`9eSxtB zo|kQQ1D9rJZ&}qK`qodx?c^$^c2FGqZp(c6zh1rq3k-=OKLSO$9*Fg{5tz3ltlkzU zr|BfX=@Grp(f%;;k7i<4$^EnaZxGX8%}$dEj+E5h+WZ_R;opQ`q|Hekr$H(0F^w+h zlg-_OVCB`B?tNeb$l>~Y6O^k~7d2A>aaZzrOzZ)hTizUs{+BzESBnI83Sg>+-DCo9 zpK0{7-Yu9rMDF`~yDzwxAN=+VP(|f@fubZd+%630@uB+iVnw1eb;lPj;m6o4;bh zFoQ_xuCUK^7dUObxHwv{VF$|NaQ0U;n>{>(qDU@rj&3tkJ)`Qia@w|cKo0-_6A*y^ z(dU?C#}h3$oyeO2ol&t;H=FFKJde($gQKHyOx;^#^v-Cm z4t{WEVKn2F#1=F!(S1i?G=F=)WsHmrjkNX6eq?br>pv5+8hJ=!*Lv1N z;Ipe|>eW3Yw)KCxkN#PV4Nz2`_qX)EfjIzhqO^LP>2(#Kah~`SvcgxiJQqmXCmyt` zVO{d%ty37LwCHRRrGBIIv9I6=cJIg%XxJ)x%qb|rjHcE(Xlfp^PIdDFb*5VSz6I7q zkRdEv#F@c~z6{mTQ$8yjP|QQ<*<}~jGg+em@io)5cpNJf>V7lYxTDy@c+4*Cx*soY zSBW9qQ*@L`)49%%&I(kTjYJ=kNDGh4bX=W?Z?s_gONnAhf4HS~Sz}Dc247y`|fk@jd0?lgYG4 z^4>s~h?Zhy0TuuHx6wdpksk@)d~28xz7z3oVH#y(rE?R~pHXIhkQM=zo6wAzyr+W>2q&*axioyJp~xA^b>Ul=!~Zh*-|wp= zQG@mKrGn#Rg%fEw7_vzdNWRfYnTR?o?`LDu@qrJ#krj=^a2MKM$NWSGGG^L&>)E1s z)|m6Yx`%9=6g%ShzlgN~^K*9aE+==sH`6Lk@9Fol{Ec^fI9MCnqH#=%KU`?^*k6ik z(0DKkIqzkwNaKH+yIA+4PV#Fym)o4x@f|sZ+R*$$yEvl1=S-TAnoRvIM&XND%*lqb z9=4v9LU1@_71Fo~&$&j!q!V=?mQkW}W*%!jMX98Y?sud~PzYiU@uy*sRT(vNnly-r zQ43eUULW#P_H!3|rY0kU3U3R4R-ok3F&7cfYVz?DBg{aHuqkk^#u-k-*=a;rrD1H) zjqqv+;FdnxD=%xhc*9o{-3=)FQ2UQKIztY*HWvz?Zi*ylWox}#caoNot=@#(*%j^5 z$n)6Vj-(2I&BM8y<~6orwu25=@0g?qGBQ73>(o$ipE-Otm}Pf}A-`G?YBN(ZWyJk@ zW^Rs0wYC?^fR|*83~oQ}&VAKCbaP5KX;NJC##@0T6Te|3q*v1S;4|__F<}wA(RYd0 zudPHY|2NE^yg~6@o#ONY^1&gN;VIV`t1`op14V)sCMa!7x>OXq!Lo`H`fjSM{CT0~ zTsr@bFVMoZmZvaF^rpuAEd>Q$g8xSQpyabYNaTNTpB7o1j98|Rznu18R=e5foq43|Ur*-i{hP5t^ zVsDS5aMp4IB18p5-@F|WQn4BlA2<`W_-;5wikZvU_wFb=0#yY60RA@Y zIXp>{9PmVLU%XwGd)Y_#)`IehsS4fzP`xuvMRaNJKV%e$p55`(wr;%>)^s+~bi^>p ztG~BcV1L&KIxWYQ!swH8q{&UAomSJ>jnQQ><(mEk!$QgK+lL$H3S4iRCwH|>AQ(mf zwXht&G7xTOKCL`yXX*N7$&?Pg1o|d;MVm|Z@shLZJWY`X42(G zCs_aGOH&lOYeK2k&999V-W3p87}aAT{g8*x4bp+UpYI6A{E68@6uKahTIfzHvJL3+ zmiPEw@|Llu;l|3c~SupkkF;Oi4s2y=*X7wA&}{A z-5aMG0!Y4^>V(jtC8$E*#-An}C_~+|I72w3IykNK@?i3Se-g>`>?{y^)t=yWn?av9 z0)8h!n()slq-eH!G8!_Xds~i{6f5BQlI|XUCFvexP^AM;!Y4S()ZPCzFaK`M4bXVt zueLb|&KqbFFQzA$Fg$td{K#-!)(0CK^N1F5u0pTfG7_qtw`%q9)_0IS`^3&jSIUVa zk6a)--p84aWpn;Cggfs5<44xhncPAR_V+w#(O($v15ytM@~Zh`N%5zTC1qu0k7Z$= zb^HW^RWLvR0`#I;tIi0MWbHflKp(hl?i%ONuHkT_z3DuBPT~GrF7{vYAJa)ay|nYR z4LN>{kPSlo7**f&@&vc>m3TdK!TENAD3rvj;C)5XN7-ZR%Q9(yh(^KnjTj~KYu(6~~}%2}vveCbuTHJfpg5}tbB@$~dp(ol&CHU{*5&xz( z2rO`2&!7SdgN3rv2geefI^~xQ&UFS@3AuKpRFmcIoeW_zMzcW)yJ~t??TqSWn~F>i zi`Z+8Tbr(d`bxe!sH}=mQBU?vbqeMFU<+^5!bsnv#Jy-z*;wKMlP}&} zJ+Wt>`9_uzeD`jNnp@KDEbWzxg1J?9p#aB`nc3SXGPQDak6%YoN;#m9SAIOOOOMASx-F)-o2D7HIGRqRey@+3VveBE!4#NthlG(;E%}^?tk_6|K7yCM=`i{ zdu7EFr5w)A0&XeqqtntM5AQt{a&dAI@ew&G!D3e;}jdA)_7q z^kThH5y}jsW?)H@lL|vr<6}PfO9m1^a?|YI8-=dT;4K;y{2ViZ7KCOxn;V3RIGa}P z#R|OQBYZIs;#&1Y@J}`5zi)r*9{&#Z+`78B!%>eMNTI+gBpuyf{?Dt;YLp0ce*5K&JGp2WU9>q%8DH@yml4 zSs~(xI-Zpjy-o2L;m~HLhq8BqY*AOm+6V5=cKOdK0NV2I*4DG$uWx!^DOt4li8|3d z$j#UDcah(ExlzSdTl{L{~vDY>;jUsMEV?HW1ditLN}hnJ3h@hEC;9)1KetdBu%nq(PrI=O*FkC{FgqP5F<27Ht0Y#v`hvVyU!EI{ zsL7%oV$zKJft?3m>SRCci22CzDKMB+BZXt(LBm~ecm;EQo_yn>ub@jJgu@K z&7w6+NxarWnfYaLOgJHeZbLV^M`HKBg&#<{~tfmfPljU}imp@N|Udk$qpHx=;HQ!X( zi^Pm{9vd1yHGEoQ6ZT{9jdsyTZ>6Um>w^bh3?JtY@06rBmb%KQyww`fi_so|@FZxI zQ3qVCP|$kpJp>NUFo0mKw^YeZGKYlt&k75rbGCLJ=t%+UC67+l?8RJ@7PJ<--KWu#kSXAE^ zt#o%uH#nexNaxT)mx9tEA>G{!sdP&tDBX>;(%m31BHazr?}ESjzxVt`z8OYj?z!je zSbOcY%~#s#t17E#vvZRUzUG#}6!!%}y*xS5nyGp@!nGfM~xebbN4Hf$YlFYEY~#b2xmn{XcorM%EjzBnf_ z1>dJIt_4=eJb90=AWjz z6vNbN@#Y$<-u@^piDo9~JB?pP1L{clEq+F$PdB3dLzF~p(1ENH3}S%Zdc=CfTg4+1 z{A<%r7=QzPo~xYddEe&g4~Umc|7#llV=NXjAG!Xiw*L9%2=V<6#V9=aV^hla5&2^O zUEP0HRDd1s*t{N`Q7Z`wkokUEb*iKlD@M97^;LiG(MD*XOvYV;Ex0+SxzJT}I)2(s z4%`yo;heRz$j=|tpklKx^yG`Co{JB5d_oe2$@-e<`YKYNjHaGVi>jWFjb4U^jb5p$ zUTMMl%DSoL>dK15tiW)8?`XRF#RYD-%2UA~_BuT^pkJmY-?LsnCHs!|UMgoaU&JZc zkpUUw8STK;VbEf;69ti`-YfjeEvPN{3#H?w;f*v`=sNSw2p9s_@SInL>z74?&C7m!-ICB=)ksO5t&RtJpK5xa(p5Dlokp^vWYz#AWXj z5QPYOi=QrwQ6^cdvKjVYj^V*XgK2J(3iryX_x^6K(@K4G^Z%)gwo#!%XlrsO8lGW< zJumbT#~^&)byRf>#2WMSR?k*J&+>NMvg7e8k(U*@C({H{rQhZlBFvE$4Ly09O%t!j zo`{)kMI24`e(xoo*~Eu(ya}3j_;a|?Avrb^Hia}zj{v2Yc891~4-ac1VyEvE$vG_? z37a!f3I)|=a=F(Bf2dzlZJK&Aii7Teu@k_#@Aw`zYft!t^Ef%e&r|)opZ@}az$8pG zQY@8E$(RWm64iPL`NsHu_jJEE0W%)55q1|v9> zFe(nf8D(CGu3mZYo2a$e74$~zYEfk&=>-AkvD_MSL(}}ob0&r<(Gc%6iYj6OhaS~9<&W^X9!GUqhau_36{zoIXzM)V#kI1(7m7{t z7-?ynM;klP=Ej&aNzI#gEmrpJ%Ce_a6cLeaJ}^4Zg25)%?uuVomQlOyU)(peL$b8- zfnZ46r+EIaI(i+TSM&e5V*mi*2YwoxK3Z7$IVacZhUCYzi;cZIaIA%aR-KF54|i(W z&x^u*zl}%%KkXz6>|)o};dsHn!vfFb;_lUY_awLgl;_?cbTLH@bLu#0S;ehC@O(IsCyKcpxmqGb6TB4Aw)q$%X#BF0Y89b?c|F1Xl;DWQs`1j=>=QI}& zXd`a@C}N*rt<$v8V9OQKckes*${z6CJa%ne6JbWvu?7!Zn{p-&hjL{ zq0%95B-J9lSF2OLRqwmh1%g&5fwz-3JoVGN-;*SIw!F=^bAqc)ERD|;ZLIE4X-q}y zH%3Cq7esj}6oDY*6o}7Yry?&(lmGfWbD>~o=TktM#cU|8!D_Y=_`~xtFQjp`_J0C9 zK%R^N?j3%u%@Ed$YSuU_6uR?HoX5zNI$j(8f!O@!KWSMCXhb(X!x*5C8&u@bCBF}% z(jI0ea>&X#;+L0?>6#bC5thp)QI2pZr=WqdCXR3P#yZKaq3eC9FDHs%*(jJH!GzQv z8(FkKW}F+1t6_dN!9h^KpbnbM8a5yQA{}BiQE7`6bb)YrF2eDv>&%)@#@QCWOt|%T z#B^fmwYj2YzLb)P&TVg8lyI}n>E#|)V$%;4c<-Z2su^281k|2@VeQmu5(`#n>o4EqmhIMtg&^udT8Jk>2wd3K>q11q;Jp zQ~L^26#wnMq8JZGWvH#^szLKU@7{p|&d(P;_kq^&4r$^>0=T`P|9dwB@-2w_C`43V z2CYk-$Pqb^M~E+-Lz=_CdbygYPQksF_z^C;m7dX-jR4H{a56W%eI)W0hH$4Jrp@H< z1lWZy;|32EE#Pc;u=&?mzVND{Xup!7a(@eSG>F1`%Po_#12DY~0HJ|wg^u z8JpUHZ-q&hNYh7=Q%@Cv(b6)mmydyhIyTaQ4&Q-3GJ(LJtUm(63d|wjhQ^lSMh3+8 zN&@n77DX0WZ>`5<1xAvvDY-90?Uy6Te0Qg9{8~IZG`q1Wv82-T4=Nn1Q1p|a(b74L zX!I{w9<%0g#FEDDp$)jv`Fk^}=;(YclQ?(P$(Fs^&b#SyWO^{D{pG`k{X)1>aP;)s{~c0iZfs=FQ%xEcSiZcRj69#Xyc{i3uJ`3ZxOU-gp!i-V~9(MoA|5x0f{^`BUs z6rj^oe%O8BUj1=&=TkzhBCQ0X zS468?39GNPh+df&6z7)~Ru>lJ2jEZEbf~M?-yvWNo6q2N%6MOGPm77RYz?(8T;Oc@ zZ&O{vSbc8e-rES4Z#>GU5!8px9}1h`^RNf5@Qw9+_0^Y?K}XxXlDgExb4!6Mz(jb# zG8y%p^lBCYUm|TVQ*I~`f^E(1`pj{H7Di+~JLZuEaLDfsVS2#5^4KrqpLCEcOMDJZ zMS(bRbJ=u^^xPq6wA?wm{wAw(HUZn%i7a_eeo*1d{Ox|7Rn}+fT#OS$u~elvtEsv1 zI0QP&hQ&j#qIV{#1!%4FmDj+nyWfG0flatm7bVg9!h2lUih@odlPmR0$k9QM3k{?);eB3$%iRbcgY&OpVEGMTaVq4 zX++@HQN!p7OXCSq-w8&JaGJaXKE-*mnr^UfCd1t5ryq!Y;>@5RuHNVFx=c{QP1!u7 zD;GU$^DjIN@#1u71tD!?rRu*6YK*u%%7r0qa%2+zP~;g2ao+8Lc*Uv_jsbrb7A>{l z$295-e`5g>&Iu@JPUQCjN6mM)SL?%>Fl(1@5r8Hna~sir6@Q%g;O6-tn(EuKb?P4Q_P=7KbPnRFUv%TuFj}u#YIu!P>LS?HpUhtz8uSz ziG1wv`#=I)^u$jhTLKw}-)^K`>pKedYp(;b?;#XIZm3Qh{++>8Toh#7zs$`~@Qjs3 zJm8)BM!$dwWR& zm{3GQlVF`U_Q&3P@zxx+M2|x4eT$|a!X~GDXmTcVqi-{l0aTdbSPK5Z(dJA(hHf297CvXK1=qIq9#nYg~_O-}#t6}Ho$ zX4%!ErfvON+V&8+Cy&!F!)_clmf%XdH>A@byCg|@)bwpZCOYBGD^|4)whEkHk=GSC z2>ejQv&1$i8o}JUzvs6d3=E8O7x!vre{Z@rG2lvc!+^u3yE*<#OE#+tuZ=P34gQeU z87Ka;+Ml4~xyR`<`?b>XJ^-{K( z?^U^t=RNmj_`R?NQU!@9n}f0lpW~CxR`<)g#^NTb%hR0@pVJdtpPK=RR-d&c{k!9(kc^w%&O48l zy5ZZs82%`c+g}97iLLeS)5QAvNWTse{OrWg3|UEo20zakn!M$dyjy~6k`eovOZ50N z7Md2|)AuAEjiv*9_3KS#U-6d(S3BW?K#pUM2S-+e;_rKVoOupRAhgv^`>3Ve~OIj)3bJqT}KM{XDd7vEZ&MjJs=3p8~=LB}#txDC?X*u$;S(Qq-ft zybLsXKr2G-1fC^jRu}1#g|=GDk*b)UmfqQzXW8{_Q1{*K<@mnOnPKnHvmbr(b+2}2 zG~P5e7h1_o4viJ+nn*u+y_-LWm7uX(JEz)U>A9+8u5B`V7M`xPnx`F68=qtYg27oo zgBfbqYQ$SVs~h9_(Fcf^EjBp2&Q2}lX}c!a{HQ^gt90~~-ArV!wfXII^HFnA`*%`O zrq^CNKVuWmm4_%F1GXWj?g-3FhC`2p$mnn;LBqqhxw2MXlq=yxpr2vH8qMouR3?;V zFpWR1{mSNw)Y14s+p`IDZ~h(x%eSnX&b~9%r2BgOl|SES+s@hs`ctK9yg#H4~q5 zOWdQuMfk}ah8-i>655j{5@vDC%F-(>hOFBRBaWzcUx0I>NMQGI&*(u~|1E>x_LK1p zf$e&i5kHLAhGjxQn{-@O|D zp%+1pr43X9=O4F+o7Wm0TRm_1wWiaL7q*8QkogOH%78)I0Jm=NR14O8TLf!X+9pf) z-EEro-u<{8t$elTJaRFuBMQ9u(89Yrx3Ac==czy5sHf~SShMcEKIho#I2muN%;(|d zzPi_7JDgg#BPF?4VG)L*S3Ms}xyeKiFwlmQ*Y-TVUn2mZ=^Yy}=iv^ATsj&kpELTEd;GA*hVvA$fVsGBHiY2)bO8jM z@PF>>EgVR!CPK0l(Kh&Mn}rC^p#9$`pnczVf;xgeaoTU63i3R=KB%E=s!Mk`0gZR- z+g>6cX{AFbxYwdpjj^e?=TMR8xv0G7qrt{F z!pNmvSd_vxGaJhx5mdZhm`BSYQ6fISlL>;OL<|v+oz_ z^5X8^$oyW8^5jCj#~!jQ2{xV<4fpnL=I6cC)N}=!ij)4z;RHZ%^YH##>}>V?V;e7F zg{w6Ku8(su=kma^Q;f7F+tSDT@(4P!hEF!M>ExgU*|~Xw1KDag_A}x)IxO!(rroOo z?dqPWj2Q26rii2?dB(KA;a%dGyd=n)d~#saHvq}1LB#~7u~?-xf4ltwyLGWFiSq$s zFRk~FZh)|c7D#&5FP_%p<8Qzt?yN>PcW_RdHPY(TSx+kp*hOvBY1KI|n5Bx6bN-B0 zkNoJSk|~yv`GJ`LjeQ9;Om>AD{Y8E=CiKz_AAZxnIAOmN-R2}AOD|AC9$wQ;4IR=m zA?}?Aqfa`BwP#O}R9%`=@pYb0$Z-|3O;GsybtP{-sioR|ng_7X=}3x(OsX^Y z-}c?Wj3;CM=)ZI0ELnIp^!#2MCkQQ>d{6ZXq&oo*k{g?J@*y^(Wj_|eWQWl8gjAW# zy`FrT(QEY$D9_g8cZ-<`3jMiatlSKd0Uab*7IC+O$waxQ1PCZGPMtvW!m?%437v;( zs1T^EXH3VMAw9;uQZkcrMp}CM!q#{MrC@D+ef{JDP_m8&o6)v81Jgx-BDPN}-WVL( zi2{76Ho4*XX=#g!stB24u}=IRTTyWVsQ|z+mO_EZ{Nhx^>5xi^?fR@5yvD>B*w4L* zO27HDPqqJD^xF|s1eBzTe7P4Ikbw~H4Gi^SIBrn6hE5hX3mL~!J=(0&%6HxjU;FR! z+I@A#=`DUGoL%{be$hN=A0}RT2X}ML`lN1~z7}iz7KrHa#6M3yY0+_57!V_7XJy^a zMyh?x(dW^RK`qqr)%M_o=?+c!fRHh6g>ZmkUcRln` zPqk}{zgQ(-J(OuQ7)Rz@&$X)S``Ma`r2vVgzY^vQ$w#h_$uQ3OwCH#f=t?o9=<$kP zbIoynH&QJrHcw0_p^K8v(W=O|Cw%{{I;Wx-JX)Nu%77oJ!)&z5$M$v#Uo1mQiOVRK zJPeVuUz%}Cx|+_k6v9Q!9WG_;Pgjww!4=%XB}K3L2~{Vz*R3XH*@$+?CokjVMNe}r zwLcmG6O9`z{CKJEa>{uo-0~AEuzq`Tepg!#h-qwfMr;6Hm}QJc!%=clbySdCGd4Ua zpM6Il;8Nz57bFGzCa;@Vcb%jLKy>Ijga?FF97J!mAf}!^G6TjpRYMO$e4z_|BM({b zUQ<#@%+GleM9RpBt~FLc>-8aqX)^#E>%rb~5=YlRczcAmtVH^vx2PoLxU1?|a{#9I zrtoB~{263(&=5+LHX|PWiJ-PIM@?4K&509!a&j`18s>X~0Tm){#uTj;Vxgh}d1@>le0H}?vEGFM z8(Utq+Lo}Pnn6XPdh+|C_20XdTRd>_=1PGl)g zp|r1`t!&30#Oe9C+{((NyY+3Lg?*e`C%)TWJ=bF^E5Ka>@5}_RAjx$RA*a6J-0eKc znFqg?4J6hOgo59^nMJ@6@+%ka=t8Ack3a*Ad)=u_pzMC@-7PS|^w+ZacEd*Iy-5Oa zyZL3y_oAgKc&aG#Y6JDyJpaunqroBgkGU;gTHZ9XT#LZ6wU<`zvyl;5R69~nntMLo zqY6NDohIE+4K?)(Z!%}j4C>^S!1c4dP(GV)5qy!`@~i#1b_qU5x>+(b@THwUnC7Db zNI#%=EQn{9-sFaS8x5Xn>q{g2oYdGL*0dLY8Vi|J0R#&$6rl8s!X#^_!ul6-?tDu- zoqA(Qp0V;2zH z=-Qw03 z7%dmw4{wsDt+YcRP0L%4YA4zqXYJmghVhz@J)3KOC!MUPY|z(3h9#ptF8)>*r_@oy zESyI_kM6K#|I-t%9SEq5qxB%(m`RcBg`w-@(3_H8ueram01Pv>ODB7G#fC5RS5ywL z^90!gt05+dydl@ZT#hidPVr1PCoX>;Zv-zH+tTg9R?|T;5jz0g=ebAsvn0@}FR7;o z>d8ecY%4hamrICip3ti4JZqBFPW0ypi~zc*mE6y*V7(%^Y$1pOg8npwCG_*5WAxuj zoxTaySV$J$OlnUH-As)~wO)q+np{ayt-lG|?Ld@|!`F=W>@eTR$hAJ#pM$eQn?0jR z5{H1^vXEfKa`o~U>GdRTufML{2O%z5?}75XuD|fEoj-eW*|#yyO!dOPGjZ6g`K(SV z^C?SNfr?x^*`fh*L2yywen!oZOcv$hBiIGcLV@Nt`Np@-p2>-0dHQhVB{ zwQ8SJtM^v(Xt?ITyV=Pe{x_Dp@2%eCOocI!m^Setusxgth(Q#hRyRP*m|B9cGmONc z(lka!JJU$6;`M5%(P~5*oS~45c>J1d{d&0=@b&f3^Ug^A{EeTX)T$XMB9DW=`V$81 z3p`@C`kbj1Lh1DhN`WsxD@_HFvj!nK5XNA{j`YAyTQ2n{}( zezgJQVJ?%RWq17gBCbctz1npN*Q?)|GxTr8-%PU621O~sdBRAZe0;~D5H|7haj}*7 zi?=^ivjo!G$_+aOys&h@kVi?yz)r<3dJwk)s;tmQx2?rBxAmr7o6-?vK~3SLO*1p2 zha zP=l*JSbfz>5Ye%y&eakg3YC3(FNvPr*o6$H$kW0cy)lw0jbsk*xnDKp*&}|P=q8ZV z<@i`EpKYN@qhZu&3=p8dbm~wqv9MN)n|SI8e}c(Z&av|PD_a}pA_a(I;#y?3d04=v z@0&5x?tIO1=6kc&%cUYJURzZl5$bRA;JBxH>8W#Ge87U=Zj(RxT29?B zvn%HR$J3fW_n-2bYxy2G1_WwZMffVA%9gZx)^&c-yma%S8AZA4-j(*NM>YFIXDlB% z96In2aM;67hwQ5i!n63^qHy(`x{WgsDe;X&$DV}~S6k-gECB&8S~jaVdYscNg$D(( zNeh;NhsvnPI9Y(HH@aE0xJ54wtsDExyMSA>2tm)Wia3{jwwrZ2$vu76GSD=#xKXh! zRsHmMyYQBDBkwMU^?(49zhP&vuE%EHl}o}Th-XBX_&ETUQQvIHcull4d1cI_0C(1t zpsh6%7;bL+{*AkQ#j|`Dv&?z&e{Vg&thqWkFSNr!KL>l@Gwl5tykOvq=G0eyb88d6 ze2*!7@WXQ0){!YZB{g)16ckDhUha~+XzY=38uYZ^;D~cH@#v+V#I~qqL_v{-8D5i0 ze_THaWKmL^G2ji~3`i*Xc=*`V63g*JiDj(#>o>9>xq zzXZVncX$8D!oUT*{$m@Z=LWM88HRbLC74 zJZ38-m=t7nMTY@QM^@%oV~IIH#=6W4HG9v1+aulwUXR*??_(YSna0IG*%r4mcziAK z8Qam3xYDP?Xe7@X3I0GWM;~Gd+HvB`#^st-?*nvpK$Mu@mXe|Q-rt<@8j?^>_zkwu z*3-BwuFu9OmD$s7=48rtOJ{deC$BuFe&+KcB*G)mgBW7dW8KYY{CW@*V!%J7#-A^6 zGZ7IHIR|ILl|FE08oA)E9T&D-A1&eDX}G?7sw4F8%?FM^HOPN+{7c>Z9Z@3Q#eopm#R5ISYl{kYJJTRzFrQGYEk?LK0N=8<|D7wAAYrE zxV18>Gi^L{bGQ^+O!eTZ4p~oWdIoX^+Mq;K0%Y+6;%Wmf1r@P&bd!2+RY%4p$`TG4 z>`f8N$k++SbbjodvmPrO`11lxQ47U|D?ONN5?v7auQLWdi>a+iUqFx2 z=6mp$gnh<1S!!C3yy%NQbqM~bRhrXJolIN-Wh@CIsc-w%Vo3@1ta&1`aVh}{s&(iE zgU~Uo&rf&UZf@YR$p_(5yT+=XQ1&W2%l?iPN5>=70qM%LQot%?Avw2 z-)V7Q=p0(zx7U21x|uF@muYqtAyS~Gmdj|PIs%d4r6)qNfR(8~q6a3N&fr|#uk&O| z$4?JIn9qZ~oBCKxq+2r;yAguehy-Wzuku%_T;Vtjmq^{p**uY~Bm^*c* zZWmlj1I}a^i9y88k|v~xF@J||^n;I{VUKl~KvwxTmC;#;O#Pg6O@d-*1+Nx&&YT*x zGv%qJ^KLCeOtJdREK?l4m+jDNDZ5fZ!|d8v9!ZiVIdqxzeA#!k{)HkK4-#L&NRskkQ}#o_+s{BupfR{=D*`Bm^r z%f)vZgI1Z;HR19V8=cK(0079DS{QSHPq9D~k-hc3tGb6u1@tg4Kg$TAX z%4pt*z2%jLH?3jfo6p^Eoo`qa#A(l{QB9ugh{L@qV2B+VM_gXQKxZj<(xPXoQY%F} z=nEf62~y0}gJ3#9p$xGFIY@C}bnV!_^REc+k<-0@z3HarcUexv^3^|x;uU$LmXIGS z-hm{WK3-@kc>X;<(KuYIn?ef$7O@&3f;n#rr9KPph&pG@DR{pR1~L z9N(;nd7~%^+aJx)OWV#=-tND@Jsc`Dc@<{ynngeRhfK#yU<#ZE1>9A_ajI>LJ7CU* zZI0(T?oE&rBHTO{kiTEw4h(3A|0%$s03zyt!f!~H3;|!+=KaNv0?^uzPn&kff5P=E zu}A&|H;)Ii8AdJEGg{5(|Y zg%oZlnrcjs+IwcVTG1dWbucAgnj!(g9L_}s3W)mEDy@U4+ZNiFDE{kYtkl-zi>+~a zjb~-ynin0fEmEuBNv5l}OTsTCJz|ryZQNvn`r>}wZ)sW?y5GQA<9PRYum~`AT{}E| zZ+RsE0u&#AM#^UO1nO4?gVN{f3h|@Tf%xU>`D2Y7TWm!_VJPC~0eD%tj z@nvehA_IX+{pXu(G67i+*&?^V?+gToPTV?PbQu<-mL1q7NiQ>uaD`5zrBR=0G!Bl} z8Q770R%$8R>aXi%bMvhos9-3^K@7&SJlcQ7O)v@z3FG@Csh#!Zes0kh?_a{^t$->7 zZrf@!wkbwUbLC0ixqiw`R~31!4WC<^SX?Bj=wb3b`3co=Sknj`kgUdmcw zv3AJ;sq+Sr0iJY=N`_hslqB!xqnk0!PGl;lMOm{C0lhj7d>3j0~b;QVN^^+GGs}9T@M!!y9L(>M8Hf$;S147I=0s^mC;* z4zFSV+H(c{!JP~vnu`H54hL%?xf+@bN@mRT2z8Z#OHQf0u-$+yi!@mb3k zzM2e4Pa%aA4XBBI@GZ00nW>~CqLeeRQ&tX^B zfk`mkpNw&FGMO%$1tiMUcQuY(iXuCH*^wi6g~`dy+j7$}rQ4D^-N1;+X&sf1FKAWj zI%=rcCZI}7L`lz}4P_nRdLB!!Ck)WprE)`RHn`{hs#+u+zf0{rOwRiT`+w-ZYH^=V zv?!Z$jd#G!A!&(^cF1+0SfwLrp;t&c%FQIqSbq=545c{y+U$-Ju~C25JD{1VS8FO| zokL7c+yUM7e)^_N_UD)y2Vl?`{9fbboB~>B4lt~`?q~Pj7ED6xml}9=un$O|Jf4xN z?J;>U?oD_Y z_%2AvsIi9lVol|;vjh&7YJEeen}-pAO9bw?;4pdv{+}f; z1?*+``?Zg<0D)lxyQnLV4E`XGTM(vwF1A@Z#va9oZFtp*jQQ-t=~jAzW^~+sW9c$P zvysD5haoMdsdQN`Y`cUDmGjB+FRV%;-8Mrgv13Army24m%ZT1W>Rdp*4h=2?5Wzp;RgJR%HzFW1rc`)po@-#gYdpMI?JYM);%Yrebq zf;RpBG}62JV*E{+=*{gq^)~{0g}i})Q#I9g9nBYHU+_4sI$ClkaA zXj+ftq5XudH0$1RPwQ0GNdR->9%2m#`09J05;!zxh)fE}xQj%+LqjdVfx_$GKWkb; z{$r!(;(=v^dM@M-a$X4EulB?XY+GjQlq1_c|Lim z^(AU)Iaz`#l9o!d7mtdzQeo#GmYJfc><5A=$@SgOYxwuS9+cH(-mPmpX8JE?oDXOp zXSh2qio(*)%XszO&PpyE7e9)g?xePa`xRZOc3vupWflf&03mG zy-a$`vNVOU(pWkUj@;%eeJ{ur$p-QE?P|Zl6&w;9tHN>=%FB*l0t6!s%{6I9^hytYaTcAoOVIZM)6O(HOPlf{-t|ue zBUyC`p?>KQ<$BXjDM~FB)VJ*C@y)o^?Y6LU)zdg1&Lu_y;KnM_F_ngFy%!^5@N5sG zaQ@KtBnY+HC0}TjG4>FV6V~bELi-qlM3vd&?cowkXq9f`!>E~pkVQu?y|TbX4}o$e zne&+JCncf7{5T~Fr1a41Tvg^MQHyKIjg7-ERKiOWpO}4ClhubFdtZWN6oJvL>&vIq z^w&{D0=K+Ei2Ek(?w}U@<(UP^@=EDZ2L{rER;ujQb3K9&o$9VQm`p>@B zMYW?YQGRrImB=@F&aURNCgT5X5D(S5(g*)?(SNT!n=wcsZbTLx4lTV}e||=#OPa2M zY4ZnX>3a(MOQ)qp1)5D78un9R^7+OQhkz9U?3G;R`bozWiCIGE_VXHRA~gs-t)fJ9 z-esUgFu_1}Fy)kHGR7`#TLi%$WAo*H;mwD7~UA z(`k6OT13s3*1V$cm?(O8afXGSl&3a~6liDu8vKcLk6vbC@wi#J_4i2wqoY~t;Q;G? zbEC!a$B&W`YT_&(Mr0CGxg8Gh)~R@%FMDCX*Lq2O96N$Yl#_j)A03kD2;Gvy(jIwx zU*;ZvQNF&E8p*ZQ;W`YkRcieCD-3vhv*RwA>t;-Lyw!V)mp#R)eN-YcbXsG-_{s5g zu5nAj&!3#HH=K=_!Vw#}{L|9X0IJ{qTB0$DTFS(6|-C>m}kkum6Cz0a6v`NyS8p}{d%%zsr>bF1x7rWcF{Fnwdip+ zKDaGJ;83T;(e!L)UL!%aQA+OVQ5?I5$Z=Pz>C=xF(?PyhovgqQwRh;gY8tWB45ke& zTkk%)Tq8d1XC;fCuJiTLUqLv&qTSD&uFEXI?%5aQXobYM z6KlTWm+OkNZ)-PQBDj72o|xyeGO&-;3BXn&%a^z)fPwab3K_HiwFCla_m!EQ=TtZd z_Y$!I4S+=KeLaFoG0W0Mk0pg|{>IO4*LKoCnKJ*-e`j<*C!cK2-PUo$A0$vZM%Beu%FLG+11`nceCqo(~aqz^G{ zlC|>}^jitevuwg}d{<$$*w>cn9g4Z=rW{t1(YX`P#H2EFah)rh9h7A`{%sin*8znE z@4DZQ8_@90fw~{&cpUB1J&-b*GOa@yE~!kD__g*q5{j0JIQFJI+e=Q=X(J}dC=A{4 zq6kGXvnEy+J?#pEsg5s6Wm>9@MO-XZm+Lqi2@Cy1k@UHw=v5P>QnU=W1maoa5V=i( z7@r^nW6!c=eM_A_KMVzX;5ENv142Z573y21e9UJ@EV?MrIV~6+- z_^vm`XuRT(&3ZY5P!8FTX#h;l2+y496T9+OqO=IPp(JQyEi)Ss}e zdrb0Tm(vs`M1A&`85V;Vw&_(vPf0BIosaOhh^tX~^|kQpXZShYnxf)L%()g%!D{RC z+LESPi{1G~=XH&jqo@gqNgc?A#U%k2u{FTV-V}1-HIb-^V?*M1_~`$YkOPzB+0FH^ zF95#^kT7Sdd8X5}Y0lfR&=NV1L7fx||J;_#oFH!imN5O@6vqT#$gm#NVN0Wbk!hoI zanw|1JyUB4X_XkF^c;HiMb~2%L=qunQ2!oIoSm`WrC1b+49`dgviKasLD%f0{8Nkf zB_k}w;gnbD_Oe_|f@gpJdM*TmcV{LPRrsl)?dJ(>q+swyIk$h2nhZL0OC2Bo6+K}! zln~x58}B!j5|9!$KJm}V$+>r{*&c{K(fY>tWKA0`^;Qo))F1I4%Jywbo%{W>J@7vJ zd&&Oe@3b(4zV)ucFFgz44i4_PvD*tf2rFTiq zeyi=gPm8l~bwzdV*U$JzYObES6X)X5+gJScBn%0$au8Z48;mCysRpJdMTU`GT6bgnMUUsD#s_)SAcH~A`TaM zI+`rI5rp-0J7q(I_O1AZrQDSD(}nmtCNx3QJ zvva5ha`=m9#*2TBJ$nLO%q4`F6OYBK_}guwV+xRB6hQFxzhYz}d%H!a#M1Z9A`A7Xl{> zDwbs7&KF<;s=)$y=H9dGnF*!3hZker>!QdDH!BNuBA3P+8KMhVH>OhvzMh9sBiD87 zY&y4Np!N&wrh*E8kUSV(xdeBzg{zLjFPtc`&@M2HShA) ziPwqbc;8MFxQZNZ!LmqT-p;3q!&zIBCAz>QcLoOH2u~VxR}-4~fYE2>z?CzqmT64W zepDzdpc1Sv(EAVh4xGdU7^0-x-}P?z=C<(dCL}rYp(U)0r`Ms0mf6o*cj>fsuw0{?fyziD6G6WKPk6Vb_M?vG+Ik7s~t5Z z8ouaz4BL$`{Hmu%_e|-Vw-%I}rX^~h3I1Anzz3Lrysry&MNz}*eSy3(C@3i6$L!yO zY8Xb|pxNo(+oQi9!ta4_U3mL=%|;fe0Bj`JM99&rMWFI*MW2Qfn^5t}oM`p)60I)p zWDk>VQs7xnBq%jnV28a$7kJU`1zLh7shI+E-EyAG>v@XM}cew>R)ktHSP-9_shSlwHV0kT($6Mw9)yx&R5^Zw(W zl|gQ*z-;^K104N;2r0t@%($VUAte|&{F%9et>NGOAoqjyo*aw2t-vSUs`b3YSIcFc z{Gj3g)>a>wP7^Dx#XsiH;Ly!9ongh6JEu`W>Tw)5eRuMUIvY0R^*fui)!kuFALg;| zY<`nC%u?tgRx$qsXy;9cOCo0xWalOBkNjR~{HDGN|9w}MITvw+>31W3tvBxc<^B#B z$JsjpI}jJdxmRz1v90}y*5t(Oo}?aUHE(c+J#a6V=S4*he`T7r2v}~HQmUAI6{t~a zBA+)=o%z(ENV0kX0tIO^X^Ep3864yDT2;SgL0W)!p%VrI#Ite>jLpRV2#0~E@0&lS zZ+7Y(@HZa8U(R6|zH@9~OlX^oX(NjJmOWq*&#BvTqg&r zZ5|<0ZzDr)C@`n7JE1=EMLHHI<|r$kIEfWgxgd4M+C&_%MtG|-^5F^_*M{fi#7^*3e6i2=GH*5#EAi9UjXzI`-cMWW zAndZR7b#8vtd&qa@uN4YfZmCbd4zP1DlfjQCL)L|3|wK(C5UXYYAP|BmQ3!PJ?G|Z za=MqknpY%xhj?kQ8XnZI_ojX)0fW z8!!N5{%ra0E8pg{aCroD;{qJ%F3;b|&RJv3>fIU&giRQdnyW(Bk2fzxiMv~8shs2bNkT5`7V zv?hGcdB`5`<2r!-qzzALm3i{>B0zSub#Z8+C8R zrf>Hb^$YyYgecvQTh9in8~ZBPX=#X!WN?&GJkLE>m)`~)gnF=2cGfu)dNTxdo+s>I zWUOC_Xho1&Y04QgP=W?&P5Q*Qkhug)5u_+KfSfj99|tw{XD4xT9SYpgKX-u! z9RmZeDg;3K!Fu3O+`k##x7m(aZ~%kMXLUSi@WH<;FI^1%z8C z0*;{TFN*5fS0)+DSNgzszI1#x=w9 ztftj_8AUxi^LlTpwdI=d&VhrB&ux%TV6j1lSAF;Tzxjq!8Kn0PNlbCg!)&|3$7sZs{yEgT%MpAtKE z#xY|04yCjoH%!-7w_lBgm!z9Y>7W8I-_<=@eDfM;VcTFre5u<; zCgv4JZ*81U%M>}noil~*@2M*`zlc1k?xKcH%CNxTJoPiW9JeDTSwRZ7E=jEinMsW3 zg%*-C_zTH<3epTK(mK3k{szVSBB+)b8K2P3IitVa&zyWDaw36SpX48V_IYGx zrm}Q$USt$m>Dwr4SbfKOZlndXK$JdoIX(*U-v1Xjc+pk8W|#^`pheV ztW2tPUI|+I&YMsw?-ABaQCGKCeb2)2pQ2UKsBo5Xm)-uILjt@mm1x@(kYAMpu0a3+ z?ptP!T9U#$I19;{AfH7Yedk#Wj%-}qkRam7iKElsFGh-FL{BfHiKg)Of zt63{%EOnz3U$Tt;jwz`3ER-64)T+ms^38u+38wj)DR)>rMYX;S6J0#2+m!YsjE0n% zX&g&!iy|aN0@V*SNaNGU%tUfbTMPbAzQ1k$gEe}{HI<|vxgr&Fma;laJUS=+h~Kpo z8Biv)F^B_z%U$ zR#@k^MglBZ^*2(V7U(3CQ;DZ!bCjQDsA;rN?Om}H>}R;O>}xl@R(e`QBbZ1XH!E^- zJC~2?Ne6y$E z3qVxJ!;SVsvS`HmE?}ryUkl}hoPS~)@NbL~|K^-WZ%GiQo6xk}tEGP-;_*{M9-=R& zZ?Nf%t96|Ib%oq)$^(zs0Wv;kQ*-b-e<> znbTw{cb=BkUqW`*Ttt|@r^E09mLEp0RSig};0hSAsr$c^Y!8w}&7e1rFa^bA_pXH| z(Zf?Es!C}Jw<8c@;vdjDy^&LP2ii1}c35OD!>-e=)663!qb5KUQT|$}b@W2)<%pC$ zGG*2;&3UcO#hA&w)1Vs(W)^5obG@7GB44P6^<`Zw zx&!5OUkr9`xf1>GISs74QApXnegPiIv8KJ?tA`o6+wMEI27TZxW7GKgGPxe>mt#K~ z2FYf10+&{botjE;&$3d4XDW{97q9Zru};}mcxm-jD+fxPmP@E6_tGR8`hS@E>Zq)? z?rXXmq>=9K5_srtk!}Q$Zj^fHknV12k?!tp=|;LkI=+MNz4!P1j3INS=CWDtMJ$Bj7HM3CpOktFRk-4@78-cdIbr+1F9O* z@H$h6Ulzzv*jp2=i)7XkoZ;l*Br{g)Noaf=fV1Z00s2lGzgzFY8zOn$c#VW#G*$`T z69z_HBQR)|=^-^UMdUw}qb7o#qs7&DtTKdSUyal!wK1q-pRe_6vuw({Y&KVZg)Ro1 z9{QLF3|};${a=2{`fv_rmMrO z?0!xNT%eurG8 z!6m;$*-hmv%ZFv*%fwbgiz00%)FPwa_SJ6m!N>hP)KKUl4e$th8a{15;M)w}QWE+2 zd+?6`?rcj2zRKWA%e~VmK3rv*Q>GYMY(97=5JOFlI>1{*SV<1JCg?Tm5H~L5Ah-6I z%e&_FG`aiM!|2-1`_G7-f!4K}_BP9goUacayA+(a1cPx6a}ca9Dqz4xY^Gz(`08VM zEN_%$A@1q@Ft#{e|Ngj4W?kuA_+bO|f2#nb5^C`6lZ7rr{b`Afe*G1obWeyQ^k8!< z9@i9Qzlc5ssp%&`hzMfsNx{J3k;Eq@WSx_iE*89C4u2Y#0=6rQBE&{9ORG1_>lKk! zL^2iILWwdxvV+KEcYUxfg$#>5nSBI8b4NQHmSUYKoZwH}~;f=8r;5*TfLFCcz?X3c9v++38kpKgq9@!ac`aD~+0kC*M zCfhb6trxW*tDDVW(>CLp{^M%e>CdNz+ybn;b<8g-(L#wwC|J!2YDkuC)Y&5plF4RT zCP2?qHYb)0)zcr{Qr8bO2e|!M|6im8Sx{6|Q=H%JFbKP@r3lAmHsI=Ah;NKJ{6H_h z2h0jJCYW%7K;^&)U7)jGl@2RZW-jop=QS^{u;cI#pU1(?8$40nlDca`J%l?oYN7E< zA!)e*7dAacRSk7rRejyJ1Iq3VcTkAFwfOX*{DHJT;=f?Gu{?-@GBH%tia=9@bcUdY z;mu?-V#}y5s^#r7A<`~2Vy52SfQB^Fx;@72(j(!!Mla+N$zvX)!WnExW z`0k0LoN~YSKVP1XQ>_(8!&hG(P#VU>oY@Z6uzSBo8h>GVS$9e-2Eyso(^HN)*wbc*JL&mk6?!&6=Y-wXrHGI|q47T8j zd|e=UAvhz%A8C^f2JG6b>FcO9@t@FERbSp;GYC|?h{9isu+a)?x^bjW4h)vS$w68l+w+{rn=BOcX6B#eR<@g+TZ zLZlKUCpSrMq8d_8AOa7IPc&sLvm#bR98MFhwHxv7ytuT!2?mKoKKi{*HbJ z80bFTBZCUfH&k2XHn}E|2$}w$GW@E5fM_Ye15Z07+@3pa*j`jZ?)3Ll?a$Z#!6*k= zMihUTp%%zAX5N=AwjeE5xIFbS8(K3i5syZQaWHsoi*})5cPW33##5#$*-}yB;uR^) zCTq@ib7dV+Y%-{YzjHuWlO?SOD3+uwB6peCo_dMwhHw~jp7wqKXMX%Cb*#>LKA!S@ zXubji)nka)Pn@bP;cLgjuRg* zx+(mS-s=xlz9m-*y&U}+Yd8yqV5o<{4iMGb*u57C`qNz>jZM*y1o<{4YAOSAW@KgJ1EL#T11x@1h>Ni@MT_qx3Lu(6 z0mV9Cp{`)QZaQ6OJd1BY{U5W)CV)az7IBI?>^8RsZjRW-fr8wq!xG|#cdO}?qp?5kY5}Ej*o5dF{zg6CWo=UGShWa2KXgJ zeJn`&@vjyjJpHlBYg~S&FUL&rowj~jS>&(|WMeG{RurgY@unm;8kh)r=0%VBLvu*t z{5wYFqxGk)Sw%)+9mv*;&TkX#yx!J${vIM9>K4iR!>r}C5K2%F>JorC937d66aVWl z%>IT6Qu*=iNAfU$FZV#erM4eNunpFJSAisi&WN7uC-*im(-emve51jK3; zu?Rwe&&$9fu?SRz`E<;yg3Q=5Gia}FWfPs|R9I|j7x*se0<*GWDgJhBQe z3+5AU7vDhFDQZXU|0A#y>@XOOtSh7Kmy7IY|MtA2Q*|)^Po}SVHE~-C=CmO9J&}g+ zwQHq!5-a$KM%0HZ244Xi`@Tss7bXWG2LnkXP^KQUZ_m!%6l}{d#L6(j=&WC#+f+qk zK;(|W7K@dFA(omy&gX2ck6_$Xqwb3!f<{U1-0ZIqIK?&J;eU^FGq}Gc5NS`9 z?`4T>tjf4_|K2NFDci@`HOnvgkjKrqwwj_7m_vJ6%GA6V0_G~F-))G4xRVq49JQ=2{JT(% z@3YhPhrK*j;moI@=?UAP@0!_oOWh&?-`?o9Hr0&~Gm036o+k;4rO_azcb^|`3f5qr zeh&%0>>gx2jcd5Q%q55fw!MtLT(11|foC{aK_40)yV;bLp)-_$-SCmoLE_aLQNL}& zP|@m*wCC)>%#_uxOoxUefywac3)IxNUvVq&Iqjc&CdcfA zpc~*K2Xzu2eJ5skofAEy8I73pek7tX64_45wSwzoMrWn)#)M7L2G#M0jUFtJ1!X4| zvgOPO9003;dn+|9gS0jI@vWDpUJqv5yHa}aFPEpDrMqDqSVsR@`U3S&j0nEQg&1E+ z{??1U+upKbq2n^^i@C;vpo$Eb#Ym7kFOofaPG>&10rUG0s!f)MQgp?!_sFl@po72|C~(Q{gX7%x+D^by1NQDg21PCodk!ceKt*^6o6Uh{zw~EBZ-m z$jm6lGPc@Kr$BBbwhNEV63p>@Yin*D)I~fk(6op2R~(vWg(CiI60;HqTC@vTaq?e& z11thy6N6_cz!2$&vBNN|H@85ekN#6#)whNOka~&^OZnBu(k+q6^JN>ZFwXn`uh=pt9nFVZ3P{nZt-$j7*ZT!Zu@f#v&vf1W%>+wfEL;1zxv$Yv|C6P zorQh8M2p;>`B885q=c&cl;gGASRer0sGDu^!4mO?!CG;Rf07q(ro1FW#ifPjQ&QJs zG<+1&Cv30vF<9@81QqYKRj(X_#@4E8M>0Vqk~)k~iRx;{9LPZEY`+%2BH234wS-B= zF31-s4MuWFvR0-Q^BtUp%A-|Suk2+l1Hk$X$0biiVzVvW--BymGX3Zi&&WN_m9I*l z7D#(yK zDB?#*5c6)zQ2EkK>cv9g4vhTn+|E!78NZVmx~#o&VP!#3m5Q`dkD<>YsS?tnY+2-} z%lCqRlRjd`H>bTP*G*aTdA{>ipXkRZDjFlM?*IICTQU#x*$)QmJtIao^1r;r_lXMQ z8z1bKly>F)hmxwrCFK`lCBWnYv?k7`l+g7A|CkP^R@{uzp7FqTwONH%Qo=yaB|n!? zPWcWt&^+|Oy``#v?8Djg=`*YI!h&WL=@%p%7t=vAwRX^&d{1jng@2p#6nh~k>ghM; z-b2#zJI`A-I;@Cavwbo7(b3wf`UQCOf#4AvG~fJu6~~@V!=`ILqHIgrET0{AiwvHN z8e=c?K(r$-knso7y*>XZ;q$XNC87J8m&?(#oGo9*v&EJ;5@5N9BNNh3+5d~;!}?aopUlxeX`47EH*LEQZMI!G%$&rX z3VnwDwt7Q6pC?fld6IQBzW}@6*#PR-`G}jlLv#4}g4=4;589vM9{=1D^nI&9CPGX2 z&AiP=LqF{RQKuXRx zSLJM!W?%o%smqLCh4W=!eMo#wi;TYR%i|3-et=1-D^D!Qjl9F__3XX|_#~=mlHtb= zFPm7vXZg<8^;B}J4~%pFR<=U%y+UnEfr;*&>!+3bUlWlTOUq1nv2tPgvC&vlG9q8s z8AE@s2Zf|1#l7CMUZWb9C)|iv_$s@G{(A@?7}Ueeu!ky1ES(~aM}=!dW$y%2Laeu%2wnrQMzPg(7w+A}0z4lM@v_E;{(-Mva(DK*yZ)C4@ z_S0zP8YU5dhV2!tDoCC7KbPb6UUiku(AMNysc9U=Bdax{%uLxR(u#DjYVxhxLi3h_= z#G5cRYwq*Wk7bPHCfO`WMRnO2ieL+Cp0fjA%|Owpqt7^A#yN1=Pp|hTY8`;$Bo@s7 zdIDb6RAnfB?Gztbc`D8qwYf~IEj=m>td1uLO0Os}QZYHkV)ZYIch_OZ6BdSLzOF}$ zxPy@(tSuq(cN++Zc^&kxict)Nx2RSB0^e&gyP+Wa;~Fix^Hl-C$swquO}Lv} zfde*hm`S}fY2Qj9xv=)vuXy|KL=F^)hCYGBTeS)2-;p;|c{{kcO~EV@`=hXh?7h_A z8@=I=r|B%p#)&Gh^~#qA5pVB(nh9p{L)U$7 z2^JTC`EwH=VD{Xy5FQ`mqH??lhhA-#s3xcp8*FzBr#vsYk60S1SyKOxR)58XJV^u^ z>Be6LVopjO;MkavWjh<`$~hfMSlmyT%kk#;xi^%b>_?yFO@-$>FkaoXPo}<^TpL=| zuC(&_V!13&#>MizHhNF;&6n(n&=X8!#bgG@huYKptYr-Y9Sz-WBwE4PV*wXs>kbUR zk9#Vb{&MzR;hzGG3NGJscKIY@>IOLzG2Hj~|NB&c-g^}v%40ike@~BHkiq+=Y4=3X zW2mlC`JW82M+{^CD@(Mq{`Na%hV#alKP9nD>Q(1Gx}nxN1&(qLIqSZxq1e!6!tOpX zFA2^GFJELh$e`ZfVTPjFCCyYf3|{x@^@=DS59XD$I7u;;`KS33|3i= zl6Iu9tt-t(N0gS;1X1LMdty$;s?z77;rzVLE(uFL{`egO7uTt(YPn1+JK1}omB{7U z=lU5M6uGHG1RCGZT*zNZ`0%B6h<_IW0V6h~kIb)A7ADwAq3X%d(4zh3$cos$W8Iz| zA4Lg<+z(S03;1Eh&|-c9&u+?o`AtSi%EAI!OaPX~{ry$3dg0WZkdV5I`G+G62Rf`( z*H5%6T3^e^HzCw;!wSM)I;Q+BQXKR&_<# z+|POjmABGMl8TnT6U2j?1mE7Z7nJX4eL1hA6r4L*Qc+W1q^21#=ik8pc$1@_hHDhCYx+p#+U3(bw@!`<=Vp#pyZ5s7^ zcb8z!;^q3P>+Tf)X*tGfu1u+=LF8pYc|4iqv9}Rt zHp(4aEP%bnhWOD3Qvxv`j4=~@;2VM$no!4=*Tg5+Z9p}Jfdq*VT&)zb`sw0#^pM<_ zD*sJN@}0;<2@Jpag&Ep>6)F@7&++#G+wVb6ggN#_b!`O&ZBj8~{ap`*C04^@;&3G; zC5|^cnZeNY;&uOi@D6SBj-cc53)qSkUAQq;xC*Qp=stmnkaOjHVNo_p_>BPqh8TQu z6=X81Kp8=$2o*$CI~vz{_E42y4sfodY-KkpbEvkQ0U9W=6iidl0!1oc|OS65qh5KdW?ur{Z|WU|FP2U>+f3$-T&ugnRBnf zo*Uyd0Q$}MQ5fX@v~8(3+XE0Po6$|@;TmIT&~=*sECOF40o}m(1KEH`A+3`Rd{S0Y zaD^N30FPB$NfpN=7WxJ);doP~y8|O5x`Y9tkQ8ou!9FomKc$0h9~-5 z?`Ca)a3GHQ=GPmD!vOM>4wo5ShHq&+VVf|-w0ls2)RG_#n*R;$AK`+!)uC;GdpTgM8mgto*Q1Aixx$v;iZTO11Nu zU^OIa9ie&&W8;lJ)U-{$V}Eg#cdNI>3CqizB*OjfwwW%6N35$Z(-HOWH8irW8<*gi z|L-Jm8g*aeo%PnLSEG1_qtKQ0PrNf#q@ktwK{h;5L19Kg7d>g%$>}&FXHsb#NR7;u z?u=F{pdFS|#8Wl}UH@eVJ)FU#c4VO>eGp_?v>< ztv9qEP7SI(=Z3rj;bY^}Sc!yf?gtnJ>W>4|wC^Pa0~r3~>q{mhRgU$++*8?fY0>W> zf2U8KwbU_p+NFec9r@?n8xioup|ren8zV8iwI&9fwQOe}u2MAnnE6+lY0bA>OaD7T zK#YLHXG?=-7F09o+JQ(5NH6qUKdbCsLOX&;2SFJ1vp=BQe?nS;P*wXXr)DnKiv2uZ zQV(9a^AL)J7#u97AGDlbmXVc%REwKdQ=5Y1O@_H`Q`aPc>vAFgNq!Vesf0yjkA+NZ z0!?G?2=$u=k>Q&h?Dy|)H2gwHjj4jYK0v^y8>w+X5MgS}{c35w);IE*Bv*2PB&{~S zAP9t;)J_iH;`7ZXQ?bvr_gVGrmJ9|uI6?lTV*X%!p=HNiG6LCB5P6gKA*Q6ecH+1jIE#lj#EzgMlFf%6Gx>md~RQK(koH!{|{9p8?aXkz4_Yp@Qmi$#aj@X(bm)J|E_k2xR-+C|9Vwbm@ zF5=l`a9A0+iZEH^847n)p_8OZW@lna!TY0)QlcmKu@S{%uvg6_Qx%4=(^VEXVD!FC z^u>q1wkCLevez&^-Puu=X8UC?34Oe2e+Pi>Pa`7F8Ndx%=bFz;|NQ8G@gLX+`Y01| z%I^Y0{#aU_cv{g3Hd}cP>=O_;tT6nyS1M3iXrXzmUovsD;en7q<_j56#7#BDY>2E3 zjQ%7%w~nNl(v}!C{qi20h+BRUB@HbOJ3R+l0l^=eQ4WdfW`D-xVB(uy^5Z_M(!+Au zxAN1KN+L!>zuzV2R?FvMMaX1C!G{7Xi*BvP7lkLOcfCt2jdx)iNjJwcj0=i1E3o9w z=%}kxX+6tZ>f*$t*)+0!y%ixMO6(!BQ6Uph7}ZPFAJ?xqjdW}NegHf&Xk=VwLD;wV zz1?{0=^|b6&cC1M)*d>etVf9g10*unHOPYNcJ#jm~%6vZSjoU!KpZ+hLyBZYC{og;3vBo(;A$ zd2XhBAIJUh8=5?tU7g!Ux8=xDVTyB4heUc&#A2$~tTF5A}@9Jysk0Tm4mOV4l#`f!MNb6k8# zQ)`V}q`te0W&UXVFQvkLA+DPsX48OxNrmf~UmIT*eT}o#rv>}gs=R=?Yz|~gQ}Brm z{;E1I{+RkLw5s-;5%{4@i|}kp>h%k?;xT~$?lSs&Ry)AT-ik%IMdhU>kX13RdeoOTN$fvJ= z4(HoUn@3!|d$8RnSrPak9sQ5sWCDfAnCZXkr`2^N$C!eBpWw}FtyNVYo2N&GOe{*@1K0My7^gJ#0XE=@)eEEz9eOjV+Xbe zsrq>IpOk5(K8o0c07H#2Bj0X*@{}A3q73xzL+sKJHgBp{CL~dy%Z3*KHop$c~#0s?`9nOfpQ3+gnhOPQKn1c|c?s zyT5WNf=$oeEY}yD^d`nvtDcRHND^y2J$!*h2J^&tUE3*(M{NPWr4I+SkkAeERl1HI zG&t#XI-C1EyDFgCc)tj)1@#PA=n4PvSi7?up+3RY-25Rb`cQGY*oe(cW<_IFlBbR1 zwG3k2BlMRpEN~0=82}64tXyiq3~aS- zfQwq9ucS8H4MkuqZ4bgNK{rv#?v-3+ovbQmVDQt|KdGg z9txjD4ewIQD?@nZ%+8yhr{|uu=?k!DW5)^J?lGhC43WuQ7^zS-6+`?iuRtp56R-$7 zG0(>sL@READ;qLZohJjK-+>|53BDCt6w_FNqR!WP$afa+VF514!V$~BoR9<)7@3&s z%cDzi(cIAj4#%oEIn=7DBy?sbvT6{l{GHj#h!h`UjjBq3hnNV5OsoAH+fO??ywE)< zf%Dxky_RK(r7MZ@C!vwGSDscW_1$5C?xuFYdrX6;Ez@>8f=K;(*FQJy4>kccy0|$4 z6wO7qM{Ak3+FuXCiad6JMktv+=h+|n(`7f0kMGX>Z?C!v*33Wc1RX7Mm)bRLG?_DJ z3#2}YfBPxd@#36QQgvbGFO7$cH_{&%f=9m5iKhO;64S8vF(dPL={162x)qdy27c0)aH`1IOYj6L9zP=RYwd=d) zeMzhQ;yhVb?|YZF)7tv%vc7Y=4Dq!sW0c{U5YkIe!<21^t^SsX>QIPg4X$e02YMLN6goP*xcZwXJ4_oOAO zEq2)UWi`>?iCL&ZLlwLDA<_6FvDiW|MNBv*j(Y2z_m~QMM5124|6E?-7JOhrFS5KD z7rE&Ipa?9Jc2$|BEEn(TA6^xt^bzB&AjS5F<5QpexGaKfQ3ZY-{*Cywv~N-I_ZZYg5W#ycGtdKH=deX%8sy|jRkU=#7G9X}f1A1;-+t`p#^U5__P0oZG}p!_ zkF`|_z>cG;uESye8d8YEE)J#2sZ>>@CZjc;iyOw(6OoSqr34kf*|X>Wdp#Kky=+FI zl1oA$#U<6Yzu_A+gKSQEvKB3Tsid?O3U+wUW-j`yz+pb>04EMHM0^}XpwhTG;9D9xbNYi_ge(Z9CIr3KJz@GM+srE`dv)05(#1YNCv#w(3)2c z(%fmgYCZLA*4_t5F4+hDUstq?qn;|~yDtyZ=6%(%l5~XA?_9_TsFvNveaJJt!Z5wt zVbL9SMTD;hiVypQL;@}*#=RX6I`b4!)`7493)^6FpAhX(ra%3dhScDBu13eZ>dcwn z`9O{&+~5@MBj0HM>>0f**6E(hfy(GGUNg#;0PB0?qi*Qt<8%*J9grz^u29qK%TrV5Um zNyN<71`%DkAsb=u6@=FoOE4}Z6KBIKL7A2!b~uw^!c15r*7!P2A)wN|tx z`0G`xHyylhAEviDt_V0IFtOfwzBG@v4?;F~cFmJAgIvOA@qhdecqjTQBSFurDLw8e###x0Hob~&Fg5~&E!!Rq zqF=FWCzt4e6$H{SQlhmsO8jiXZ1LCe?*=6I(3B71DFT}uMxJZ*TlWftlTOwzEdIXv zAV)xYa`(7{GU^^v{FOu4p@>ZNu>fX?O16D()?uQ!0~G{q_M4-nX9KI(5v3Qbz(K%M z`)=)L@eeq$jc9sXnv@9;Ha@%!El-M*!kEVsYCzU#_Vev-f4HOZ0JVG~g@S>W*35x3 z+u`Z+_>B(pXG@8Rr%2Y-+AAuN8)-bc(a}*GZFbwno6HB^UT-H-Ua>D1z>u-Kl{^(; zCTpR86chlTVx!DgJn=ga9FYOLhXfkX?(uo#yJABXajn#}RZX^oI6$(PV{odZaiMb5 z-_*DJXUmbS_>P0jVYCjaE*6-M5Ppz9mV4n)Ih&B$HleOyiE1oHkg+qY;K!ID(5HSh zp&H!kL4l>@1w|?1aDBQ^i9eBVNqal;2`U&YVWVU1^txMV(;)@DUg4JzK1l1y3duzwb@!b=q`j}Y`m}{8W?k^4g-BFypz-IJxTk=@ z{Sl|4cUzVg<|vC^%aMo3Ev8sYNFemNj>=}G*&ZpH+p-Idipx@23_=3Ox<2tDur#`d#Et zUPm{vjwLOnZ_BOC?2(ahTXP6Wi0_rtXJq6*e*8`#Ufmwo4xp-@Qwnf&56@)e9S!`^ zXd5XwC6!fbB>BrW>GNaKg4=QgB!0GH#~w3vNEhFn?L1VX>-Jk>4(Ctuq*j&xFlOWw zNSD~b-lKUjFnsPGSXw0C#U{cddWb5QiG#7%kflP(wg4g634tLaRwTq99h*KQ6`h`v z82byZ2zd@d_uB6MveXCS8t0_^y0n}4?bV*Nr=<0o53wPkI5Xyv*g@^$_$-J6p&!zmO)@oII~D|cd0&U zd7LyFq6inY`C--H9^RIch$@<5o0C!sf zp!oO&Sjq54L*rL_RA6^?b#?psDEylC*=z~-aMn}*^qjuik9~`+KE>0^{u6{IN*=q< z`mPJ#+i!o3@3viRlD<4@wa_~kdLcz9bKdo}@Tzzn&9mdfea`vXLh&xk=LA*{fzAT2 z&h5WF?ui3uJ^!3nVR5JfC!B-Aw(~%Bw?A`ZB-8)21%wDQAv{skq4q+Y8+Gg*rapflf?Ny_S5SxH2Vf+Y>EO8z zodBa2+e=1|H+u`YIAPvEviar-KHH3~&ue_VNnpTnlF8*jD6L#{Y^ruZK3+mp02CX~S#;&-a+stMQx>T;aJ~R9-Gs zc5hZxQgR;xJ>ioid@l%0dA4hQnhg(kuHiT80Fyr7KGd&V6YN1y+(Z}lwaiiP*?P7; zCN%q;mj<&mTQlH`5Q85nzZD$4;W^bkM6$O3kAPBlqXOP z3mVO-Z+-M#4i|i!IryJ~#jXIft7oF`WxB7q?=OoN+?B2F8VrRDuYP@%O&OGC3Xrb0 zYG{!YMXIFkXqNI?t&@d`z1!JDP7#saG#!9`BY-eh0co<-=U!*FS&z*Y=Q%zd$$B6&3@KxixUYSLvcgcb#-KJ@>%O!JY++wSh zW=jzPIj70XaK~c7nkL{c@@aDvf+T}xjQ5=O;k++LA)T*rn;EkCU#9Pgh2K#iic;l9 z9=z~_EzV+7i()fmSL1yWbdF3236u&7TkKfXZ8r$T4qQBG;*2k=R-?YAnYrtM$y$aP z>ORpbqh=0)!2d@Y2_2G^#rx!Z?)mX(X);O{4IU~Tij7~QOjIT~ie#a}igLYB5<*F% zGoEZc&mYjnkxY}M#8U#sykx_OXUXD{6M_c0>`%Nnbpc{j z#NX|w!6JU!Z1({&EijV_0lSk3RZw^8pP968dp59h|1=4QUL~M|{JFs1ShsTf1}c~; zQ&s4&Ds<7Vp4;+j0>(}DlhC&8c*de=@m)V89ZKsG4Q5ULp0+VwxFdIF=)z#%&^Ve5 zDCP$cS>r;~&X(}2bhs%HKQuH{QX)qO7ostq%u`EX+q5B|{+SA@UIojFT+rQ~(ZMR9 z{uS{N+Y5dJ>LtoYLT~SsxM-yEjCnZ&V?%oA+;g8s_7GAiWnKVFrc>>#Hrvg4RqXzV zuHO4zaS_EwB*{$~ds-BNMR!Ys6hT^pCY}w=iw3FlFWhO|i*}(dh(0YFX8LZ!gP}Jc z{}=CGb)F&#in35I=}{Y`QYdKHRTU?2iEi$Y?X+VAZ-Wo=ICqCGQ?p)q73MVlt6 zWzn~pxuAoCu0O_W{z5p2vOMqc&MDh2x?@y?!YDREPPqe!AjWSXaB{Xo@DL@tj79v6 zyP%qPr7Mgg-7|l_@3|CZ7=Y{P!dpY`xJX?c z(WFqLezhqnHX;&~7L{`Hj%>;4CqG__zu;sbgF3wLx79K8eYUrgTu&Dlj?%Dci8k;~ zKRs3#B_t#iA&l25z9e>BrEnT^6_q?1`7qR#3S*s(z4}5c>r=R5kv6J>hX6I}MPrnnIQQ7DqGjIRz%^ zS^ir+1jTGDt_CD3ajNHgHrqXgp(`b{fFOLr<4f0WM78@^lnddc~?YGN+A=kG)oLsx@+If0R&Qg0IIclEh)!Hw01?u9Sj4ai@ z^!RE#d}yz>?A+M;l7?pY#bk2rx$f%pAq<_Y?^N0%a33@cPUS-^)%{_eR>8;(A@Ma_EL zg$!X(`Bgn!cCsVir(T4k2YkszG6i4H3^r`jC;_e?AH&EJis6_GBpR^_lA~jCtL<@c zar<7*9+KJ|-GEF)?gQ7y^=C?c2f(d^_Dje0-aYJO6q&bf`%X?yqyQPa@qT)yk7IeI zX_#H`h^K|f@57sOMjKWY_C>PWD{*?gQ|+og=)mcib}ara`E|e!yp}h>c)yeeInEq7 z$mHp0%0jiUaANbX#z!ibyTpe3WoK1Y62h3fqbCnJ9B52pha6Q*H={`x66;Mj67Rm% zu?VYp@@-1L6RFzWE9aeSKfeyD$F(u2aGtKpt;&rp+5F{^_;b15vo~}5&~c80S*XSP zrO5_1t)6(LZENp3!6T`?*`~AU>2UVMpwW2k-k%n&Y*4>-d#_;hfp-V@XvNWJE9mL4 zo_Muodwpl+i^sPi(s|F87We)b=3&?NX0!g>$FSTG zCh<2bDb9`mY5`JV(AezJ^j$MZ4R6x8Uj>d=w)mwFOmfmV)Vp_42%Pecb^iKn^Gs)$2+N=izoNP%I4k+9qx&12wWC^l_3mbB6h zjnDGi<19je5w4v#efWp0{jYKvJi=WT%Kp)_0)`qh24!l5u@rd;n5| zu(9U6^~1+&v-n2j5GZ2ZoLUPpLt6wvE`9r3fZ=n)2V^9tU7dNLU1W?B4#R;~4nbL3 zgT_4etVN817-8c9VTI8U6P&waH1&cX8Vl8_+S;cl=sJ6~p?PyKzx0uJ9i|Tr|6xCr zcbRQtC#}%)`aZP!-uWbMy6xc?c6l22ej*X=3RmSzk8sAlGLg{Jrf5>+3IAuaan-rV z=98xLxu{c4J)x4hf&IJ!Wg_;c1BW>!5_X%Zj!^Mt z^b~g6zM0M>MH1J$5Wu+Yn%LoPefTG#V6{j8iRpbrqxU9iR_41>pCoTtrD!wo*E6}D zK>h=TJdi}zmF95cNA0|%Uw@YtPyn#|!5L@$ogJiay{OKk=Ftfj@l6by&AFrJElnVv z^+~1`nS@eSnO57IYugvR*Mtm7II@+A3yRG*E?1h}2t4#?o-y9pKJE?m$i`t2efe@C zAF92F@cVSN6*~-)1_A%6OnxGbFX;5?tRH~iMw@3PN_YCf0PV;68Cl?Zyqv-}O=Q=^ zZ-DgiEV{25cEQ{YnF;qFiUHIpLQvQoOpuT4LA3oJT@j*5g)2^ts&?_T-E{v&D9Epk&;1c^9!j6|q4d2}T}5&2Op*Thdt{ z&`}vmzWE+E7W+YRuk3M5l+i(OKPQLrJd|VrP7lwX7+#=*PbpA~Uphgnw6NsTA!*d( zDMFEm=;gyBpiBQk0Y=Von*zKm43A*=3vG3E^Yi|A6uJ9LH|f)Q-I6dTz`1(p-`)LI z4_Twt(K$FboX5k(tz(MNEh#PCYSI97A5v)7p1n~)K|c{$sotmyYOP9}9F8V0su zgkP8xyW`DRMyOEkht2hfam$!#3xkyTv9+u(3^6b`PaBmqTXxq(&V1>TRNjct-2*^m z@^{z5Dwe7v0C_(yVKu0Y3s2vAurA}%DPGwndR?a!V$0`fYJ3=Lde&LSY9Ihxyv)uM z$}#`*CO~N_7P9OXbyHHIijypM!I%~w0iqmQIRamw7?Q`EyF_@yN!TatZ_wnGOcHvj{1_gN_{zw4`i>wZnD++MxT9B{_T zF*T}}38*>>(|bRLuCBrxo^WCwyc!$(ek|}phfm4@LPQXxpeleB*5ii3mxCEi8I3?B zJ;TKBSd1`Bp|a@8jR?sLjh~mLrRPTGovcBG+754_pwWlNQW*)0%gA@7w^^dGpy7w4 zF)^7#tU)X=FQ^$*kX2SzLT#!L9P~9aTcWC$MWbmvqJoW`id)LxpZaa&D5dlDvzlCN zT0F6W$xc3qjhp@rR;f4>Lfmq;I9iDey=n(yK8!jTqWMe~jjFI?9WHhyzYh4HzMH{h zfW^aOAn-A{#7ro_sZ{vMwIQiF(%fq7y_x?ZXZu6~KD=gW`zxH=yK(PWzCH7e-Tvn* zK8o$Nr{}0m;SflXOJ`FiR{+&pn3?s-W6nsE;pS+YzBUrDdS6fbJ-(ATocp1VYGx*t zaoYMq)Ym`o+L(CLcDZ&Yz4x@;=yY=1Wf8G5aI4&eNvm{(_I>ZX&7&=>NQ8#taC*)d z&{|PO51!8_*I4z=(3-4p{q3|#Cil$OIFntTT=1FSv9D*noGKew-akA5$}EPY1;pWO z8Gmo@Q+M7)7716{AHTcP9{3=}z|O2xF1~TOTUoD71zm5!90FD3-FE+Bq_7DDh(7y| zZl|k(x=_5(811AgpB+v(F&-Wxs)GznDq`1aQ#&~~F2Ca3Mzb2Z!eCgd<(Xd;pkZS3 zD-Z|Q*}rEfps|-p*Dv6!D8gtU4uKj*>37MspSOgQnrg*#z!`~0*eq&Q%`p#6D=^n5 z8&<2=rv9f~o9K%i=X-VaDWsKd0~!9jm?A!t2sx!cqEH2+LK@*k-Z1gSV5lLZ>Hd3? zBDZa{UCT3#nY{IK1^wFxQbAh>5xw<2%Lg(79+KswSxbHlF<~J_u|e zDcFCF7mB7nLg67?#z~=Q%=P(a%}FQDF>7kKD&c*!dte2?BwJ>5L3zRQ6vvCn6 zskY7H{sM>B1}YX$IHfHVOkyEe*VE6Bf|m$;G;kkZxxZ56S|`0rVvV^%Bz7&A%E*uNW_m^}H@}w731*oUmfNIgm*>+GYW6!e`zI zf#iKq{IbcVys{(B+RLWo;Ba{vm5Z#Bf!_!}&dHMX6-eT+_9>~TZ=abbkD^d}NOTO4 zalCFGL~K`AYJiczkx(7;<@$=xjJ`dL- z;{Hx;V#nOd0%3kDq*$Zfjya_lnL=RAP3^j&XybFw|1Dj-7o@76N=bkk=O2_3t$-^> z8P7u2MUS*k0v-KRuv!e2z6(qwia0nU%LdZO-|R2~&$g_8dKEoe;Gq@VQF;85QM70$ z2dT_A^NrN{T}gmKPt^qu8Ja>5QyTFF8JQy&z7$2v1UFq~ct#%WLW%vy%e-?k*O2b* zT2OF)SurIGbeqRql+WEd<%?JU&F7ZSMla`HjIOM(*Gb!NOWwJcmCMASqa1RY^xc+1 z6beJQYZz##!h(jPY`#f6kUt@WqKWktoZ({W3Qampu|ht2Z9`+OeRKb< zzI0Uwa%5YuCd!cjLs>SSchY!$`i4n^ys&4CG!`VGM<9BrPWHA)DnRgL);eE9Lqk51 zNqnFsP$c=et>-R1v#XqJkR<3)9M}*P}3uGJgUDvDc!qQ-MI$(<_R=LxQe)N6{sw_;b8M zEbkKsY)_MnwUny)$6#uW8PPBm%@LK2{LT@zUk)-K(sDTkp~}$piP6MA!CLe&{Bj|m z|K%!;C75e9D6oI<3C25e7<^cDD8OD}^{C(Pu+dv4-HlI&JUL6B~c?jA}~It6Kv z?vn2AM!KXM>G&@1=Y8V)!EwM0Ke&eT+_Bc$Yd`c&_uS_dLpQh!XOH_h)*WT|xKV6~p6Oe$Vc(LZv^*)+3US^np$T|XojVLT) z4Q(VtVzkYKf~b&eG2kv6Y2}157Xf>Kv-@t7`u?ZEHc`J)vRS>VLwgjnge zNH#8Un8HRdeit8U6G{_etz7_)r#Pv+<{hG-IpPb3@Ixet1t{y#x<>Q66xB!RadYHp zPEfxSlgbY<=2oWK8vQS1XR$(kbU$67?WUB`6LboM~*Rf8jj}2CN zgLwnb-(%>@?hvnqp?-ZIr2F!3EI@K%RN}ZQJvjpj23m9;k<}kkfU!%B*qm`4S7ZZy zqYi^Pfh-Awidjjq6)N<83tE3Ll1liZP&v5OUKTRh4R)C zb;^s&bvLT?AGY-ge<%g(<;HKst*q~#Aczr1)5YPC!?E$H2)p*`uV7Y~6z=-19PgM) zwOxeVN8fvGOT^J`q!y)M%_64a4d{Ixok2o{do@We+xT}1cRx1yv=^N;x=Zv_dT!?! zL@(PMTnh5hbi{-XM7?q)`chDsAeNS#hm_dU>MB#4d9ai`vg`>;a`Ko`K^lCfXb;ph zd|0lg|Lsu>3Boua2oI0aPiGnE1n;=2KRk6L#PG;nuaZ5{Pf^(MB0*IHifM5X63ndw zPIF4rND*pSnvq`Rf0Pawi6XMdu6WTd1P2_DP&cCUbl1F2K=Q4k&s> z`SK`B^6;=_r1VTtw`w_YDO7LM`~~qUBM;AC*KYlTDM7VtxL-dLD)-!XVgZp6 zMVpv9QQY`rkBkI|2#yp*kEpktEW*0X^>mn&qF1zcni6`YR}LAmap?#KfJQ)9gJITf3{uO9e! ze>gjQ?(E;g`@K|Vy=)o0zaQd!&9Z;Fcm-EhzDz{-2tZ!f8~tEL+Yhd&;8CFCyOn%p znctPLmO@QdYGFflYyJuR=2c07VrYn;-}w}RsoeMaOGX{m4LmX)@iYMTZT)t%OUlcqir|32@!3QT zUxEt~68q4S`)JuQIoT!E>vAa#o~lpsaTpnr4N}6Q%K{rs3udLa{kUXN3woh)auLns zYT6@c>DyV~6ikB1-Ky_s84?M`LerJsmBv&b9WlfKKV zb?7A~|KHnWISvY}KO>`^{Ys0$t5_CN#TXSnI6A7}0`WI4o-%q`L-)H5eg6teu?#?* zHv63B`e|{FC45|OyTQyo1h`<0*6s0@DVzwUzeM8^r16>qkQ_{gIZDM0682hHL~LPw zO>Z812wc_Rs-ZzFgl$SYys+xVB7Cgo0SAcelepW?YPft}cz`@h()DmO%kT|Z6<)t1rs&A7ZUvS;UAQZ(A~GnF_^7B#+Q-g_9jkZpg%!`Cz?2(gzHKk=eM>Z z!&iZR9JVmNHgRFbD>5+-EQ-t+mO*u3gSRESeMrXH<1lsb=+|7I-i@S)Uyc!hy*rEs z@o_tka`7=`U|{`ezBD`{3At^;GiL;978cj%&N$+td}TFdfgbDssX>6ud(CF3%g+F< znUq8?KKc?9^Gqs7T>hdL;8Co}@(J!5iRQ*r@a0~wlImQRIxVRUTbKLck0};2U%Z*0cg=Qlu69!cqUx8IGzH6Wxb5xY&d<(D38HUzVZq0Km`pjv9z&YUf_s=Yhs|>fe=8+zMQph< zE8_mcEmn<-mCpNbb8(gez9NkB=B$Qn%U(KFrG{@7sX+~`{xX9mC?@C? ztCclX8bu?B%>#uzBP=0MEI0?WuRiGU93UC~>f^%^L9Ic{Um(o zME*nlrfV9yQ{7(pIepLnaUkW@<@`=GJK1{XT>-Swx=onGA*HM4rkdgNX?-QLCoZ3= zGt~Y(yTwgii@9=(&3pl`|HCwqF!!ICl&@Lf6_5M!+Z7iwhk-eFKcnAx_U+fn)T{_i zm;>mZpvTcmMg(b3rq18d;7o%EpP1G#cxqVn_=M?cH4ZPgW7IlDn;$Zy{=L1U8zUg8 z&u1VNki#6*)}UH=9JTW)$%60UH%*&lXlDb@>bWTjm*?pb=f6j9;^`TBpk#_x2hQ($ zoA!OSeSMw;Rp8d&@rb_yi5xE%TO9mNXaPbPod!hsL_&-3nb&>zSX%ETGj&y7%!}~6 zVhQh0q0r5lkHf@KU3ghV0!=h4iXC9Fd@YznYt3z#*7CFU)zj3|boEuF$0|mg z1MI~}E9nFL6~%4qvkSA!s%Ro#-;=h70FAfE zc6oJ48%@ORcw9s4EB-X?c8(F%XUUm+cQ-B$U1VgbYH&}%lR@~G7M~!XnVY=)N6FyYzcE|d)bO~etACge?7{Q zdA(cccn!IZ@qfWy+ROBQNz!`d^ndBDx0yd)s%^~9&TehZZEs(@^9t+P2WJTltnkyWJGU~9;A;`LAv3mm{m z@s_+6#XU=*9SgzW^E-^1nXtkaqI7s{O%72#Mx&{K3uUQ}aftHk?)Uc{ zO(5EjYv2|$+48BLXnGDO#EqmPs>gArcSTx9FZKeqks`li zeBY}V6;$MKGyaDa_@{(Qmx%HYruMswLfcn9Qj1ifHb;z0;E_S2aLN5L3|~f5;AOJNZIUpIPV&=tHfbLd_LUo zN(w|D|MD|+SKW0qFv^QK>~a0s_sB!MRz+h!v(_!N>zcHSiu)*cbHcdVxI5U;Ci&A- z^B&d}_EGuxz0P+7ZFOy829`3$Meonbrx!wfe+JMJPX#u~Dz_r^LIjk9m557Of9bV8 zdNegP{npn1sBfibRj%co+kP#xgt_mkZyQWmK)Y`4qcy2zy+IKw9Pn(T2d|vvNlA`tTBLSi` zPCN?SDijCx0bwZn|B>eMFD#30ff;t&remy*tyc=+4 z$#Q{p7%7Ol1^s76pwZX1e<6TX?`E!r<$1W#?&*xq?;7TOzwz}v-ChWaNxK%~?r4Rs z<7Sl=tb1$ARLodmNiQ!a2RG<0ris~Ede!mDs>?K6&N`CwMhos~d9+ zqD9N(ILg(v`?=Hb+I%_EsfhhvsO|`3Bfcv|fynQEl%4am)sBZPcOuNe7GK3f&S?K) zXaJ`t`s^_-7T)D3ex}jAV{P@(WoRENpRRyW(2CtEnHrT%ltUarhVI0eI9*=P?%xW` zf6@vV+AZwtZ#n4yp6MeQ@L6TYfkX7DnNq2hl=()!)m&un=SIlJmn}J%cYDc`4T1)- z**kT_C@9U#@Szjnl%KYe+}GUhE#O^9Hr}B{+0JlO5({i2YmK(s9v&0XuK)TWpG6_` zWzB7A?=<6LmsR0)D@>otJUY$uDqmOF`N9_aQ7J>%x?d67E>q|^+KQ@h0^fH|5}Kv` z^FD5);?L(=Jb$;}gJ~wsF>Sl?y6=U4f0CE~~{{7RCA%Xejxhar}4 z?oA|;mAlQoZuyMO;70RWiYks3;SyAsEFLw1jToCX`i?k>Nl8b=gsRPsytjkN{zWb*j3*=W3^F?cB3_;7lQS!&rW&%Iv(_ zg}N8sbva{qoX*cUU3GS9Mkf_Cy&-WQGichmGDsx}_t*?0izxcSHrEKZh$?0T)r|>7 zN$Lqvwp`M>(^x=oHozQ8&2vZLrBxUJaT>oNGyRxBGcc?1*3&gMUN;(N>3WKQZDII4 zW08=~JS_HWP%a`|d9g=?o+rMKT6^NV(Xb^lVJDZ9Z}s+V7rz7D*OuxWO#rM~=Xj0E z;xbWI%fRwezk!Gx5EB^y!NzC;C!9xt*J?R;dyrXmlD=iY z<^RZURJ1`hEi^YTG&Rq!JIR5ob7-eZgdJyz8gqoxgwf%rNMK z+u}}_H8K_|6*39=$J_(rGSyTeN*NO^K1q3*o0uvd8@OXd)cqB_scQmpmJz$T4K^>-Mc%LeBs@W z1!|7Yf#3K#NFdz2Fc|P19_2*PMeQtn+dGzJrfaLm0@H z;a7HT&Gr8kHH@;Ii>@bZpl5h}{sk6WgT3$so%tbJs^Oj$C|(Zkd+|X_^%2qCB*LR&@s9a3MR+fOsm?DQ@Jg4`$SLrf*k{+wv)G2i%kLL9$1^xY1%-$O&od&~; zX#lytZ38gAAJXVNUGRZ;2Ryq-!-l*4YAUHp-}3Q;Y!NlY38=H}t9of&!twLvGo~1+ z-{D!sIA?*K!H!wvDwW!qcn^e(+KFeLsDE7PqRG~4%$A3fO0~+hlhV_54S-B0{%F+m zh1}f#9#`v=>zt;Rf$N_nK>5mU(vJ(QD^KS^9Zuq;5zXS*%>`b%K3V3aqH3$Fa~X&} z0pv7#ZC-yW^jgqJ-f6@dsKjQu?+k8=hoWusdEK$q3`FB5rzFqLYlMhX7~GE@^YJ;* z3Klz{(?Z?Wg5Z(TQvVqZ7^j7v8h~HB##)m*f9dHJv3AmFiDDAjx)2r&lO<=hE+|zK z+?;}mOe7!9Ov;4Kr!L4w!-$p;i;LZp9MposeTIsOx4_Sn4F58s^uoU zduCjHhoL}}H0@&J1X*P#l!~JB)>jWb1ncqEF*?A-hJ-@^bWnUy@zRfJR8QNWx>#$wu|1)&dCotgyD-NRgg!` zuQCSkF8W00cx(kkYFOe0ydW||-Z@1jx1easP(G1KD6|$!RIi%!#A6tW1isW>u*ACh zibh@94oo0%aQqhS4?bC0grVA_c%p2PPQ<3;Tz69vClWVesOxx#TMiFue|#1C-uO-F?woauRa?GaA}3F3{1$F{B!i z&G4hoU^$WiRacTDM#2|ic5IP%UASSISlBfX%bUNSyAl<2^+M7fL+9;~5#E0KjQz^S z1tc`p8^KIYKkFie9VKH8+}AI5SNZFli7Nr&UmeH@H18zd8hwAu@oV!QD9z!^0fp!N$1Zcl+!(XvnP& z_~f2-G@b`QReIa^q|}691m%zY3V@-*PH7lC%D%AWarT9{H+oM$ z+J*{OwXCYUIXx4D&S&NSi2VLO%eWDsq?p{widHg7Z(#Uazy_L$9PHSH%W@b|0II4) zYe7S5$`FvihBziJBNu*pKsbm)b9cv3*2;+qR-(5zzK$9=A@>$h`Pc~!A{<>x5>B*5 z$1;%^x!aK6HQP>Nyj6!~J@mmO(~by+@RHLwY4b%NyRmf4!?7D2C^pA_sXv9|hsPRH z_AmXH!`_2|_85UeuvhwhG9<%+NE6Q1pNu6OE@c`p&%`#;lHdIU3>{)xht6`*kiH0i zZMobm(j3oY#1rO*M8-}M$86zqWu@`sOk?T#ro^E0vs+3ikll54#Uk1uxz2PT+U(qx{HDQY2n7+tM zA=XbH_1oPi*1i()d|s<7E~;YV=CtvcwdOO0%-**Vm5J{Mb6BW+ZOf<2czblBq!?1CAHyBAmfDLq@?9aqdQVRYy=tOv@UME`dQ* z_4v%3I4EfGB%zaua4XbDRocXf@)?a=E*^E zxfWt#Xy)|)6{zyT6IkBR7OYRLd8cmmmp6C)Liw#%lUB6VPUhUZt=QgZiTR}!=fIK?_Ua2hK2^-OdxVhe=!WCAy z94|xf2=J#DJt*V5%9vFRkeX>~l@--b9DN-!1O23)St9Q_>GdOPKbL`x0C5Krogon_` zT10EBUvB+U&LuE-_3zERlR+8$4bCJ2H6SK-b9SkdW*e9ZQ!9v^MYqx2q=WJc3c;dU zqzE3g`y}D}N6SXkC~%dEA?3*M%4s6mdgk!t-2946(bx__VBU>b?C_21?aiONRT^Y- z_ArwzHWNE_)F2@*qluWi<605SN5UwJkZ$LIG!Q_6pkijryJ=->-LCa`e+9({4P~C0 zuso=6AV06q(H9sFi1RRWW6LPpq-Q204jQb+c;3|l>7Z8vif4M3irUz9C#zyf`gEsv? zn2&=UJH)-V7kqy_{2wfU!Ul57Wx~5eTeF15fK9ZJ8tklK>t**56bbh&k+lBB59dA% z!&z#TFTdgdyFkiHGMDM*V{f8$&@6WZdp`|tI@wUL5hr=Nju}XUr(PP>kaYP8lvu=Y z8rmA5tuxIs|3*5O$i-QeyiZk0&VX4aM7VF!<2&wl|J284c1$#SUfO+#X+d)L-lzR( zET!qUm=BbcdFmti^cSQB{T0`ByW1wm33nv!N_2}!Y>Iu zIB1~}z%tav_wEbhB+_VPNKH!X!fgXFM3IBfkN{|mT4|6zL$??FCZo(J3#uuY+7+fv znf%;b%8qQq^%v-t+v6qw^Nnhg0c#r@%%G7$nX}1i@gL*go6X%%mP#!;{Ct7*&*w3j z?f3jJG;zzx=`+E|#I)GvJn)U%CYjZs-Fog&F`aksbX8YP?H7i%2p(FqA@#HkeUgKP z(H&?A{2uvl`eWiW)Q^->+Z$E#efI}0K5$5+6O}#-)M|V&3;KqdTpivO6)I&dM?{XV zz=5{t=CBmftB#PZJ(i)wFB0l@5+n;28C{G4E7b4PD}33ie+s+o2?RpB$fFxcbNvKZ zEwDIP+O+J$u>yY0{^(GbhNzmHTuo=doYvbllI8^ntl@y{T(SCxl+*8CE>`QfUv>Y# z!nF#8%Vgsd@-i<`=GfoXXOA3OJL2FE2{vHhNbaUUN|o$ORZzutl_ae_6E}@W(@SX# zkIflZ9J)Gw_vT00N8D=1g#Jkp9QtWSt7%W|mS!nt1UeGkY`fZMNc<%KZw0U0j*stF#ySHEe2wC^>T2j{6zS;w3zdru7 z@2dzb`8D~IpGu$veH)p9LkpDKLj(6dfeLC+03Cz zk>B{Ow91uw-VxR55OQ-QK*C^iImBtwmBvBc+1W*i4pYjftSdZ>U!6x08Ky#~1i6b5 zprfF=74rmo2YC0L)X9-!5v{wi&8g~h*^Fo7(ES{eb+)1?Q4@!k?l`xjf~MY}#YF`J zoVMs!S<1V2DT~=WT)1bdE3@}vW8afKhX$N}22S-mfI*+;EQP(ow^C5>7CHxn$VK$5 zptx07k&G+KONBi4JEOk~Tq0j4p4}cUd0!VzZTH6P$ZUW18 z8BRK8G&eW*NJm$E`gu6Fp#QiBgz3wbpG5POtZ(1S$;zKBcf9tc=sT_cc>gDD+5WK` zuft>DyZegg!gs(S!sO<73BW;?e^hk5#LKfBEL8gIr1O}W(Yc*1RhtgwdcJx-m-DW# zRhw9NN+wnMz$2p%P;?8)G$r8PdkMbHbgl5gbA|_hITnbT=ND|*RXHKaxM0DvQ>-KGo?)3m zkq~NMW_zlq2&I{wy(=|K?1U=GG z#P5x|v%7-cxt<0WwXT>pEjHb{EEIiyiFGC4cxX4!*LS=djAKsc1>)%EuI`Yly$ORF z(;)ympp27ob87@on6&#iI?U{3jrzutQQZ zzWDKN6zo3^i`>g?*1Af(azDBi+qN_w!TD^eyF+7#Dp?g3!EW?Mk3>^-c*S;@OemmX zga=b@s@U58`K4DH&>bIWNDit~oUlxfTVkoT{c%GQa6NmyV~AAcbW3Qsw<&~6qY1g( znKm zxp-qy9LUq4WR14IdwK^1g@i^%WZ0y}l(OTO49YSR$=){Vtax$hhZ>tU= zb_q?x!UFnbZ7L^YFm?^GyGG4`M66dRV%q*k9_6?MJtck|o-7q=AT~9UAP#3P zjxaTG>Jz@978-V~u^K)K_H|*Hj&?i?rf7U30&z8G4o}S+i$L)6EcwsbDP7SDijjiO z90zVyIED|OK0+77&S?#Oaxs3h7lv0+Rx-@Ww9@GR8rKs_mAUX!KIH;?AO@eCvevdR z4TaN+t)?vDi2S$!BaN}NXtBX$AP^QA5NP?gpLbu4q_7CNoEx%X94|D!ULSDUx17|v zuQ~21*4j1S{4jY$JNeZxCNnWOIygE!d^lgeoGltetDMPUGha60f9l-0 z<}_lKqPLbOjqc)dy+73#`KImSq19HmF(o z^TZda4Hs1g*wRePxUsAeOI%u$Q9Ec%IX~$Ost8()eDQ?2U{vF-N*;Uk5&$s#0 zW3=W?sNO$_@X*N!@=@rLPpP{Q|Eevi*!0gbxZk>5-CLnPfvly2lkVQ!ubplgm2BAZ zmt=<|w9O1X(zNC$udqD$1#~Xke*#iP+l?QJnkw&#LiT&n5ui`xDi7B&wmeAYBaG4| zkG|offo3NaEfqmqVc1Y(`BY0pMM3l@h!MkPJD;T5BO8hM8nu`x;zl%BZGZyL(1(p` zzxVGlF2XTZ}Hm zq9Dk_h4Pai|K-tAX65(-6FEhH zTCGNsnc;8GDoe_&6XNHY1pq8yO|RKaQAtTEAtRPp`q#NHun190TcgfoAey^W3y9P{ zeZxsa^Y(mRJ4I)YdMz#_{7$nw4C=u7;lkW}-Ryb1Jz6d{*=tC%RaaL}h>s8KG9<@v z(Bs8NDa_9|H8piPT`5-1S_2T_?`%fhi45xfKmlK>WzPp#*M45F_PzS82sD`GWo4bE zaPDu+u@dmV`xzU7sSMNTaqeh9FHq{V--Q0*A4Va9)L{gM30PdKbJ=!50s7y)8*S5J zV=voe1|ub+csVzpksT7C?3~CI;!YkLz>k-=yGg&nu5k~sNTIDNfJ-Z%!*OS__%h_H z054Q1653B$mQE(r9Q$X#FFZr^9>x*@s949wY?&rd%AO*QRu|K#BM?o24;4%GUNh}q z2TG%%X}D={U6VSewmm~5(Dr`sv8*=}^Eo|LIpK<23d@zzY%U$%`)N<=|EM6TeRL-Q zQNsr2XTA5JMJ|#xo{b_*l?cu6?Nvk_RlEwX0K}#~B5-J=eAoT0jlah|ccYAE`p3u| zqUKls51R!hl+KL~yR&v+dBg=sMmj&5K@cM;L8VLt@afNFl3|)AtD$`iy(v|C?n&hA$y$yNJ9UAz zRy{is3nvkQ&A$jMbHAF_6ot*#&EnR6_4-ytx66;lbt1BlwLk(#TYkdgw3hqHgMdhyN2HZK! z3e0HelyGoyckytD`JJxMH>p6~Mfdmjl9C|_*>^l$ji0yVb>v_EBxbVsOMh#33L?r>xGJhUw-FVp^Gw;QHiU^%2Dis-9`RABVhbnQjnE68f3}O&0P+}0X#jf z-G`&fj;#nrICO&ejCijnkBfh`y>2__am~$Mv+=qLc7k;go?Jg(NVY+uC7!=hegL7Lq2SFhrX(V&c&DQ+Lj#1^XTpy3DWklR$ z-nFcp{7$aO8boiQZ<`rI71MCwg+#ga4(9x}G3$Au#oyoY;Oa5v3?A@KU@l>TYam;z z7IyC``EHZ>?UuUA@l^^}1Ju$Brj1h>jJ%8cf-u=;fo^EIo}(W62?<#EDe`X93MG~} zen18sg{l@*_n9RucZLo2dMf_1EoL54?z_oWWwUqMA$;GM#Q4E}rRhFulja#W06A1b zAR%T{q!-zLhv)OZoEQFjYiHf2ocAbq&ex&%IKYiIhnc0Bz8!HZ1_XQ@&(VI z25hsYShyMP&o5fBlLpehLx>RgSa9mHRV0056Ze%bxxx2^Ad_7J-5p7yo?K-qn(c(z zhHk0zSqPL&T$4<=TR4)zeY+YY;zu$Il5qRkz3>w#FIA1!_UxJRfOGkVbgk6uLR42)OVj%68jsP#xg8s=@NxEtiCSjm zWtcdFNI$h2XVt7Jh;;09aR#QLWbWkzHH(>_@i2x$l z#t{7N7Zqw&bJaANY>Y@enGnlbq`rqeJv0I44MJqjxAG)>M?~lrV!j%Eh!HGQ2Z0J( z!4jjO0c=ccno+!Fk^Vl*DUn8mB!$2)0(wy#R3^1G{LmY?mUi5pG*P_qKXyveus)&T zR>HwT$nx8bdsk3~SJHG2-|QrJ&WR?#7{KGoaB~F@kCa|sMmpV+thAfl7LMsIROmH2 z&6VtqB(r|3F`dfoi(Kog_BeO{ZysHBMsRH>FCjC0gx#1*$$zoz;mY0_&;_!o#wcQS@Yt^A$CX3bIm}Dmc9y?| znCU1aipkquHB}$*S^vOkU3vx*6HHQ5#P#irao398Kr)9~HXsu!_g3Fiv9Cst(QWT! z>~ygJ@ZAvQ-C*SN*hl#E@gJZpz&RiQTN73DkJ0ldwe3e&!ZoUqsJGlhZALzgUp zh3HeevC@S+VUu5qDBq#!`R`V=%D^c5eXs83n0yP}l%(?u(^L)T;_0EvU}i&e0ou>C zExTl`wCL9QuuWz0*0*(JWVx|&aDn&R&Mxz(#zpIq=H7^_Z(!Q)0ZA|pIB*xVS-~C# zOTUYZmwCn;EVjFwG!RYl-sNt;Ldd)E^HRIr>KAUCc>w6KUaG+z3D_Ocs?h%F1RI?s z!-enmya-Q9&=i#w}Mo$0tdYa5pZ zTJDQ<45?^RKoFc_+i{JqecPX8UHhMx!vs{)U-u_-eAzH0kUh%o+sPt=j}DKlZ2*wg zrrYSMY9@x4dE<)!M(nMgWPk`cBcsh?RZfb5-_l}rUW?1YCv|n_jn06R<%Ye}B`tM5 zaSvI;OKqF*M25E-g@3uJ2EQ-vN3i9(<#WIh1VQ`6?__Y~#1Yv%?c z6D)1jQCx!)GeQ$|Szy}!bWCyA?sjbbdfVN+S09+W@x0gPv323QSHH)v_OCY` z0Y41}_?YE(&w)Z^zE`)0F9A>S;@qfnpH-mv zDjqDb4=6`yjgOJiY{{2EfZ1LMEXL@fr5YbL|1rIR>(HT~+j(kY{zfae{rE?I_oJy3 z>5;3;;mhb`GT6&(+%YfF)KqyfPQzJK4-y6&rU-4?BYb1Bs*AXe z=4cdAQH5xdhGcYd@GTiK86TR&4PNN&n-J$nF=;t58t%AgU`+%$Z$1680@^RQBZ{85xU^NW_hj zSZashQmO@HJb5D2xUqsE?C_4bW^TJtfN)kv38wzj8#?5OAV-hG!d$x2^1AVWn*M>w zp#JmFG@v53TWKb7&$S30Gw{LPdn=Gr3x|@BO>pL~yGBPgzbly8mDotV5YcT)w zO;5m`OyEP*qUy5SiBt3K;3?qe-%^&pvt!5>8#hHuMYRgET0lj)ev8TX-fL$NkUzf} z&5E&nPG;7YNv%SH0cvaB=76=e^p`xD%~jaoU-33H)LdmEf1H${?7#F(zorCNT88cj6?-s;06xR(g!Od2ap# zJsm%D4l*=f*8<%;O6P4D!6g{zI2y0VwRI-9rK2{Su0jzrzjHd4pb$Yr#P`i(=Sf1e zfZq9PY$}LM10LOJ3M^?Jt{I*-p^@}PJArZ|U99$fsIp}DP`GA+OUt0^hj@G0LZ1LZAQiLCBHpfNc=KO`tYE_ zdtJR{zX?3JU(^~O~p!hUd7S9w)L`CwQ!qP^JO zcs7{3e3*DTGy(zNEi+j=hqV%3(#)&D3H_%zR{e)Rta~8z*R7CHGB3=#IdRsf!@Y_3 zPmz261>oI<4v&|G4xbm^4j<9&zV)X|;LCd4p3LvS9eo0p^#0GMjoS`SS(DQUTzkg{jaJ9 z*M*-r64##&cMYIBeC{_5#-9JM8a#}#?!CP6@Hn;iFIXk@+imRde0J~f+rI7ayP7f> z^WNFyCG+*{v8mVZiE^B&#CP*-nN@gp^t{x)@c}^u~%x1;2-$J<>}Mkc_{ z>O*@<2F|E;O44e*`~<0x7ghGJ7Oja)eqd6}Dr*klwj~)V3!7`Is>peH-A4+?fEH1= z&HUtV#e1^meQik!RhpxMa7)%~LXD#E1sPiD!92Lr))Ixjo=+OT^G}zn6){Srs8x8n zOzv+#Ma7e@pim&JRPV34FI4FJ-c4>g^p04s8%@S`VbdwM-^CXL5`>efJPRo)DRK-M zSw+!QK@5kBn;)W?dr8T1v;ITzG|0a@+B~=WONNIVY?sl9c%EuTe&~6xx@zgU?(WDY zV3;Y}C;YD3PR~k;zf1zUUlqJI`^74wzG2*v6xQ{2-{q-1X~y9ZqQfJ{yc}t~@0%D0 z@1k32eGJ$I@TGwFL($3R3*;?Wv4ov=CIg@!fkIl00$tIhznu~kF+Cye8%ntVWr@XR z9&JPGFDxfu4gf5MAhMu<1lLiSUIhg>zL8QaWhxD87~>&PIs{X*x{2Za*rc`gY6Y9% z9|ZbC5qz1%=J4oTCN%qm>>)GhLJ(2zyR`%cPw3qhQ|G(d@!SB$ooQAt!q8ovwa7}Q}21) zta)`G!p#hi6ICtDPQ#Ya3FpuLDXtCh5t?NQiciK`GMjPk0oiui2ajpCBh%F^=k;$nIhCYJZ_Sr`~t1XT@P@}Bx5FSaO|{`p0dG++t~ zfyqhoj);y$OHN*UeO|djG?4jLc*dK%A}1k@_d-LHT&(RPc4SeA3wO-&ElDdSRVyiV zK!r$iUENUE@S_?<6hQAS&1?TzoAfMr-o0rvUR>BB2SHs1J_tWa^ zbdc{Zx453m>?R+T+UzK4;TVVwHbWrW|MT;9^LC3|?N?ZD^=!Rrr|7+nGFEB37zucM zz_UBrSF2m1WqcR3eNCI6cD4t#{wdE(RLdhwV4QHvAk*UT_rcUEIpQn;;S&{*I3eqV zvuDiMGtEiCj3TOGi6xVg6G_K5koa33?L&NK1QBIvGiB|g%=7o-S$S^F_ba+}c6#r( zFWzf1HEA5r8-1p!GUh%l6IN_Ts@(l9fG~FR7FG zARV2?YO*d^^qoPg-A*Z&@68EIRq;iBSFj}^p~;_SO;Z+v!X6G%ZNB5H%hWA|K?gq=1#iQ?fUI4!HvOlmyVfKFnCSl%IFTDtF z;ti*5cA{`wT3Q+!8jgk(Pg*PUu{pkcb0~C$PytzaT`$jdZ62ubpuwnE5ow~3B*6=Sv9TeGeeQQROh-<)2h&pzFwsyjocl~1 z&fww;qG94Pvl6Cgal4t}yZhA;$(lB0Q4M}N zkt-o2U6h=G0~fQP;L02&PUJ(#&!pIm2gc1h>Fy%hwY%eq7Yu?KZSMYs=YDM}&8<1=BHeN?U zpsMg&jaflk2HK`ORtOK-&x8sypkV;U*31N+I)+$y^5An^Y&?TJer^9@zSx>lHMx6~ z$f4aEn`0J2zMZdH{x4q(Qe`8n-TnAU6zdH}V z1A(05_$RG@FFg(3_$iG}$ln}HtQ0!$YG${|izk%{dk)TKXZYiu0ZH)wFTcyy??1Oy zJ#Vf+$6spKe^=>y?rI6SL$xZ~4o9IY=QQ8iD5`#ZQ#x(6<#yWDG{Vup|IBX{uo1)5 z#rinHvdngQsdAEeKfrJG8+_90a&we!lqR_4T2~Z!^L5(sb@OUsm2JzlN#95Q_C|1O zCHm7Z{mXBkP9C$B{SO0$uD?|&b{&?QOt&^1+tMn*2Xucv6QSngNL2i90k(d`POj`^ z_kP5IVurU2-S7StFYtciYv*IErlduKmtlU;6|xu*EmS`AX)kkbQz{H>EZBBg+mT%d z>0fVj4L64{;?QB-RC+*^CLk@w~KJ{KgJ3mOzd49@$LoA=P0pyJ&C#poGCHZ3$I zx51rtcl~Quz^&~}%ATWiL#!Gt(#0J8TQ2Z>?@o-w(@5#}kdVCPWq-XUd&HZgRds!R z9JJ3PD(M(vL7$m4w*0{uDIzW(&RQ@l9PoRRP)?t#;*Gv=R3d?5 z-`ErTsiiQgjl8^GLEqcFg=2n?lkpWT4YzV2SEvV@5)#R3__f)1 z&~Avx3BgKR3vrxFi<38efp!)RBMu4)zlYuNVnf--@?`+SuW0YyR_@XF1-;GufM(ii zEREZKu|E3;}y)NZ#vKhP`#przh>XYtR7 zXqCDRKAlg6&jWxyM%ZiJSXLG}|5M=H5Oglpo1L9-Db| zMO8J531YR1r%&`NhXhzTsx|JS4~uL*VqveA>FP@@Bfy!(wyK!2bIY*hbr4|iU`I5Z z2ffX?Kn`t@sNNu!X_>6=RP#)*c*K+g%|FmMVQE2R zFnrV)_6XilrKSa81=K&o4`4W9Ow5AvPRyqn2-fN474k5=rpdQu>F9%^0$@SPm@o=` zmM1l7)P-FBB*_9c(TEzHeF}HxI(ks2_3n-2vN>kmTHtf;4#;#R&e*BmcL3$MnN)0+ z$9;WSRqI75la(Rg^(OMxAR3uaff3+>bZvD%j-IJ;EmAVz^E}B)oNjVFr4R=L@4C;y z%va0bnK6vhRcT*D0#1cGIM~?_4i92T1zSDFzkB{=(eAK(j;HdDST^G%2#TUnAm`Jz zrVlkhC)rBkkb>+*n)-p0)4XA{w}aDps(+*Ji-qTLLVQ*{4g{Cn|7x_?D=RDG2Nqam%BLrSzQ)bK-VGpd`5#s^?RIi{zVM$OEjL`=MQ&}# zq!Cu|(27bk^YS7zS%=>o11D7G-nuBS9Jqqy!jIWh1x=zlW4gg$$WRV8x_Xv%%>(a9 z5Xqc(cTm8_hNSyQE@Q8>gNR9Zf9m!<>G~(+LV&5;0fd=mue^>pL*e|}Zf>cBf{HL; zs`ftVZMd@-4=BKy(UD2jS13^aoTHd1`1rLsy~`A9ox(II0CukxlpaJZxBtP{C?5YD z76KZQa||44E(@CUAA}t7!Q^P0{vZ~ZPpfgw!}ad9;W!k<;22z1e&yW*w>6w%SI0k2 zD<30FnKqFc)U|o?CMFVkro6Dfrf23KALg_EsZ>OKRDX;W%c=N8$fHcyE!}wOw(T(w zJTl$VChL9B0j#K+1j=5l(OLQT(xx{thu{RzFdE0lWFH2^jYq~Pq&X)b3L&XCAts`x z)WS51mevD?cF;tpJ~9l>8Z?*yk`BVc`sy%3lIdifLsK$R?n-IKmZCtf**~>_rTU{iOmOgarkB&Ip&*~&Kbt%qZVo~N4Vfzn=_Ydbu zsB$3@f}x^L63ZAgX7)z69>>sW&JszW)dG!P94rzqVt2z62sD%+rxLx;7}t%(&Ox`c z6Dxr$LD*ott?tL%xtg^yy#!NL<9^>lnHesr;jMhwRf7dhrEX7y31@$g2pB;j&Rz)^ zNSyPY*m5?T3}RTAhwL)4Y^xY4T|BEb^}%9JM#S8Q&lPf8pLS#kRoGVZNt-{aR_w?Q z+|8lC{gF7j%~Tj1tNBouoaw)o7^@>>n`q`k5pE5d}b^W|GYJ_U)(GIoN9# z8;<8o%#@lO{2y;b=iRr5Ft-N3k$mugd|5@ZMB6cjb}v@#H*&Cbls%+7grmMfZmAe71ZSyEUC zg!38fLFVs&uz|1SxkNEGN<_S#wk*n%1QL=0&?nWp2JU45mi7L0%Wby<$TFr4Z*Qrv z>%y;lke_UQ>xXpUP0v^TKDQi2ug&US`DGYE8&+(QyQd^1T0Vv}mEZwj=1G~}AMyMa zbzfJu*9%5KcY(s=Pg}dAS+@`57^9Iaa29u~ifG#LsQU;OX1{+d2KGkr>?ulgY5WFN z;|mM6bPpdU+mA+^nfS-(neyqmJo~*~Q{%;1{OlU!Z`tgOR29E^j}b&WPEyR@yQEWS!Iq!7|W}0 zb&FAFHMz?7W8eSZEI=c1G}su6{{eHvZqXd?b9&^q%BGTxWw*+^|EvYwI_BgAPJ9J) zO;A=8^YCc%96@uRTJvdjKJx;%F&SBTmt>_2q!|==(waD!=r|%%{;!F(27xNzGdzpZ ze%wSZwJzt}{_c5+Ul$M2O+2o@-qH^1(PKseM257pmsn5fJqgt}Zg>XY!(%d}d9%tg*6PG}fC&Ojy3Q8S8H&blcf0$Hoy&R{U5KcTY& z(&wlcTt{GTbU0TLjz%U{&;FjK(6h=gU;0}+5NVW1t1NNVQTDXlqS&2W7SX8p`HBji zeOby{w@ygs=C2$H|QzP)7&iXq`Qa62CWjPc_| zl|Wp0Syju;L78K_^V}>{pTq6$>?%4M*!8&|Lc3lb+IO=9UkdSeDZ{z|M_NJx79@*g za9|t-6Dg-}hP&j5Z$qZJvlA9sd`cWUIQ~~|(b|InK}RqfVu?BWX!j#<%5=cL1Hska zvn}>Y6H1|rjr(ceXMUO7K4mBV=&><_alUd+l>X89S7{Z4kBFyyZFtmvm@X+e=&fC1 zW~8-$)$dSXeC;QuQ$5`X@!KTqu3V(c0?Ge-e$HM9jcJ3(om_~INNcugk5mydMJ9d= z`grVj-a4H;2xgS_agFpC#>EPLEEG_d6xg|E+!Uv?`z3zsH^qOw&B0{pLJU}E4F{(w_{!35%RFcD~PI;(@0UWW&AiazEP>lGV{wXTf&7d~?Z=T4g#Q#ApcrqYY zdvPy!T0dWQ%2I{SjZy{A8%DF+trioFx;CqfvR`I8II=n&|4!c;-8UgtJ}iZ530|(x zPWWwo=l_YGh?v%ErjIcjKg;X?F^n$9nm){fyU6k};1xn`$ar~DP?)yEBP;Im!+9TD z&i#G+F(wDE!txzh&Wj(X`$Q&w(qK|Y-n+`m5;43pT!!KS86*AHAzWVzAp{3fN}a0m zIeim3v|j|KBuY;iR9Fq88_$p3>Saq;5i#e2cjib^JY4|e9ZLH#0~VB{oA`zc#lJ-) zb@(7tRZp(vV{ye&5RA-5U6Od%S2P}lm}OlEvaf-GK|nyyYDWvuJ@WZ&I{{*-k4udp z@YP!mpKV~9i-=D8r_4%e5bbzcO&t!a-pbMxD~?(J;H^Uze55YebH zm{l*0bNyWt)iTjad=216rRelUj?XswfSy%irBFVlQm@P5vGF(@jg-&%iHGR+>H{)d^Qb{;&0ke*9Z7+A z6|5NC`Nr&4z?RdAnd2<-?c2e`KCklw5K)HKyDWyzsss>2Dq089-N>xbyJ34<*;y_OO1+nXB`tSds?s8L!J(gFAY?dFbl`64=B?Y=kLCW z2@CM@;YIg4Yj^Xh3b4h)?)NJ^t;)6qzyz=R4@Ot6EZ*)Paor|yNG$#D3HYr$xlnwEFIgg0{ z=|ee2VdOm(6#5~o9C)7@`O7Z1H?Tq5?TvTBRTTlQj_1K@Al%_=3=Z1FICAJ(fX(o`r6{0O>I$0 z)c{EoQWe8AEi_=ky-QX>k0CE<5@2B>YGD$fX(Fm=V&bkU>Yghl8jvhyAu1{<8sIHz z=q(x`2`liXG-~||8;%Ap$XinqarB}Q@5I||G^B}}*=eQQLY(C|VF7_jnXCtlYk;QC zxsTwQgl8I~d;AYX25heh!IYhhCAK_jYOXoWwy$iVBhQ13xA>Z*jFo)a`h){DBcX$g zX!R1R1HpM0QzLu=o78i`iV~A~h>eTUc$(&4RfMl1=9xSwy%kl5O26Cjg%d_}NpI+4 zvx6sLGos^Fv6X1i_t)d*aVbIwZ#0M0O_U{JjBxf~;AH7%<5*4zvsENuO(t#yK7X^c zn`=emXr$o`fmKrSP@B%=wo$TWM=-F2w}ZX1pvDY&LJ{x5M63;KQ&UR&nzl2S{8mGO z?6_M3)<%3WxlT}*9ivbNr8-f$xfVV#NHNNiA*-9VJ*gRk8U~-Ow~rwC0@oByu3*=D zXJ_Z}hGae?BO^Q8NKX&?Q!%5{-#3;B5NYnIBCT3k46o5iuSld->dD<%=Vv-w(^Jz2 z8&}&ejR|!W?&OLiQblE_$8S86@~r@EXQw}+a#9jECucXdC-JbCv+-HPhInuEy8@|@ z(#vspOCTwrjw}JzpNk?QJqY5K|O#6T^%KkUOSqxwHu+xRx&SqCjR0zt`DN6mA&+Gj>%LI`4O# z5Nz_A1O{>)v$LAVwTp^rJV2@d5csL*n8oAl2dwU&wTb8He4^O#=Swofhc4kU z|EHQ%((tFg;=OyN%=-cPr#^?v8)(g=axcE62Ce_W;x8v1P3 z@PdbgUg&Md@4KXfQ(vC;GKGa7uu5O|<9We>e%pn@xkf{49alwb5s>vLBp46v#O_i^eTaua4oWMzoVT&vLBDB!1pz3RwZ=4cNi;t^UEke_>?7f z%=hW`^5vp;D%DoJ0jkjcw>CG`oVrO>X8up#7;f4hefovoPM!mGyw+adH{YJ$-@dFp1+uJsrXvnQ zB%+2v2!dOe4R3-Wx1Ge6D+!=2Jed4>XCOB)k!bre9?;@Zv#D<^(jXLp{vUG>Vb9nC$ckhX0ku3T`dwtem#`G#2LNnEj#uGL0WWqG-AfQncKB`Y@c z%<&oTEWLg#^B}Dd)Ws^bC;z~ZmXMN^m7cUp%gZ5HVj!h9cr#4GPu(;MX_R#iWnLAR z#WKBtaS)=)jnG6>kPQM!EvLdk#+S3=*m5{~40dN^|5hwv?S_fRx2KNA8`s-L!{VwV z2`~`TnaKrv`=f4vND>H!dnnHQg>lfJGjp0BKc=Q=CZ(jLCjVPHX7Q+|WE;h`vD^;D zQvouUbi)1T#~V^!zj@&N``#R7CC>sgrD0Kh@kNo)AnS27!necS#&nc2mc7D1e}x6Z zCnjJFj_FofPW8>p1Mjv&5`k`an!2NQA;k2M;qvM9O z4GjJgp_+!QMk-35V^e*m%AKm60tl8DQ7*!p4S?}JF>$)gvr6d)Ka~?R?mAk8)H@VX zs?L^_A-L*ZNSw98U3fEZ{HbqzgqDH2fl3@u+5El+GHSG2OwSVrfbIQilc%HeIoom# z=7c;7*f}3iuwXx1S~MstrDUHokWsL>7&O^if=}vIsic*%1nvP#XBMZ;MYTyEuxKNQ zB=-vR0^~BlCf%sUZ{VTf(8FQP`K-HVYhdtGX{rB&r#}7`Iye+5jg7;>SO(P;G9$uC z{GYwdi4iPhN|=QoX+1}j7+|uAqp5(G5CJRTj1`X~Bd2T+x|-M*&bvh-XUYe~VJT<= z!IV^_IcG_|ZRm#+98(b8C@~?_0U_APn5I>D#AgGJ!Y;w0oIzY%cQZkHP6QfQiIGbL zx!g}Xk0guk;0NtHgT!u!r5iQ#>>#-AcHn!c!(n-Acr_8;Y374 z+ubFdow=lXg$>sYT<%;%vs~_)M6=fVDn=k3EiEl{>6~0rUw(WdZ3|} zxq{Y3T?V|{)-Ngs^vf7vqJzHc2GoXGfVbo>NhgPfN6b+1?hO|wd_?V%XRF&-eU-uO zYzE!Rp9Xs?sB;aACHGKu94?wjupzAvw%RC4|*kfK`}p5mY7o z{k~YQt;uEh<^s2kQR^5`q1Kr$#6(FhNmeds)oq9zfA?<~y`6cts?*{XzP@t2^VL(M zd{wW5sjIU&iV_E@fVQxZuE_={Kw|XID=4q7zB`)?2@Q`q5%z+=__NV2E*&HGcV|aaBMA}C z0hro}heM>aJA7|5$HpKMf`ziCgB$8}3Dg}?Z`nZaWTA=d#1Z1;8JS5*D_p)qaXrru zx2Ma6>bkl_Le{`&%cT71X$6Hb+ios6@~|` zr=)9RvB5%Q6|tl#<7;aZw&#($98#wOxtGW0#3rIE)YWYd4pF*%BakWwKjLp?=U82Cft!HXw$Ws}-2pHLn5d zw=TPdDX5|Mb#kgvx^Q66js4PBlvu7XF+hRKOT)#>JK+8YtBy1!g;<_lC1xQ~RwRLy zPK5J6jW}6IaF~|*^dcYhYIdL>2m_PaXs*o9hHZsWCIXQ;ij7Q+ST6||p`YbXJl|P) z3SRwK>iiIJhLiJY>Bz|*2?_9l9vo1iqV`iGu_7Ymagfq6mWn{h{bF2XUhqXyhiDKC zsBEEyQuqu)3d|hfV$rdn)B6qZQD)fFmui&H>L3REZ=G<>hu^E74@SFSe_a!DlrO~@ zlK9-AkYSqg-o==5{s(PIKgrc4H~P$$_k&;SbY^FJz#_uc$kb^p_Y z1Brik)*dgYi!t;6D_>Ys>hu7O!$HxtH$NQ6KsGr5JxKTbUH%K!q)S39#1}nh( z10_Y!mf06p|B@i()2PTqGgQa3Z9S$+F4H)ka_7zSjMQX%v7Z1b-!u z%A!wES%fIcIccr0C}s#q?lf#r(OkEoL+w_yIxGUm|AO2zZe|^RSFF@lSw7}ItbEQj zklgs;y>T2azJ&hSgBa~t0Bg~kr+rJM?{BF45*n0@5iR-Sqq_<2jRyTT^n;f>>IRTK zt*KdD%BS)9*xK$Mi!J3HxZJ$ma%ld`FBzvQQ8iEd#}8cm%8M3^9Q; z$%OcVYj`>uF85CFNbZ2z&lJ+~<3jY@P=taWa@*Zb3-0Jmnu zf|Ypzm0cvd`Pep(--SdNGlh0`9-(QX(_Bs%j?>E3)fKY=Uw&19|0A$Ei%bUqP|kN} z8}04wfEnMZxTojtdd6Hzfl_wx6uC~x`i&j62#WKXEvIvBI{G+_ydn}Tb|ns43=Ji1 zo)eG0s-dCFCJ(c|t*&m>s=v?m0Z(l-^2C>>Wn$iMw(3>bG`LTD1R2x6B z*B8_@HCtZ0UvQbU-f34m0e%)eZbv>tzjDAfnlBZT8Ra%iqvBM>I5Eoa<2L@i0TATq zHQ3JnRm`|KUJK_ht*rcgdma<{n+F48+3*S4rd7peCS}BaonSM~oDC=d3S<+3-e(5r z_poVbGMemk+RcZj3P(MU7eg~xF_0`9?V+9ij{p*-Sl-92lFHSD@-IQoe+rE5`@tZR ze|Kp$VgK81_EwQ5EQS}sI zZYs>7!&;T61&C^GmTpJsvmIKGq#~y`f{*p52n$S1S;q@S;uD=DKG(eTjSjRveqQR(8AGE zM5dZM-z1BblSSM`oXHg^qUZ_}>6tjplz1OfGt}r`-28#mi+!DXV!+vQ5ut@i?SoMT8asSLa1pN;r=PRUw;SkcPCdcgP z=x7ys4GnF#v+mq{=?lQZvi;kWpLO!6n+26ORJhYZZq@yopa!P}-G>z&++;p%s z<*tZw1@u zZ8}pzR_a5Eq1;So7zKz31@$kJf1K|2K=3^EGy7(mMtL6j=h$8%uK)~n;<5g7b0H|F zu1bL@Ux_wgPDgVaI=Z`O5<(yAJow#K^@_sUJW<`b^5|)kef*hHq`5IS_a_=scyG|I zca)zpVa7bC*)1~*>84TwL=;;%l3?+fJXt&`|WM@ zc$paMgOawh#K!R~a@||n58G`NJ7I!1em4R!UO15|^hY1UG{w`1a(tt2c2;I65Kl|u zf<7AeTN8wXabUN`Q^hbfHBVDDq~*$b-BB+($0dGc#}UziI4-`)4E2>vI&h}JQ^4@6Rxk4^MnvI7yxrK!aUsSjbE0?Kl-SQ&X#o(5x*a~pt?q{aIMf4pYt04* zX1TcHo#qF3v_@{*0WnNz>B#p3#vW0JBucUQ>uYKb zR>JREp6cI`)TV>Z2?+_n(qA%G(D!O@u@GRVySOX52Z+8*w&!ck&Pq^M@}v=v&w-0^ zlvyLQx`R~*BB*pBHFwBIJ5xMcynL1c*0ty=9;^O}P9jEs|iCt%JDJA8@N)Qx_ zgU$A$$|SqNtR-~9o+N8F7Sr7W2kVJcX~~0CSy@t2RyHv-L@Z!F1h5sD+m34GnOn?z z!;q<@Vu*Qdw}CZ7kv^xzMG`|OBB~NHZEAAL$x5BowAAuVnO3=Wjn8Z;AjsRs$C-i7 z=5o2^CXq#YSqlgXc6@>|gAmaO1T8tg{}AxLD4xs*R7pq84)nxg+ROmlVLyx?8#kYd zT9xpUjl4bz3-)XEKTv=Z8rxY`)kuMenjCAPL52r5qB8tXNZZ~fW-1)-k2cS-gJou_ zuaqbv+d^t&;HL<>v+9Q2B8Im_5@?v8FeGsk?HObf)KFsO$gu#xuL%n=MQl)kh+JxU zW%oQRHuGD-t*Q7_C6eY!v>6y7=?Ixg)S~ z2S@?gE6hXQ3$p(hy=3U1`5RRrd+~excO7*t^^fkox4rK&bhPJfMDX{%=}-m)A_uf? zSX(dm!A;)!FOXj*E7h*Rqesy!2#Yt>j+kq3cVJ+%y@^07WJe>36Qc^+WeQd^gv^gf zx7=a<8Z~Fc98QJ!?Lawa7>J_7E1vcQ zy>I+yz@Y|xy7|GQYQTanDPVubI@P@;ojvfbmDuYGk=|IbYpcYk;+W_!cx+}@cD5TE zboYrdnkHZW6g7uYu4RTyhQ@0eR6E?u9xjG(^upxh69=`YQ%ob#oa#p|NRi-YZI2A1 zZyT#mf#IH!3K5Cv_IN5&WR=JlkPDnxBSV=Hwnu$B{brd(9O6Kt9&p4mo7*@aXlUY+ zw!v^{*d+r8OaC_uU~|CEc7Ds4K-^|Mqw2-)E(1@cB*mOaWhsULG64Sf*j^a?oRGX< z&PRq`LLTf94;#r!`StaUR8OVPzObO3Q6$EhC6vn$<0$HC4vE0#!0aKA6T@8K6`7JN z>CGTdpjqE(gLw)dNvE?=C@wA#;NgqDdCjC%0l+8wf}bBp46|dk9lh86U$*9U~!)^munUdc%iS3m)g_^(Xw`d!*FyK-)FJ21Hhy_E4Nz5no-l! z7*dG1DEAcTw~FQX(lb9+-0eYvep^$0)8z<_wSpB>5n9baQpR6qRMm1NukOLiW@f>m%q_5@Uf!F&}-u})X?Ak{sK2Vmbj$y zKS%GN-;QLjAwA9)NGojE0r0PP9p}=TXmy^ODN%pCJzZV0BLh2{=Hgrzuo#i;?Z2#`l1bem-KY5$lF)GY&d|!?T=Ytf96$lzfcLQb5W(OSd8mC;N*tBKSnYe^Q8L zX8bwYY@r87j$`*yI1ecwv(^3*=~VC1BtCZLCNypjhkYnLcobhF=X=|R(63O<*{j6} zUJE}s6rdnN^Fq!y2fyJTE(y2?1ktgiF0P-~eVHF&2hpo{<0g=P zdShM)Uii0jy%CJQRnK&O6^Z(-`bOT6ta^P8#7grX`G zuTHC!$!gkv253&v30QLgBFIXs1yvXs61;|3)Qv(yvkjK|5i3nirVz(33BQZ5iO&(E zK=WB+H9>07>ZT+oCuVmIyf!q27MffQou|BG#mfB>{Rt80=&dkyU?fjc5oFm|l6va2 z@&^+_V*|`7kzHLR$;q#wk7^|{)R43{z?eW<;n$3r%lxEPNd3bVy1o8YXW{ zVpjZQm1Eae=GMK$s%-S77q{iWUa~Z`QaNt3D>JN-v_WJXrw~ghN{6(B(_{^;yo%}-WG$8A7irw zzo3$Fi%xm9V=UI&sB38%>HFDDoB+2Cad}ypg@$NqP-jQ<9~RUx)XO+72+BVTQgG}@ zQ12(k)i1*$zusM_^_Rux-&-ye<4&Z|Ya8xHBPv0>?l^nf<0uq603fFVAs>R*)#_l@ z%W0S3<%-qo^Ym`eY2fv%@8EkQ5ZH|j&TaG{Ynu)fUn*1FPN6ZU;@U2yqHo|@E(cTC z-1<>PT~A+KOU=k=5eB{khn%TldOeFOXsI&uIXQ#OYrSsGdq40MB=FjYpY`z9DEJed zzF_S{b&nwU`|!s7CX)b-zaXw4h3S+rJ2T}#$gNl~Eyiq~)pL#*H<-`eloCWq2_R(2 zSU_IbfY|3yS)OdA)%~q-z>|&52)F6~pNQdegMNP&6yyioKR^l|d(g30j=js@G}!z$ zD^H^uvuD?@e@}R7+ZZ+Uuq(@AX6Ag{8v^`)klCK~T{BMQ^rC*lTA)9`mFsh7)ZYbB z4Cz+o{wk}&<(%*<_#rm6g7W54piQmQdA0O&6PxWJ+9jVN|CA= zhg;CsAMNqy9n9yK*P{+ZV`8W~v)}JAI|RHAENazx-zrL)(m1S=&G|^0C2wcn;n%MD zbJxiCMGrJec3sr3u8~^~qaKT{s_z!?X!H!;VU_f*`ofA}Q6DhlEm@wtuK0T5zriS= zk4(@$*#OuNg15^z<0}gT#P6(iLPaVSXR)ZWZj{`MJW0L*spd=dMXv)L=ZcS}dT5fd z9@P2BEL+HyQUu-9Ib5~O2CNS*>^ZW(1mQDe7DBo`7?Z_*zjTY+9o{Kp%GEP9fAcy(*ejH67yq6yWFgiGt|C6KDx+KTeGi0d<)F=ZG0=;x#* z)J8*VDQm4X8#>eWGz+7h9=(g4Jpt^0J~+N) zW_(<3w!6QG#HRc5wb!Y&66%q6y)(`tcyU%0aDUbZ5TAMh|EiC#?_{t=jlEg+*XTp< zTEsw2&7I|zI$$d&kBejhl3C_nXEsUbr&30 zb$xR9Xh+}$f~-%*h9jOxOusFv(rIt4EOpq5!SA}cboAe`GJ5?bzV!rtYPUx(oLHOw;U-)4)U-;<-_}V!m{Ir^n?x*~KcEG#qz^YANMC?o4>w%`)X!@{ zFFT%H=cA_q*mtwHf1ZHJxFKGw-|JHBlgJkTCzHU}rP_nXwFj;ov(DEF>CW5n2H-oS zT6?}(JPo*vKlQtY0(Nk!HQgp(e|yDe{*+~|R?{TKi}W7@UT(B(oB7PN0&Qm3))>nD zad{%?I=;DUFuOlXOuAW(37RoLgM4|z^{`4(f119zxUpIo7m1#qQ*V`8JjWA zS)>^LU|Y6zVtIO!2WmW7M7&cCB3g4FJ^Y0z2qwTB>gR~31 zksu5iF}5!_%~K2VkT=keHXW!1QY0{Rn4_HDCk85=B%Ah_bP1ctHv$*%`mkNtDECoc z{o_rG=*>TeejJj9mA7s{ z*}XlAc2-}A4w}8*kv7DB=}0E-o6fLq!kb>v8EjAGO!)?nLVmNE?7!Aul-&v%$s$hp zNk5$`3?!1`^}gGZjUrbYr430eAd|yQbN@UCAtP)-0!2{_r$?(Iurd@-Y=ftkWammq zW|yE%R;QgnolM3bK#6wg@`xpTAGFE3At>aAIjrfbDc zfI6aV1JV-Kig0~q=P&|2(4rQ$Uz*-?g(26SO_eC=XBLJQzHw_}2x_{qY25WA1J zxZEPzAWoCuu>*@(f=n)B`JBQq&d4W^907gg=&HBEW=9a?fpT1|I<0aQZNBF(1JfzY z-&@UBx9_``WiPFi(^3bCd5Yd;ez4fs-2d?IM-8zy+b-UE%0g3lS_@p-Ea#_R^EQcZ zX;Z$)2dR*k500-HxnW|@d}$vH`g%!j1u61@_UYg!rwHz>>=G^*x)${~yO6qaL)s-F z?dQS{!htVO7Z(>z)~%KVz&&GZ4vvPhI?b|@XM5^%C30bN0e91?1Na^77Yy9T-lj4A zb7{}ljoOyc>Fr~oPRFI5AN**?tKMD{zp2_hh*x0k_*?xSC+(MFPv+6KfKS7qhaB-* zgSZKQ{f!8jJn_uE)Ju>4Ofo2|GBp9AT79 z+M(z^2ugTZ9<3pWD~E6wVe>8 zNz^zR^`n-OAFIAM0PVhIXmCK35mj9h(>NJduTTV&61I)rnj(-4XnD6S+h2ke>V>D@ zPFz9EA7Cud3)-XUifq_S64H3(IzU1^Iv)Qw3vke@@?~bkvcXE(N?2LQ;9VwKbLQ#X zH7CA=cJ1_HBvkM~Q&x-9Zu$t~Qon4Ih8Qi1F?txV%nAPqIJg4XhquRTQ0S5t4s#Z3 zz*8n~-R@XA7a*LkF(VoojlWA;3;#8X+1n?4wRL-6%E)whc=(n`O7r7LIq$=GX!0JV zg1x@PrQ`uh=^tEIk8JHRq3kMZwP+g+O-+Q*9&`5DrKP3ml?){-f(jSJk5Ln0X>}n5 zBFrSPr4T)F-so04NRZ|gO$5zWHBQ(}Gx-HAZP-(-udI%Zx1^+qVW))Nr^Aw)#uqPP z_JiVq>)^SdZ$BF?7E%=ZvoK0DP>^5T7?X$1F~lV@vC{}-khbR#H}E3D{bNCWUd%7Z z6n0BDkI!RxM0dnSl9D?G4C+PWX@-V6m|q7xPUg>N5|t|q9`_QJ1%bU+rTXzPh~4#6 z7fsCOlC-uv zW*m06d^!_&_JifkVOZXhlR+|&UtNL(Bm6Yx;#kJYd9V5lh}bqH$KZoaakSsC!5$<> zGdrbNbYWW;+ri^0Yr8;~xw@qTcB!yV@Ajlu8O}Gj0`G$Ph3;OKgq=>`-@=`?-rojT z4V=QA>Yw{|w|!iGJXu;rd};#zNDu!j(%n4&E8W?o=hJZZ@-^oAt%KL@K}*vb_hm|J zt!@q{PJBL=1EW`!dUqyH8iB~TWRQ2Pwl75&ie;X_P=v|)v8gfOs{%N9zAO2usi|G( z{jZ0lE1aCbeZkBR`YGPmoiQHtuyHz0+JEH;+==>35LP|B3usu8sK#dOyC2PGfy>Vu zkAbi1*?y;k!sn~X0S_J7ov$6)_hY_7uCM;Lfe(VWf#BxTI=QvibYJ1iqT84Kq9D(} zgT(Gr|BFPT)0df|ljrU1)yER$wa23D&eQ2^?}?(*zzHj%H|@7|+5T(U|BtJ;42!bu z+J=FlL0Y;yhE_ouBxML`q#J>uyFo&_1nH6xkcOdCx=XrKy1Ty9>yGz+zMq`Kz|6KC zvDUu!fcLv<{duZww)osC+Vt#`6|5meoYnH&axLGCva#&z%bWCeK5pLg zr{849AB<4?H&o%aZ^@Tt=2u?2q?E?f`l*Mt(MNglNjorr6o-B%2T?GJ*! z7YDlh?%3lokm_QQdZN^Rs3`y0r>P&wg#s;k|F~sOIG0b;L~r^M&^Qx}z?n{QNu-m6rW*C@z( zX9{~A{TWY?iKZ0vg7FfdzXsGV7FP$KR2gSxeiOb7jvGnP4~;K@hH>+f zgcB_MA-~ebL$gbl>m?krE<&iY$!m!xb0%|fX$}mKL_AnK3B?9Y7p;O_E_CbsmX)n| zZ*M<>u6q~KSbMn%WRwVux?o|_$12J=b-~@C&%s_|B1eRW#~CPLVv>vcdatxTLZQ%c z%PAK4&>!nKi@}z7B7x2^xo4`c(i)c60_qcFu(i@3YpwpY!^t= zG+(Jgy{-?1y)R__ggAN=_eZdPDqt3iOaHSl{r@SU-48LTkE2JC%Ud#Rj9RTYwYfrp z=k_9?9uwkvIM6iGLl5+yc17CIe`}Ii=mmZd(^lX^ba`u@tKvpvS^zQx(hkj25xtyZVw4d~;8@}|IA25DNC#F- z&xcsq--sEUx>x=U6P~IuxTnZF^vl zStylfsSy>O>WG-bPE0wjBW+@F9~UtMb|mtoM`*P1+-;KljFZZ_LjKhwKrn0EE-@JD z=is<<;ECZfr$F_%9iVnlJDee0BO$!lNRbco3sP3cAd-9Xea>Ys6AH&tkW!UbbZnYt zm=R~M`piq)4XQ#W!X*xZ5&|+H+I979uqQ*GOWd$`RZ)#i59$`9Q_QqbgAX4*XzFOH zs0=_d^mgXwbrzIrudkfxe44i6uYO91u}e@=ZpGhqaUV93mL zn7cRJe!FK%wMjoO1oXFfbmZ|TBIK%(xdo<+c0cg3xYbt2m5;ogTV0m>)VIjNUywFH zM7F|k8@`5UojW9)Z1p+~nF?=ku7a)UzWcMhm^6PqlDQj(XL6pzeQN^*ttIIn>BNiIsutvFg^ja% zZZ){t-FxHvV!9!S%8HwUEzJkB$Z#+fn|8rjQa=wn=kw`I1s$(9$6s3Od9T*XW_u_e z$y|HusN^Y*>=y)i3k4?&M;JdU^(xhLM`wJYcuOeg>9{H>bM|L9;2rcaROyNe8daA` zDh83UsM^pyGnHAN-3>^vz=vAkGbu7e*Y(8IVW;!Iwm*a9KI)$7Dizf7Ru>9aKZ&i6 zLmy*oql7Ej@4Av&SDGj8Z?=~$^p8Azm7EQ{$jjk|$R=m}+4*1s9kGhHShg4)JD$A2 zZigR^MD5Qu<>-mx4x_(H#@eR8a`1LhmtrQ6B$CCJrB>|TXxudHmq%nMFSx*^ zAPH3Kno(CkE-9EG`^An&VpR92uzLz$s%xtx+5sjV_b`678 zn&^xrF{Bx@Q0hy~3RVy#i$#M?OlT1LV$34>y5MH1(7mI-Ewat$2w{hTfx5c;M+ma% zQAC9NI>?dlXu0-&wsPatT^FfjFdux-zn!#=6hSzWB3uY7Xf3R>*oU#lJlttwi(Z^e zDv4J6J;Ueqr;HbV&R8|qZ9gANS2o4)Y7fyu>Wn0dY+W7u(y4&b1mECJ1VaAYk-E3X zl2Eu<;XDUik3PMJb)y-)b;ZTSb#>*XwTDwhlPxVRBz*5~(1NQ1pCahu95#9t0FW{_ zckyzfaDHKa{v&rK01=9qr5nP_VtkHohb3IyqF9Z%k;eSJ8O4!L!d2R#hB$e;D%j9o zqS!#epjQ+N(4itM^9gN9-m_ev?-hFiOk0-Mc9J?Q?t%0`@yfo(i6()=lMe2J7l-SCO{J} ziL6ojF_`?pn!<2vAhEnQER64;U1vuGGGAm1f`7Ph4<71*{Q8Te2@o4TCNOUdeLVS| zGz^0PBW(rw=ZDvUFz~`a81yA#MbvgV8sWlc;f{FGKiWfZSc4MdRW{c4sBih?hn1}! zeps?ORB_uZ*y!LTbtgrMXps(`g>zL3y(D+9>JEC3R{!DaDB7tz1fCL(@fBNuk>Ni@ z#WE)H-QA=+W(-+$tXuA4oKpvys1_hn}biwR0FS?$Fy(HU$MkY^B}99 z%to}4*d&W^rocC-;|%eKh}~DduE;@**N4*$`|1Yc^eN_bY21hspXhP3tY*g?Un&Hv69V3=F`sgMO#JTbK<*4uaEeJO_Rxq5lin?Dy%%> zSvcf^VNtD;$gwFJaR}Gsc$p_c?n0yTg}mch&p__dNQSVrwuYIRl)Tf%pOBE;+=`~P zm_Pr)0?aCzg7zCS2^v6hdVJ>c;=W)LhLaLrf=}W#&B{UNxBf(Z21>sXs^hNPf0il| zX+&nc(uxR-Dgwc_JZ$~I^UKRHuj@=rn9Tyr<$ig|3!6%0-ggh8WM{yWNSJA%ohN{k z*}jaxg&(Dx1xe=}zK!C_{*?IfSWwGR$6t?87o_Vfo&*h*kV;HcfAQkGwblNKs=Q3^ z91zV*KHK&Tam{iy8f_10V-Rv=zIM`pLiKnO=d0b00aMwTa-HkH zqeUU-it6gg;d;x!eYOQ-%iBoKBl$u)2i~PwaS5-fT3$_!n?JNN#Z2 z{_Dy<9hqX%8+j@C6c8Z!iZr4ZWfO--nQ|!d5;*ks=06Tq1IAen>8;C|Wg{b6;7rA) zvu?2i7F6RGv^5ff{|Ca~H(6cwI`&a`0aWWE0Jt_Y)rCm*7v2xB4uG!?;p_3g>`6_V z{(Hw>cKb&$>AMdEGrjXzrNPgR;Nrl{I=r#T0xsFlV^D*LtGBmhUvqqCbe<&|mF9@e zDxNzl^PoG$jv;j2JNzQOD19FXla%JPL=@PX+ad013dLetYWwr#7|XnZz9ibD5NE60(o;)X}k&7|#wPokU z;ixqPi<*Wf1@g1}xa)L-({}mcLRCrhc5`x6sOjidf%9mw_WC9amU*=7ED$9-M&@ljr+%@iq7H?Pq%Wg_vIRoHOJ_X1s#xwQfrmHgXH`|FK)i73tA@Oqnx;aF(aa?s zSaX%#ib&pxLqBmKzwts^E`$e%YQ9G*k4e zQE>*E^sm13-@WWAt3`X~clXN+y+XDV0s|6v!k8ffNJdMabT;8uLF$CxEmfV*)AhGu z5>4x{ABk_x3$N#PTgpR(UD0QuOm(;GF({s`lzVsS3GU+Nf6RC&(QxxNUvs?*=b-t; zmG|DGCSkE8{l?n#j_3Sc*Brk1*CX1@5%ToY%fCVEJ+rxBj153o z{fa9YX(SY*tC=lID?!l?p1g{Kdj&~J&bR5sq=5;|gkMhfM-O!*6g@Jjcq!$%QYhI7 z68JgTIq!EofuLbdK4tr8(yFAiR>b0nn5Suh#&9*8@F+G?iS6CSEQB09Li)5Rk`mmc zQ3a|+xw-b*pFYXS$?=m*bKnmd#eqoJVjrS9lCgL+`Q31$^#?+S}pu z38!Ptb{)3jxlRule~Wxy;R4k!Balf?pigL5Nab~$e80EnpIV&Y`E<4!GH&AS``l2y z6I%^LO!Wd*NTWcsS?|5@=r0%8?A6=WiHV6Mhv9Rgp|%eL3czh(Wo0E%GCiHZ*>*88 zLLmqQX{r>d^$gSFg}Do1>EKj$&;Cwge~G2RKr9(_;ALk3u(L<1Ed+CYd^#^}U|XE@ z+HZJmh3n_2!@Yg~HlX~vrgKS5LNiF4kBZY~*x=A)_N-ANthL{28ft2r<~+&o{QQLC zWYL>to&EYI_gj+k85Stdhdyqb{!_^O3+l5rS_M{XQ`gDu`A%E3Xt7^dMj98;EB;;8 z?h2T?dL1`3S9_@yDW(5B`tKCj-<0~m>BN#UwqHJ-z;L6Z&6AdM)ZH1IuS%1vAhPIY zfND8#s3lQ^{NUd6$1zplxE-=FHlnd8ImtK*!uT=DT`SR_d)w?|!GKmf;D?=kKVp`L zxM!}`p$b}t^0Vs+4P=!|fAAjbx7nm>zTSlnINnM;hf36Tlnf+2|VjVrHC2uG+5#COwR}V!#7Qpth}0VIs#m)KtiS!If6w-BD=3 zwTH(kDXRdhnjRyFTQkLZ+2+%f|+1Ax5v<<=HMoN?BMDbH?eW+rTU z8Yo1XZ`LEo_(Yy3A~+9$u$9Gzqpo&1ymH8AUg={vPYa(wvy&d;c3)xy8)D;}H;L{0 z`k*>O@c`xe9HXhPED08ntZ{zNNa*RQNpGg#)9s6WKA>7EEv;qGkgM({WCedYJVyf3 z5mM81PBtHpnvNUqGn?#y9FVCWApw%P)z!T7GkjeBx*p`NI$mCGN-_H$Lzb4@l2ALB zkQm{$%gak3)$Jk?*m)P71%SjEn?4~4DhM-~n{!7rQSCdWsD}~=E7kWcY|wAwp?Kg; z)POOxpOu-3Q4w|H+sHrf=EcheB^bkfK#%O9{}e}tO9@SA@9FYqCiyon;qRKYm&2sS z2SkNcnlGeg2NKx~UJoJuhqj>J)2_p#mjLsVw|w*Yf8GOh5{sB$iSB=!c>e&OK=k|Q zHDN!L=Es$_AM&qP)W|CyPkRF^kF}2E@OCjqQJ1I#cJU_i@?M=)RiNagZ}$7!B%Wbn zk13YARLGDwpVSv@51WZuSXY_+Fn#eA5g&gI1;i*(&=2dYoP)gr-&*W}fX|2i2}tA4 zp0=o7G0$SKG5y011G|DTD%eqVAVa3SZwYU`51fckVclNw-i?kn{KB5W^wuJ zN@aLpYi`9xHTxj#pz&b5$t?xeEx2tMvX?J*yVV(R zK+vxl#1zCP)Q&GZ!KA7W&OAC&aJ?!pMw=NdfNjdX>Cr}L$tgt?cthL+m;C!dLcZWj zvV&Citc#R4wL@7+VeR8Z-1lBU;~iF$zq6)6-Ojd!n8T%A%Vh47gw8FX_=y=!((1=Y zU4q4*^qVyDJmz0spgfRPMP+_JdS^ueF3mwP4i)Hw{anw@4Y?Db_t_cE60}=R2QnIl zQiQ#FE{YNu706#(smamDD;5e0j#9k!biU4P0eG^rEf%Bw38mwf;Eeu0hJMPe5n9H) zcK-LxDh}2?AurqOkqk0Ez#hx@ zaj?AEd$TXDtc*jK(}rAQ{rVoT+vu&W0ZQvXNjkPIhtms5;1(l5hX~Hd!mjH{?P2yt}5XiEI_R&khp;JDX~9Io15~yLD}1g?J4(#*^nsIyCZw_^xj(*`}Q#)oAZwwf^TmnGy>W-iiLcbYfr69fQs~(ljGh{gFiS?x%TE5ByW|n@V+rHk&R)|h1ja3-Kr+qH z25B)jRojHygFNT3cH$hy2jbFnC-wWaIuO)9Ok_AL1q1|)l$GI^LYuBBXB%Da2RU0D z4UeB%mqi$_gw~8r);I!Sk)V4Vrlj2WDtfCpjp{HBeQXcY2qOMn)KVAOoKj)BU7B~e zx8AKl0qd~Q1E900F~*vuAAvNa(z=z_y(>Wg#PHZx;9KSbABFR`epl>;K-~M809zRC%x8<#(gw;glvM35>NfumyeFkszADx zT3RUkV6-Hy0{d{3f&y3Hx|ZKTU)(?ZE>*zrWwFr$m~|6Hblca0BVK|R-y{RHl;QzE zX2Vpj>#@*s4Fo7GT@R)57M8VgBq$y)Ro?@F8Gm|3@77}cE;f4o?iT>su8W)7;99co zOjM6CAmsLaIPInK{F4I=kbyuKcqv5pObfv?DrqBH{~CJ@HMFHZGaSa80aOtBcXpOH zJGz({)E)HTBtt@fs~6q$bhV5umefky|F2pwT8$@04VGZzyi@R`KLEDBpPWw$^}p*3 zF3j{^f0xW1S}fJS#FYOzo9zJ;!Nw|C;k%K@lK)@_bGOr6t( zHWX%TRug3iH>!kP9T5FF|K$GYa+~w9U~LWF8Q&7x^tCh)rSENUO|Ttie-J#0hZk}C zOGb&0Y@(hbE}&mkeJ>YDoRpjku4$m4QH#)bt?LyOXo7LHh0L4Oe8<#gK}SKTH(CXU?=(L2aiD{+oX0HY`j*XgIXCXe zKhRspCDw%-A(kqSRoO`7<81P4FdM!Mu4k6*h7S*VOK!O7J$jYT)$$Fi*MAu0JLkdi zf+KU|v1xNPXF>;h+?USiU!F#wpznaunMdXc&yYT|sb=E6j5^W)9dUT|&=0NrDl8|` zI3QwM^i>%FrZH**NnKsx_08!tz@LIHKdb`^S0K;J^#VBhMmQo$<7Lqt^K+DC^-X9s zp1*ua;h_CPH3skTj)+svB%CPx-AgW&!wME|lal-Jh;V=zArl2y43%_qTdDb&Y|Nf+ z=oBbs@K|q0YFC>>Qh4%P%cghueSCbFqO)eJy+)7pngBfV{(htEVWfq+K_y_OcWh=| zme;+>XJ+b2;`R}cCU~sf?!;C59RRE-kL|yP;gmw| zfBsaO++Q7fopvjXikuGtTcmO~cqfKJkdz82Cf)@Y;_$|`znV2Re~Z}DZs-`nC8waG zOrvbbXwIn8$I^LWU*ar`?ZY3599KI_64BI`28sb?tk9$7h8$oRaV981bM+J9R^6yStp3aBd8A+L`rM6 z-h3Y;3K_Ad{autZc{q^t=|&gbV1Wn4Kr25`rc9!)0oLjCS(Q@=UiDmq*S{-<5)avs zJD+#?)M@t{)(fE~-n-4K<-)paTLB#5>9|M+pErczf;VFy zI$KsO0196p=a|l$>Kzr@UB=1!y92F*>i%QYws#$HgL!yfyI;wUmAW@bHpd=C*j8hM zVnDta-H?#nM_b*3p|Pa#F*~&0E1b`IOH1FD{hFWW0Nl=C_v5|eOu50{!|w zDj4C_9TIu*{Q!9ND>Md(lzWLb<}zBgaCSBbFjV9)>BA!;$fTpJNMxK?M> z-tk7;5UE-2c>fx;np#uTmKOD|(O>9zkG^m+<~v0he~DRH!=)H)5**lZc2))Gzm+ls zJk}l>T3()kOPKzgY7#pfLqZdFNWJmAvkYBxy&$vC>j9U{qwd`}y9qR*8TG zgwru=Y3BEuSM)pnwC@QbF6q)*{hpsPk0?#ofgHKCRGqzj84rnrrRwX`Uf=V{=X*cD z`&kp9fZ=z!gYYCeI|wEW2cHD(py^)c+K<;KB_-A^U1i=Mc^!NCJzp(oDrE}Q{%p=I zEWh4jFBiI-HEDbJUG8_gFGG1hZ0Gm1I@)@lVfVE2{8%UK<%&VWsF3QuaYkm>x-|{V z{ZImd{d*=vE5-p2??~>^mn($dFSn6x6i8Nxli6p=b9y5N_MLZ9uW4BT^R5Qa%a>x~|Mz4Lz$I=U zb)a)EC;gW4g?EPIgDCI5wcKPMySODORY?@-}$K&}tbE+1|*ZHWZG-I9|4(yFa?C z?0Y2ziise0mn->~!?)z3%OYdrDUp|jD996_(~EX7Mv>L4-lbOGIwnTksHRGHMpF6f zZWX-8-Q_2U($L|7Q`7z8@{a{Fr~6zIYEt>$JKoCr3+F4dk;?|X59OR3>!3{J#={t| zM^J8gR%7lI#rNg!v2Yc|MT0cScS0l=!!W)k~MgVG$>Bi_@to|=RcLO4>E$!&j}yhMuKq(%IW z76k^sC2iOfxC-P!;D$&RsqF!}EwAgN1wcimk_jyM!=v5P+s9g$+Jr(#CZFF(e160? z4((TNBL@!b3Q|&1JPpj}aaHW8prOxi!tL;q4a^9(Y2YPFaY?;EA4lJl*yCud@hfr> zGjnhNRtUDFg68v6EWG9rJ6o$jrbI}l$Zuajma z2Esl@HhbkDoHLlO>t=}jdPlE2qriv{Ttlbsi#z91wB56g9m=sOzu&z3dGwwvSm5{@ zpRgM7|5O$ysl>#5wz~2W51U*+e-A`FJw6z96j=VJ{0KNF;riq?95(*n4e_@wDdRyV zWeqQWALzawU3f0b+E0%I7V6VP+WJ<^y!!Pe%G^F(9Lc9(%TR(QgodDp#%Wpa&ZIHL zm$5FaF+WulQ#wO)G5~5+u&#ear@sBRtP@vjT_}9aMXk82Y=M2Uq`+b35rpzn1eJ9} zmZdzooqRffBEQd@xw*uHLwV`%q^x{Y^4FJzaqo{0M=vB*g~y%U462$mb2#TFza52} z`zz9Kd+{!{>>g#Rx!DyrasH-IxR&HSag&zPjP$%Ksxy!@p&ScE^n3N0+|O<(;i-OU za#Z+BnCFsDitR<1?^qaVehHHYj17reePtCNQyXTZt!kpmOdt_Y*>KfM z<@eB$C3-XF_c#LpzgUz)V+#xW9voLcp$qj1v1W78s=DN#Y^^>5?yO60OFn;zPxam& zs;YF=ZV!LfpgIN~fH6B=XbjJNnhxVnEr-Djx20OoVXH2ns46OG6d*?BhYcs?Gs-6? zBZyBew*!V@+FfJztUw^7WaQyD2xIQJrRGe77MK)^DDhh&ofFl`eG2OuniM>8jNgJsTHg;^47PM!6$PKfpAO}Q7`w>cN%%o%jz6qmLqPv!v_B)^xzQV%?3 zZ$%(bTl%Q759@rvNeh!n4LNO5$27f`UJKiN^MMQ=Iexl~X`-wT6+zcDBxnnbuoJKp zs3HuzdF#c~!JmBzYr>kB6qONbwH+5SW$*C{I z?zj;d->?zhqj4Ah&W3m15DNY>&I?9jy!b&df@f-q3R>+kUNp6*Y0k{d1dhn#xu)aQ zoL@VQ0?=MtazWRt18v~!11g`pdWYffd)91hfSO={wi}=n!NGfr(@4*}VrE7_sH)4~c4=XI}tu6pg;>|MYvS6U1U6Pnljv0n=;1xuEl@)vCOK>p#H9 zG92gz{^~`|8-*1iWXS0MIhggM0v61p#l!JfP~os|0nVKNEBijCOLwrSiVT8+u!MjC zbE(teNBI~>t9ODJwO8-t3*}WxB-w>L2n4oXF{@rR(GCB)Sr$M((}71;=I06rxNcpY zus+w>Q|;(c*N3DHEtrWm>n~Gn#Mw6sgmZ-Qbk-ITNu>zN`3T^ML>wn9Nr)-@5aAPi z(EE;w@>jezHK1gYxFJL>S6BYQ9xZe(xCm$jxa$YyRGDT|9O6f$2MuV7u&$EWpm$@igyFOuG*z_qubwzt7r4EMIEwyxCo7d3m|jY369j>ncOo zYuX{5`#YKWaB747+QVG61%!A-MMqDY0PEIAWHA0+1zxB6XYoj0T(&VOLsU|s}WxVrVu&(8;NyVl#JG`ohaBw&CLL&jJ3^Jn^( z(e&NfDziaVCE?o=gQv4yrYvAWZlR;&WngDV8wU}O@7DsmmYpvG%C#%1S-(FgYe!|2 zcpCf!Wu>KAZ=gL2Y__fEWlfr8A72B!P=MYt2;&84alt}f(ygj5Vz_Jmg9Y&Rvzu~r zbDJUgHW$D(%b66k8|u9Y0ziz*YbpU}%j2iVjEsy~$4kJ%oAk8+0XmOB=Mtt^1$dQS z(BF%=N?+R!>wkJQeQeOp(8-S9(@xe+7n8{I?Wo@>h{|19FZs=XR~zwwzo!+?l-c_k zLXi3E|2_t&NuJ;A9ZKC-id zkyxhI=8P#TbjC;%v}12W$b($TGECrXw81L7Q*%x`K-h-I=1H8Bh2Vq8^PdpzNq|D| z25P0cF$;89V(l@Dc~)PWT8rCpxJDpCl3$4X8oqppHw)}5>7p(C(2aBR+7KH>;*40I zPb+DXbXTly{skSjbm(UV8Ca||I|pl7X%UH_`4B(>0W60kX&o9L)vT>`z|&bDShOe7 z5+ijvkWT{D^(rSM`FRr%-mMyvOA=N?W^~?Qu7yQLU^Wt++?N!yGAX9l+pl&0jsLz9 zv~Am(*4Y_J(=;%Mx$iHUEOa}VJzaF#_x-+^Vv*^yyYFyPZj<+qi{4vec~dhP)E1@O^;0U{oHXr+p@CL{=d z4e@j2|JJ43++0>uKaBj?pNGi?FKqr@kj@B!7@>Z{2J3?`vB|jq>AEzjK~!zluoal; zh>FOF41$=%k1CauJ(TC$hVOVqf#f0HOd>m9Q{3rSKTNIU&5(BIFx#-D{h)7o<*Z`~ zk&YSM4ZpS?{KJ+G-(r#-S2C?5qbIfLiyk0FKo~kdr;D2|fC;40it>+nQ4-tmn+6S~ z4uTZW1Bwx{rD5zBhIgID=tw^s9ap9b#@6M!F&GI)$uwUTGQ6s#;evItU~7xqHg=Rq zzV0%GHzRe~#pIHo#si&RU8yoD!Cal4^Cox0!_ShE3V`g3*U==Z+8-#tzV*6QEevPS z2&AzOk=EYR6x51#3ac!ip4l($wPm+tW$H@*Jrvo`9rQ&G9g?-8`B5b>?>L#;gmvoK zaeisi2lkzgG%8=E4}Xx$NtX~gSx({_DecQ^Es`-f?swK&2TtD68(dBT8SgPM^Hf+C zUxOpw<*fc0{|a^WkZBNpJ68@{J_FsV!)nun^?IK3*SIbI(kZ zWmN5_$2>zE6ePYgheT!D_u|X`v52kj{i+<`35N`a?RZtGMb0}1#9{+Bm@$;Xd$X=S zzkZ-4Fh)a3$GrC*IwPF-7hT^o@YKAf~cGxiq-CtHSGR_FoGBneTUsn83)4Y^h1!c{%7E~zbqs1v7N>jp!{>HorGe?N2L zfWNoDWB$nS&_1bIJNSPGAA`MO@_M@{b{X6JgjJ_~Vtc*1T>j}9RG2gk&G)h8gG6}? zan3XSH=Uegzlyl?-q%f#Dh;TO&cQ)<@Lzl_yYbPcB4G5J4OvQZCl?}am*if=;{*#-Sac{VPeu_H znKc`p81hpbcrKQAgqWra>oU?&(>IPOKK-3|%Nm|x-1gF**|6HAa=0rp1gum6_Z3}C zS6QqSQ7AAH)68F6zp>R4Q?6?oHVw>2`BfGd7hgGa-V_v6zIFlmJs0qyqWBrg{ZQRd*bg+u>jnrlVl=12Us$f^*Nxix!S zo<$o6o|JZ}u&YZm9ui&Kg{sdv$^aKeRdq6wWYVtQX!o#0qh*8QSPb27`2p#5tqTB4 z+yJy$n61ZA!%n9AgM;ZlpWU+epRo>rf~Ky1x!7=iRKJ1MRbFq_{dejHsNz-dfPoMU z`c}ne%>$sV9zvlRdU{39$dmEq7$G>@AyLwjnLGJv=$Pl@?bDyB#LJjGz3!vQ1@Afw z6ae$-g*H0_T9L7-2_EZdV04lSK+oh^*vGw+U(7;~wb12^iY@{&ur2QuX3YP8@alh& zD>dYy?}74W16_I^n8_q0|5C00MYX6&+;`Sbvzop1737$oy+!R-wMA9rc~CwId?^M+S&255Ij3rlI`Bf(lbf^aEB`1uKcmORjc^kWFe87<((E zBZ#_l6c>``PfHDrn9c2E-QBbyZjmO5S08Xgpj%NI8Ijr^;A!8nHw&9o-eqHlLCMa= zS|fGgQ7t)V5MT1llb3%Bh}^&GHMUU95Wm*j(U8QG1r%u;$CIeG=FGYl8a}C z{1+9pRW8FwEEp~Q`Dbz(Ir8Jy@rX~|{dO8gT3i3}wCWU5m_6EArT3T05l#u?`Wcon6df&=g;!G6QOOY&l)#E{P!H&@t z$%+)8D?icr9*b82I7R>qA!L7yVd=IS$JAP!E%LV}$p%Cz=fCNp1?j8j-h_LbTT-j~ z>v$NQB`dUpFt~+Eir&IiF=f+16+;-R7$%&k6@1pl`CH%iV+=Uv0W2F7XmSd%8x7K1 zlApRiSeQ$}yBa9A2EVdXU*4#tY^MF#W77Qo5xY2@jQjIQb2m|}w?Fs)V5UG3_#0D8 z^YUPL^9ATzF#KfLe=q7XS7h6a*H=1J+Lzz1IU3B&H!gbi`b}61Ba707V2%T`#YHB? zjJy-fjVWq!trN@`b< zd|YxyRb#1R~wIBl;vBPD_Ht9EgmbPh~&l5pl1(@ftnh;e|?_WaDs z@3}duD5Qs{yT(SKzXfz&2=H0mglv^yVmNfJ(!lL(f>^96J-Dtp92FfYI zMQjs$L8H%>f2b{;G1~d5Jn1bDWG-?sUj~!03^+2L#ritu(CmMRBD@u^+!kx63L~Ja zBV}Q!wDF=&IXFCwkTU7(96;W#2V?$VYggHzt~D_PWB>TSKY>n324_olyf zN+nIRvffWk{YVJbjZ}L)Zryh~ZnPebXNX{gkjFeyVTJmLWPJyULvANK1FEXa{Y;(f zi%F$6z*qyyq?1xwbrznQ+L*geOSa36LDmJc8N}BYNStVsHe~6Lj*vEyiJ|Z~?H*PV zdg8ADtmezgjvD71=c>2CO-8#1bG-8M^4#dv>4Jn3m}Y1&L~dMtXUGH`kz;5);Ri$O zQJ36dp8ujb|6;5Ei{zC!0O#x5q*7?UZAM(bN6dx)FDc94t9YSTjDFLA+vLDtv1zm9 zc@(;Gv$4>28YkF#8~1zV;m;nY&mY39@1kck!sT)D6Lc0z^U_svnsOOvBT5?2(5k!R z_}6|}KW`vM)1WbVxxCP z{X}VwU^31M&Tjh+abJR&O<^pBTSASMImT@wD^`y<7fRLOy}E* z`$jy2JQ4fHX6N0BG~32qAkwAfv}dc@VwhCW^;KD|S-%%nBQ4`-f}imk(5s+ekpq4B ze#nb`1K-_l08+~5O-bEL-7eVf>FJrMQjvdhjPnjKEbF|Ctsd>Od-fu35nWyxcQAhr zc>K%6b=W}e19WSijrZ^0zXu3n4Tfu-MXH6bB8#yRA39ajI0qTryhu+{5SXz2eVMrB`cJhZ#4 zTIe|+<4TT3>WVSH7cLXSCKT9@D{D2rLIRq;jkfASYMHglM~E2^SR>N&avFqlX^kQmaH2 zE3!T`!&MNFbYc!`i_;;Iz||pg3(f5rF_CJRhoFq3e|;&GH1e?le+QHH(>gzP71L{! zcmsB`?c54Iy|oe+m-n9!>ViXCls+b_b87Ljp*RytsH5%zVM8fA$#swYi z?nhnrw(HX4v9Pg8u}&Rd^M2-y$K2z^Mt_NFVU5;<)Q&^j1%H8zlpu+{laIVANY&?w zC4iA;NC_9k&ybkWenjihH=aDZ;}qBdh<_ZtVLJ^V5GYKt(@8iYj2MNa0fM>N9~3b7 z4a5=NOu(N9Oq4ls05g9hh^mQ{6a#M7{(%rc*joD~Gm^dwOdZcA;-W8t#0U*g1Ki}! zZoT7V^9o?*6J25Qgq#hR(>fZZx*q4@FVA;IGq`+pftWT2hpyvu9YS7wo@@Vo51@Cx zzdH6?3s(47sF6su_0Gujw8o5!Oq5l!AfQ^1!YF{G0nXw^I&UiPWYQgYXKDIPCtO;z z{4V;&=|FqsE4;<~BKMSuH;MCsM!@;Y3c<1!@{qeG3CNuRzWmK6jXLrC~2&^)*-m|t8J-i`JM_|s8@US-TT75ZFn|4Q@UaTPsZ zU~hw`T)A6df<0Z$Whg-fD_`9Ezq40cl{JPp2xyKXaLV}f(E0<8enYxFJl?ylDUn{qqPb1~`qmR{{GY>nhd!qL*{d&JW zT0F}QGJZa^ZBCI~yheYEDZjHO{l zyz(zL;$ty-=n-jBzWR)@otwPH#||``kRv*^Nv$#bDgo-wF^u4%vV0x1Vee8jsYUdY z-A%&R-ONpm+7j}mFekV9onA5hROHuBQ(VZ4kr0XN>uV-FIo&2hW@7YU1m7$mb?b8w z5BB_@0Bu5CY%;?nDdKMk+cX`wiX8du#%d-WQkP|e)@pi)sH$mjpGGlcXxNGaL!=B_ z39CnUZYnra;=U;4tE4!mPF-o7lT3FKtl)scHE-ib_04q&%!*0_uLbE-gGP6NSa(}1 zD;rx|U@slVU{e#dn(Ze&-d|r|U75$8;~@}5r?3OmGYKbMLCfAMplAmiszvRV7lC|? z;S519-9dhLZ;>oaItez@Eqo9tBSR0E=q^>c>^qH?daa-KW_j%G0tp&45e8~%K$LY} zw|vr~_xUge5&K`&z1*j;;5bv&PVdvI@ZNP02<_rC(F`!b()avy4#+JY2L?H_1Y8D= zv0uG@&8b^+Q}B|HQ`_;^W|3-mI30qur0N72x&XGj(K|9-D$Hbc(*M^1{ii6xzYr@jX8n3? z7VshQX+7J7ZUs30glV-XhZ^D14Doi57_-B9f|S!x%%t+)$Z3qr1`Y=`6FZmje2b)= z3mT^ocL>12-=Y3Ye7i2&N2)`Vyi8ood2K>6o0o?aH{WNK1Z*_zF_eM%SW+Z7jgA^+ zo>jFJ(;1IJZSL<HqbH}QVbFURg`itaZEdgdhaq&23kfb> zfp;cis74F0(H(3=1YbY6VchC8PhM7X?pt!AqYtac_S=Li&5( zmJBV!@bHFhz0vL7YqzpSli#`B7<**WNC-6MIym_9s8xA1P5o4~Y=)={Z;yLC2~35E z+@n4b$6_XLRB^Yzx3?HmW+*z7x+Lw2Q2wDwZ)-w&w#BM6iC(fav}%HVk>)DKhWW+AK8=V}IT z^zklkhw`=?AN&8~>aD}7irQ}PO?P)ncXxM-fTW~!r*wBqcZW1c$|gkEba#W4v~)|! zxA1+Q^S$T%i*Q}So@=gqjyc9}1l;X!!2j(Ed>rh0?B0Mbc)l%w3H-a(_3$&{h2Z>4 zdBD>Z0lD9w(vRS~W~eooyw*XGeS@5bht#@Zcw_m>1^qcTDYcLwsl>jQXe8 z&6{iMmH(xq0(hwe5eNOsGG|~`DIW{Yw=+}RC?|5#%GV15=vbA#LMgQ^r%t&jo7aZNsd_jj7Gd< zluK|`Si*;_^zpFD!7OUH-&jRbWPeb=tG!FK7hpk?3j&vP!?G%fCDYQ@&l4%3$Kha6 z$6SU)kzzts40v@^`$*zVC|T)KoYYAi&^i^yNAJF5D(%SjX!~mwwy|-P&FI9q5i&)W z`miF(0wT>CO0 zYJY2c$;CMA;5vq8~sEpcWQ>jC36q{v6us=PIl> z5&s&&8O(~%YJ7np)UhbI1rs6ujey&)&8?Kk_?9;?da4B??qI{Mxk}fQuVi24^)X-` z6HYdK-7xB}&Q**TrwzX+oIu69liHowi_W?VC6R+>jOf0T3c^uYx9j!eII6z1X@~;8 z$F;ye*KDtV1t5Sx%i(-ImJhD|vR_=z<@nq7$B%69*JNczDAbd+r(ip$|LY>H3lEM) z+m=TSoG3Yw-?5-6zxZK+>i5Er@mXLYECPx8A#N6Ug||u(N$5dU`jqTjD3K5QQe>25 zOhD^Lc4viGNi8t)@M}F^X!gHBE=N{OWOmE4)T^5SkvCH1F>D)e^(li;scr=g$XtGp z`q7#Uo=I25z$P14fCv$=EL35G6&XesX8NEYl#;sL1JZIW61@rVh_J@{L5LC=#>OA4 zanrP2C7>jjL2^)+=vyr|n$1qOZ@r|X>zRJRl9k+AP;lc=yrcO>lX}0BmOWwTkh))WIh(ng29uWC~7wQE-o5I=2H7Dq+ zK?`0i2?(#sa4;&;lU_&}Y9+c4NLJ205k$#7Iuu!SML&qU9r*R0B2N*fHe;II8%&tk zH4{4NhV0b^zhvoc*2KC5GUb6qfm|85LCcu^nEu<@`L{Fv6n)cRa(SXt1^7XmgJB%) zBFoBh)^eunKZ0>Nd0{ov`}VyphjRtT!s`uP+&)722cxZrEjIX;AO5Xnj$9~JuV~Fc zr@;M-CIlfLeQr8$@mxxce_Lhdx96KrrQ?0t)oyyu->WDEiaVwcFm)9{whY7~ZIYQGPy#%$=^<$oVvJt*0@f?#0M%Ou4e+$jg zUpkFXo@GxzqB%G4RCd$amAUJ5EF?sU=z6ZSiY3Q?3uohNxBvhs_mQ`LNmPS!>chR- zSVEZ;y0HlQJ%X@Rfu)U(MLE)DIC~TYD^v=RkReHPv@EB{ufYt2GzC^Eq=aeMA%qmX zu5dlj%cc&Gx)%__QrUb5cDu zZap`idED<<6fmZ(!QxxG*#^L`6Gyog%i_!s_RW6?Z0|N!75yHFT3k|9eng<)(e0zolF`uia}n z8IOPC)2c(uy4!YKlO>=bSSr64u-CujHxzNB+$ka=Bvi;1S@yYJYKV`IcM}3U9Nhj7 zpI<%LyH#4ujz$e$!@mHd6(3(bD?9Vm{S&m^^mwD*8b%sxD&GjOfe}Urw~R*$4*~Cj(!hY0UquI5 zwXcdQL3JkLFrdRz7yY_4jgC<$Y1AyLX>I)0TKo+t_C-vKz=HS6g@1r=$7m^HmhAtp z7I1YJL{t zq33g=r`qWBR22vsTqQ3csVQv3Z83<0?_DaZZRYQ6Yi9lXV6#Gf&ixRz| z0w(Q|3p5+9`I9%i{VIB7Prs~ex5U(y3=jU%;R&EIBoC+VdwV$ticR6GSW`Gb76dkW z>*(6Is(*Lq6D{J)Y#p;b!F^nyF(gkHR+l%Omo>ebbNbzTgFY) zHTluNfK+N>VVcB^W9dU(0aRA~fUMdgycOI}zD9~$8Wx=;i3ocF%=CTT{CZmwVth8k zkZ%t?_pt-_gOx%F*dC=aByP|}U|NJx3D zOHjF#Z6C+yeGj-=-S~Ty@NyH0A##4s9?)Phnhq$e$&HPSvt?^8gL!AZ8;^fi&tCqn zdDdoed4H1iCOY+Eq#YXZb-d;68M9qPvQGw#Q;r+pujSvF)ip%|e)b`4(=F)e5JXBV zrw&(~wOIailLO1(XiL@8AX0@rRk7H_HC}(I3==`Luw+0>xA;e4=wO3a{!*P3T>C-Z zspHBvw=Jvbb#x%(O7xhea}6Xj@@z8i1rb8N2wMNcHnCcO1Rr@(7C#G^yHVUG1>P^I zj(eWUo9{n38&OvG(e-KfMUc9Pd9oRVI*0)K@4uav4yFgWGP+X4pO2`{9*R}LHm&N7 zQMjJDLssht-bu#qR8~pOBUfO~S}Az1rChSu*idZ^hr%|o57+}3C__OO(&iN+YGe7v z!y@>UbQrrN*M(6`GA22Oh!yiDQJR?2)YGel^6)4$rEV+HggJ}YJ!j_?BarK|UoJOA zF{bnfW|nwGyzP@x98k+4A|?{v$p~Xtl+0r;P_J8RWN&|xf`CDv{mtE-r@Lpt(eL>F zNA6nYu%R;Ae#GMMGU0r+660g{x_Fto8;PPZ77PbMbYa}~i|&sPK7eQ{y10yYl)$aq zt;T>Ijf)rVusUE#O`VYdbzVtvT{`WFJc7T3BjjC&N_q7pl<@`?Z$6Hg*Ztu-92h$@ zTk6}qbHy=%yD77~)3sMhwh--WM6kn~`|@HHFo%3GU#vA8s&iWZMx=$f3=9^d>|JgL zo86u3oyE@YUEhvA-Ct!eW_1~NU)yK^hA9B&yN3Z=Z*YtZxe_6G5y5b^wDj_MEC$ed zgm(wi)X_4<-)j7x{vffAzgyynXAQhi*|ek&lfYrYA}cC|w=bL;Xlw}Z+RWf12F)bL zKLvy0ItB?})p=Tu!ZT*miVoZFMO(|s_ND0BPNeGF@euolQY_W}$CT&1=?Qs$QDl+Z zo)!6mKv)6qTN(MY!+fs{vU<=*$Ue#+s8tuxXwN(8fc2ynv<@6!sdXoVTxXOE3IRn# zFB(KoL^xgneemudQpg+BKcSL=6hnM4(=yiztCX89>>>}Fv*P{-%LU@!cab)oAG=d? z;OEBE5UG!Dw&Y4AoWKSvUu&ESzY8y1SWfo;BWV5$#|-`SyapQ%puf|3{Ux-zLHT`# zcvn0rJg5eCs2%%dzU<#HJ!Sey?ex>P`XQu+$>L2p%|t==84#k*%t= zvI#$wupz%#Z#hkar*+_9l)bW*XwsAou+Zo}$*4#L*ctdcUuGH`hKu9Yv!Eon8zyfl zc%fJ(2d3vM`YI6DyHX(L=S$f!kW7ZSGghZ*#G^`Y9@fI*_|VCPxm9_a1oljvD7R70 z<6=>*j^H4PP+^6=|>yo{q;84K#c&VX@jBHqj2@{c_Y9ux$ zHc1zUY`$$Zh+3`kfsPWpPi{mpOQuE+0PzKe#Q6FL0fO*H)}2q9qkWb#CqbeSv(* zSrB{Zp_Z`QzliUg{SoabYz?I{wU0s7Pwwm|1_6^{r-!r?Z7yshZy|TkqNp->^`t=d zD_G`4c9!t@Wa~VE!Z6q3vuzt)A?q>TMH@y6jSe5Q|4r*-#CaD{Fg!Z}`!K~%i1E3QZ;OBaw z!?ADYM>Plveu!y*WXo-n{{m=mFQ?*Ne-HrLO@F}$O8~+!O6aw}zY!PqoGz{3faFRT zFtE@MPkzd4@%lg@TLqnH?uaGdd)<8Xu^F=R8iA1x)=XQt?+~Mk2ISn+Yq36~)LE-1 ztH0vR4I1qHh7ZCk91lz;K^L%EPzmb0W-8tl^z9sp=+Ghwe)wAQxdYjdr3Yj8(#Zzc zHgffw&u_@imDAuPQ-)Y|MP20-Z>oi;i9XkNXByN1w`k>(akto?rN%` zKOu++;#l=u5W6V8i-`Z!fQ5-fA!SGC<7swf^0$hCES#_EGV!Z|@%{7j^NB+_(7C(3 zOk2<_OGTTOmR4GtBmq&i_pX8pL1Pn#@ml+JX}uY;6)yIR@VAAdzP5|v@B!zS^2to_ z(|`p~57lwMr@uF@&*3xnVUR%JHOa791OyUAUV5&8uD1_e zNOaP^h(UNDAy~leJ26B1HUsEm0z9w)p;67ykPI0y?oDfnlJgw}XTD-SbU1fQO_(yO|xM_UU+ z4`ajs)P7Wx&1{xyG&ifC*Su{!O$@KABijm?TwTZOrdg(d?_n?pO|B!y4!mCe0#@h0 z=kXYeI&)p_+gVBf^a*=C`?bZz6q|o=_#dcV!Ig)B zbz@Vzwwb%ByGVroij|+~jL2pUon*~y%mdpkJIn*|r~&%$TsnQ2!+V;co6?Ce&hEVk znzjpcpMbxA8{Yv$pAZ&SrCLxe2g90cy0S5V26a1}^#{ zAP#@yY5B%8pHPJoE_E>HYIqC_dc-knB8@NofDa>6dj!yi%mx!2Ry!SfA8zV8E|orh z{P^zO$>*#ltBG6yO9T*l3Hgna<93SBOt1M!&!N2Qs3`TBzBRGAzFu(xE_ttaP#Fus z9rM+_z4~u9z1bYmf}9P5F}S@36Zu(4&I*y^PeA*odBUr=pn~W1f|msLz#Z<5&cL;J zt6=c@f`lyMIbNi{tcfrtLGp9PKVX?BzIbCyWY<>AI)?D35*Z-pm&VY^g9XOSMbimfD?qm%^oqSAiAp^#iqJ zR4kL}*}q#-%9LFEkKzfVaCuTjn#yzY<5q9ILZaG+hRH20y`b z!{gEvsP_)Rpj}b86;mLF4!xgt$9FD_tC|Wm9R_7{aPu&sU7{+SMgh#`xYufkcvi+} zz%)BsuJT3zXr}x7tl&_l&X|}^tI}9yeOq!XhrfknurBGvWVT5)x@DuGg||{6_1V(} zE1%v-bcBS?dKZaSz`JV4yGlgUBog$Azmk;06dpS|<*S3R=b4!Extf)g4qXZ(Bcq+I zBl+SCcC?InNyn^D6&_I=&;e0rSfR>R0kO+gU=mnv^beM>(e>^4`Z!T+7DdD+9eX-)iPO?4@|P?m(6Cg^bKnr$usKME|P=l+>^~88XaH+2Vm({3?Hw z)Us@@^R)n9!QNTF{L|I?F-JgR+aZeNF1S|{i$hEJ!*JoP=R4`thjOD>q3M`isy2X(9M13@QcOrNZ`2cMoqw!e4ex{O=w6 zLeAOGk41}v%65n_OBFAFHo7!FzUS}rbtf1*MuSotCmkcyJ=)(Zu|495HD;dpPtOfXme~ebYRFxtF9_l zz5lJ&CpW=Hdq#|n8dM`YVP#B~?K*qCFT@>(?o|I#d3v~p(1cPriqRrbY7}UjW zH8sq(E8gEgbrqaL_m;s!Mss|^Ys)9*_mWWt`V-5q&|wugF5l@=gdy?WmciD-*P`ls zFpySN*X4;hjG*ze{Me2cKJDDk_g%jb1}@wleLze@8{W_cKVJRdQ#_! zTa0`IaIKnLF6W!wXe0~N2wfNV`*+97{+Bz^P5TpaU#35>;3o{GL|OcIcOcwoAe(riBN6W+J%xmDLJeaGS1jv{A!N+We6ebapZ9hAprF zVBVAb`?&}b~@?~FVRH7)@QK{hbs9vA^d+>8qA7Q!4=88GROYqr>LB4JpaB{OUmg)_2 z$_5tq{o-MJfr-DYDBtg}h>Jy09BLfnj)>suSSWT5G2OdxnO@Hsi!-bV2B|6|`m@pj z)zCHsJ82!-+$!ZmVH~s00>Vcab{gefl0l-cm&Jb7SJ25Kh}uw571)qk(AKm(A}gV*p>YdI)w{_7*c7F*O}do*ss{yTl#EhU zRW-?Ae4dC+571tUKT*)+b`b)28rcjxtS@@tQ1&Nt#GTkKADA86EY^F%zYOwW>jU5J zb@%i8i2NWT_OqkW0#p#-zo<0kjm#wdpgv*XyPsmwjN zhWBt&*Vl_A#|==o#Z3b1sFeanB-wz&ryiPJf#L-^0!RD$MB(7m>oSGDNWCno4$NRu~V=cKVQz1oJQp^W3iIwGD1!zI&O+1SEiQV1U7V23FYb0~sQ<%89Q9FYQV?OlH z+TmKN8_!sIn?{LA_r`tAnlOKOd`v2Ga&QoL-aMZ${BT#>E1z(Cdm|VSXz2g^=VCjk zswxIJ!d()mo&QRz#y8)2XA*?sV}Xln{2A24OQSof6lJo#f~e}14>8flMTZ|5rMhX> zGLxH+b0Flk)byA3H#B@VUa_*`vaP?zp4nwRJfrKn$J_iFBfJN|dOhB~0gz$6PH7_d zJ1MVY2Y_wL58Q&IIX3CNBAmHN5WPL-o;>~8S-m}`J9x2}U@I{ZF#U@*mViBoD0)8_Q#Yh6b z&IT`bKk;%uwNKvRx|Q8x2W@H43*msLDni7*T}ofdO;Wq4*VE`|B;sh|ex;9wY3)T2 zSTc#HRz2qQWuBq)Jnu_fO=7=$K@!$_xp$k7Tq`c^sq{e;&7NXTgG!egLzW4($_zo0 z2w=#0lH*ECGPWq5!Bn+H{F?pId;7RCLg?d^DzxrM>e29->b_6X7PL1l+@VZHDjAYL z$W0Q9kQc#pq&Ya z!!lUtI97@8r+P>}VW`axJlSDQacSc&X1cbX{y|@3eU$P*?9HJ5s>;byv(;KeoT}*6 z^&Y`T5ugRRt!aO$k>oWBk;CtM`x%JWY!#d8)YI?$7Cm;jw_^5UC@8yrAjE826h1Xu zCP&T+|C;nwgZ{)M*PPRrDi_LtYWd4CzNp?9*y542U}GP_Pe3?8UI_3$nrlk;e$L{~ z*l?ST1Jc?0^@zidzkWsi#9n$4_H= zha*Pe2yrd)AQSkyP^j5H;D7p`Ab7A{1k_QsZnQ2?-$ZO&*jap9w{b_H>UYQ=_}tkg z^EUt(BNP}uq1BACY%!UF^wCMqCc>*5kOD8-3aCvI9K7+yH~AS*_uB=$ zy_8uWO{4~qMKl`|B3Kbu)58K{Vox2Yo-|Fp$sx6)Y~&ARj44=NA3ncViaCBiAFOB= zyZ0`JC10=IS?O?Wuej{DgbSoGNuByr^lp=xsi3#Wg)H&gL;ECipSKeyzYwgdr}diE ztUgp#+vf?QS%4~Gl3xO$P)rBkFx0U)+1Ob-s>!aUx-`5CyWhL2$K=BKB`?`K{A1Bp&PJKP2QrAOC1isQ|# zXX-6TQ7-~InGnbyQw~avG+r73-Ii3_HFRRjIDkTM{>LNw@%nd+YTx@^)|`3>@SFjZ z4pHB`0zp^4?U>7vs^Jv+NdVQjcWnb8$mIeS8~yX^cRXn2R>(n4(nP$ai<3cqa40+( zRw1UQbjiS5_(RRChCNnqEESdU5cF(eq4H}5gg)p}nX4&GSY3#ErdBN?BdJA<3LFy? zOOlh5&Su#DX})S6n7wbnX|WkBK=hL7@?y-xN%h+g06$4B4_wx?05d-hy}mGDvCN2} zws`k$oZE83;8QP}XN^(Q-UKf{+ebc!->J#JOx@Z0m0(@|uJ?yYL1hvaUdgt<=SC!F z>MMcJJgfE5EFxOGuDFgH)FFaGKfF&Li_Z*R+Q6r`@4n|FsN{<({&6->*O zO&*_1(OU+kK`2a-wDnfQ^(sh0%D;h|%TS_fEx>+a67}Z_eK;`er(Q?`KRy0vdI~!S z5A?46f$;vc7;zGXv6KM@h1WUxb%_H#g*7d{(3XC{*Ng>VlGGZMvj6sX`R_#nYrNsz z$2H@BnlB{1cR+fAJO@+X<B7jH5wRdw86z1{%-q5flVGv~7r8%;CIlyWEqa-yMP7j%2}MsnzlM>WEL|<>x~jq&?TD zyjtzM!*$uoSkJIN+&{zv*e zz3SpqlWi0(F}u-~eLhEn78h!s5&7Az|7rnpetT^z~>Lz|m#fIAU`Mz`Ug-c32#gEC4=iLg)-xQw-hctb-L=0RDFL+&Fop`b@>XnvG? zLoIxnMr|^{yDXWD7yC69sR{KQA!O+{@POp;`tAWNo33ZzOxbb7Rr257lyP_tS8XTbq( zvlOT!cQE((KRd&JS7HYmY9=L+778Bil@PPyE)m{2#!EbtWJ9QQu}-t2*-2Ik@z8kZ zys}g*xJ~U!FJs9oKfoUbUU$^S;zcr|XeJc!FBHe;eG37~}ez1~SHSXgEC$E>fpu&8lH``{OsPcIoJFoN2BKVX>-kDqTQf zT+r{ouw)X?6rqoYER0Apamq={fZyk#U1W!t26dxz63#`OU_>xgSLbBs041<|t_w9b zvz9#k3Ds4#CZm#K-=F6WVGu9?Qj62z#F{WLk9L|Z0@;<8v!ynN$9M9g)&bKD};1h!Gc!yO_`?XI0RS%ooa*N)l*6@LNvhN(O2LM#? z_wez0)7{k87a*k>JE#$C+*;ci(9;*qZDo4HU9OTR0ssq=XVeETkdj##XTCw3om;OS z0AweBsBaUK5)gI$Z0BJ@Dx53)KJeF~p|T~#y`lGbjEqEp|A8eGpDPgKb}xmRI{IT*^1rj>Ax9-aN}P>v)-1Wk*v@CJ=GN%5QfYS7D7O5oEU z9kGWNj3@U2MVMpij}VMa4fHm0khC;Vnp7iUuL#_Yp*Z`cN1T{Zh}|GS5r$xls2!pj8m1ZyI{i#ML#yDWd$4t3Gdh}> z(EuYxf6x*FCU6ce5P2>r_`9zP^*BKat8(YQsf}FGS(@CU1HQ~=_xU1y!eN|GtW4mb zC|BzU|0;L=J7NLK?+;F6Lnjozqt~L>|FeoEWFo>uV16ajZuAlAFv5(bA~5Q+hNY!v zmDeP3!-bT@UO*N!RISPOvZI^^4$eme@w%F79&W?6P|DL)h;bY%Zqx9DWub%*%p!;A z(i8{|6#S#fF}zLe(5MkaZfFziRsXP{-`D)A*Ln|xzAxiQa56;&KW%r!d{1BOboD+V zyJ8|xiQ-v=rJ@8AP1X+MSr2}~tU-`Q3PHS94U-=niI9>!q*MYkxfQ~ks1uhSXQP;X zV|~UQ|PNBGkZQ2EPnVcLF0yYEZBZro?Pe%@KGuR3Q%`DB#T)n-+9dw@? zygWSt5sA#Sk_*@4L4n-GGM zK`E21XK9Lv>#~jz8hs^^L}A*u3X5e%O`Y9jieqN0OM$zo+4U za^cQo9LDMYKi;i64C;s}t30dhe{GY%bCVog?Vvt1?W<%!o7GV=o8_jWUdfZl+?~Kc zls_!-g;xlw7F?QhU-dLMC4xqsM2#a5Lu%7YNF02P=ht2-F%$X3ml1I?o%RHyliBy~` zgO`^V=+)4mQ_ThtJ3z8^Zz6}=Zea}2D&J)bRF+y8jJCS4DrM*7?6wi@YI0CM?MfVZ=28Un9C1ZzSnIHaF))UeOlu#Exx zR!Pb3=y!H@&T9>x`$#Gf$WY1Qa-S(t+#aAb5wB`*=ORTH28VhrQcr5EaU>m!S7*Ja z^M?HEmJDXjhVnyi!`+mQHHt{=vSMg2HmoD{gFA@6L8pOFuB6#+LWi)3U{I(cSU#pZ zxI{vigg?Rn1RA@fz+Xc*B7VlIrE8k)0v|Mq*(#a7=5m_Ab2zPp^fx(y@W=Jqt@o1K zH1-nSaAQ@l)G?x`K|7A5K@bAq1oi2lcdTUZw-hF&eKuiJ3rZ!`IXEdZmoIfeAzJ*W z>7jKXc@;8`a0CitfvBtxw}t3DB5$fJVf#gvoWpFlxlj!P;FS~pvrPhnqbqQHVa*1@ty%)T;XK0j}A|F`6}S*5*q?7AXf-1<8jwcb{( zyJ(DmcdYVXeCK}0^0m!Oa=x*$bao&@F6}V}CVhM;ta5NQ6Tys1fXDc)IZ8zutviE0 zqgF@e%-14z+%Tq1spiGzkC#2_QJK-r_po0?JoCKtB!_1 zom#cdMuX+JEh5i^=Xx7mEbq*RCc0OkpD1ay4z<|gc|7ld{I6Ty%d>dtUKsz!Y+>Ua z5J{qm=TL+$3}29f@}7tCVZ5;8n~+t?YR8Yfu+78M zi}*X5yX8ohn?(|HMk0G9;>KoCLM$mu=uE*hQCkfnCFkM0NN?-keO}F+lL2EoH!9Dg z{8S?9?!(vh;_5vJ=HTmw=-Ls2>@6$J$;{Z0&p{>rx~!|CN+U69=w4xop6>~=gN3Qx(^y?dC$+=drTZkwx&l70;xQj33lp>QraQDeaea?rv7Z-OY2T%hjT z)4sa39#ad;c7W4LPlZ^{IMJJW9qC}q&vgSR(ufq&!r$64+sZG->-x_cLeAC#&JY6j zdm)c!e?iV?Cuh21cb3LI2gCCg%@TA;6I6L4B^rce{6rFHrHl<u{4B=K8L1s z5(`)&J6De&7V*5~F65`!eR5bM!ShdY<{(QjD@pwlw7Etr$aKl4=8BhrqD-nD8!%F~ z+7fZ#{7*gHtNI#+`hnW#UvA<*O2R*RmdRrC$!43fVBfozx#?KIB1yEHH5erDQyE^b zcih}zalCNh#=zPw)XEvrS<6YUH}G%afU5;J1pwQ6{@g5KRS<=q)8)s&T#%W}gULE% zi7ce8xKzvJLOk-Uu%$|jmphG{FV5CsEp?F`-T#&jk3G+z1J_6C0xc!RDF{uO(WuI~ zH+2Y2LkV9sdaQsjYZg0SNJylUa?XH+&~Ueo`A!R0xF2futk=wtZUmYH| zhRKPC+;MNZ9s#upb&YAR6M6&ERXX<)Ih(jk;f+8~bvGKMKZgneRq7fdeBGkD=OJ61 ze1nLKhnJ?4x;Q@%@LZ;5W_WC*Mt2mq%T&n4EoQ$FfC?_Zz9~J2*x!e17;vGu!eDXVNd^k^8`+F9|j0{mq*DHm|eU6e$7o>o# zA_OAkPb~`4o9myqQp>JjvG(sjznZ`Ps}vpOKsEXN=V;vuel-W!9r|F01dxKhA{ zj}Uz%7!sjqV|UZ|Fq4=SDv6N`deR$3pF*|>t&W7+L2YV1&()>P{Wc=kfNMI(krb9l zM3?0%wa1u3IhnSe*o!B7yQK6o7qOXJHWiXS_3L7b)?1lDPlU3=x})#ch3s2gV-{=1 z>~&20T2xJf)lw+*CG+aI$gp(!p||vUX*ksu5eJQc7M_5P4yHhuoK{j`pe!y4aYN-=EOtY+>4K+es_x7&9trD|9NV zD-$ce*3?y2)qSn04t}qzqc4bDQB7JprKXc7>py;wG*y`Q#a2F{%BG^O_A3)RGb?%| zG}r=*@9R_O{!%MPGz379o+iblhDK+7HgJT8ojE|JX&XG%NUXD=Jo^v|k9D9U@yS4{ zxo52mvzDGKpC8q^9r_MsnX!+EI=(^+71yRaY>3LOq0;iJRlsZ9}}K0rgs z_nx)*iy;j*unwB8V%vMuY$1EVS%^rK7)F1Nv_V9zlBl?e0!fC(j%E7nV7#ShhCzn9GuQY>CpW6l>IpQVHs&pv3H05lOPgLr=Oh;D2bQq!M_T- z7&a}vFTj2O)dCDa(~S+7?KCSS%2pwbi}9BMDaiH97QAqZZ<&*0uKWH zPkRIJ2+m$Udb0i@+bpwb8MDbo7<0@=V99HL5^ed3;P-1i;blA$h%R2-1rWF10jWhE zWmvzRQPh$(8R9c1Wcy|O#EFubVc0}PbI=x&04)ocWngWyeH>0c!TuCH}hl4X^;pOdrb_&Hp(H{YP5j zfj_sNRELI1m@SFMp6p1NDU8{ z1WN&w)f(0#$0W8uM1jI|1UW`G)_N*^GU$Saa5Q-`fKw~zvX}%zZl8IAe+pe- zBhb(n@)4cMNdnqH3FGK3Bs-7Ar4fDdYImc**{FTvpJbupEWTk6+b z=b{!;^WN_ET+b~w9~-i4{M>0$Or z^{+>-ml#UE5Ks?I!S3Kk4&#!Mbov_H7@VXf+PhIjsd% z>ugwMhkU+f?}t9l6uC5h&^#^guI#Ra?t4KsqDoQ?Zum?dk6+*RDj|KGIKk~z55;P* z^-I_ah1=t3XCH|1lVqKJ)Wgnc6o&ZyJBERF_$}!SSTo6Z2SJa(K-`1hMhFbteZ+HCCpySe@6)B?b!#=LMDaP+_qyM?Ug4LtT)Tc1j_ zbs21iN(j++`-mjWbhmbVr6--1AI2pB1N^gb+64``UezlSH!HIVJ(qSzvV?~l=Pji+ zMiyKfdF5Gj@{!W&P1yc>bSZz|{1S4v3lyP2EX|ClY+Ly$XY@)Jvts4rU{v(4}WH>h~!IM`bFIFMsr>e7$CU6|kJsxFDB~ z0S#>^wD*Ww$yIe-7z$-6pFNe{cD~uvz+9KxiKKKWQUe`Ib1o)HkhZ@+6Z3+(e1uLa zMpV<#hDi;3J6~{*XzD{8MF!QLVh@Lmdkz;gqji_h-L@MYS*(2U=ZtAJN`;4`ikgXx2JMrOe+97y<*1n!|5K%T zl>BP%VPpTU`|W=X48O7%EnZnNdG(DcgA`4ukSysewc)5{BIeE+>tzK}+=gw_%4njD(<$%;z4{a^ zd}Of4$cLG%6LjUVg+_w2-tXjo@QN81I(wV$POc_fVIc!GX3Zq2zWbIV3W*AFGN^P| zZ!;NvcR~y+g7wF5V>~6@uPeW7+UzCeBDf0cXlBqT7NDIz$4ap@xAWk0+sC@Bng}3T zAer9lbi?Te(X${WuSyeNCWQ5-2Xg$Ru9O8MPf!NE{5roxYx1w!Tftw-YWy%2nu{6( z#HGOk+=@fXO}v_Btk|fMsCrQ3__FOtJe;^V+N0{4NvYZa5p@6OS;yX4tlko%T^LZOMXQTq)m+ak^k(kekn0!3Afqsfd#HB^gbswQ z&_n|6zvsGP5b7Y&S&W83P}~2*V$#8ZS(xY1<<|LMR|T*8{%9YFSyE_x;g+)CZEYJ&Z_qJ;a4@{ zW{$$9A-+UfZ9Yv(7P_Yz^#j3;vZ9cnB6JNOSE3=DS~P<}$;6*@sMGM@8l$t8k`ps@ z=tr-z;cPyX*cOHJP045#k;K`T{OyN2FmPB<{men>=0q_4$(rgd2X%f4IJ0zI9oa#b zO$1THlqgyoZX5}keQT<)Mo}U z-O-Pa-zNjN9yffAPgdkNLL@hiWq;Vdgg?oDY0!xa+=kk`HSD@Ny?g17?0iTI4A}mW zMPADWKW6_=A%y5dK;%&!a9=Vh|DPA)>mLw>>s(t4k!C7VD*p4%7uew^syzBO7HaiE zY$jHA>a7f8)AYea41Q8(@f<WX{_Ly+JS!@y;SYcJ@KOX>KE<`p4{7Mx{b+RJNT`}@55s!%Epw%v3A-jqgwy{}P3 z8RZ#;)ysk-1)k_X{(#`>7^osK<(+aCXVpo9sODio=|J0%6vg%lxk>U<0aO`Z-ylt{ zq5#1KOUk*s@o6FpxH8yvem9LuBL5*zY<$M8(vC*w`>!ZE(4&^MA4I%~jFCtv61aY2 zOam8QUeFFxji6k-=RtG((S;%OIJ`;|7|Fm22E;s~ArSig=-a9w600@0yP5A!IiA(y zsEPDkHUtu`W0*lu3es%BEVTJYbibY<4czj#&8MP%4~c}jkh-d&JS`|PG*&yO;DIP7 z0)QuV^5xC_Qnl#`b;1utZkcuB=pC(=wO~Ptq^G8WzfaT63D2947*EgpT~WuE@-KhP z0DRgD9hyI_o9{Vpgp*s30 zA(Axg#axIzn7Jh>h(>gKHImBVd>4gpDqVDGlJ$U=D9&Phril6@+)ok5Xj;UnaY-u4 z#i*j~b2948^cLZ|1ZfdynD)grP$Y#?l7b0RUH@h5Z|a#!e6A<-e;m|@ecP=ojoR0Z^;E>Iy}0i}U@cOFC)ar3gPK07u9ZjQRFlq!Bsi%PYp3kHMp* z=)+)x4U6P+z>EV>o_$E@BfecA{N0pRprOta`Nab?<~%GvzK|Y1VcEr9`N2bWHvIph z5F4L81u2vWoUyRQ?>M}cx}(Dbm~r4E;2i>ve?drB1yzhPb;5NL%+Ayf>VG=FhFo1icx{f7uY8kt(GiRjhCX? z?^PmLnfXFopNJ9Eq!)ojt->_Z1C0^Q>!#DFGN2F$Zm5mGf4Jb+L*{LtI|oYDLMqJ) zO{iy#31N~As1o1lqQE6XFM4l6Q|^g)RWOJ%el4D zxY}h~7~PPq+ipg0f~efgn3G zi3?lsUuMatr*V+hvVv(=us+A*!P4NXD`KH_K-~*6(Aa_H3rtmCcS0{!S@ud}%t@Ml zk@6(JP$3l*F99&pOk~aUKcwJ)%VqW;SgNdErJuvyhWaA>XtMB^KC;WiQz9pHhQ&gW zJn7OryWdmrnc#+o2nS%e&~lj>E6GMj`{btn*waP31!0!ZB`2LkIwPeV=c_o?M}Lmd z9R39=S1jpxOPoV2JjP$B>NgDfQ_51m3aJ<)-z#?as^5Xa1K#Y4F#n zuQA_Xk+e}`Ek#fZ`43}4AXnmhpN1n{hKMm9WHzNrSV)kT{4`EKEhB<=;~_-%)8~P= z@f~2vAW*=8VkhJ`cecr0(=Mu++V6u_$2RhXHfTTeX$B;5e==^mbW1lexvcdT7mi2c z=IN_tH9{1PoXn7Eu7&lR;W*h=!88BO)Mq1n6S5%`Vtn|vEEJ9BSeU;>xoFvZ+{Fi{ z$+tR-PRoE)RP4 zcMKazl)XPcJ(TFSAbh;pU|}suItiZ?$a@syIm|BHyOy8Dv-yS=6ULQ?E;VL=W3!DG zQ^xM6ePbIP(c`L6Be^RhplrUVO}58k7Nk5y=Y_D_w1kK%;Q_c07mO7@q+9WwRHZz^dL zridhv?+NDF(3ddqSd;d_UZacnI}*O-Yr>ZdCsOdft9k8DTsBrN^U^By`FXreZI8q< zYD5&wUov%WZ_od70kMj~g?RIBwVfY%EWae8aik}@@JCw0O*qv|(1)A6>Cop;)pD`C zaQe}Sh<*>bqZ$H@L;i@)(?rLNJy*G$x7iDTAI6^LA!(4R&;ObT0dpQ>_rQ#Q`b!ZI zSlMpS{QoHeUb3M_Ka6WUAl#Rr5%8WCZIktfrVQUES4Mb$PXhym(S4TmJ!PJh#5W4n zoxIRVG|h4AT7*)Q-CUCX8@YjN0<$l5Uc8o;RT!bcpYdeWm+|S)#gcVtigBg%>yTJ+ zcg}Hybiykzpf=r*6Zu^4NbQId=^Xa{ zV5_C)O>uH{FiDr7sy4M8HME=>YUI16 z5k2#iiBOB>m=>32ktihnPBfRLb3wVpBuet)iAm1Y;D^=loW=D7$2g^z4Za5`%71q+ zVf@%pG}qsjhU+x#B625mbiH`lXhsJ#`hDF`EqrNWp7e5_&>eCrkB^V9>L+ba0OXQQ z4X^Qv?D$&%>;S|C9^)Z@Usv{wb2O4vJ4l7tNY`7abAW?JvKD&h_EYFN~RB+K3{oyIg( zh-X=hHrEQk$%@7TTP|7?sYsu8NnJU)rii* zMFYPX(?3j-DhBvI0(+(9L-RmU<*^3j9y<930-&*uK3#|M@|MUsZpStENKD+@Kdtiz zx)T{ZV*mUOqu)UzYRA%v;&THJUn3dy(Rz^hlsDe$Jx$*B22uWnPa99073Q$UT>QK- zL_}%bm9HiOe;)vun&R8IMYKONyozJgNk4Q>B^soW5#$j51jtGargaWmi>95{QskT> z1J2zCpUSTD^a2sV8<*7n+v%praSf+%A}#Su@+a8&GaR2mN($e@z7XY3))a*H_<9?q>-S+?dtgM{>)AM-=J~r#} zyyJS+^m#;5vgMXHHs|^8KK1j^;M$F7X9R%&+-GxOP&M%485xFT()KSs2+WI65b5}I zmjk6E`)HVJj$QhEKn*$pJ5CacAefmoRR%ufpc{3^Z`qtf-Q2${sUjCgROqfYv^aZI ztwWKiMLLBrVuJ%8r*n3n0B+8KcJ zdi#2M7)8|M=x@#{bYJp*4ZeySp|GpbkrdMI|4OcybcZFtSKnwRe>oIc@xG%q4h-a9 zma*^l%&(FTe)Z~9aU*7Qy?|1B&+xsL#%MKo-b%t3Ylg{q{}X>1cgdl3&<3`|7lXsC z2ePr;hlhE%K%RzOvF!2lZts@@u{0Q#Ul9l!9>aM`tc+ZwC3$ahYSCSG0lpXUB8d4p&!qgJ-#;$9{b0Za&Aqb>(c$9lwEEe zU?Zy!E1u=B7`F`%j-y7ohm$tNsoD`bVA{M#dZguI6~J&d{MMzms@S#lj2fqqn>qFLB5X3k%8I?}N(A{*X1N(Os2K>w5VEyA}-CU>K_%DQAx!{U`h6E43 zHnby+-DAndN&&0zR7-THhr_o|30Z#?A@>%gjz-TA$$Oln%C29_XZJ5fJ)Pasy!g6o zbJpFD}!3G4A@SjDCiD-^!Z9sHV6CfL+H6APNW>q%@1XRB7_!lrP61wFluU^Tk_oGpvEJHPgW9S*p7gK+$$Wc&x*5ga6KJ7uD3(E$2_Al zGRAuo0S%QnX;!#jjXoLG%+U*NG6j7f^lEcIDB_GTk?{V&oiNr1Pm7E~gbZ{fEw#Bc zb&U;7`F+*iPp_a}2k$+)NZi6=&K58UXxKX1T1LZ&Bmd02`gu!>1RR7WN2c%Uj5?8L z5Jxkb)FEAZ(wzE=VjYR?Xyupq3rpdT5z~w=6Rep}zhhrj!Y;4@_E8^3!A2&FQHO_t z32X|kkAGTq=`~{HL@@}=kC$HFWgV{?J!$|j7DUG%d+%TRW#kLJ_I!Sh|7kq*G-Bwo z0Idm!*_JR;ZsMpDx*6frV%K~|bm%#TQyv6t=fm-E#ns1)wFunZ`~{>XO<@ku*n1Ep zE{CBH+n^^cx!?G`&nL`_ol%vbX7irD?)6VWvAQsZ@BM{m0&n?2u$XeL*NW^f2LW)< z=Kckv7I?aZ3u%$~$xfTY5aIG@DLs3H5{w9 zLDi3-vmTKPy$4!ydmG-*cX0hVcA64g9A{0|Lm zNVn&XabhK~F0gBVW-M-VmL#hc=RDch3C%AE~E-Nw~H!3f)1 za${W=g#w_bBJ{k?4Kw$&Z9we&_wM9;MF7V+Xmub>uVD{_Yu@)jPi>m>1dkLi!FTl~ z!$zXD(k6tlAg>{>v8@L4-^o?upBNcF@2Otu*Q*O#UtSDj1*EO#VN8t^CB{)MoGsB{;LZx#PO?hUpNX6tL>pi7$^=?a43r&8M` z>tFL+@YYtJgY7eeJ32b_+7~zJIq0;Ofg<{5J-IPc;BVtaJ*Pl5qxo=W+W4CSvh?33SO}rg?%B6f;y zsk3rHn!+3Ug3f1suXFpj3_N<@1Dy3!Z71}CSmMSHbW~MfhVLV#bGk5Gz#LC5AVCoW z6L)$I3|;;L%(Q=N8;ubq%fn#6An_{W$XTxZz2sL6Uem`O3DN|iQGd;V{rN>-=Iazr zA6lWDuM*P4G8-VEsb7%vcW4MQyAQc~DMlke;Hj6|wDY0761ugCeBQ?YtxF;5kq$)m z99x#FA7BN(xv2_V^<8xa^yrna?OQd8ALk|bdWib~l{!sRQ@sTQ&&?DjjOTCQ67hBT zeI5cP$o0Dn^R*m}gnj!6QUE-U46fX*d105%_Jg@`Xh;Q!9tqj^5)9mf z2h_i~C2KE-pPA#_2cDAkCctBFE8vl;cB?KEY6qF*(5T6luSE144f4%5qReQZAo64A zrgugl^t|&<)%4zf+33k8-*lyoRWi2G`+$8b$rw5LOG*ldaUDziuugGLvZ z(qR*p=ccV0LtpRvqxoh8AHcQ=A0n6JhcW@Kq@LA>bd;B6}1}q zUb)BzkzQVdEkTwdiE7L`nT1wHFnC8FMNX`YyR4ZhFyM>ii&7a=2^mw>Fb;6@I2!`u z#+{yonVqe>k1#M)CxR@XY|ApLu<0e@4c4-9|ldMP3`ZQ*NYipY7%1!ux@XTgq+|4qbE{ ziV45d#+_{|Sq0Su{3W>?c2mpu3lds%ewZ-FK4@W4j9vM#UMrS@sV^eFD_)Y{GsgHc z5B!&dl18dfA1yqs{Bvw=9q^ks$oQw)0_Z|A+uNJ@#NIK$S{lGu0ASk-guiF;W z^o|){U3dHY%2~!oD`npVaLJlGtSKBCtpGKQF81@9XQc}k?w)wC=wzRmC zGyw}Fz$$&t&gDeqT*v*$ttr^`RCN8u??E|m``Gk(o?1MCo2-{db4aSL(g{cv*wIb! z&Hv>Bp0}h|l+6fqcOCjRunH2ru=w3MUxjEPVjAtUY9Z8Q&*|Q4QQ(&Jyvp{yaZP(@lVh8uL=xbVQk+m%$C;k-^`=KQ$H^Z z-e_0L!T|MS_3L`K&7OWnMka^KVZi9};+BWT)f6p))`rG;fcIX&kG6f0#wv+fj_o^~ z@%${6j4nCQHDdVUjWgA4IFX5f4$(Jw{Vf?wohrQd=Qu;Fp>=jfnm zFzP&vGf{a;4GPS!cmC91;;X!+8FR)p7hBAH@e@2h>V>p!puITLiD5&iCFpld^{Q0 z8rqM4TD`DwB=MLS{~qOmkg)jT`p>-O&O@2#`8f8@6DtG1VHZfE>qQ9d1&TWt@m-|- z0S?c)cFS+G&%wB)!d^^|of3~Uc(?R(WWd6?gU!ZegqKi9-P<{MX=izuPwG z-FmI_TAA0H+rFv4E+id|$I+-7oCo#QMBcesYxi?(^=TgX0&0VOEc~_NAnj$6&gJtb@+KJlzgT`obi_^i1@|gYLV^*?}b=wvNAd zAY|->_=n_pa3>30&Lgz;jwwhh_3jb+l%2`Iz+kRuAg86(mD->C&M>72{T*!e><3u8 zQa#ukUwd4_r%IxPoOVu`iRgEN)pOzU?RAYDA^Go!>OZ141rZsnz`W5<&UAx}`*rX; zsarGATGEfY`Ct8M^2Vd{BaTVo9?TQ?S~bZ; zjhr?(i{g3+fARhopJUVDZk>6+3F?D|Q{*CeDx4KHq3e=ygS1nkAJ2?^*|2Fg(e`pJ zq%tpGsXAlA5KGZc@kzUCDpoC4y9C3Ia_UmLu3#$nUoyYLuoB>^m!+hm#uhblhSHQT8|6V-`bbfv!e!TcIh%% zvUNbh;y99EP!nKKX9R<{MNGlevV$JDSy>2V{K2P2cWU1*;4mi#{4r;u+qd!h!&?HD zvwf=#xsni$M1vvl$mPcEf+Y{=Ib^SWV{SCfX^QGPj-CdZAtLg*fL;o}&F==)uOhFl zZI;Nsv2w8J*W5S%Og{g1Uur5dRA$(`(vX%cttec4KqsSUyu90&K3nHD7zZYume*Hy zCHY*BZ%(A&c92BznM{r;Ce)0>Y3Gam^}`?>3=7ux*>tU45%J(pEUxNBi@#h>~ z=Zu?!gT+R{n2={xs4qY#GUaubNiIB@o+2JD$v(^k-@^g)O)vSGtX zCEjAi$+sRxDQ!P|pVp<8(+&z~>(*T#vYzO1Vx}TM-Xqlo{exV!3-gGFb^mK?*?iNZ}k{Io@V}Gu(dsij)v~+V?lvo zYfa6ALrNeIc&rZ!(EBL6EN*=59@6fssRkC5vQw%G!%Xn}V_191QDBW3Zd>8kjZs^B z8pnPSvKP7zRh2UIrXVP7c;j7+!7q!Ci}A0GvlSMPw9&D%)pwN;i+k_T5V+8fOeSno zFS(?c#uOQ{n=Vb#?b@N#+a20z>L!=1lo_LFWn2zzhPFyVh1?ljdD@jp*skIMtL}hp zYSX}nU7MT$$l}?!SHL6b-`{H?N-hXj#^e28qwa)yd!kkKB-9EZj#{{}Im2PQ0Gw+fX8Tky`QA@(U zPxTsEL{DC7$QZFnOx+YJTja%bxygx{xKPDWT&N?z#Ug@B9bb?|6lUp_c+acwE;26V7^C5&*>OcxSQ zhBCkHeucwi`5D(CV^jQ0n-oR?Qgt=n!9*VY3H4$4`gSjgY)4ft3|Asbe+>g{65YV9 zjESW^4!wX?(U-7I1PemeE30tCW84O-Yo@~xlkVa$F}i9P6w>7|x<{GayZXzqxAeie zs&%gNTjGE5nO9(+8hB>EkMMjjH`z;fi4ZCKYrs#x%2%}g zMPJBR^ikudJtNG4{-0MB3!jzwS ztXSpl!UUf;R&9!ff;VpIu|7WvA^OqIM~6+zXflw611r(kloA--6a5+@2gd=36M#lm ztOk`psg&3JO%yhwxH*NqD)?^gcp=|Q zwdP7g*7A+n(znQDI`8IJTdIXt`92NJPHclXZq8v_KP)^tb>1`(j0+b;TmH(1m|b9( zO@k+>tIT`*RoLKVS}LuV72u6x-1Bo{X;fOEz zYyIi*)t_cTJmE70$g3xTjNHKhQezlY3Vc}Ox^Zs*>x>qj3?NC9p<(GcAiY`{~F#U+bDlp87A=p&z)BZ;mBj#P3={Ej_R@K$x|#!qV$S-hZuzeSg=S?23KsbSxn?|m|wuzO6Bh79}+?E1^a9t zc<-+!PoSSM@p)DtGp)x*D*ZpQpk1x;nbuyHJeG9EAwr@}#+eq5e&GLs?v}M1Hm(eV!f~;2eW7I$-dNct z5l|@19vCmGkW6NbdfTS-MqXPK>#?T_+w%~JZDUUkyj1U;EJ>jo?&+d}_6wXW4r4KT z#eU;=c9g&s+nSDfyq2J%j4S2|F1PLXy*Q;$P5PiLyX~dwq)+^$vpY2-rR`WygK!Z` z|Nioih2#vKz%AYQXshPOjDNulB^>j>xioat6p}Pm7H;LJXV2+oq@%Bo!v0t;WYN)4 zF8L8D?kOW@5CVt0N9i~1-L%T;*Uc6Pu3mtOcb`&;-9HJQC`v*ScT9bJ97fJYAwsuu z0b!UOZH4qv0>1{KBu388M4B}@v=TMCTh6RfJBPZa7;~cKZE#{<=@PRf>*-a5a2vKmMj%Y19R@yKXo^< zs50-iZ0gtBkNt<7_pTBuohVg~d;bP9E7|%yHOIV@;^x9-`pCt#;R?4P2gji4hR_$1 zbZIU}8%lOBJ0wLum+l&`NgsktBS;=4yH$*PHOLSHGw(0E;!q!8?vUTXGy6QJN+gcl z7}(v`j9Sj3!hMhLnYDw#@h&Edhdzzh;@K-5DN-4#kG&S(C(7!=CW)!`SMwNo-_F@cW6%%^H%%!I_o$lhkUv^)g*C=8FG(c4vS&}?x_Js*jSir zhPZsgS@({bb3)R-pBN@@#BM>B%{nuK@ppgSY1Z*^*R@T4qg)EkT0+c-k5Gg6OV$VX z>SQ{n)NkLOrW1Kv1MI-`Obze&SVdKm-{^EWn9NDu(z1P;R-g+-$N2LM4hK<*d=idA z9T|=l4_mJgqYaYJkt$GiE?=SsGHSXvpvDccP} zQ5}CJRxX@Msx~yMj}^pc8ER1p=FHE(gmkPloL^(#6;gkCOg=ZkUgg`ljnvZ!0l+>H zrGzUATNDLr908$x6xO50y`;APg21a>j@AEi0pwq(gk5tloP)LUUITJ%W6yVSfHmy{ zxvs0X>vW*M5L=pb%(1y1E#YU1Q`>Ty@QMT6bl3LhyZ2NWDrpHqFm)FuipZ(LJ{326u4LWcEI z4{TgcwAC_~bgnlesC&L?EKo)*P<}P>@!|Y~`zcUV3CQH1Al!ua@CbM@xPcTbjRsR#lW=%d}0$|AX>8}p1*%5Nxi47a^D=NNlTi2waqMKB4)ls z82UL|r^C;14yBAaSOx#U-LO;@Df?lg4R?O+p3^wWDi(gfql{@T8K{6*f)PMUdo^dc zft{uqQxfq{?|6`q?(dF!^~pp|*pw8cKH5KGQ+)YQ(}xhgKLhHLa<4;zr`wc#^Wcyl zk2U>VA$neQ+9>8We3~}4`j^O>nsunYh~rWcbFLUHTZ(>Ih`du%ITPlCR2|g#>ymyP*?B6rk@Q#! zl~pk+bdFAcyc?kxE^4J{GxD7sSy+!%aMnD?xX=nRv)>+rFKfpb2?@Oe?x`M|fWvXT zytV9g;06HAjYTkENC z2)?%`u&PGZVXUIS1%A^PE)aEpiHd z(?5h4pSLk#0UL_suQ63E6c1Ge%ff#Nj*XqAvzV<|jl4|@a98B!c&e8C^U!-v-SSX; zISs{WRc?RZ>NyeGEjbKh9TH4>s=MrlB2#2@9KV5G%8YX?-pNWSFJlNcr0206 zYHlKMkuJ`5~rF&>>0ze*BYm9Cq%fWacO?Iftt ztccX{1P<4beyR2H3Oszw4*-dMwHg2-B7Ae$62GeeT~n$>DC8bDn_FRSlwm)a|wAN2p8||qAi?Ss)0h1)v9;n-Lm)ah64gKqEDaH zP_8g=fe%rXdY-P(zQ6EHbR{k6E2867@;dv{PjBin>}TD-&4@=nC@%~UI@^vdu%koA zJ|@}FeR<1Kyxuwq+>(d-E%9lSPgg>e&i!1mAh4~iRZfb|HTpgV!^Ay`_sAMb1$)Dj zCQC|YA}TGtP@EsceBg5D%oU_YO3M0K{z$p3d3)`~7!codFFBwIi!(etiOEXV5Jo(j zdE7}hBz^Dtr!lWqU4ofk9Q}%p&2UE{`hEGBrpZh+k$hhA^7pwSojZOJkK?6k(Uet& z!E0lb-T}*=Nz|f+$10U@fvtX>@Q6E5z{R&7*>Iry6qZVhbp?huR}sm~^7lD7+6Twz z8rm}xjEszhO!XcH>WEE3l$2B$-WZy^1;2|59H!PC8Ke(Cv_CZ;!!97Ue(k^2(}ao5 z?lnixzg*<}^?A2I!zwj7SsWVKeCr0!hq1TX?kA+pO80ws*k=R!=Gg$8_gGThviFs` z`%{BF$J6EDp4j!@+FO^i)1`WLi^~Qb0UKA>nqY#^9^FCTkZ5T}!EdhgwkmTDjq4vY zFco@Vf}&N_T^a);&@$w!`}wN==b50L2lB zo}wyETX_DBh)+O7P@nVvc_3#DZZv@wmIYupS9txHHcXuz@x?271J^`Q*;b)9LW+GR zc{R5_+7_eZY0ik6AXSg!ZFcqSnN@MA7}9I1b{Ma+W4#Vd6KnGseoJ=>AWStcdp5H$ z$JN$lavSq503sLJ4(IQeiH4gv(-)d(H|5O*4PJ#%0&Gecqga~%$^G()`e@4=(?`B? z1vnlWtGbn}h0}iF)myO1#ED92Oev7t3*tar4*_cs9Vl}?53Dec$G5L5A=&}DknStAG zgrDWj?vEytPf`Z#((SQ7>oK^r^lu6(T0H?wRjOOjMRwA!Yj3|H=aDMqb6g{-opX_s z952KbIq+l-g{_txp%G965K|)gqyp?HLnsb-~BQJEt(NQ{G%3 zU34X4(SBsQ#855p6|pP*qjc5TUN$G2JD zQY|z-9gj;s?M3Hr(QCdU<1Kw_*gTSs`Br7nFhzmtNwdV6LNOvsQ+S5%$Lkq}f=Izx zTV8Iow{H(+*$I<%nN(6G#L5W@mW}F84P9Mb&8B+{oYH%0_0}Qk>wtuo z5>F3sC>cR&tiIhl0|XbfZmxLH6?5Ry=ef#f91q+V4HzZ6-It`U^*(ePY`L#dC{6Rd zKahqR4fLI1>`#q9q!+ttPZ!1Lgc=ZWWZ48fZ_u1BeHVsC`baZftO8yQlrhSgii7>C zInRsS19j2so=&|yl!Ef(YjEbDUrIZrTn@N<8zX}^WtFA~O-qk*W}~gG+FXq{sIS|` zB^XI{skq@nKW8o9tlYBNfS~O&=`JH?xcmDcP zg#a2N%erp|g#M2@X-SX_Sy%aydrIgVDYep|+TY(6g*&rTS#&wM(#Gl^p$XnFc&W_Rh(`1p?*(uu`Z`#Zcc<>3`?SmC zjylIPIv=$lAL4GRZ&USAThyLU1DfhiisH_{LLk&c&DUqj*TfR<4#!!{EPr+5lK^}! z*s|{%-dd|O?QoioiQrTpK!?3Nw>TUUaPPS!cxY^w9g0170uOa!(_UyC)q@ZUI;fKd ziX9PiWTEE~_x!3!2F*%w@OxD&bFgDz7!fMhwUHTlja?9V^>aQ+sr6{Os1m@ocmm zt|e(xO`krELCy4pW%@Gs`XjNa!^85RFoc;lTOLqw&~_hmUm!5V4>J#zN#TRJoQfjw zlTHNKCxD8wT>#RS(8;w?=4!PDC(gb5vL35Vv#Z>B$_;RZZ!6^vhJV-j(-Cl-{qhZf zuo4NzJf4fCa4DaiU`N`&5yS2%@Sxk&V$@%{*xvPe&IkZfQ-Wc+VYAGYj}Ng*vFp!v zH<1PLgH6O^p)ynEep=beBsEvG&^K&CWqz`JsCFun!d6x?ibSR9LE_(L(br9?-x-{~ z$3NTnA@mFGLjMVAe5tRM>CGi{PT{rP6=#okXpNH!pXiD5TNOx#_R~GG{`PKJw;&=h zNDcp;7>QqXj_lD~yQu%;jZ;VRIn+E+t6*>=wq;9}w+0ulEB>utVod9y!jrVPkpaD$ zFbj}?8BuME>OR-KzF?awRF=4RJt z&)`PseVvRoOu5*ud{lI9yjKADO41}8i$WrZbRt*mmA%lBJe^jFgF5*^PX+PX!?a(X z&a$Vn+qW<}J5$GX0TX$0F6D0)rGTz`nL zE9g*BC#e1Ft^d!YDHY|nGQXS=tfdd{(U4we&7b@K;MQKKCNxk>=XyU;N|#6&*=?Ia zw#PMv#?^%tZIw&>C_^$hM?Hu!2cI!plO492r=J)=i(wCgX~~919U~1Kr70D6Y^;8< zmo7aT?~;pjJiX#U;^4GK=eHVNF#Y7KEhW00^iR3A*aY~Z`EP7MhOxwLSqdm|Wn zjxyYP7UJRiR+(qI01w8Wyf!f42zzBAXa{4B;smo#HUg8H6r?r;^gAm1Zp_wYJx>?3 zBF~0LOMNjv`H>>%(Nu^S2WnfYz!5T-fwY%Z$<|rB8&}Zc+{Ai>nSp;h6G0|!`V~ET z2xmnKZ*C|AF#q0=OeY9=JuitxA#yQ0Zz|o*w9`+oxWrJ7>i!|xb5;Ei%gSa)n5{6s zTNGy(R%OvwwA94rjGbnAlWU*3v9wlRW5C7k4{_g(!Bq_a3B7>M2_ z`iaM|zRox%H|mv~N4_-Omoia68U}0PJ3!5X!8IwJ-N4{pf*RWT5TpfkxV7;8?xL~9 z^Oy^^K2!JNcJAtVb;+1#UxkLpe!%@_uxT{A=GE)VAG-xjwI`DAs;owt5Tu&G2{PTP z^Ah54H-yA#utM%D_seD5tv=JmKz~b>q+9*1rhbqW^*hv61CD5zm4g>T%)@RGyKy{+ zH(+*T*GO=X3no(WtEAf_;1iJ0?L*-=TAt7*Pe4GBi+&WZe5)Jio&(Th>q}ivo8V@* z1R|o8JK7|RuZ?uw-kquCj0hHtr~({wZ{YAjHxZ&FNB`vlMqTUJE_|3;1C1|s-KCR_ z!GaQ21EAWdfN15e1am8iIt4f~lM%{9sQ@|{c#&Dx`qvwQgG(|?%IM*FRrqsD!D7%L zpYu*St@{wpvme27zr@GS3+WwqoF~muZaX6W+y=UD*RhvdOri|PXULHTagf3QbOQQEVmVl!d+ z>$ypjWv8p&ONQqpF99DALysioN#lIZcc^Bt`Q?6?|8tA@pOE`cZ_NU(?)3O_czY^7 z6=wTC{H#*039#GO>3Xw-P!2k#Y)UOqh-bA&zkd%>SFfmBVpgv-CHB*Q9@46JFKy@O zD!zqBXkfBpYBcz?%W7oc(}vQeyjo*bJ0{`2;5DySk1qz3~L0!V>1h;s^23R6k zmkZ2{@&%apT9|0{1hM3chK=?4F?Ey5BN_5Io&8E>)IgMqea@9yZN?@q{YXOg(jOdp z^l;i4_g1>Q1+1?gj5bL^O63ulh_}9iydB|Y&dY}ASzq#wqz+}hvcI*p3aUvEHobHMX0^o zqOiC5knJkIYhyMTQQJFVvwpHvb$C9JmoLlJ6R=OUZLb&^MUTt!{zp#6G*Qdm6mBE* zK(7&UkUo$7gl)TX2l902%#i_1rK}@e=-=D9&o50HvMTjs@6g6jdQRo`&_6-zkHrtl zf0GNgT|Y{$Llv8pMRp{wO`El9Y08wc{9-n3c%;;_e-z*n1C^A+TA4>FkwdHd0bKov z-A}^eIoDi^{5tIfZ6^5vCHC@E@q~y^iKPFVGD^k~vL4&KE50Md;lrw{{=-!OIYF6* znHjnBr$&ND=t!oIC7AcV)SsCUt2^!f^iTbRA2cg!H7a&@vHh*3mtaQb5-rb7<6XCZ zYO}Kd)5@if^ji27LMdUAKTo)<9p^Fx-a=zuF^Cemoo0r?wj2C9gV?bCqr8lc3@IFHvX#HRt-Q#wpCgicaz6(BMPqy(Q4)Dg{OuPM4m*Gn=4C=icLy(!S5xlL8i(EL zaG>w~{;ko%N|DVa*aYxP%<$uTZhffmPHS7 zGbQP7z5SCx-h$etqUSAoS5*u9%;Lvu*@}H;6+mcyeO$)7CZF#DIFvSI?3sflnw%HVc>wkYI6yQIBUe}2KS zBaSaErF)NK(P{7M>ZV1os3JDcmh!^BiA(8mApRL$CvK%P)Y~PFa1`Dql;)>1t&8LnwSo_Rxf5M9t)?NVSG3UGvpD@JCIA z4xk4IJ=wR&r3POhV}7()!T2|QQ_#bLM^8xnc(Q={;S6^p>ZOnXJ}?LuPXT`GM@jPI z-Q6;bZ$%q3KhTvz;6HfjIW4xac2A^|yfyS2M^eR~USg>F*pCLBsczda{*TX23#wJ} ztsc?hyK5Dg19<-QtUVO{C@&1s+{w)F9e~th18ADv(|MF9T~;_7RZK+U0PueLgPzC=($UxOw zKF%B%^{uV_bc3tm?x6V5ftJJjKvN#>TUMN=rQ1q!B}EdjXUUGuss)Q*w~`naxnDp& z63`WH8tb^pV`4E5#y@Xv6m&B9T=cOqxeCv_8jx#6(d9G!fwB>cHE$yyrqt>R-YHii z?sGqw(DL0BMUhV|PhnC%&0saU>|hQE9ZiT+BS(iltL4Jw>1YtVZMelQ0>*?o4<3+f zGB=qtr% z%HVev4tw1XDA7&0w)5$&^KH#UOHaSEOUPY*TU^#(y&Cf^n?N}x&g8ACXc{A`KlX8~ z)>7A1AKl|LU*0dYkv+_Ng{n7FCWDAQR!#_s9qi8|+#1e`?Td{Hk~^(-zA_FdddkUA@C@oAIO{eScCDv&w3ZO~QvdjoNM7v5 zMu{AL*qN{n0F(#gIx+F{Q1!6FFxWzSNV5QXS9rB*zZ$~;gHEgVlKKde1G z#6i?O2Mk@RvPK_&uOSpA2S&a$45FY^sSrf=LrF7DgZ3)c7B184~Zr+JX;oorvG5+;B8qLg+#SBpJ}GyE*7*$8*MuseU&6r9;ZKj zsGV7oUVnb2jusWOu^PYf>txmC`+L<5vecd_YaO^Yfat{r@J<-1qEvFA#H*td=K$s4a5)d z$>;0M77;TR38|&Al`(IWW8bg~C7%9?!GST)dVV3q=f$9jiixCz_s6Jsp#G(sT=(z<7pQa@;Xuz^laJyLB%ZMx%7OG$XAck;`YYvUC=2@RJ!B*x-DXryQk^ zr^}x7)htSv1BN{~L|>EpeFD-pG6qE$8Ok-%apueY7wjvYtHr6!Oi}%06#Pf{VNCT;CrK2QJ8k38%1=i}O-1g`h-RDBcYB!+TI12aducwBv)Lg%q?=5? z`SJ|LCH>dIPkWp>cm12zc>w@RDufeHi;*%xhu?qBwwuvA)I$WJih2L-f##!W7 z)dUp^eZD$B#bzH|QSH3zTC*Eh3(fXD)G%rU!N_MR3A0m-pfrmBNrc(X?ng z!t{g8zd;2Yiks{popY})OrWNHWec}L@lUGm;>8VmN;L~lgNA`=VS7Cex8z>{=#kU8 zWHIR|TDJE%gTCezS~4&rdmdyYxiR(6?3nz|h+4oxt31iZ@EgKLy%XeOJ0)Zsvyp1Q zf33#^(qks8o_V_g1cWrVWp4jCcc?k`IA4*)M@3mh;jhD#ydJKDh#C+B-Drao6v5Cu zo;ZXuzw7S^$yD>Sg5mG-xYLBLHy4L0LD#_IfA?LgOVCi<_roQUFz-#&j=r~JC1!-3f?hNRZa0$MVNyrlKQ@+ zSe5soHO4;Of-otC{dw?MKO2t=z{crX+mgSVR1>zOv9z}Q>XV|Y5QY4|rAR^$PObwt z#mlI{D|fgR>X)CqB7>7=rh-_R@W`}I$CG@fso#^=u3D;`ncj_=k6k6q z(NY5Et-l}0K~V64xa}aZC|dkUgqqaGC@)jzXWohgTL(2yz~6?4M@0qe{iBrCOtg&X z-N~cvzOL>0ykBk)JR&fqV=jdeY(-BmL(I}~bbs|59?suJ^2kR^!%sw<6aM2|ok3}q zE->noj~<4b@F*i_&T7bhD#ZIge>zR@fJ{(BfW_STHCBG$QCvm&!$t6$t=$v6udJ={JEizHpnVaFuXJjmyQkG^y0YU@3p8w8P{0GvO&;{4D98X1rb=$>T ziD^*bo%=c(XCM2lE_)r%w{xd_PsIdihK&x{ky*pnWJCgE?xsuEZ=BX|J`bJ|wc>9Ly(lGLbd)#RYW?e~kFi>H z+C$PANbs^Hm+$-;0On)LNWcm4yVsFVK+EmxyBW84;ERHk+xP|aE5RQGaBruAaaewS zCH&ufuX!@qqxWAfpc8eXp>lmcz7)uBC7lROt}pmriH65)gNEc*Qw1tY+my8h6s!M- zt+#-xs$0K@X+gTALAo2HOQcg!x+|xkowl~_4?l5{eKPz zV+)SMIeV}5#C+zQYj!$1TUBGN=Gz;7$L$Ypv`a!rgxv1(ii$&7fWRiv+^06D7Za_P zga5)3OJyn>mvU79Y6~#W0JY79E`eJMP=>}CWbvg!a5ZCIaQrhE`cEgpFXj{a=lV}? z@fd6#eQ0SE$olYYkoU2ogj`I(JsC=+#3kW(-kU3KTu=Uo#sdM|e*b+H?Q;>CjrxQB zA^O;>s9t^He8}c9c_*2yK@~rkueE%J>6`q7-Qlgsi^K6aHTAHQ zyy@vN(Q~vEbP05CRUO3Uv&h7(Ej_)Hu}aR&nTPYaJe$M!S38?$dt7u2RC^Zid=cK4 z!qdBS`*M7bC+@SV)U*i5$2UJb!4y zcGen4fK2%%E||{%Eo+N50=4%eZ^L&|C`ko1B=%^vA)qkq%f4;673(iq~swy-jK#YBzTQCf zV%%4IxMUX@A+4rcQe*RE&6Pigzm45IELB*Ij<6!PNLyhAJ-5j94latUS$Flpdz)1h zRy4_4R zJos#=F`_%(4%~gr4yykw?`k=pN>JfU3Ht(q_^EsPBjzL#^*4uOtQ7@NH1@!RIhh4I)UUi}4eD#dLH}@Nn=*+2}50mJ60_X1L+d}FOM-hWr zB7#B$zm}awy-2Q6WCxv6Y6b=@KkZzP_DFpZpQ9_PX2qSBj8L1y^XlEBBk`+3+v#iX zTP;NwH**ppYsfKcwYs^3vrM1oClsI-JgMqm{TCb zbp5(8X6S71)wiPGJJntEDQB~VW+Y1Cqpw?7knv@@&$~U{S%1Km=Ea7v)0qzs_|>vz z-s-uS=8L$lAU)x|KBhGhe&1ijx$w&KTcQP7e;}I9;;?iGvzJ`&*OYA?DX7^MhTWXAUJW7k-KU~xDj5HnE zxrBM0`06*80dd36)Nsn^ws9mjKe(SvzSqbPO%CT;=Jy@>JrS+kw6wJ&Eb+*Y+wk|V zFM_-%BS|calfmF7jk}U*o~f8kYKFaS;%VlYau8!}y2qwylB23t_Xhp`;DYs`ney3T zUn0?QE_bmE;d)M>4oTnz&im``mQm-!@8vsdmzkHszj{T4XAy1g*B6;HnlAgz$o2$| z;2o*l$KFvWyT9SbQAB4fCMWpb`C^Vq+^V>$pDLGM!`MAJbxXH*e6zi1pEEQs{T@9OCZZ$5R>Fh`|Jx|1AG3#(t`RKQ4%JXkXRJh{wyVW3{iFXN( zV={HqMtaOBrI0J+7%_P5+@R%lOb|F+t2PbCCQ|nYk zXM}V9`Q_EPtN@`3;$?2NpS*f;oZ1|_3ias3WmnB;pDj#a3hzr2U+4YBa;KS_L%WBW zFX!IA_rET4M@!wFXjOZCmjf!}%S*kv@=I}ZZ<9zO7C(-6siHfYJ99{41EdjZl1Z2T2a2na03##+R)^vupIf0&4BS;U`+a_7V)z?MQ6UafX> zmd|uK6!Bl@TEs5A(JYSHwaN{Og{(+XQ^cIWiTP){8p)sb#6gtKKw4SWJa@a+5kTUS zg(+6*+i|zOfpg-oUiTJ>QZkpoY_^Pj(sVJS*22nP*Sf8dA7mzC(}CPhyK1bOh$4vH zp;wXeW)^yNSA_fKu%-_MiHieff#rYq0OVJVDvEQsmb9ey4FTv4yrB5@8vne477Hl>Hra2WVHW zl`JBArI8BN2Bd9qT(Hpgd2>xaQI$5S@fEZDbZ6(PJB$~oFJj zBfaXx&aAf3@e(F@mn_;GE~KGk3qRL1C)ezai3xZAfbC=M;St-ESY*>TW@gWZN6ceL z9&`IvJ#Ku7nmiJ1&h>*~H1T^>M@`jQe8NOT-;@CPMgSL1H|EENm+}X^C8z19tVAB) zY{m#|8btTs&C@@O9m7%*UB^o){IH_mA?Nn$6`f{HjIHXkozK(kwWn)NOOhP#QUEt@ z$_8dQwdUvSFGu$aryHiXPLN_XErl>?4N{hfnj$gj)?tn`pCydawbdtnR$wJ?-L!jy zIvTR<{BRpfR6J~FoCL}nUNksfKy~?D!2JEnWecOV;{!U1FK5xo(ck!Mxu;5;g3)Nx zrW)L=czzJp+cGIP80Kj3ori99|Lh*A&0zG2KL{WOE0riWH}FMPl``EhQr2*LmS8a( z4FAoWX0zbX@RTzw?bl7D61UC-9f;e=*UbCcF1*F!cyt1kM6#uvif*=VPGjfOpif&Y zByA<@KDKJm#M87zKw_q3+j!Q{Ssruc{HKM@AvL~z;&7g6OQJMbNV27bx!o+4q79X! z_Wel(rT_jq=92X0@EgVY%UHp7ZSMzsng+UD)|QP$-HDJ_l5NU;tqngs1?En24?iQg z@1956m}(NBoXibq=+~XAM{Mh=ne1Xalr)tjR*^1vuNSv^Q(On&4EDsBq)L~g6!DSw zJlXd$Evz|&ZFM(vyAATPM)eoP@^8UEQmWMNT*3HRYd7yda1t2J9A#C4_Eu2Ljy1~|V1gq?9Qc*DV3o=|C0w{z>XdSJK&Nd4{4&afi3o6_5+l4Z=LFC;L_Ye9LnvxJD9a3?t-P24LEbn2v+#7m&O|Vz0sDa8O}$bx7QwZ|QIx3&}k+QnlY@r_Ag8l4;Ns z7B-D5w=517fKuagXVl=3fI}SMhGlL5S4Z++x%GFFEqDTB|1oP;`RyhCQn@sNOMQ+m zqiXRG&@{yn_jQ}XAe<4h{cNO^^0HemXm9mtaN*xtnR%26P|4R*swDs?tp>GBcZ4f_ z3tE%A{dD)$#plfr0P4nkU)xH0qoc{=+{qcV7K$%m30VHVglX zg2w%FA+AIa*0PFb_t~9`@qs{I!YFi{$V$M23twIkf}Wc#8y0e>o`KdQMjJh?Rm=I5 zb(985rg~wG)BNw7+34+r{r>Wri-{}?h-5;(IcFV?Y%>`tcP{jK^5a77Uuq4_8LUjY z3}iSDA6ny1KgR<;3@<;lx{T=5oi;9 z&wfCg4aBSvcP_*?<$yiT>{0~|dZgN>qdWoSl`Kk7#giLQ&#Iuf?uReO(13NfIF*Sd zo~04N)_rk>y*VnF@5vWkQ#e^zY}j=Ucy3N2veQ^GDJDJ=z5jF* z)E?<4pFFsl&o7nA#9p8gJB(EG=%Ad}ZR1Qm3T$CaXbDatpE>WjntkeQ$(Jc0cx51X zq7fe$B#jdK`i|z|r$O(A+_EmI_fwGuIHw~~%r`Gkoda5;gx=b3_){8-hM^eR6Tv!IcTxD(5q@K~)957$TdCxR}7~r<5 z>6s}vvU`*MDN)QqPwUK{%YjNo=M@}iLyIcC8~L!jUxgLNS*_PU?nwHyD>iWcqEEcf z1H72+6t{y=s|kg1eZf|;$4xvQZZd@>8%<8rtYt2qBpv@NxWM$Yw6ugV7uH|x@yqp= zZSc!N)e2?>7LqkXl!YSU!IU-dxm6u#ReZN$C+M&0dy`83g9ZF5r8$oCr75Jp(bu|R zm6y)E$5@g5^Wj#K5XWjnXmn6$e19oGjj%MCe1{c$lhk7GZAeOqTdJF4a6qAQB3)U* z>E-MpV)AsV^Q$C+A1rgWVL&EXULtfKX$$rOV(3 zpUhvM<7jAk9sSVJat{{H0A7YorE7jChcZYaZ_=U|i=P4oSu_!jWWgJbvDf+OSzwvK z$MTzQAB|@SFAzFbc97@dE3JAboq+woJV%UL{YlY#}@HrE8_)RzN? z$f5eloiE9yf;lEDS@;{#9sAu9-{+fo(~0LcN_|$&DZuwrjVOF?L7Yb`>9g{kNJ;3; z*PADeDh0JCcOGlQozH@GM*R&YZl1M@B>F>YWye@W`7H9w*}8OHPi9_MtW+#1Cw=ML8RS81N4MO9v$Sg@pY^3#Xc#{fB# z@M-+Rv?Yd6)*?=abYpy!px%@{d|5fyB2%}F`iyYF$q{w#6CKgmylbzksqTE> zLyT=s^?-!LqbsA}VR|=XR`0NtNE?@zvS)f8g<+hkyB3xtS8^is(I@icb(}?yU+B2f zwnnIsqHO9hwBlbpT$$7t5HKL)b}-stP2$OK;;n(yKddkpEf*7m3G;o`6YM(^!4LND z_su6|B<-smoGv34OB=2j%um86y+pivnzIiD>dy<6Yex8PzZqtCbOv9N2|LvB{!I2* zYmUnAV96n&R>8-jbQ@?L*6UX|^#VWGXueu)_!PY*iAyCXYB`-92S-jp7%nOSC^NV2 zBI~4%&7FE`1v}`h&4dm6QrIVU_hj4J=`8$TO=}hTXzNSdSXwU19v8zThredZnp&l{Vp%44wfRRiPMX52@|`}<(7TMLK*3H!7Lh6%vjsynk`UB zcsX_704yLykdoY#>bK4^6Af!6=1*IZDTaj)*fK=ZrSm{*2qw9u`j?Xa3zR$(puduU zu~H#kO-M7DiJA;7)8*^T`I_y8&sXz|yF*5R=gP)srKbU!lc2Y*!zpJNbD2}fY zz}0V}3M4-DhXT$R~7GBR!^MxwMoHu&YkY^jZ&4 zKguVgH5;)>YFBL6gKbO|=`TBPb+02_C76t1^(DEjZ+vF(!(xNWp@>1$Z)h37eOjHl zEZ-9@(4_<@T5~WdFxh8_Yij#uH+uLEomDPL^Dln7jF?6)9ZQ)HC`{UtaYOSrYL||r zH`WMb#d=V|knp)~+pj`zORqu~m_PXTtx`s9H5`qx*G&u{I@g*E4QX|&&MqVRheYCt zO9V#{&v^r;iYo@gocHWU{xKxxIemxrr`reYt-}wcz4vTh)BFCt;&&rWyy|ApZMjs4 znM3Nj2SnI>t)J}$NRaP~MnsUw?)bdY42CPzD3j&A{YE=gE)BUwuc_J&AWy-b7@++k z5l4L7NT5~&tpIkO?%P+?%lT-=_zCv*w%YU@FexNjuqeyq?kIdJn=Cg%bOl^K zd`c3zEZ9x#i`z*UPGy_ZbpfDUgO%YDExM`4@${AnZ&9H$4B!Z9*1y>YgbT56!n}|E z$smw~0`iDBTpbSNg4Y>Rk){)PM2X%}VQ@t@ewx28@H|nwz&H+E^ zdp#I(g>!jtzO-$!l{X;V(@SLXVyUYIMBVecLXJMyixH{)oW@X{&UT4b% zp|`Go8Du4Z@yy@yX{x+=o?j&bYdO=Psu=r5StGFOYo__j7dU(>dtd1sa$eZJ?P7M$ z+M^lH1RFfvS;b}Buaf0@+AZ;XpWWWI)ha715#>g#^iV9nnlD$uc~DIu)vaVp&aEW+ z#8D@H&bnT@$^u@DQe1+7$xAYsVRC@=cbsa2tK$YqUh)O#a47k|FIZC2LS^V75`(&o zrF5O~6N~=IC+;9`POB>rJ*AiPN$+mjVX0jbe#zflhim=F^2)lIc)r_TJ<)g)P<{-b)ZXq!qTOr0}BapdQI zVx2nBKZ}mhw$};>{Of)GH=!|%ngEXjn?e*u_BE&fYsLhn+i$EA-?$wWZA(bL00z*r zQF(FdGRNc30man#*GR?fKe`$Zb?e=2lmXJu4d|dmoK~CS0;)pH%Lr_$`iLunCHj{= zfDsXGTX;k^F3RfYJN7j#okISVhVoOxzDzt8iDYC6BvA%vdey2B3|8nqX;hU$ndHy7 zj9p>U)2zCeoZC{)`Vb`CZbb!=1z{KNUInc2c6PtsXCP0|A6CvSC27EsLv z`SCDKUjfbJC;h8DuGexY@#*Yr_sLL00wApc%TI=|RF82MzkK2a1}bxj$LD@NOb(};3UY}|STYLVCc(V9 z%^5Om<^UAp!e2hbSuEIXsiTSytB*BI^fHj6pR*S0ylkhP-CEyt+@58pL*X!;^Ix;A z$6XVJhlfXIIGXZ&q2iR#2B>VP&9{#L8B+j+wcbTXN|kF$!U9&SVYYl=sWZCkM*4dl z(EX578OV5BAVmeSw!wb~W_W|UPNeRO+^Z4Sr<<8?jz4dWF?2|TrkL0)Rms3amFU## zz>JYdAR{Y}-)=ui{tKMT99c*(uwbx3^q|C6&(|Y%-*RJ*DPa3DB z1tZ!{l3>Hgh_W7_;=|(@g8+N19Ac)XiC4X#-^R1%xAX!9(SGyheC>hnJF>INags@) zqpx^=WWq^o`;VuzQ3)MN)`9rX@1Vl9P%LD>spiDfBvbBIiTtT5A2V<;kVL-dp7XQb zm&JJ3-^K}rL9h*qE@(@WB0myLXYd`H$(Nh}OHaVx!&#GF$^*3{;ZK`?*u4c$p~2&2 z#Mk%2FRHlm5g3d#u`;HOs(M&h(3vff)6;kcxIuh;)GkZ3@D-oQF*GW8F9P0c9CDI< zV%F{;lIHuA+ZC4)LE^2gmm!Q*$t4_!!x+hwV%<3 zwZ7Xzn^HR0z39p#5ipP*O}SR?2o_P+X>c7cV|=tWMB|9`Wc53(_muJWYwAdQjaFM- zwx!LPNB4E&dbgZ(HQaov8p>=yRXf`qbp8?6_Inf3x#>RIwAaZ2o3Qhvn`JMhtR4pM z9>+7;Ptr-5A7|=KyHSu{ee}s6+FvD&xl-7+3SmN_M4b%wbA9fNO-ri-V<4d zmcLV_rl75BZ)s<~4X>`VYzKEXzkBHKQg?#|6!SS|&df))$;WM1$zzB2r>i>;=OSYd3&;3p!KwKVzvdb<#}jouhP%as z);%Apo~37EJ7~F;AWJM35D#!Fb-M`mz6AQ)^*Zu>v4{)e4NEuI7N#X7-BEu*K2R-~ z{^-Kt@I{Dw;mZ%?fv*3cOjBksU{n$%N&#x`k1sT6z$h(qtCoM>qi&UFnf+IPuXih9 z2`3yL@kNd2`pu|q>y(3!sjY&o>jhX3(KPrIx@)zE#>DgLS_0{F{9i4C)=f*mQ0j@e z%<#)eNH41hH%PE%Tx}dYdbd$PTM8Un4GJ$XFY$kFJvbGjN@D&5W&l=pw@)}Q8nO?= zZtjC@K$)Oeyd0@Fi)q9_Yc2BC0ztC#D)R2ql7QH%k}gPRhDdd`9RMLrv;H@cP>4_b zUyJp@2}ER^3C(|{Akh5y!iabU#uAaz*am_|Imt5q$Nb2g3$AY5UB;lZA;~Z~(Z$f# zw&Z+H9mJIGJDq9os4!*lM@$au8NZPVZ2 zuevM=LAcS;&L1AG!+=E@%h6#_E#;cd+Cn=>iD$rvZD{tm_JWK}+0tHUWvBx$(dc}-p}vf zIGAy)Y>%Pyd3JI0Y8kQZ1yZB<5fG>$dig+c({*f0ApkBO1%7bPLmHg3=S;dwCIm|? zNQJAK_#6g~Ux|;7k4=UJ{CxOo+vx_I&>H34H#S;4cA-_TE2r7H?G^_f$9s`N(-gVQ z*R;&7*Vrac!dQNaPo2!6k9+c1Tx%B}^=hkJXHTMchLRw+inSZemw%6&EW@E_DSQ#c zK@#%ki(f!Qdi4Q{_(058YOM2n;4X?L9`QYkk>49mEmQMXGz&0Z`>1$w*hR+yTR*dx z2)Ftu4Q^_L>{{=UPH-_dQ$UEiA&1?85qLj}7Z{VsWtzkV0v3zTJ6w?^uRCLFIE0$T z!ZQg%biPgXv%By6cVnoH9yS{{?d>l=evcKwhVu5j%{heOfO*P<@YOF$4D&hml?#6{ z?g?(DCC+g~GuH*FJtrMA4T4;25!|L$rtCwg*5cWBDy}*BzE!LO$q3k3uX@w)I*AYoKCn*fxlYJZ*DMmEz zAbc#RcTR+AqR8)ewyPpYQ}qZ5MrnLZTr`6=7Z?a%P*~-VN;02KXiwdS5!je4$jYRb zlyaSmIwi{*M#3l!b>G0d{UNwr(0f%i`?fXH*Ky-#b%B^rfNIbbgZHLmeZh*-mIwz? zGYwd;V5Gkxa z0w1ONL^`sRbyn^B=$O+Ga`SU(>6n>GD&h$&c9fkZ)xH*ODqlwEdN>M~&R7Vs2sE-z zJ?ypIjdC;;Y3mnwU$)#e>ci+Ron}ra4`(jWC`@)*0A`Is9I0$DM+X=$ODaro zzNkp43bW_!^nI>S^)d)8_eS>Jb3@9;ez+%3vlT#SS;b@3G$i1-9!L6KLkv)H8b19Im#J zLIWGEMc|&Iy=8;SZvPx)X3bb@cQddVnIa~IKbuswC6ONMzv>N?`aEi(@(y38+PIYn ze9~q0<;kPt6+OJZzUv@m$0_q+dm|;Rx$XBFo?H$^4c>@8l`8WVr&E~M?!}1(JQ-Wj zFU7%(^x}U?i)wKwngJiL%~3u8pQ+z_L00u6ojIOX>qnSsjt^d*hVxHnFaAiOnljI9 z3M-H`6d?3#<|H0GZm`geZy)6?knM$|4rV9!@nXEK(faLu;*A7kzi8$W^*t(q#3M+8PQ7VCm%mTMUvw4k< zi!L1U{$A&un5-!Egr8l+@OkheQPan3#fP2);A7Z5XL13owH~p$7Mf_igRAK}r0>O{ zl+usnqD#xn0wx~(*S?Qzgj%AD@zCB8I@j4e&jW^-NA=OZ_6+1EG4Ocr*9*FKFc27p zXz$Ggrh7B6eBGm)SY^E0+Gb-(bCNjHxjCJQgjmXMrQXa(V}>~)SnZ`!Ih%>}LX8R= zMsEi$_Vc>p=~h!*3P1RKI+z8A<_9yrSyr$4c9*{VYJPqvz zlccdz0@|v1^ulOfI{3KuSAJ?F#WoCC9%8sAiki49px4;~85eKO7!Hx~1p{mcUhp^F zdqjZJ+N_P^b#RO|7qy5yl*+N=1?X)=T5Fp|ehBuCJQl-M^(jM@9)ZH4>EN(tb(HDX z|7FC*BtykbuZnU5-itAvCejm1)l5?ST!D!=Pe@&_j0|_Q>x3yNmyDKtzH4yBEXBax zUUfTdkTY!j9tD0GnZ9BE*9qqb0N=IiA@(qRj_V}lern39{V}>63sJi)e#49{TUUf* z=0&^0aqQqGK&pGso$Z`pQ=>XJ6^TUlTD!M0yKMUg_0 zLbLF0XU`dpqLk?6)u*3~y?Sy400FRk)QwzZtjv>eS71*nc&thh$Rt{TuxUc8e#*Hw z$zF1h49;ws1)+p_Z}jDT_+lTcmzO4}R3|*uz8s=i7-{Xp`5~MR;C}Sxg3fmW{)4Mr z@Ioil$7Ulg`%5H2jwdl}c|xi5t%Yw3sb*K5F@|`jylLPp6MAT$>s4`i?Fw$<9_sv5 z&AW;Yb`h}v+C_*XLD4%({9JTFN25a-xPg#-gJIoq3*!qznXd~` zHGo5ulLv_SeG#R{f3Va7kT+YQ3dA4u2yE#VvczT|*mz7f1Y0$EU&i9AzYLl2X; zWDN+3ucDxI+Mp9J0*}7F;~hS_IB`!8mURu0hIryJ+%r1ON>EDa#EzxtA1E{&soh+1 z`Ww>`U@S^S)J-qNCOSkrQ_L3Bs9$3n9?$yGZ6virg=KKi)MJUBn8_!KJPnT*ot3U- zNmpGuvu^Ax$KUa<*p))?&s3-0GZC;}95Se6^Eb8OtFJ#oGZPjhx*!iD#($>LIv~g; z?A)3+<@2Q)7tc2CFu?hMBtRW4y--@FP_IoOtG zLLhA;RkifGC&RgCF5xOj2lS@CIDXMVhFS593C7fYqUPN`yUXror@>^Dlic@VyI>KQ zUezM>6u$gc*zTX-?;ftP8f92M>iOf<^Mb;Fw)fu#lEyuJy~(_<{jJd!W`#@&+;m#Q zgEc+27B7tg+g0tflMDG^Tu;mIFG03FE_R%XwET`t!+O|J@x)oI32FJfpZ!9Bryhy> zZmo7ayEGm7yl$z6co6#poH-MqM{&bKC6S7T&x>fM7ImjQ_~lB3Xh73s=8ELhHKR)W z98{_&8k}B66!8@QyPOc@e2Bi%LdHh1*xjaoG9ut=K7|2(L+$P!70kCiWP03QP7%xJ zoyzic&}hYFbSJ8OmmkQSyAALg8l4ZKvaB1xjyfVyy?Bly50kV&6mwA3`2ym23tdfP z4b7O$eM(-Jg%r!8+hpFB>zPQk*!`gl5KLpmvPk_iXT}B}8EfDWh3(1szO4p^bw?i! zR~=6e$pW`*4xe}$-4kO(>H0%4x%JDH0>gz&WH@ca&1NTel@6(1U}~EipN=NXwHXL& zlU|wYtLm>-;2DgzP51oe6!4fSY=2!V?A71?-tToJR1s=NOkNk6O}N|OL)XW!H>&Ub z4FXwJM1=7U2*&Lwu`6%ZwZk?^Pw zpA1F?(IXmv_p>2am651nAWBF+L2hVik$Jq(IVm=pmx74H^KxWco&aJAJ#ov!<-NO^71$jZQm)f0VIay78YZ zFw+9v{!Byu6KH~@eLTKsf}+SOfmP~X^{Ou%j2s@bpC($X{dU<`Ah!LmyfY7_QF)ci z2hu=srOF+N>21y85-vmYijI-Sx~az5fYQm0;r3_BBB_sJY)@c6-XNb^x0j3L>ICsm z<3tdu`7z5$%@P4TI+__EdImCVSO#rFAtI@Sp;CAn#V)_l&m{x{pbr_=)GVr|1q^Ry zqnU{8i;!8krB|O)1g`vOBa}jT&gv1eN}wH!prCAPYkN;w8SOh}<+ak{Hl;zW6#QMP z=iv*NnrVKS?m#~Y ze}zCf4QQlPkz3dG0`>yn z)#Za`7bcrbF<(plA1t6DOU9+vWp`T$oat zuG~G9cG(-RI<6!3yxecq0`$Gr) z@W-pbZoRr_S6YX@`$h9m2@U<5vi;wzlvk9{Xz?*X(H5tn@f+s*r~c%YFu420W1v@} zRry5C>Yg&PM5lS$-Sw)Ne=j&o9TmL>!H(sHmAxiN@7bYKZ9_ifA?&L!jAp4>nJR0) zj>w+eoSG^2+Fj>T{>Y{LH3Ae4@mAA6)UT1SWlR)uRAD0JGx+z#>ITb9%BZZx)F@&* z>J!6qKPemiN};47J9n!UWBI8*gYaeVWGk-VL>X3GZ3W^ zW$*{HyYgnP2rhgd!Cw>#QO#A#`bP@T6UJC-A7bMx9DVP9!`RL;X0D-{g%KeYX~wBq>an_LTm!amC;~>gKy!-)~qwQ&??o zF5w8Wlh=O-RdHVrd!GBo8)iwg4vps=4ugf1~FL}Zk9r23~$lv zs#y2r>&peM*AqB#QokfZDN=Z;tSpi)2l3VC%G8N+F!Y8et%Ae@8NjM z&OF;fYwTRl`_^r?aZ9R4Wc^h;JM!bmYZ6>abrY9a0gB?I`$#eZ0Jw#pKU$u^^a)?d zK**TqMce~Ha9^|!u3S|+3&vmN>DwdUN3F;Wr|$(m}}=Knm(7W z$%W)O-P!;@F91TYPDY2UP%(cHnZs*!Ryz88MC67~@hix7tt|s74EbBVMo~_ zFhcdmC_`;LcN|iQ32?@nYc*h=G^BP$o~@fDpQvK=3SEuLQYV*ZpbpIDbeO`=VRKqB zkv8>I+K0u(8j4?OvDf{$C$o&v zvAtj5167D|bBQrwy=U*N_!YMM#xq9K9htA5gwQoXf+b%~R^4L_-X|Ic#-QhI6EV#R zwq#L7M%cR<99{fB z+ZDrJk_>wnsiRZJ#&!dYU3&YwPh8)u8Cc%nF8f^R4SKJS12>>bhheQ-X^C+b81+z3 zp31{uymeh(&_fT*fRRVF|0_X%9=)6E{PTOVw;E*LBZ4UdZj)$=hISRp)s)AFi*TJ- zC%TQ@pPs*cGh@49r=W%1kj)DeM7KpcCmj{%cFq2F7zSM_FVR=`-->-f7tQ<#V4y#7 z`kxEmAn8oSy^l4X>Aa{qZ~*TUqOb?JZYl9N(a*Qz^ylW^br`^R7s;a%UC5b&SKJL$ zzo#JoqN<^&u)2jF4}_<_TrGwe1z^)3ZgrN`kh1up)_A|eql`qoO*g=iaPwrTq6wIq zQVx0D-*<#08PTA3(vG9Cw;yTm?5_}`v~vP#R;(euS@h|$=#^iobCL9p`-Gep5e;~= zJ99>5FQVZ?u{?SBIBm>-9E^mU~OXmj>Tq6IF$GU0S`SGZiOf)zX<1ZuXPh%*7_=-BH#`E=d zIV*S^##BHo4xs(4Pk2tYVK-Z!09ovdd?qMh`iFJ#6`3fz9aOBIx4ALzdbI%6 zJ~~n_?=%vIJ~a0c(jM#GOO$~WjP?P`(Ne8?ogS1DRZDBj3@(f+YL;#6M$h-(LU6HZ zZzpVrFN}JA@o!}JG2eaIIr0CR>jb>CfD5(&7{CD@h#Z@~Az{3enA+eJM_;+HKOsws zW0Aq*Y`kf_7DoT$*_|)(j0UUd+cPe&Z!6FdxK7^R5AER6`fvjNNM6N{W4*^$NDPd1 z6z6p`!DvLn-m4j_cc70VP_%t$%aDe65c1aJ666ya6YeY97ai|&dLIxsg;R+`9MFni z1h9Qu*O%q$a*?!%bi;UoVv`Hh;lrOVcKtG*~{eVqAeXg|4eHPLV zMnA|Kqwl3t_A+8;D=e@#;|Bt8v|-t+=ExocKu!~QfU}b0OXwV3(0IN09Q41t1z@P$ zFuB$@TB-5afPwV)DFFqXr#-({zuvU+;t!r9@;3e_Y|Y9613QS5$k5!yfc7webA)TK zk%x1u`;q=OZ(Rl!jHibAiNT2sgiedqZbG9(d_T03FzhL*TEy}K0|Rjv)H~AKP0uUv z#5JM6X`|QTPCFUiM)Gt{HUKxcBeB(>0m>9ruYz3n83ViFUI}pTRHa@Yr55-x`VKY% zS&a4LM*r^6kJi2E0xrN^tS87ber{Mzu(I8|iuZ?DV;ZMX>+b$MOv6-OnPA!i5Db#= zF>GS22^5fK49}UUle`1S&!c5wpfDzzOFmqvh7cvYcC@7Y@KlKJxcF{&6I z>L%9JEyOhILcwV+9v&Xnd&7!HKfK4FRTEFepBX+5V>2+=Gj_hpN@$7!h5PXVdGk>T z1%bTbRx{YS{qoA3P8IEei{zBeN0EhI)jR&{W5? z9YkT>2}+UYZQ=N1rFQpWv%??yJ$H@6n=i+OjaCa9+X#j}E;rVjzDpieTB@jDFWGoH znjN8E@1uE~`h|Vw2VO#ZCJDDY3c&xZovY)C>VGuPxop$52g1X*>^K=-Tjb&eDHytx zxf-sP>Qp|Nk8_J230Y%q6xRXoa3UU;*4Vb13EC_M?2%FHsS+bO&AUUL)!S1$s<7|639YyJaq{ zX^X9H9?T0Pm*9xmMZl^Xbdd$q_q$ZySsH}sbA!)dcmV|;;`8y43B@llW6yq4u%;<`(3K zXz-?hnGAZ})a@cFpTbf8@jEdiDiSt(<>#zP-?#myKIxpLoV5903CFX<1262|PxjOX zOU8t>JUlKyi?;XF^<9q=dlB{w`3VQko^zM9-h>IwES|fVv&)C3-2<8S#o;vWc||S& zwr|6P`FtdResVlkr)}H0(x?2l;QD(}Ety$9dr^%L_g?`=bRVOw^w9J(v-Ut!qPy7& zd&hL~-dK6(O99)ZR}R=Ua5FC~ZTyi>Skiz9c*E+);w4QfbPM9b7^tUXrHVQ`YBg5+ zrmM;mn?4bKL2{=(+Ke@8S6m(Yup%i3jL6DGYEg*Zd>gCnau-)C}pR?xwHY zEv$!!%4xXb<^-so?35TU72 zyX94j8i1ut)8Tu7@9{>mBa9c%zIs^7PnIVW4-~&9@g))QId~Hq^(w4vi@^xCrM>8s z9>r?!1#goSU8Cg7Sk*o(QhP@lxPSszcltm%QtzVI7e(X#1ptYF<%6onG zB;_`KptJjCCmbXOBL~}qa*MQX`t$Wy%jwxzO-u6bX{N7WYi%apV?(9#*!UT;4*H1u z8}xVbJb!rta1TmX_Drnq!sFKVFgCdCik}_I&QUA6Qz{lB948ZBaFJ%vONg0NIxP9C zYnOmMlOQxr9sV+`-7pQWvQ(BX&erEzJPGS1Z18|`_aI| z1DNiS;-q z<3)rX17>X0wLczlO@E|r=T=%G(TZ)%eACt@Hi0Zq(HzywThQAbmIP-=$3@AX&NPw)ZG;Lo{Ne={t^N&-mJiQ-g;+xhubYYk;(wO)gy;raMBUw>;)Hh;~{(R6<` z6qa+ykypgpgBk3)mVWd_A4(RD(1WE+$_n+i^5t(x6hB)V{$oFs4lqFsK=4R0R7VRc zrc=N^WZ8fP8(RD4+mclQU}VJHAPTwy|IgKO`GOcX^P}KYo+;r+r2cI|WbWMti23Le zeM`snp#I59MDLNcCnev5H=LC?f)Rg@iZqQ=But=V0A?g|ObCx-y2Z>Y|APhi(rZUt z(f~tbTib)aarRQVXCFKbUFv+IqfuoA!`S=Tx-TmNp?1eN2M1<(P~n(9K74$%ZuOj3 z;R2hqf35Rzpj#ME+B(hj6c=>U%Ql7Hd0o(kgHGD4NvOl4hk);AQ(X^us;pn}ezL;{ z4807Fhtfg$fJe?6|ExM008Vo9M~f3RvIwyezus{WANT>Y1$LmHk5KpQ zV3)^%!jUh?IKro^F|CG80N&V!vH75nZ=aX1=z&2Cv-nk_ti)fDmPO9|^{v|`Tfoy~ zMD4cU4=uN>4C>(J%#{=iRo>=AGP=kdIC6l1j8U_}74r!!d_VpQiHb<5Ha|`NY)9Wq z;~dgXBmG$t76X$qvBF-9j_+}Tsoo6?GZPU*We~y9W;Fou`21SF@oe8nXwK zQ@gsY3k{bZ*Z*u7-(QjjU=;BYn)OmeDgFHe@Rw+-l>}b$tgB)JK|r%m`pR;z(k++L zZya6=oCjy9juO<*&?#%r7D$i0CTscl@nZy?ITtXdTOUmlK2~qtTP|gl zbnahV&}C%s=8=ew-vA+Q$vP-syNy<^XygOB+p^)4ZR6eJgj(v^tUD0c;e)y@w7`)i zarsEl;i`2U5=a@m^ua-0l-0WS&tBrJ&gZ1H^eeQt(2w#WboF6}zkV>QFZTckH1Wpw zic z(|4w3a{=@wG>!9F7!{OIcGHByNz$%2)R9vucN({#nxU-IP8pfLxjnETf303 z*Y~+a4;jM%X^@QS6R`AKk1he$n`uT-OjcGCR*7rq8*Jmj)9;+v! zWe{7IBss0-W2K?F zX-DF}c#hw$hQO(Tpe51no+<^kcMaC45nK^mM@l)q&O^Tm29r2>HD%7wQ!FBR(kGQU zTtTX69e`H}+9*7@I_dwc%D<3=?@x%ISc9}f+E3ax%)jcc2-!CL0ITV|D#dX;Qd*sU z6Id*TGR0#3h|jn|e)S)wrm0NWv<9Gt0)GXpgy588x%Qh5({;|xTbhc#>d<5UDyL{` z%e>sc{PoK)4jck7ma2@bDw3k4D($@hw#**g9@LSl?Sw;q1cUqv<{iJtO)L%BH;M=n zxu$`Um&Q9M=DeUFW;04_NH^%~F7G4vFR1+oM3IEBF0)(V2w|ZPqW>%wa3~Ep$&L#! z5tSc&{bLe24-)3Vkcn5`R)RVu6SxX1*#-*KIaJr{x~^}t17otG-AOah7_gC`cwB97 zxJDffs+VYn8e+Q}wv{Y4Wb4JvGw~RzXrv}#>IuB*(gCPM$j3|v{yEgV1(+r?ln$tn^4UnwY246WeMXY4+Y!Y7m;??}8vrO4uwf7ch?QhGDeSHg zeiR3KHc2Of$H(D})YMS-=qW0f-Uv3zO+V_$@6?$^5r|Ra3@z{|5qccfn)EPijYVXI zj`UXTrl=k6|Gp*Zr)?DfPZr?-S)>{zZ8F-2yzMu``TyWpkVo^cp6TBo`G!C?D!U>O z>Nd#ueEoY9{{0_7(s2b>C;snmhy2JF0U`{A6=`1}MS2wmmHkHn^PA)J_nJ@``XK86 zhK5XAEb!}iMv>JeV9w*AW)%LdA^$5hA0gQX|NGD1;o}=bMO^)3-2i<5n}pBW|K!kr z{`udU&p-dchg@`iNqGf$@rrg*x1~cs3DL(e5!3XR@gb7U{}`nI{j9tZa3K`71K&w# z&qaQ!AXHYgW3M3m-DZ%_m8pjf-hZhQ86J>$D0{&1VuRNcr-*tYdq=d?DKQ>YrEn1V z??)y?@RcU0@zml4uSeKZQXje|>4W`$DgYqp6P>?zRo+UYQXWNe<#wanxMnb1NrQ8z$FZphx>o(rP^-faOa+{C2n1NQbM^cni-9rLOYi84mdEjX{~ujf0TxxawFOa1KtQ@fLZp!{MY=)h zM(IXCx8!;saZFsJDk;rp=p8oA?}OEuQ%5l zo%9(UXv@ny+0uV&!8`jRML(wN?0dZhTl%N%S7rF%4mgH2)`C{m=ojSojk7C{NLMX| zVz?`fD^f~1M*}$v2Udo>G&B?x=V-eCJd!+K&;H=C<|X=P_GpBkZM7XscmAr4e3Q|+ zZT(Jj}Z^8OMUNWTPPjKxzHnp3?yV~~5p ztk&1J{DE>v?#|Tb8OC0cd*V~3OQL4$YIm&=%&M)uV8q9waZDAFX&;6bnJL#5ANI}# z5)tdsVXKNfxe$sUlV|`6z-+3t6Od8~iYVq16*sOCH98_wf6IM`S0ySTsDx!->Mz>t zA_OTY!{;b>HqJP#Hm`ArX&4xmX$!OO-wllX!(MsTu)^py2RaF9U(6B{E-K<*fS$_$ zz#GXU<+i=6mS?9I(I+ThB9nd}z79nHxVM|D_%L<$*-6-Nbn6|MKc~`NH&qPo9f-69 zg3;|kA9Dl#c$FccldQVXjHRDGn&ZJvX6m|=JOE&wnVOVk^i?vPAclzpAKmfrQDAo* z^~8P<9_C8DmKX0tg})RBK>-eB?D z52J>+SAES|QhD#n9>Eq;{UC->H(cyBq@(=CzF(C(buQdtuqmSDkm^nh(qIN6>`OHI%i`YU{zH@Y9f@ec4h=tJ#K(#b#E|h8-W~}YZ(ZO^^-NEq1Mk$T6eWX4!a35ZGMkV4UTuM28FR#b?t-j3j zY5BIgH!=IYg%dD_gBk!PKIX5J9c8&;*tfM5xZY>EiMHgrU!MOAZZhtNK1eS{KYIu6 z0$)>{6hT!?_M;%-3V@8gxI2D=;<;K%Ikbp))>&QWtwjgsDC_9Pu28eLRO6jcEH-`Q z%<&!D&30p%Ufay&B+9O!rbxZ%B;=d=MA7+1$P1Dt&_(-%vOB4h{t5i{xQkJh+koh{ zHv9C!ktl(1iP>DOy}DEh`TllH5rK=i73p04&8m5}Z(FIn< zD8m!)@m}tKsC)i?#`E+2=JRdJdD}D$8O@TFi=UtI#k-~49rTIB7d{9zi|~>~62_2cvKjuCXarZ?OB>Ycr&o48 z4YGrdyHnI!+E=*~ID99ap9QY3>!`I)PacjK;@n(MQ4d{AS0HP-b{g%nBBv{-rtX`z z(#dw6tUoZ-Pi%&6J{Y2t6*vUC#m_d|s=&Nu(1Zsif?x&}f}sNNp!Q{dV_aHl%^Db@ z#h!S3q=-CzW!&25u6EEH1<`W6$+z2YZrDahcD9XbYAY{u%jzASW}T5onmJ+>BYnFC zV7t70iZaPHaEU>9!qrv9AqPuuJKDaKwZHzbUe)3!ZtM@+ivP&7veetX`$hb&6-AZo zBD~>sX`%SiELfYvaF&&$`1~MS_T##%YCOIT->Jx*aSe%R!SU68emN1;eBQ^0JPxMq ztALMNTEE4my*rj_d*{{=s)tI!q9l8;`&H&v8bXaNSmKgtmykm`E9|8eK2Vn1^{q>$ zO_dsS2%uqMr;VKZ*gD(qO*k{@->Wi(TdY@)O6As$`;*cSQl!lPo97r z+d8MZX@!jr?pklvt*1E+p68~{-QRKqm*1CY_| z-AYEmqSKd^5a%?%?~Z&tX)_h93LCSK;nkH2FyHEqEsul1dbCW+jAJomekQH>YQdqV zv7*3j%c%5sE?`4!OuKmsL#d<`Gn?(ET}eJSdWz{;2r%adJ_y0v>qUQ*hr&C#8U)VJ zy&-N^sdbP=3U=|&$vQu%+)ELTZGRxkDH?ZiTXMu*8Uh!IP}yT~>0QAf`-LI@%;pv+ ztT2UZ>3qhW*N{dSHin`_3m`S6To^*IJGAq`_L|wnj`mXls47NlymWYG72YcN6mczJA#-ScJqNyNgn|_ zx#gstqq63N;iR3bA34#mVLZb5_RzH9r0hky0Zp4*B*zZ5qmVGgksRwveIpqKgNI*e ztqzTs``t?z@!5~ml|wGZ59D#sM7fXD1@NznmM9o_bGvATJLch&__ZN5(j*oa&(#R+ zN}VcbA-W6XSQ}7vCnWp#E^VP#+sb%|W+_8^3{2 zlMxv*GB7 zjM?I%Ew~|lIe^Gky|TC0JT+pJmoL{*!~#N9H^7=yWfVEXQlZ*cxb|5|^^@^1cY!cL zo^cSn2>lFO2wlN@;E%p)klaGOF|s!Ob)|4Y&VYJ{f15|n#zf`dE0sR>siJ9v8g4QI z<0MR*>FT}#i*ZUmK6=*7o zxW97kbyB5ImLr|iKHL$}t*nhj%!R>qKE|GFXE~E)T#ssO{Z-n-g-eacX7O$23x+Pl zj@8AX&}F)n+(<@hVHH}g8>8wHOeCI+@ciS-xR|Kn>)9yol#wlOdLC`9vdx7VXubT7 zbluHT-P+Qbn(-8=5<*Gk=K-%A;0$~$7|3$@ZHoE%r$iCE@w%yH zQIG2DLw#_9c=|jhlR=YpMH%|?=(4bj%WB-!Ye%MLv|Yv{-nl|m1Cc1JZckH-t0OBl z8bVax^zg$$-Ly+2Ux~mBIo|z61TGnkwQnxxFJ!JtUmOPSTCiAcWQ0>VN)bVWWDt-Q ztR{)5oklhX7>Or~^t+Vyul2jOZf7b11U{p$CmQjmA_mS9#=a`_Ja1atORBLZc{jcW z8U?PN0f-G&A7H!a+N?ipui;Ur#m7Wp@oSkbHr^?Z-w#B%gD^H+Xm0XkrCr4%|69u# zLH~|tn4H;f4d-pO-v<%`P-c=I*8l;wcws(5Pu*_au4)S*bgQu9>7GCictKEx5&$5t z5^wRQp!YJ~H&%SpIoXNxEd6X>K@R2?AFJ0o*^0C9P#wGIz!XPil?uhUOCBGCl{eOe zYiNZtQW!T&?uqf3KEiG@IYlJ70xfyxen`GFC zJw$Wc1+CLVN%!mZP+4eOwdnKeB_||Q{+0b+N^$MZfzwKnQoL`hatH6<^t~O7hU>p8VaUYV!z~TYidxRsykX5k2aEyk$~P35*tjmIH9>VRe|B{VqsqxcDFsCOSmOH?!XQ6&|&X%uCb6C_;SY-jsgCuCkq1S z`>~3|J2$MCjI@)@%@US&b-~4=-x-o^)dCI`ne&DZMB8KJb}5|<`KUE^+KrBiN3!6O zV6|-CJ|TjJhuMVn@O34Nf9`jxS|xlIyPLR$)<-%IS?P%VB8gXp*y{a zf0r%rOT>&2yiY-D@q%#kV`t;VX=cNG#{G_&YL~LD4tIv@(au}-qp*SfU7O5KJ9w|! zB_j8?sg@U+4<0%+Vo=ozB(kI|HZFfl6N`bP;rd?O==fs1o+W@ipi&<<*wpO9*h__^ zWWBSG??s|2ArP61`EXnH8Vl%!DarM&uY-fx%(jRIE7ux_Y5PZ9PO43Rlb){%Jfzt7 zJ50_2Ng2vRw1WJRik}yDv4TlE)f^{80_*j@VgQv!%2}HNxt+^vnK1IU;cEFY@+{#F zD9{AXK(-g}IovN}69f|ZzJPJ(-)S!ll1vbLl9Of@5Uhy3VT+Z@yiPAN#Ya$LfPO1sSdby70kbmR9v z%aFLLGO&Dr@<+&;?scAr-pdV?B%<9z9%#1D6P9m>j8D)DqC4Gt361fVh+Vm_tv-ff z&eW)#UWneFUh(h<%&&{^!o%Rso@h)!Ty(W4Mron%AGYI2sL}379R5hNY*Dow-WkEa z9wvVGT#!FPt@d(J&iS5S<#s*KxO;l>41H1kG2$2o%vE7>}$>!%%~Do zVG3i;TEx#$p=ujQ1uxRx+B<_McO|+|M-4!7K~7OYt{u~>Pl-d>Wla$h(y!)o+6<(~ z&L#EJ2=T^S1RY^Q85=s{@(1L>wj`dz}O@p1;Z$Vi6agR#N7cnp~(@}MQ9|?=WF*eQyQQe zBPEwH6VS#FRuh&dJlB=1vOXcZB7EJp9pC+%Lio!W!DlmCv;c_dh~F4E>G6v9(e`y< zWU_WJwI@H-wk)P4GAL1fcUYwyc83z*ZgeCW0^vp>9*2BIrPsc_^mlh%B1yBDBM6A* zgiZ!9vc}GChiSf??q5@W_DcEQV=Cpkhl?HMn*0Rz>QdY<&oSi#4OY+_&hJItY6+>;>Ct#^XdxJV`I9hl^Dhco z0`*?>K1VpGk{)qdNx5pG1Dm?)?U|Oi9n|0~OkMAn4EUqYk4Mp@8PhTv8bu&iyoqGOJv6fNr1)lV%#-?uWlMoUC%bmI;k&( zoR;u53l}6$Xr1eP)4n-B54F^o=qP6ncJFJ7tkZ*hiEQ5)hKm_v0x(hmRs!M+tNcH# z^!LRdwzBg8;$m(qj9R!mRroa-s+f(H6@s((#pvtXVz1F%A3K^y3j~g%fJwDlsGg9x z#QMacaPV~ir_ns*;VPN0R5%O#l|}c;4o+`4{dBM)&(yQ1;Tv6>8=N&rG<*Y*m-v3G zbC?s5W0GHyJJJyFKA~~v#YE#+uuEb?T-UUjKJ`EDw}`zy+B(&~=xYYezTI##XvLLT zIv8r*fl+mri6SkLEBbYYn#~Co?!P4DZd*gYGr)3=K)Q<#9Lgu2ZjI?7Z2ly~X<>(_ z5wh$`at*1FzMVWAUIU{M1!iS)WmJYQx71C9Dyh+BhZOCuSGBq4d4&rFb!J$7)rt-{ zSuDQP{=P+UsR4>!e>ST1%dn_G)*ncQl<))ON1!hZZ{_m4NahNs){ zzoh~Tv6Kh#O>_&$^%B5lcsp#=`L`Ya`Kqy}uo}pKXHG#0ly%TkKUgHdeF^eV)_5{k zzX>s1Z#wMqALmVNHkiL{Y~0wi({{O$R^xnv>I+P<)tO6T%#QK$WV9hHA_B%^u)t3* zMLJUvaMUmPUv_e6Nn;*ab!kpaE*G0BjNv9UtY5pa_>lxrGBo$dGG*Kbem(Z?*{l0> z)UQ)acRJNjye`ZK?r_i1G|m>jNwiIRui-MaxzP0eib@r)q;R?tYN-x&b?YYi8ob#}gGcUyxOzbkQ==9*=-%9=zrdC5)>!^%uB>RF-u?OKH9t}izZiAs9>UIa zl__**su>z)nCfV-0CGjqQJjTE9K(Xu07g^@UXv}3rDfY>18=bNy%%gUIX3*M=DM9R z?rQU-W`1%@gq%pm#L=K8G&gyiotlG!UfQ=!%|AFf-2PRlJz%zt(=>Qe%=iK z?_5CSWG9=*i*b(Nj~~~>q?>Xz6xZ>D<{%a9hpY%fJPI`9eeImt?>YnA$tKZ)9-Iry zK7=Z6Zae_+?-&cmh6mK%Y1vHSdAogd6Tw`l(*~_3F{0`Uk+~SdJ!?7aIvf(e^xrJ} z#m?b^_n)?yf8&Ea3t8WrYk02-{H_4=)BQNR`-=#7QVfsy$*>vd(-OKVvtAat^2WXm zE%KIJjT8fTd~pv*?3yG4LX0okk0_SDgl?s&AMakV-y(T?x(5ww=B!z#ow(VF9A3T% zY0TgMHgRG+k&$g5eScHWvj1enxrtb`1<&BY@I43?_%@EfYQKZGX3H)xG9>wmT znN-tz1x+vZF5Zbqjk)Pfq%Q#-zidryU$aFFRmurEKJeH12a8S~Hn@No_Ke=Jt%UOs zc}Q8?waQ6ko#vnI-%(%=F4b`j@9d;i{uCe-ARjs?cdi~vz`ax8ljN{ratj~+=#&st zgo#|JOX3fz0_Au9WAnKJ`0`Kq~BEfqNwZ9N$(>*_H!&JL zKc4>qK?qDq0YDOK6?;)41dAR6D6c#MbIu2g5e%9$DOL&=PEq05q8jbQT{t6?+;W0Z z_Jr_^5?v;eS~|u;NTHlhLh{7QOy8`C1b>d8u&|CGpoIosNmWi_CM4?lHWD0umS+rP zqXNnR9nUDx88NKq@`8-3pc6W4uMw;3S%O|=j~5&JJB0b-BG0=sBSR>jppMlS``2kq z4498+?R&5`JFK4LC1k`YgHES=2Oc=1>b?o~5$oO$9lnxUYLrq=`3}{E&d_U{)bm2P<>ZaL zJe-n?MNq$$S{n~y|F%NIuo}gyOMAycveeTi^VK@yHyVW3M-VUfrm9PA%f0Di>`MJn zbsovh-IS#H`Koi0hML9(=RTgardx)U^@X|X8=i&*(Tn-LQ&-o6d5cVUH|Uapz~x3- znh*)7=5o~B8So%GVf(W_diEEc6Wd3`q95_P9>`f+%Rf=~PS|g?>f-N@g2c3$RB=7V zL*iO0^`;k^s>8$4aM|+MPl<}nP;NdRG%cuZsXNt=S$8Zp^~v~W@T;eYbb2g%GcekF zLd&_Rut%v-#nYo)P4y>E+<%xr04i`t27pGpM^_X`0T>ZrYA@wI<(U&+uy068r8}DF zD?r@cYh3e9eDLZ34A!lorEVTe%e2exzAzd0bLI^+Q{p7h+96;&LO)z2a-kI^u(ofM z;hEl?4J8T;s$6&ZYdJkH?jmv8Jj7!hNH`psO%Sm)0I^Y%B)qkSO!TKbRl9bVT)1*M zoiLzU?z*HKmN5NBNw@nITDKHDwC3P&11Xww262|27I>|8U7(bE0jss+c4Tx!v8b!k zz>p|OC0NQp8?z#ZNLy6N^@cMm=|vy!l8=G;%vv=cbB+1snz>BB?{u{&e~t9zEao%^ zPliwhCJ&qcw4Ko~D6=F=t;(Of%EZ#nW*aJ(tF)UQrB=B;S+41?v^!m6HqXtIjID6E zDb=YhDI!ukGerd6wi|)EyO%A7nUb8(eYriqmJGW(m8@`Y?%hBuAWpf#IKHDdulqo< zDkInx&gp%s_yq6GfutdoFvi6x&6lOFqu>+QCy&1a+@HUEi>{=T@2l1fwWD*9j<+&D>CJ5P@~)j7!LyV+fBS*LM6 z+_zZlpxoKK?GCzrI;2!y3w~@&=feT>F4>4I!>p9i<(0JC_4gdIJMPOVadVs*x9(E7 zXC#;pueyks=g#^(Ic{uPP;Tv}fS*m2n$ydU-ME|Y(7y1SD*#mQCQU@3VRwYNMvHqb z)(s@JcA*Ypy@&;6S~jg*%v_*=zN`oACh3yz(xAv{E=kg5TUVNMj(vgwTc*Qaqj)%#QDn;=<%xdsTN z1hv6`g!N>-;k2>LzAu-=eU)>X8NRdg$=^e-#%Me<=S>(Z1g?)qBlB~j+y66&`4P(8 zwGZg-KDIJ3#2>s%KpY@^(%pANx=o-4FMpfW4=Xg(aAx}oeY8ycAgiq05iwQ|RK3pm zMO>-&ZBk(@HJkZVr}9h={ft2kZz~Q9jfAUdbCumFc2R{|Wv|KnU5c3s@F!xkXR#CN z=vkB2*b#k&;x#XonmRp$wNZUjqGXI6JHnjjK6E))`njFYCEadca@?+?SzLhk7mm{w zASZK47MJrOH7NBA4z9ZDA3S$$}D)T12;6=+cLOS?b@a=`Oxd zVE}|~^}8+wppg>b*vy=lJKHNAVzvQY2iJg~+7yJ3<5snR2SeWcMDoK9*2`4?jNm24 zw`$I)D>Sudt77_aA@dub>iB?B>zNiN!WtYVdc3!CrCU|!Z3y)%a!YKvfIW|rHJ{49 zL=>5m;cS{g)Rf3CqLnZ`#cW8nBX1kz-2Pf@SjDrwgKC&E3Yw{-t}>e`%$u~OxRHuH zn{WF5SC`)lA)WpCrmmH*B`Rg#hcbj-RK#*KJ^NRu#P7Y`MH_7WL?hLf@#`@S<+3Y@OU3F?3uY@MqRC1LM~zVumsas_Lk)Ic zHIn6$;4!F}{Mggj|Hpua3x+)QKxr<~tC{XQKG+!+K9cIAYQg@kCI5u3cdoaeY`V%e>pS`MVTJP1YO2)rMY z8)zK&Wf!Wbv8a%QskTHCepDMRj_)hf6`v0Qgu9e|4>|GSVZ&b`-KXGOaX@Shb4U(f zUPQ)nbEVng(p~l8C1R1?pZL^;V~>vm&5nlh7DWS-qoPOF$Y4U3eWKmt*|RC%^8TZm z((4g-<{C~1I2L%v<+WW;z5(YSiA)(gl|%12|FiQyIR)UHybJ8NGND}VhfBBsLxzOi z+i~I?@3ivA+3%C(U2bcS^H<-m1LhaD)%k1Kw$wO8;E?$_`5CK*464lC;YisoM zX-FnzZiKLJL|euoRybbYBR^#^e5MC1V#2+Jy=+d^CF5l;(WxD?*|G+fjCzuQ^usgA zd#z}$N+l$Pr||ZLmB{Q*-y-_z-2W?aD)#`EfWu<8+FX70Az(gCvoDn_M4(?-;c6fM zPoAn5OLqb$Zq^DF2LK6aDg<18VyfClD3|zqRZ2%lY4~`REfk`}`Rh(vV<$RG4a6+H zC(do+qT$Tb13@ksnT#n`>2FP#NRN;djotaPBTlowOLq(661@^38){64YbSn1c$7E3 zCWk~pLztrjik`JEi5bu9IFc&uIoEbeNt298f18X8W%@5eNtPP4Y45 zscsjWLjI_4-@`%8|9~L>Ef;s9VFHxYfDVCKGXJ}p0}Sjz7V|SRW3krQaU`l5kr*%4 zUE1AqC}(fyylAVf=0xKna&evYF1m-8|6}dL;M*P7W@#^}V3)eG_D5KKv2GDeXb@c5 z33_@UaV0>d#s>ucks5?72;X@jz6v`JB-`5Lp{tBsT&+}BobTsVU$!%~O4^W`{%iHI zjM^Z}1<;pAUV9~=3zN4ZI@%a{#IS^aHH`i9dcpHG1);T;CO|sI2w|2L2pH83hLB>L z6vV#PD{)f7GBY(}1zWkqL?XU?q&0*xLW8T#yOvKtFc-2Kr3qy*y&C~ybq3Mx)zIrh??TQn^^ur2oC4R@-Y9450Mz+rcskZkca#F6glCB5hm( zbq->{7rVkH|8Cm+^D^LKiy6jN)`(BbZI?cU6AO+~_oR(yP2oRs#6K6Bj*nOfX!e7p_O~RDQ?HR38|6-{ zIbjjmm|$m7|CUagwONwixqx5)2l9JB@nawv0u(>UNN=k5WeIHYiR-KjBVryl*2Jr? z4;aY9EX?q7F1*f!JI8X%WLF*4k&rN5P4AQs%}fc$#5p2Y$L!{`G3xp-JbkR(dfyb| z0Wi_xl8-O}ox(SHG7A-THQp(^jMCh>TZihTF<#!1pP2N)>jdlyvPKESyT*&(#^;;x zot?4-og3JN_+u%R!>@ zXciI=-d%7gjs{Snjj;qO*&h@ge#R;n(*fWv_&=1MRm?p|+kbwh=b)?6t?mlx*t6u9 z9Wu;03Yu$;hyChO-S;~2gL3P71Jd#<(7mFxOdF1RomekMe!3^F=Z;G_BA3l)F1K+a z@Sv;yjRHngLS8vDvybi>y#l%9iUQ-T zNhUg_!cRZSpg+ppe~9qs0wv93^q{7jh}idDl6d+E94r-k`D2lhUh7J(Xegc3=ZLPj zwxC=#t>7yI6n6sln%u?dg3lBkNw;+na9D}+R@io$qf5K3?F3_D4OvcIS-&l0Tz~^d zntVmy$$sy`aIM(gX|UdKtq_Ng!{NH(Q*zBxog+c&$w_}B3b=p9XrzuHEdWuPAT6c& zu^plnU8KC_#t^=?|KN(tZJ`%Oo4Wn0Ge6FgavhO%qL{$lP- z^Yl$m>Mn_UmFu?hOpWU~Elb+Y`SAj%+P!Ldv`J*eA zc?^>Ja`+`Ct5;xTL?}1S`uido9|MW~&0@je?a~3U0Iu5&S~0i#0n^avR7y0Xk5l+3{8k|r!m{ib0=-L@w`CqsoZvt9?q-epllC$b# z-b)}|_V(vI$aHK(t7rc$|F|&L8F~*uKsPga%iP#=n4*I;f4C7AEG;(F2M}WI-j|W9MF=6vo<>Xyl7mDc8r*$P8S-f2g$&%#-Zv7iV&hMSvj*n`4)H+6!A_ zm!D;tK;QD$g{e;(53*8SVe2=tv=+EGVKMC`{G*agy9(dZWgn(xshMU=i6^yJ6gTAq zQ)F|bf0QNX(epKNg0?~S1=;FnRQXsPac^1iD4#{|ADP8f!;f9%pv%d^D;v*7n- zKWXtoJlzk`>g);JqWrePH+=sQ0AoIM1Yziz99FQXyW&`?QtEgwT>B21?;ABIPCkn0 zSacalSzuFaypha^R zml+{L7G}(@c2;5sw}NJ{H3cNk&h5x{M=JmXzmpg|j*ZTG}kaOObvwk@*8 zP1EAF7Wk%SY)krf+6S*@4#FxngtzRt_8?ouqlf4c=i!8evYf=`h(bPUaI9PiK1H@Q zjXMG`n-wFcj3Y10i|ZeL&tvlhN(4D0u3`E5(hH;RQbD>*Ki1}<5cv2%R)&vwmknBZ zZJPvilnP>*R{5!#cY3@;ALGoV&0VQL{+=H$Jw~8gUJ*d^ z?I$COrZ31O*-%qs5qX7=D{5Mlt`fptLZhc{oq!K;?@{x=ZZ5fJ2m#05Oif9k_nXp7 zfP{C!4AMi^;2-2l=SN>u``3X1z)fNYR%ds4Wd#i4TSxp#XoAI>uR9+U4V8*nD2xHH zqD1kJEI|&HUUT#TN}N(XU{V0uvd(`Yih48i5j6>xxI!w`xZoZe)6~qi$G9oxPs891u zDALh1EqV#V=$ zG(}6ozc@AhKo_k*ef-^YmAF~RSe!(a``O#+uoE^v z77F|dLXo;7jjLRZYz;KLZg3_-0=^>KVP~VLvOA3cbNhLM1f@zjP!3WoF1Q*?n}Cok zhE*#-;+LwBuqDf3tiUSr&BI2oSvRa`X$_3L@*5T{}S|2YJgk2qWIUT zfRkMJ#w(#D$R*d(XAIS6Yi_D0*M;IkJG39<51nLHM`zw7;bz-4VhQ8*d25G>;X=fg zS=RWrB;R(n@8;pY>JSsWDcr*EED`&f%Fc z#S|p)Vg|*jBs_H4>r{8o0gvV$BbbUB&9E43DIx&{~^8f%4w~&f> zF#gTCApw9&?fg((r;KrSJ9UY9P+z<~h^e`)aSu++i?YR$3MTTin8`y!g);h)gy%hp zPtov`zkC(1t5@8&OPz@+0iD`?!JtlYJ4v2&)67?Xj}i(mUj2}IlQLFQ+$?~Vc94?u z9vmX@#gD%7&N|E7PRy?-JMZ~;!ihgJg}6gi0oKi3sH;t{waSQ~@JO!ek-D5R7I-P$ zt;$;AsxNL(z^A8|)9nK4nh{RUuGM+wCxBit9RasgU6m&SeA z4@L_QHkO;>n=cmkwuBnK?7o6O)h;YDw|k#vW3-bPZ$)arxGQCa(H^(wFPRM&?SUZ& zDGA}??AEP16}8-Po$2M)o`GPGN0!CE1D|ToL9X|AuJ;_ES0@DxsJqRR zGjt7X&Dbp#8i^TCQK<9`Y$LG$+RH2(OcuELYL$_~TZ?%m(sY1KR?c(vX0G|tL4|wb z&6e^{@*oNeq80o@fFIOo*(FOizMN@SRhEav^zfi89$=2BrCKSbRprIzi9C$Wsmppr z9XaDCy9nlL&WvsZ27V!#z@YN2b5+3z8-0c-7bjV|*?Sdc!YK}L07C*t6vw~*M9MCFG? zuld~1krLib1=htI=JyLYCyd#~GNe^}+$=O^%_;TfkfMsEH@rQ+f6P?T{`HaK-*-NV z+T)n(XakH|x1wg;y zv2t}2heMt3q(fTP-8!mBx8=M)C@Ia+8u@rfGAYg3vCo##(&=UuCGBz~2i(-Snh}^C zMTK6`7mN?|VPs53mnIlw5%LzvCf)J=e{2odL<O?YC^4QHIs^F|rB2Exltt3t=VymyuvlLblUt!Zpmo zv=(oz#8WySj}J8vZ#^D?4M=#)q(7cDGUc0HK^-^95czgZCgBBIBKLc>)b?U@RB~tb zIKs|k4;87VKa%~g-FcS16m-b{Hy?{&!AQ@9ed)?bl84pz^~fq_Jq?6wOamF*GB9^{ zIah}h8Q2_uCws%-$dGwo{wQgkd58=?Fak3(J|ap~yyNRW=MLvr^avqxs^`IPUlZ3U zG1QTR?QDAAC^wjQS_CsHb7ysWfWd&vb z(l>W|5b?UlqDjFVGME9}A0JAO%7(1^h+=4%CF>Y*`Sd&KWe3d(2q_vpW42h%EGOmr zuob?CWF&*AFWD6bBQp%C724ao(=R191N~YJ21oi(8^zJRUe}8Htj4QUx0)?WQ!P+J zh7CSoFh^(*L#1_F@iI!eF#PVm?YEBno07rquuiX@GRYuRR#slEPzyMk8m(I1tXY;5 zH{|9T8exi!wR*#s8I3!R{ie3gBXq0IAuYQCfaQWkU?Xc}_mC0I+4^~c-c{)erJk2I zQ0{4h4Gyx|H=Ps{lKH-e!x^?cQu+#~$|D)F!}ZnY_)1!mN?iLDm7#%|r(9AWStxp@ zfgj1|Qqs4Kq$>~8+>yZ#CXB-OUj8qR_Q1fsg**0xaw0d^9 ztm-f`H^*0rNVx?R6RrxHFX$fiaK~`R{MvUg7VMzPRNkHQ&i9t)4R3cQM6Y~PYvLqjXlDMfjNGE@0Nm%&S1!75CQHzEiR#n#${ zEZ9GIv{{lWO$-Xi8;Q0P3gC0`8 zucKZ{$2_!&#;jBx={SJT$;1)AS$8?!P%h=L&NOQ-&0F9h}yzm|Ij*(yS=C zUH@Cli1>Kq?25u=%6x{SB7}LD{gj@*=D5PS_(aT0BXUw~)j1L@%8=A3mY8OaYhs$f z&-w|0n7H=w`OD}crYQ8xNjJ)k9;sBlNq)skipb^o)zOpqdwgGCm(qx~4wDj&$B@&l zAiO!HCr=-J_BXTsqv4j*JW{QWF@cE&O^Qj+X=&^Jzt!QwP?lAsPP)x%j4bQ=&3;XY zEi>!r_QcGfdh&?ng&)~zNOH}3J$adpX7PGmDx$5w3NN_R`3B#H0xUcls4?NqJ0Tgd zj)T*{Mpd22uu0y(j#i_)DJnyfwL2^>=w?xyg}c57}H|(wB>aKcrc2#TZh6kJ6pOoeE$3$)oti@JWW>lm-B@95?B@Hcjj%fm=*-g?v&_i zE+_T`#~#}xF_H3_DDs)`g%{xGv30l^#0+e_BEHA`<@0)2D)r-=-hgRpT!^*-o6S z$xz^W4y_{VevZu<95+p&ANn>JTaYp!r*Bvxuc)6ZFRGZyc!uu1s1$s(zF9y2M2dl~ zZO_bdcWXO9zrw6~i)<-ug?I2lD}s-{`1g5EYvLFT+_!KaSbvMlzcp{m88s7rD6C!3cMk{SDcS)3EDf%k)f=tASy(8|VhSyTgw6 z?e^X8{(~-tF;mC5Wz3auyPbF+OD?M%+mFH%)#kAkRbD#T=PW zm9FCAJ<`(5E3H5uLQ!5Gu1)R3-CS<1N(o|t#n$4UQ>hBV7^>*C3Dz>$sIb1_FHE&%^ zCIpn--X#Gqy3`)rxykM?JC?%f{D&ZB|J)-v7sHSKQH z$ccB9_G*LsU-qx>7x}iAZ-!GONy_BL0ZIW6ZtVZF(6X??!op$v7kPX$Ma7mguWBwx zxto967wMU8)4Q;TfU-qwNHSH6AzmrI&tR+PWOP!@*qI@6iu{8(6@jt&S7D$0^eeBc zY)Ql0-cY-=Gq%b#W&WnUCE1cWknKP_&$yZU%AR>zz4JEpgxTdvViD8pf*Q9Y3;mM0 zi=L_({#DaU3Iwf&>j=A{YSD3v8aG^#1K#JId}#Z%>$?5TkW_z%c?g}zf!RWtMG}_Z ze#;}%8oqJ^g7J$LP02n3uChfGOwkyjKA3)auin`KuI$JtiBDR9gv_eJayR_m&Tpaq zDLG8j2!-l3#f^;NCV(p3J{aR0mtTzvVhYvOuK|MY5W z8RWJb0DMKy<-0Gr#Es6oyj_Q*N0ZNzLYAL!#^z_g;us8CMt2t5mJIe0kGmsKFbr(H z@V^Q3+ojZ5LD-or(_f+LV^IVo28BN2{WH7YAr3CeouJ^SdI6qP@Jra#v{D0uth^o# zN?~qFJM+<#$nLnlW77QHut+LqeVWV`40zTKJ(-ufTCG_sTA=$pZ?yWE^%UDfS64e8 zN4bqwT6S0{pGRdr--T}>W(8M!h~nwfS1e-Y+ZVv8?RK{ z+=zL0>L!~RC9r{Tou<~&XNMm%>MmlXdJe-^`@e6v;Ogq?OpRr;9jX1^tfQfTiTieF z4(m_b(DIcMaEt=ZXHUi?fI%Dav%F-QAx~bsGk85n9N;)6_Km`lk)JJmpUS3*RIIT_ z5@V_QsUp`3;M9kTPulh$zEA#+_Gog$MlV#ppZSo>R5b8WE1z-}rfskpt_l1Det%s| z6Z;WVQp4yJ{e=yi_=I>q3UVs?J5KZj1_ZVLb|e3BLhkO3F)WY$4uD`#k0nY&OPimU zSBu5E;K^kt{`-dc0oDoSVG*G2aH(J@mm@5xv(~P!*a%0+DIvlvL`kzR)K;GIg0Uua za9AbFc{`8tPk9aV?D_+!mYKb2sNP!jRC=b^gz3_E1)zd8#savExtwKXgx|@2;(^k8 zc^?$_dZtzWSd++om^ttNMOROrm-o0X&1b6T0%J&=cgob6cX87cYkuWL^poykNa
=zRTD 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
tc-0h@`H|S3Z)K^|=Ai7dGUzrMEdOyLJ}z** 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 LLM tasks are disabled by default to enable them add -> make pipelines-llm as task in [moviekg_docker.sh](../../scripts/moviekg_docker.sh) +Per-pipeline targets are also available (see `Makefile`), e.g.: -Prepare -``` -make setup_docker +```bash +make test-json-base +make test-rdf-base +make test-msp-all ``` -Execution of dataset stats, pipelines, evalaution, and paper content generation -``` +## Running pipelines (Docker workflow) + +This uses the `Makefile` targets to build images + start services and run pipelines inside Docker. + +```bash +cp docker_env docker.env +make setup_docker make run_docker_small ``` -For more detailed information see also [reproduce.md](../../docs/reproduce.md) or [docs](../../docs/) +> Note: LLM pipelines are typically disabled by default in Docker orchestration; enable them by adding the +> `pipelines-llm` step to the orchestration script used in your setup. -# Directory Structure +## Dataset overview (high level) -## Input Structure +- Dataset release: `https://doi.org/10.5281/zenodo.17246357` +- Sizes: `small` (100 films), `medium` (1k), `large` (10k) +- Formats per split: RDF, JSON, TEXT (incremental splits with seed/reference/source) + +## Directory structure + +### Input structure (example) ``` ├── film_100 @@ -84,12 +92,16 @@ For more detailed information see also [reproduce.md](../../docs/reproduce.md) o ├── film_1k[... trunc] ``` -## Output Structure +### Output structure (example) + +Pipeline outputs are written under `$OUTPUT_DIR/$DATASET_SELECT//stage_/` and include: +- `result.nt` (and optionally `result_eval.nt`) +- `exec-plan.json`, `exec-report.json` +- `tmp/` intermediate artifacts ``` ├── small -│   ├── all_metrics.csv -│   ├── json_a +│   ├── json_base │   │   ├── stage_1 │   │   │   ├── exec-plan.json │   │   │   ├── exec-report.json @@ -105,9 +117,6 @@ For more detailed information see also [reproduce.md](../../docs/reproduce.md) o │   │   ├── exec-report.json │   │   ├── result.nt │   │   └── tmp/ -│ ├── json_b[... trunc] -│   ├── paper -│   │   ├── test_fig....png -│   │   └── test_tab.....png +│ ├── json_alt[... trunc] └── medium[... trunc] ``` \ No newline at end of file diff --git a/experiments/moviekg/env b/experiments/moviekg/env index 7923c7e..92445d4 100644 --- a/experiments/moviekg/env +++ b/experiments/moviekg/env @@ -1,12 +1,12 @@ PIPELINE_CONFIG=pipeline.conf -DATASET_SELECT=medium +DATASET_SELECT=small -ONTOLOGY_PATH=/home/marvin/project/KGpipe/experiments/moviekg/movie-ontology.ttl -OUTPUT_DIR=/home/marvin/project/data/out/ +ONTOLOGY_PATH=./data/datasets/movie-ontology.ttl +OUTPUT_DIR=./data/results/ -DATASET_SMALL=/home/marvin/project/data/final/film_100 -DATASET_MEDIUM=/home/marvin/project/data/final/film_1k -DATASET_LARGE=/home/marvin/project/data/final/film_10k +DATASET_SMALL=./data/datasets/film_100 +DATASET_MEDIUM=./data/datasets/film_1k +DATASET_LARGE=./data/datasets/film_10k EMBEDDER=sentence-transformer DBPEDIA_ANNOTATE_URL='http://localhost:2222/rest/annotate' @@ -18,4 +18,3 @@ OLLAMA_TOKEN= OPENAI_TOKEN= LLM_ENDPOINT_URL= - diff --git a/experiments/moviekg/eval.sh b/experiments/moviekg/eval.sh deleted file mode 100644 index ecaad6f..0000000 --- a/experiments/moviekg/eval.sh +++ /dev/null @@ -1,10 +0,0 @@ -kgpipe eval -c metric_config.yaml \ - -m ReferenceTripleAlignmentMetricSoftEV \ - -m entity_count \ - -m incorrect_relation_direction \ - -m incorrect_relation_cardinality \ - -m incorrect_relation_range \ - -m incorrect_relation_domain \ - -m incorrect_datatype \ - -m incorrect_datatype_format \ - data/out/small/rdf_a/stage_3/result.nt diff --git a/experiments/moviekg/pipeline.conf b/experiments/moviekg/pipeline.conf index 53fe6fc..5c6964e 100644 --- a/experiments/moviekg/pipeline.conf +++ b/experiments/moviekg/pipeline.conf @@ -1,10 +1,6 @@ -# Pipeline Defintion +# Pipeline Defintions -# ======== -# RDF SSPs -# ======== - -rdf_a: +rdf_base: description: "Align source RDF with target KG" config: ENTITY_MATCHING_THRESHOLD: "0.99" @@ -16,23 +12,31 @@ rdf_a: - paris_exchange # 3 Fuse matched RDF (threshold 0.5) - fusion_first_value + # 4 Infer types / align to ontology - type_inference_ontology_simple -rdf_b: +rdf_alt: description: "Align source RDF with target KG with a tabular matching approach" config: ENTITY_MATCHING_THRESHOLD: "0.5" RELATION_MATCHING_THRESHOLD: "0.1" tasks: + # 1 Transform RDF to tabular representation - transform2_rdf_to_csv_v2 + # 2 Match entities (tabular) - pyjedai_entity_matching_v2 + # 3 Keep best match per entity - reduce_to_best_match_per_entity + # 4 Match relations/schemas (tabular) - valentine_csv_matching_v2 + # 5 Aggregate entity + relation matches - aggregate_2matches + # 6 Fuse matched RDF - fusion_first_value + # 7 Infer types / align to ontology - type_inference_ontology_simple -rdf_llm_schema_align_v1: +rdf_llm: description: "Align relations of source RDF with target KG using LLM" config: ENTITY_MATCHING_THRESHOLD: "0.99" @@ -42,96 +46,122 @@ rdf_llm_schema_align_v1: # 1 Use LLM to match relations - llm_task_rdf_ontology_matching_v1 # results in er.json # 2 Map source KG relations to matching target KG relations - # - map_kg_alignments - map_er_match_relations # 3 Match entities with paris - paris_entity_matching # 4 Exchange matched RDF - paris_exchange + # 5 Aggregate entity + relation matches - aggregate_2matches - # 5 Fuse matched RDF maybe only entities + # 6 Fuse matched RDF (maybe only entities) - fusion_first_value + # 7 Infer types / align to ontology - type_inference_ontology_simple -# ========= -# JSON SSPs -# ========= - -json_a: +json_base: description: "Construct intermediate RDF from JSON" tasks: # 1 Nested tree Json to generic RDF graph - construct_rdf_from_json3 # 2 Match RDF graph with seed - paris_entity_matching - # 3 exchange + # 3 Exchange matches - paris_exchange # 4 Fuse matched RDF (threshold 0.5) - fusion_first_value + # 5 Infer types / align to ontology - type_inference_ontology_simple -json_b: +json_alt: description: "Link JSON objects to target KG" tasks: - # construct TE_Document from JSON + # 1 Construct TE_Document from JSON - construct_linkedrdf_from_json_v3 # extract_json.py - # 4 Fuse matched RDF (threshold 0.5) + # 2 Select / fuse values - select_first_value + # 3 Infer types / align to ontology - type_inference_ontology_simple -json_llm_mapping_v1: +json_llm: description: "Align JSON path to target KG (ontology + sample KG)" tasks: + # 1 Use LLM to map JSON and construct intermediate RDF - llm_task_map_and_construct + # 2 Aggregate intermediate RDF outputs - aggregate_rdf_files + # 3 Match entities with paris - paris_entity_matching + # 4 Exchange matched RDF - paris_exchange + # 5 Fuse matched RDF - fusion_first_value + # 6 Infer types / align to ontology - type_inference_ontology_simple -# ========= -# Text SSPs -# ========= - -text_a: +text_base: description: "Use spoltight build RDF stagging Graph and apply Paris matching" tasks: + # 1 Extract triples with OpenIE - corenlp_openie_extraction + # 2 Convert extraction output to TE JSON - corenlp_exchange + # 3 Link relations (label+alias embedding) - label_alias_embedding_rl + # 4 Link entities with DBpedia Spotlight - dbpedia_spotlight_ner_nel + # 5 Convert Spotlight output to TE JSON - dbpedia_spotlight_exchange + # 6 Aggregate TE JSON artifacts - aggregate3_te_json + # 7 Construct RDF staging graph (mappings only) - construct_rdf_from_te_json_mappings_only + # 8 Match entities with paris - paris_entity_matching + # 9 Exchange matched RDF - paris_exchange + # 10 Fuse matched RDF - fusion_first_value + # 11 Infer types / align to ontology - type_inference_ontology_simple - -text_b: +text_alt: description: "(semi expensive) Use mini transformer to link entities and relations (label+alias)" tasks: + # 1 Extract triples with OpenIE - corenlp_openie_extraction # ("Berlin", "is a", "city") + # 2 Convert extraction output to TE JSON - corenlp_exchange + # 3 Link entities (label+alias embedding) - label_alias_embedding_el # ("Berlin" -> http://dbpedia.org/resource/Berlin) + # 4 Link relations (label+alias embedding) - label_alias_embedding_rl # ("is a" -> "rdf:type") + # 5 Aggregate TE JSON artifacts - aggregate3_te_json + # 6 Construct RDF staging graph - construct_rdf_from_te_json + # 7 Select / fuse values - select_first_value + # 8 Infer types / align to ontology - type_inference_ontology_simple -text_llm_triple_extract_v1: +text_llm: description: "Extract RDF from TEXT using LLM" config: LLM_MODEL: "gpt-5-mini" tasks: + # 1 Extract triples using LLM - llm_task_text_triple_extract_v1 + # 2 Link entities (label+alias embedding) - label_alias_embedding_el # ("Berlin" -> http://dbpedia.org/resource/Berlin) + # 3 Link relations (label+alias embedding) - label_alias_embedding_rl # ("is a" -> "rdf:type") + # 4 Aggregate TE JSON artifacts - aggregate3_te_json + # 5 Construct RDF staging graph - construct_rdf_from_te_json + # 6 Select / fuse values - select_first_value + # 7 Infer types / align to ontology - type_inference_ontology_simple # - type_inference_ontology_simple TODO why was this commented diff --git a/experiments/moviekg/src/moviekg/config.py b/experiments/moviekg/src/moviekg/config.py index 4460916..3727e00 100644 --- a/experiments/moviekg/src/moviekg/config.py +++ b/experiments/moviekg/src/moviekg/config.py @@ -43,22 +43,16 @@ pipeline_types = { - "rdf_a": "rdf", - "rdf_b": "rdf", - "text_a": "text", - "text_b": "text", - "json_a": "json", - "json_b": "json", + "rdf_base": "rdf", + "rdf_alt": "rdf", + "text_base": "text", + "text_alt": "text", + "json_base": "json", + "json_alt": "json", } llm_pipeline_types = { - "json_llm_mapping_v1": "json", - "rdf_llm_schema_align_v1": "rdf", - "text_llm_triple_extract_v1": "text", + "json_llm": "json", + "rdf_llm": "rdf", + "text_llm": "text", } - -ssp = { - "rdf": "rdf_a", - "json": "json_b", - "text": "text_a" -} \ No newline at end of file diff --git a/experiments/moviekg/src/moviekg/evaluation/__init__.py b/experiments/moviekg/src/moviekg/evaluation/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/experiments/moviekg/src/moviekg/evaluation/helpers.py b/experiments/moviekg/src/moviekg/evaluation/helpers.py deleted file mode 100644 index 4b80b58..0000000 --- a/experiments/moviekg/src/moviekg/evaluation/helpers.py +++ /dev/null @@ -1,206 +0,0 @@ -import json -import tempfile -import re -import shutil -from typing import List, Dict, Tuple -from pathlib import Path -from rdflib import Graph - -from kgpipe.common.models import KG, DataFormat -from kgpipe.evaluation.aspects import reference, semantic, statistical -from kgpipe.evaluation.aspects.reference import ReferenceConfig -from kgpipe.evaluation.base import MetricResult -from kgcore.api.ontology import OntologyUtil - -from moviekg.datasets.pipe_out import StageOut -from moviekg.config import dataset - -ontology_graph = Graph() -if dataset.ontology is None: - raise ValueError("No ontology found") -ontology_graph.parse(dataset.ontology.as_posix()) - -def show_ontology(): - if dataset.ontology is None: - raise ValueError("No ontology found") - ontology = OntologyUtil.load_ontology_from_file(dataset.ontology) - - for class_ in ontology.classes: - print(f"{class_.uri} {class_.label}") - # print(f"{class_.alias} {class_.description}") - print(f"{class_.equivalent}") - print(f"{class_.disjointWith}") - print("-" * 100) - - for property in ontology.properties: - print(f"{property.uri} {property.type} {property.label}") - # print(f"{property.alias} {property.description}") - print(f"{property.domain.uri} {property.range.uri} {property.equivalent}") - print(f"{property.min_cardinality} {property.max_cardinality}") - print("-" * 100) - -show_ontology() - - -def print_long_table_rows(rows: List[dict]): - """ - with correct margin and alignment - """ - max_aspect_length = max(len(row["aspect"]) for row in rows) - max_metric_name_length = max(len(row["metric"]) for row in rows) - max_value_length = max(len(str(row["value"])) for row in rows) - max_normalized_length = max(len(str(row["normalized"])) for row in rows) - max_duration_length = max(len(str(row["duration"])) for row in rows) - - print(f"{'Aspect':<{max_aspect_length}} | {'Metric':<{max_metric_name_length}} | {'Value':<{max_value_length}} | {'Normalized':<{max_normalized_length}} | {'Duration':<{max_duration_length}}") - print("-" * (max_aspect_length + max_metric_name_length + max_value_length + max_normalized_length + max_duration_length + 6)) - for row in rows: - print(f"{row['aspect']:<{max_aspect_length}} | {row['metric']:<{max_metric_name_length}} | {row['value']:<{max_value_length}} | {row['normalized']:<{max_normalized_length}} | {row['duration']:<{max_duration_length}}") - -def metrics_to_long_table_rows(metrics: List[MetricResult], pipeline_name: str, stage_name: str) -> List[dict]: - rows = [] - for metric in metrics: - rows.append({ - "pipeline": pipeline_name, - "stage": stage_name, - "aspect": metric.aspect.value, - "metric": metric.name, - "value": metric.value, - "normalized": metric.normalized_score, - "duration": metric.duration, - "details": json.dumps(metric.details, default=str) - }) - return rows - - - -def get_reference_config(stage: StageOut, is_ssp: bool) -> ReferenceConfig: - - # this is a pipeline name based hack to get the source type and source split id - def get_split_id_and_source_type(stage: StageOut, is_ssp: bool = False) -> Tuple[int, str]: - # stage_name is like "stage_1" - split_id = int(stage.stage_name.split("_")[1]) - pipeline_name = stage.root.parent.name - source_ord = pipeline_name.split("_") - - if len(source_ord) != 3 or is_ssp: - source_type = source_ord[0] - else: - source_type = source_ord[split_id-1] - return split_id, source_type - - split_id, source_type = get_split_id_and_source_type(stage) - - meta = dataset.splits[f"split_{split_id}"].sources[source_type].meta - verified_source_entities_path = dataset.splits[f"split_{split_id}"].kg_seed.root / "meta/verified_entities.csv" - verified_source_matches_path = meta.root / "verified_matches.csv" - - - kg_reference = dataset.splits[f"split_{split_id}"].kg_reference - if kg_reference is None: - raise ValueError(f"No reference KG found for split {split_id} and source type {source_type}") - reference_path = kg_reference.root / "data_agg.nt" - - kg_seed = dataset.splits[f"split_0"].kg_seed - if kg_seed is None: - raise ValueError(f"No seed KG found for split {0}") - seed_path = kg_seed.root / "data.nt" - - ENTITY_MATCH_THRESHOLD_MAP = { - "json_a": 0.99, - "rdf_a": 0.99, - "rdf_b": 0.5, - "rdf_c": 0.99, - "rdf_llm_schema_align_v1": 0.99 - } - - RELATION_MATCH_THRESHOLD_MAP = { - "json_a": 0.5, - "rdf_a": 0.5, - "rdf_b": 0.1, - "rdf_c": 0.5, - "rdf_llm_schema_align_v1": 0.5 - } - - return ReferenceConfig( - name="reference", - GT_MATCHES=verified_source_matches_path, - GT_MATCHES_TARGET_DATASET=dataset.splits[f"split_{0}"].root.name+"/kg/seed", - RELATION_MATCH_THRESHOLD=RELATION_MATCH_THRESHOLD_MAP.get(stage.root.parent.name, 0.5), - ENTITY_MATCH_THRESHOLD=ENTITY_MATCH_THRESHOLD_MAP.get(stage.root.parent.name, 0.99), - VERIFIED_SOURCE_ENTITIES=verified_source_entities_path, - REFERENCE_KG_PATH=reference_path, - SEED_KG_PATH=seed_path, - TE_LINK_THRESHOLD=0.5, - source_meta=meta, - dataset=dataset, - JSON_EXPECTED_DIR="/home/marvin/project/data/work/json", #TODO cleanup - JSON_EXPECTED_RELATION_FILE="/home/marvin/project/data/final/film_10k/split_0/sources/json/meta/verified_relation_matches.json" # TODO cleanup - ) - -from kgpipe.evaluation.base import MetricResult, EvaluationAspect - -def add_duration_metrics(stage: StageOut) -> MetricResult: - - try: - duration = stage.report.duration - return MetricResult( - metric=None, - kg=KG(id=f"result_{stage.stage_name}", name=f"result_{stage.stage_name}", path=stage.resultKG, format=DataFormat.RDF_NTRIPLES,plan=stage.plan), - aspect=EvaluationAspect.STATISTICAL, - name="duration", - value=duration, - normalized_score=0, - details={ - "duration": duration - } - ) - - except Exception as e: - return MetricResult( - metric=None, - kg=KG(id=f"result_{stage.stage_name}", name=f"result_{stage.stage_name}", path=stage.resultKG, format=DataFormat.RDF_NTRIPLES,plan=stage.plan), - aspect=EvaluationAspect.STATISTICAL, - name="duration", - value=0, - normalized_score=0, - details={"error": "No duration found"} - ) - - - -def evaluate_stage(stage: StageOut, is_ssp: bool) -> List[MetricResult]: - result_path = stage.resultKG - if result_path is None: - return [] - - result_kg = KG(id=f"result_{stage.stage_name}", name=f"result_{stage.stage_name}", path=result_path, format=DataFormat.RDF_NTRIPLES,plan=stage.plan) - - result_kg.set_ontology_graph(ontology_graph) - - stat_eval = statistical.StatisticalEvaluator() - ref_eval = reference.ReferenceEvaluator() - sem_eval = semantic.SemanticEvaluator() - - # stats_aspect_result = stat_eval.evaluate(result_kg) - ref_aspect_result = ref_eval.evaluate(result_kg, config=get_reference_config(stage, is_ssp), metrics=["SourceTypedEntityCoverageMetric"]) - # sem_aspect_result = sem_eval.evaluate(result_kg) - - metrics = [] - # metrics = stats_aspect_result.metrics + ref_aspect_result.metrics + sem_aspect_result.metrics - metrics = ref_aspect_result.metrics - # metrics = sem_aspect_result.metrics - metrics.append(add_duration_metrics(stage)) - # metrics = ref_aspect_result.metrics - - return metrics - -def replace_with_dict(infile: str, mapping: dict[str, str]) -> None: - with open(infile, encoding="utf-8") as f, \ - tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as tmp: - for line in f: - for key, val in mapping.items(): - line = re.sub(re.escape(key), val, line) - tmp.write(line) - tmp_path = tmp.name - shutil.move(tmp_path, infile) diff --git a/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py b/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py deleted file mode 100644 index 61621cb..0000000 --- a/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py +++ /dev/null @@ -1,263 +0,0 @@ -from kgpipe_eval.metrics import CountMetric, DuplicateMetric -from typing import List -from kgpipe_eval.api import MetricConfig, MetricResult -from kgpipe_eval.metrics.statistics import CountMetric -from kgpipe_eval.metrics.duplicates import DuplicateConfig, DuplicateMetric -from kgpipe_eval.metrics.entity_alignment import EntityAlignmentMetric -from kgpipe_eval.metrics.triple_alignment import TripleAlignmentConfig, TripleAlignmentMetric -from kgpipe_eval.utils.alignment_utils import EntityAlignmentConfig -from kgpipe_eval.utils.kg_utils import KgLike, KgManager -from kgpipe_eval.evaluator import Evaluator -from pydantic import BaseModel, ConfigDict - -from kgpipe.datasets.multipart_multisource import Dataset, load_dataset -from kgpipe_eval.test.utils import render_metric_result -from pathlib import Path -import pytest -from kgpipe.common.model.pipeline import KgPipePlan, KgPipeReport -from kgpipe.common.model.kg import KG -from kgpipe.common.model.data import DataFormat -import json -from dataclasses import asdict -from itertools import permutations -from typing import Set -from kgpipe_eval.utils.kg_utils import Term - -try: - from moviekg import config as moviekg_config - from moviekg.pipelines.test_inc_msp import ssp, idfn -except Exception as e: - # These are integration-style tests that depend on local env/config files. - import traceback - traceback.print_exc() - pytest.skip(f"MovieKG config not available for eval integration test: {e}", allow_module_level=True) -# TODO -# [ ] Dataset Reader (split,ref,source,metadata) -# [ ] Pipeline Results Reader (stage,kg,plan,report,tmp_file) - - -# TODO clearify -# substract seed from kg_1 and kg_1 from kg_2, or only seed from kg_1 and kg_2 - - -EX_BENCH_DATA_PATH = Path("/home/marvin/phd/data/moviekg/datasets/film_10k") # TODO read from env -# EX_INC_PIPE_DATA_PATH = Path("/home/marvin/phd/data/moviekg/output/large/rdf_a") - -# TODO is a wrapper interface for now, Dataset needs refactor later -# TODO can be abstracted and implemented to have direct method per type, so dict is not needed for access -class KgBenchData(BaseModel): - model_config = ConfigDict(arbitrary_types_allowed=True) - dataset: Dataset - - @staticmethod - def from_path(path: Path) -> 'KgBenchData': - dataset = load_dataset(path) - return KgBenchData(dataset=dataset) - - def get_verified_entities_path(self, i: int, source_type: str) -> Path: - current_path = self.dataset.splits[f"split_{i}"].kg_reference.meta.entities.file - current_new = current_path.with_name(f"{current_path.stem}_no_seed{current_path.suffix}") - return current_new - - def get_ignored_entities(self, i: int, source_type: str) -> Set[Term]: - seed_entities = self.dataset.splits[f"split_{0}"].kg_seed.meta.entities.read_csv() - # source_seed_entities = self.dataset.splits[f"split_{i-1}"].sources[source_type].meta.entities.read_csv() - return set([entity.entity_id for entity in seed_entities]) # + [entity.entity_id for entity in source_seed_entities]) - - -class KgPipeData(BaseModel): - model_config = ConfigDict(arbitrary_types_allowed=True) - result_kg: KgLike # name=rdf_a_1 - plan: KgPipePlan - report: KgPipeReport - tmp_dir: Path - - @staticmethod - def from_path(path: Path | str) -> 'KgPipeData': - path = Path(path) - plan = KgPipePlan.from_path(path / "exec-plan.json") - report = KgPipeReport.from_path(path / "exec-report.json") - tmp_dir = path / "tmp" - return KgPipeData( - result_kg=KG(name=path.name, id=path.name, path=path / "result_eval.nt", format=DataFormat.RDF_NTRIPLES), - plan=plan, - report=report, - tmp_dir=tmp_dir - ) - -def build_config_dict(i: int, pipe_data: KgPipeData, bench_data: KgBenchData) -> dict[str, MetricConfig]: - dup_cfg = DuplicateConfig( - entity_alignment_config=EntityAlignmentConfig( - method="label_embedding", - verified_entities_path=bench_data.get_verified_entities_path(i=i, source_type="rdf"), # TODO type needs to be derived from pipe_data - verified_entities_delimiter="\t", - entity_sim_threshold=0.95, - ) - ) - - tri_cfg = TripleAlignmentConfig( - reference_kg=bench_data.dataset.splits[f"split_{i}"].kg_reference.root / "data_agg_eval.nt", - entity_alignment_config=EntityAlignmentConfig( - method="label_embedding", - reference_kg=bench_data.dataset.splits[f"split_{i}"].kg_reference.root / "data_agg_eval.nt", - # verified_entities_path=bench_data.get_verified_entities_path(i=i, source_type="rdf"), # TODO type needs to be derived from pipe_data - # verified_entities_delimiter="\t", - entity_sim_threshold=0.95, - ), - value_sim_threshold=0.5, - cache_literal_embeddings=True, - cache_ref_literal_embeddings=True, - ) - - ent_cfg = EntityAlignmentConfig( - method="label_embedding_and_intersecting_type", - verified_entities_path=bench_data.get_verified_entities_path(i=i, source_type="rdf"), # TODO type needs to be derived from pipe_data - verified_entities_delimiter="\t", - entity_sim_threshold=0.95, - ignored_entities=bench_data.get_ignored_entities(i=i, source_type="rdf") # TODO type needs to be derived from pipe_data - ) - - return { - "DuplicateMetric": dup_cfg, - "EntityAlignmentMetric": ent_cfg, - "TripleAlignmentMetric": tri_cfg, - } - - -def evaluate_stage(i: int, pipe_data: KgPipeData, bench_data: KgBenchData) -> List[MetricResult]: - tg = KgManager.load_kg(pipe_data.result_kg) - metrics = [ - CountMetric(), - EntityAlignmentMetric(), - DuplicateMetric(), - TripleAlignmentMetric(), - ] - config_dict = build_config_dict(i, pipe_data, bench_data) - return Evaluator().run(tg, metrics, config_dict) - - -def _stage_dirs(output_dir: Path) -> list[Path]: - stage_dirs = [p for p in output_dir.iterdir() if p.is_dir() and p.name.startswith("stage_")] - # stage_1, stage_2, ... - stage_dirs.sort(key=lambda p: int(p.name.split("_", 1)[1])) - return stage_dirs - - -def _metric_results_to_jsonable(results: list[MetricResult]) -> list[dict]: - """ - Convert `MetricResult` dataclasses to JSON-serializable dicts. - - `MetricResult.metric` is an object instance, so we store its key/classname. - """ - out: list[dict] = [] - for r in results: - metric_key = getattr(r.metric, "key", None) or r.metric.__class__.__name__ - out.append( - { - "metric": metric_key, - "summary": r.summary, - "measurements": [asdict(m) for m in r.measurements], - } - ) - return out - -# EX_PIPE_DATA_PATH = Path("/home/marvin/phd/data/moviekg/output/small/rdf_a/stage_1") -# def test_evaluate_stage(): -# if not EX_PIPE_DATA_PATH.exists() or not EX_BENCH_DATA_PATH.exists(): -# pytest.skip("Local MovieKG eval data not available; test is an integration/WIP scaffold.") -# pipe_data = KgPipeData.from_path(EX_PIPE_DATA_PATH) -# bench_data = KgBenchData.from_path(EX_BENCH_DATA_PATH) -# results = evaluate_stage(1, pipe_data, bench_data) - -# # render -# print() # avoids pytest output being interleaved with print statements -# for result in results: -# print(render_metric_result(result, truncate=True, truncate_value=3)) - -# def test_evaluate_inc_stage(): -# if not EX_INC_PIPE_DATA_PATH.exists() or not EX_BENCH_DATA_PATH.exists(): -# pytest.skip("Local MovieKG inc data not available; test is an integration/WIP scaffold.") - -# for i in range(1, 4): -# pipe_data = KgPipeData.from_path(EX_INC_PIPE_DATA_PATH / f"stage_{i}") -# bench_data = KgBenchData.from_path(EX_BENCH_DATA_PATH) -# results = evaluate_stage(i, pipe_data, bench_data) - -# # render -# print() # avoids pytest output being interleaved with print statements -# for result in results: -# print(render_metric_result(result, truncate=True, truncate_value=3)) - -@pytest.mark.parametrize( - "pipeline_name", - list[str](moviekg_config.pipeline_types.keys()) + list[str](moviekg_config.llm_pipeline_types.keys()), -) -def test_evaluate_new(pipeline_name: str): - """ - Boilerplate integration test that runs the new eval API for each pipeline - output under `OUTPUT_ROOT//stage_*`. - """ - output_dir = moviekg_config.OUTPUT_ROOT / pipeline_name - - if not output_dir.exists(): - pytest.skip(f"Pipeline output directory {output_dir} not found") - - stage_dirs = _stage_dirs(output_dir) - if not stage_dirs: - pytest.skip(f"No stage directories found under {output_dir}") - - # Uses the dataset selected/configured via `moviekg.config` env vars. - bench_data = KgBenchData.from_path(EX_BENCH_DATA_PATH) - - for stage_dir in stage_dirs: - i = int(stage_dir.name.split("_", 1)[1]) - if i != 3: - continue # only run for stage 3 - pipe_data = KgPipeData.from_path(stage_dir) - results = evaluate_stage(i=i, pipe_data=pipe_data, bench_data=bench_data) - - eval_results = _metric_results_to_jsonable(results) - with open(stage_dir / "eval_results.json", "w") as f: - json.dump(eval_results, f, indent=2) - print(f"Wrote results to {stage_dir / 'eval_results.json'}") - - # Smoke checks: we got metric results back for this stage. - assert isinstance(results, list) - assert results - - -@pytest.mark.parametrize( - "source_1, source_2, source_3", - permutations(list[str](ssp.keys()), 3), - ids=idfn, -) -def test_evaluate_new_multisource_pipeline(source_1: str, source_2: str, source_3: str): - """ - Integration test for the *multi-source* incremental pipelines where the selected - source changes per iteration/stage (e.g. `a_b_c/stage_1`, `a_b_c/stage_2`, ...). - """ - pipeline_name = f"{source_1}_{source_2}_{source_3}" - output_dir = moviekg_config.OUTPUT_ROOT / pipeline_name - - if not output_dir.exists(): - pytest.skip(f"Pipeline output directory {output_dir} not found") - - stage_dirs = _stage_dirs(output_dir) - if not stage_dirs: - pytest.skip(f"No stage directories found under {output_dir}") - - bench_data = KgBenchData.from_path(EX_BENCH_DATA_PATH) - - for stage_dir in stage_dirs: - i = int(stage_dir.name.split("_", 1)[1]) - if i != 3: - continue # only run for stage 3 - pipe_data = KgPipeData.from_path(stage_dir) - results = evaluate_stage(i=i, pipe_data=pipe_data, bench_data=bench_data) - - eval_results = _metric_results_to_jsonable(results) - with open(stage_dir / "eval_results.json", "w") as f: - json.dump(eval_results, f, indent=2) - - assert isinstance(results, list) - assert results \ No newline at end of file diff --git a/experiments/moviekg/src/moviekg/evaluation/test_inc_msp_evaluation.py b/experiments/moviekg/src/moviekg/evaluation/test_inc_msp_evaluation.py deleted file mode 100644 index fa186d2..0000000 --- a/experiments/moviekg/src/moviekg/evaluation/test_inc_msp_evaluation.py +++ /dev/null @@ -1,61 +0,0 @@ -import pandas as pd -import pytest -import os -from typing import Sequence -from _pytest.compat import NotSetType -from itertools import permutations - -from moviekg.datasets.pipe_out import load_pipe_out -from moviekg.evaluation.helpers import evaluate_stage, metrics_to_long_table_rows -from moviekg.pipelines.test_inc_msp import ssp, idfn - -from moviekg.config import OUTPUT_ROOT - -@pytest.mark.parametrize( - "source_1, source_2, source_3", - permutations(list[str](ssp.keys()), 3), - ids=idfn -) -def test_inc_ssp_evaluation(source_1, source_2, source_3): - - output_dir = OUTPUT_ROOT / f"{source_1}_{source_2}_{source_3}" - - pipeline_name = f"{source_1}_{source_2}_{source_3}" - - print("-" * 100) - print(f"Evaluating {source_1}, {source_2}, {source_3}") - print("-" * 100) - - if not output_dir.exists(): - pytest.skip(f"Pipeline output directory {output_dir} not found") - - pipe_out = load_pipe_out(output_dir) - - rows = [] - - for stage in pipe_out.stages: - print("-" * 100) - print(f"{pipeline_name} - Stage: {stage.stage_name}") - print("-" * 100) - - metrics = evaluate_stage(stage, is_ssp=False) - rows.extend(metrics_to_long_table_rows(metrics, pipeline_name, stage.stage_name)) - # break # TODO remove - - metrics_df = pd.DataFrame(rows) - metrics_df.to_csv(OUTPUT_ROOT / f"{pipeline_name}_metrics.csv", index=False) - print("saved metrics to", OUTPUT_ROOT / f"{pipeline_name}_metrics.csv") - -def test_concatenate_long_table_rows(): - # glob - rows = [] - for file in OUTPUT_ROOT.glob("*_metrics.csv"): - if file.name == "all_metrics.csv": - continue - if os.path.getsize(file) < 3: - continue - df = pd.read_csv(file) - rows.extend(df.to_dict(orient="records")) - - metrics_df = pd.DataFrame(rows) - metrics_df.to_csv(OUTPUT_ROOT / "all_metrics.csv", index=False) diff --git a/experiments/moviekg/src/moviekg/evaluation/test_inc_ssp_evaluation.py b/experiments/moviekg/src/moviekg/evaluation/test_inc_ssp_evaluation.py deleted file mode 100644 index 2e62f54..0000000 --- a/experiments/moviekg/src/moviekg/evaluation/test_inc_ssp_evaluation.py +++ /dev/null @@ -1,58 +0,0 @@ -import pytest -import pandas as pd -import os -from pathlib import Path - -from moviekg.datasets.pipe_out import load_pipe_out -from moviekg.evaluation.helpers import evaluate_stage, metrics_to_long_table_rows, print_long_table_rows -from moviekg.pipelines.test_inc_ssp import pipeline_types, llm_pipeline_types - -from moviekg.config import OUTPUT_ROOT - -@pytest.mark.parametrize( - "pipeline_name", - list[str](pipeline_types.keys()) + list[str](llm_pipeline_types.keys()) -) -def test_inc_ssp_evaluation(pipeline_name): - - output_dir = OUTPUT_ROOT / pipeline_name - - print("-" * 100) - print(f"Evaluating {pipeline_name}") - print("-" * 100) - - if not output_dir.exists(): - pytest.skip(f"Pipeline output directory {output_dir} not found") - - pipe_out = load_pipe_out(output_dir) - - rows = [] - - for stage in pipe_out.stages: - print("-" * 100) - print(f"{pipeline_name} - Stage: {stage.stage_name}") - print("-" * 100) - - metrics = evaluate_stage(stage, is_ssp=True) - new_rows = metrics_to_long_table_rows(metrics, pipeline_name, stage.stage_name) - print_long_table_rows(new_rows) - rows.extend(new_rows) - # break # TODO remove this - - metrics_df = pd.DataFrame(rows) - metrics_df.to_csv(OUTPUT_ROOT / f"{pipeline_name}_metrics.csv", index=False) - print("saved metrics to", OUTPUT_ROOT / f"{pipeline_name}_metrics.csv") - -def test_concatenate_long_table_rows(): - # glob - rows = [] - for file in OUTPUT_ROOT.glob("*_metrics.csv"): - if file.name == "all_metrics.csv": - continue - if os.path.getsize(file) < 3: - continue - df = pd.read_csv(file) - rows.extend(df.to_dict(orient="records")) - - metrics_df = pd.DataFrame(rows) - metrics_df.to_csv(OUTPUT_ROOT / "all_metrics.csv", index=False) diff --git a/experiments/moviekg/src/moviekg/evaluation/test_ref_dev.py b/experiments/moviekg/src/moviekg/evaluation/test_ref_dev.py deleted file mode 100644 index 1a4d17b..0000000 --- a/experiments/moviekg/src/moviekg/evaluation/test_ref_dev.py +++ /dev/null @@ -1,247 +0,0 @@ -# from pathlib import Path -# import numpy as np -# from sentence_transformers import SentenceTransformer -# from rdflib import Graph, URIRef, Literal, RDF, RDFS, XSD -# import re -# from tqdm import tqdm - -# def integrated_entities(path_actual_kg, path_expected_kg): -# pass - -# SOFT_ENTITY_THRESHOLD = 0.75 -# SOFT_VALUES_THRESHOLD = 0.75 - -# def encode(values, model, desc: str): -# embeddings = [] -# for i in tqdm(range(0, len(values), 64), desc=desc): -# batch = values[i:i+64] -# batch_emb = model.encode(batch, show_progress_bar=False) -# embeddings.append(batch_emb) -# return np.vstack(embeddings) - -# def graph_fact_alginment(ga: Graph, ge: Graph): -# te = [ str(s)+str(p)+str(o) for s, p, o in ge ] -# ta = [ str(s)+str(p)+str(o) for s, p, o in ga ] - -# tp = len(set(ta) & set(te)) -# fp = len(set(ta) - set(te)) -# fn = len(set(te) - set(ta)) - -# print(f"TP: {tp}, FP: {fp}, FN: {fn}") -# print(f"Precision: {tp / (tp + fp)}") -# print(f"Recall: {tp / (tp + fn)}") -# print(f"F1: {2 * tp / (2 * tp + fp + fn)}") - -# def clean_label(label: str): -# # remove all non-alphanumeric characters -# cleaned_label = label.replace("_", " ") -# # remove parenthesis text -# cleaned_label = re.sub(r'\([^)]*\)', '', cleaned_label) -# return cleaned_label.strip() - - -# def graph_match_labels_soft(ga: Graph, ge: Graph, model: SentenceTransformer): -# actual_uri_to_abels = {} -# expected_uri_to_abels = {} - -# for s, _, o in ga.triples((None, RDFS.label, None)): -# actual_uri_to_abels[str(s)] = clean_label(str(o)) - -# for s, _, o in ge.triples((None, RDFS.label, None)): -# expected_uri_to_abels[str(s)]= clean_label(str(o)) - -# actual_embeddings = encode(list(actual_uri_to_abels.values()), model, "Encoding actual labels") -# expected_embeddings = encode(list(expected_uri_to_abels.values()), model, "Encoding expected labels") - -# cosine_scores = np.dot(actual_embeddings, expected_embeddings.T) - -# actual_uri_keys = list(actual_uri_to_abels.keys()) -# expected_uri_keys = list(expected_uri_to_abels.keys()) - -# # get best match expected uri for each actual uri - -# uri_mappings = {} - -# best_matches = [] -# for i in range(len(actual_uri_keys)): -# best_match = expected_uri_keys[np.argmax(cosine_scores[i])] -# best_score = cosine_scores[i][np.argmax(cosine_scores[i])] -# best_matches.append((best_match, best_score)) - -# for i in range(len(best_matches)): -# if best_matches[i][1] > SOFT_ENTITY_THRESHOLD: -# # la = actual_uri_to_abels[actual_uri_keys[i]].replace(" ", "_") -# # le = expected_uri_to_abels[best_matches[i][0]].replace(" ", "_") -# uri_actual = actual_uri_keys[i] -# uri_expected = best_matches[i][0] -# uri_mappings[uri_actual] = uri_expected - -# return uri_mappings - -# def graph_fact_alginment_soft_entities(ga: Graph, ge: Graph, model: SentenceTransformer): -# uri_mappings = graph_match_labels_soft(ga, ge, model) - -# ga_mapped = Graph() -# for s, p, o in ga: -# if str(s) in uri_mappings: -# s = URIRef(uri_mappings[str(s)]) -# if isinstance(o, URIRef) and str(o) in uri_mappings: -# o = URIRef(uri_mappings[str(o)]) -# ga_mapped.add((s, p, o)) - -# graph_fact_alginment(ga_mapped, ge) - -# # TODO rdf:type is removed for tp calculation -# def graph_fact_alginment_soft_entities_values(ga: Graph, ge: Graph, model: SentenceTransformer): -# uri_mappings = graph_match_labels_soft(ga, ge, model) - -# def get_label(o: URIRef, graph: Graph): -# labels = [str(l) for l in graph.objects(o, RDFS.label)] -# if len(labels) == 0: -# return [] -# else: -# return [clean_label(l) for l in labels] - -# ga_mapped = Graph() -# for s, p, o in ga: -# if str(s) in uri_mappings: -# s = URIRef(uri_mappings[str(s)]) -# if isinstance(o, URIRef): # and p != RDF.type -# for label in get_label(o, ga): -# ga_mapped.add((s, p, Literal(label))) -# else: -# ga_mapped.add((s, p, o)) - -# ge_mapped = Graph() -# for s, p, o in ge: -# if isinstance(o, URIRef): # and p != RDF.type -# for label in get_label(o, ge): -# ge_mapped.add((s, p, Literal(label))) -# else: -# ge_mapped.add((s, p, o)) - -# # encode all values -# vas = list(set([str(o) for _, _, o in ga_mapped if not isinstance(o, URIRef)])) -# ves = list(set([str(o) for _, _, o in ge_mapped if not isinstance(o, URIRef)])) - -# va_embeddings = encode(vas, model, "Encoding actual values") -# ve_embeddings = encode(ves, model, "Encoding expected values") - -# v2e_actual = {} -# v2e_expected = {} - -# for idx, v in enumerate(vas): -# v2e_actual[v] = va_embeddings[idx] - -# for idx, v in enumerate(ves): -# v2e_expected[v] = ve_embeddings[idx] - -# tp = 0 -# fp = 0 -# fn = 0 - -# sp_actual = set() - -# # for each (s, p, o) in ga_mapped check if there is a matching value for the same (s, p) in ge -# for s, p in ga_mapped.subject_predicates(unique=True): -# sp_actual.add((s, p)) -# _vas = [str(o) for o in ga_mapped.objects(s, p)] -# _ves = [str(o) for o in ge_mapped.objects(s, p)] -# _vas_embeddings = np.array([v2e_actual[v] for v in _vas]) -# _ves_embeddings = np.array([v2e_expected[v] for v in _ves]) - -# if len(_vas_embeddings) == 0 or len(_ves_embeddings) == 0: -# continue -# cosine_scores = np.dot(_vas_embeddings, _ves_embeddings.T) # (len(_vas_embeddings), len(_ves_embeddings)) - -# for idx in range(len(_vas)): -# best_match = _ves[np.argmax(cosine_scores[idx])] -# best_score = cosine_scores[idx][np.argmax(cosine_scores[idx])] -# if best_score > SOFT_VALUES_THRESHOLD: -# actual_value = _vas[idx] -# reference_value = best_match -# tp += 1 -# # if actual_value == reference_value: -# # # print(f"Found matching value for {s} {p} {actual_value}") -# # pass -# # else: -# # print(f"Found matching value for {s} {p} {actual_value} but not exact reference {reference_value}") -# # print(f"Value actual: {_vas[idx]}, {best_match}, {best_score}") -# # print(f"Value expected: {_ves[np.argmax(cosine_scores[idx])]}") -# else: -# fp += 1 -# # print(f"No matching value for {s} {p} {_vas[idx]} from references {_ves}") - -# sp_expected = set([(s, p) for s, p in ge_mapped.subject_predicates(unique=True)]) -# missing_sp = sp_expected - sp_actual -# for s, p in missing_sp: -# for _ in ge_mapped.triples((s, p, None)): -# fn += 1 - -# print(f"TP: {tp}, FP: {fp}, FN: {fn}") -# print(f"Precision: {tp / (tp + fp)}") -# print(f"Recall: {tp / (tp + fn)}") -# print(f"F1: {2 * tp / (2 * tp + fp + fn)}") - -# def reference_alignment(path_actual_kg: Path, path_expected_kg: Path): -# ga = Graph() -# ga.parse(path_actual_kg) - -# ge = Graph() -# ge.parse(path_expected_kg) - -# graph_fact_alginment(ga, ge) - -# def reference_alignment_soft_entities(path_actual_kg: Path, path_expected_kg: Path): - -# model = SentenceTransformer("all-MiniLM-L6-v2") -# model.to("cuda") - -# ga = Graph() -# ga.parse(path_actual_kg) - -# ge = Graph() -# ge.parse(path_expected_kg) - -# graph_fact_alginment_soft_entities(ga, ge, model) - -# def reference_alignment_soft_entities_values(path_actual_kg: Path, path_expected_kg: Path): - -# model = SentenceTransformer("all-MiniLM-L6-v2") -# model.to("cuda") - -# ga = Graph() -# ga.parse(path_actual_kg) - -# ge = Graph() -# ge.parse(path_expected_kg) - -# graph_fact_alginment_soft_entities_values(ga, ge, model) - -# def test_integrated_verified_source_entities(): -# print("Integrated verified source entities") -# path_actual_kg = Path("/home/marvin/project/code/experiments/out_film_100/rdf_a/stage_1/result.nt") -# path_expected_kg = Path("/home/marvin/project/data/final/film_100/split_3/kg/reference/data_agg.nt") -# integrated_entities(path_actual_kg, path_expected_kg) - -# def test_reference_alignment(): -# print("Reference alignment") -# path_actual_kg = Path("/home/marvin/project/code/experiments/out_film_100/rdf_a/stage_1/result.nt") -# path_expected_kg = Path("/home/marvin/project/data/final/film_100/split_3/kg/reference/data_agg.nt") -# reference_alignment(path_actual_kg, path_expected_kg) - -# def test_reference_alignment_soft(): -# print("Reference alignment soft") -# path_actual_kg = Path("/home/marvin/project/code/experiments/out_film_100/text_b/stage_1/result.nt") -# path_expected_kg = Path("/home/marvin/project/data/final/film_100/split_3/kg/reference/data_agg.nt") -# reference_alignment_soft_entities(path_actual_kg, path_expected_kg) - -# def test_reference_alignment_soft_entities_values(): -# print("Reference alignment soft entities values") -# path_actual_kg = Path("/home/marvin/project/code/experiments/out_film_100/rdf_a/stage_1/result.nt") -# path_expected_kg = Path("/home/marvin/project/data/final/film_100/split_3/kg/reference/data_agg.nt") -# reference_alignment_soft_entities_values(path_actual_kg, path_expected_kg) - -# if __name__ == "__main__": -# test_integrated_verified_source_entities() -# test_reference_alignment() \ No newline at end of file diff --git a/experiments/moviekg/src/moviekg/evaluation/test_sensitivity.py b/experiments/moviekg/src/moviekg/evaluation/test_sensitivity.py deleted file mode 100644 index 0c68210..0000000 --- a/experiments/moviekg/src/moviekg/evaluation/test_sensitivity.py +++ /dev/null @@ -1,159 +0,0 @@ -from dataclasses import dataclass -from typing import List -from kgpipe.common import KgPipe, Data, DataFormat, KG -from pathlib import Path -from kgpipe.common.models import KgPipePlan -from kgpipe.evaluation.aspects.reference import ( - ReferenceEvaluator, ReferenceConfig, - ER_EntityMatchMetric, ER_RelationMatchMetric, - TE_ExpectedEntityLinkMetric, TE_ExpectedRelationLinkMetric -) -import os -@dataclass -class BinaryClassifier: - tp: int - fp: int - tn: int - fn: int - - def accuracy(self) -> float: - return (self.tp + self.tn) / (self.tp + self.tn + self.fp + self.fn) - - def precision(self) -> float: - return self.tp / (self.tp + self.fp) - -@dataclass -class ThresholdSensitivityResult: - pipeline_name: str - threshold: float - result: BinaryClassifier - -benchdata = Path("/home/marvin/phd/kgpipe/experiments/moviekg/data/datasets/film_10k/") -seed_path = benchdata / "split_0/kg/seed/data.nt" -rdf_path = benchdata / "split_1/sources/rdf/data.nt" -result_dir_path = Path(f"data/moviekg/threshold_sensitivity/") - -# reference_evaluator = ReferenceEvaluator() - -def run_paris_pipeline(pipeline_name: str, threshold: float) -> List[ThresholdSensitivityResult]: - from kgpipe_tasks.tasks import paris_entity_matching, paris_exchange - - pipe_result_dir_path = result_dir_path / f"{pipeline_name}" - pipeline = KgPipe( - name="paris pipeline", - tasks=[paris_entity_matching, paris_exchange], - seed=Data(path=seed_path, format=DataFormat.RDF_NTRIPLES), - data_dir=pipe_result_dir_path / "tmp" - ) - plan = pipeline.build( - source=Data(path=rdf_path, format=DataFormat.RDF_NTRIPLES), - result=Data(path=pipe_result_dir_path / "result.json", format=DataFormat.ER_JSON) - ) - - os.makedirs(pipe_result_dir_path, exist_ok=True) - - with open(pipe_result_dir_path / "exec-plan.json", "w") as f: - f.write(plan.model_dump_json(indent=4)) - - pipeline.run() - -def paris_er_threshold_sensitivity(pipeline_name: str, threshold: float) -> List[ThresholdSensitivityResult]: - config = ReferenceConfig( - name="paris config", - ENTITY_MATCH_THRESHOLD=threshold, - RELATION_MATCH_THRESHOLD=threshold, - GT_MATCHES=benchdata / "split_1/sources/rdf/meta/verified_matches.csv", - GT_MATCHES_TARGET_DATASET="split_0/kg/seed" - ) - - plan = KgPipePlan.model_validate_json(open(result_dir_path / f"{pipeline_name}" / "exec-plan.json").read()) - - kg = KG(id="paris", name="paris", path=Path(f"data/moviekg/paris/{pipeline_name}.nt"), format=DataFormat.RDF_NTRIPLES, plan=plan) - - metric_result = ER_EntityMatchMetric().compute(kg, config=config) - # print(metric_result) - - return metric_result - -def paris_om_threshold_sensitivity(pipeline_name: str, threshold: float) -> List[ThresholdSensitivityResult]: - config = ReferenceConfig( - name="paris config", - ENTITY_MATCH_THRESHOLD=threshold, - RELATION_MATCH_THRESHOLD=threshold, - GT_MATCHES=benchdata / "split_1/sources/rdf/meta/verified_matches.csv", - GT_MATCHES_TARGET_DATASET="split_0/kg/seed" - ) - - plan = KgPipePlan.model_validate_json(open(result_dir_path / f"{pipeline_name}" / "exec-plan.json").read()) - - kg = KG(id="paris", name="paris", path=Path(f"data/moviekg/paris/{pipeline_name}.nt"), format=DataFormat.RDF_NTRIPLES, plan=plan) - - metric_result = ER_RelationMatchMetric().compute(kg, config=config) - # print(metric_result) - - return metric_result - -def test_paris(): - # run_paris_pipeline("paris", 0.99) - range_of_thresholds = [0.0, 0.001, 0.01, 0.05, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 0.95, 0.99, 0.999, 1.0] - - er_results = [] - for threshold in range_of_thresholds: - result = paris_er_threshold_sensitivity("paris", threshold) - er_results.append([threshold, result.normalized_score, result.details]) - - print() - print("ER Results:") - for r in er_results: - print(r[0], r[1], r[2]) - - om_results = [] - for threshold in range_of_thresholds: - result = paris_om_threshold_sensitivity("paris", threshold) - om_results.append([threshold, result.normalized_score, result.details]) - - print("OM Results:") - for r in om_results: - print(r[0], r[1], r[2]) - -# def paris_threshold_sensitivity(pipeline_name: str, threshold: float) -> List[ThresholdSensitivityResult]: -# result = run_paris_pipeline(pipeline_name, threshold) - -# pipeline.run( -# input=[Data(path=Path(f"data/moviekg/paris/{pipeline_name}.nt"), format=DataFormat.RDF_NTRIPLES)], -# output=[Data(path=Path(f"data/moviekg/paris/{pipeline_name}.paris_csv"), format=DataFormat.PARIS_CSV)] -# ) - -# config = ReferenceConfig( -# name="paris config", -# ENTITY_MATCH_THRESHOLD=threshold, -# RELATION_MATCH_THRESHOLD=threshold -# ) - - - - -# # TODO get config from dataset -# # kg = KG(path=Path(f"data/moviekg/paris/{pipeline_name}.nt")) -# # reference_kg = KG(path=Path("data/moviekg/paris/reference.nt")) -# # result = reference_evaluator.evaluate(kg, reference_kg) -# # return result -# pass - -def jedai_threshold_sensitivity(pipeline_name: str, threshold: float) -> List[ThresholdSensitivityResult]: - pass - -def valentine_threshold_sensitivity(pipeline_name: str, threshold: float) -> List[ThresholdSensitivityResult]: - pass - -def corenlp_openie_threshold_sensitivity(pipeline_name: str, threshold: float) -> List[ThresholdSensitivityResult]: - pass - -def dbpedia_spotlight_threshold_sensitivity(pipeline_name: str, threshold: float) -> List[ThresholdSensitivityResult]: - pass - -def custom_relation_linking_threshold_sensitivity(pipeline_name: str, threshold: float) -> List[ThresholdSensitivityResult]: - pass - -def custom_entity_linking_threshold_sensitivity(pipeline_name: str, threshold: float) -> List[ThresholdSensitivityResult]: - pass \ No newline at end of file diff --git a/experiments/moviekg/src/moviekg/paper/__init__.py b/experiments/moviekg/src/moviekg/paper/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/experiments/moviekg/src/moviekg/paper/config.py b/experiments/moviekg/src/moviekg/paper/config.py deleted file mode 100644 index 507a5c0..0000000 --- a/experiments/moviekg/src/moviekg/paper/config.py +++ /dev/null @@ -1,135 +0,0 @@ - -HEADERS = ["pipeline", "stage", "aspect", "metric", "value", "normalized", "duration", "details"] - -# Only keep these classes and aggregate the rest into "Other" -main_classes = [ - "http://kg.org/ontology/Company", - "http://kg.org/ontology/Person", - "http://kg.org/ontology/Film" -] - -name_mapping = { - "rdf_a": r"\sspRDFa", - "rdf_b": r"\sspRDFb", - "rdf_c": r"\sspRDFc", - "rdf_llm_schema_align_v1": r"\sspRDFc", - "json_a": r"\sspJSONa", - "json_b": r"\sspJSONb", - "json_baseA": r"\sspJSONbaseA", - "json_c": r"\sspJSONc", - "json_llm_mapping_v1": r"\sspJSONc", - "text_a": r"\sspTexta", - "text_b": r"\sspTextb", - "text_c": r"\sspTextc", - "text_llm_triple_extract_v1": r"\sspTextc", - "rdf_json_text": r"\mspRJT", - "rdf_text_json": r"\mspRTJ", - "json_rdf_text": r"\mspJRT", - "json_text_rdf": r"\mspJTR", - "text_rdf_json": r"\mspTRJ", - "text_json_rdf": r"\mspTJR", -} - -METRIC_NAME_MAP = { - "entity_count": "EC", - "relation_count": "RC", - "triple_count": "FC", - "class_count": "TC", - "duration": "Time", - "loose_entity_count": "LEC", - "shallow_entity_count": "SEC", - # Semantic/Reasoning metrics - "reasoning": "EO", - "disjoint_domain": "EO1", - "incorrect_relation_direction": "EO2", - "incorrect_relation_cardinality": "EO3", - "incorrect_relation_range": "EO4", - "incorrect_relation_domain": "EO5", - "incorrect_datatype": "EO6", - "incorrect_datatype_format": "EO7", - "ontology_class_coverage": "EO8", - "ontology_relation_coverage": "EO9", - "ontology_namespace_coverage": "E10", - # Reference metrics - "ReferenceTripleAlignmentMetric": "RTC", - "ReferenceTripleAlignmentMetricSoftE": "RTC-SoftE", - "ReferenceTripleAlignmentMetricSoftEV": "RTC-SoftEV", - "ReferenceClassCoverageMetric": "RCC", - # ER metrics - "ER_EntityMatchMetric": "ER-EM", - "ER_RelationMatchMetric": "ER-RM", - # TE metrics - "TE_ExpectedEntityLinkMetric": "TE-EEL", - "TE_ExpectedRelationLinkMetric": "TE-ERL", - # Source metrics - "SourceEntityCoverageMetric": "VSEC", - "SourceEntityCoverageMetricSoft": "VSEC-Soft", - "REI_precision": "REI-Precision", - -} - -# long: -# disjoint_domain -# incorrect_relation_domain -# incorrect_relation_range -# incorrect_relation_direction -# incorrect_datatype -# incorrect_datatype_format -# short:ODT OD OR ORD OLT OLF OAvg -SEM_METRIC_SHORT_NAMES = { - # "reasoning" : "EO0", - "disjoint_domain": "$O_{DT}$", - "incorrect_relation_direction": "$O_{RD}$", - "incorrect_relation_cardinality": "$O_{CA}$", - "incorrect_relation_range": "$O_{R}$", - "incorrect_relation_domain": "$O_{D}$", - "incorrect_datatype": "$O_{LT}$", - "incorrect_datatype_format": "$O_{LF}$", - # "ontology_class_coverage": "$O_{CC}$", - # "ontology_relation_coverage": "$O_{RC}$", - # "ontology_namespace_coverage": "$O_{NC}$", -} - -METRIC_NAME_INDEX_PRETTY = [ - ("duration", "Runtime Duration"), - ("triple_count", "Fact/Triple Count"), - ("entity_count", "Entity Count"), - ("relation_count", "Relation Count"), - ("class_count", "Entity Type Count"), - ("Person", "Persons"), - ("Film", "Films"), - ("Company", "Companies"), - # ("Other", "Other Type"), - ("loose_entity_count", "Empty Entities"), - ("shallow_entity_count", "Shallow Entities"), - # Semantic/Reasoning metrics - # ("reasoning", "Reasoning"), - ("disjoint_domain", "Disjoint Domain"), - ("incorrect_relation_direction", "Incorrect Relation Direction"), - ("incorrect_relation_cardinality", "Incorrect Relation Cardinality"), - ("incorrect_relation_range", "Incorrect Relation Range"), - ("incorrect_relation_domain", "Incorrect Relation Domain"), - ("incorrect_datatype", "Incorrect Datatype"), - ("incorrect_datatype_format", "Incorrect Datatype Format"), - # ("ontology_class_coverage", "Ontology Class Coverage"), - # ("ontology_relation_coverage", "Ontology Relation Coverage"), - # ("ontology_namespace_coverage", "Ontology Namespace Coverage"), - # Source metrics - ("SourceEntityCoverageMetric", "Source Entity Recall"), - ("SourceEntityCoverageMetricSoft", "Source Entity Recall (~ID)"), - ("REI_precision", "Source Entity Precision (~ID)"), - # Reference metrics - ("ReferenceTripleAlignmentMetric", "Reference Alignment (f1)"), - ("ReferenceTripleAlignmentMetricSoftE", "Reference Alignment (~ID) (f1)"), - ("ReferenceTripleAlignmentMetricSoftEV", "Reference Alignment (~ID~Value) (f1)"), - # ("ReferenceClassCoverageMetric", "Reference Class Coverage"), - # ER metrics - ("ER_EntityMatchMetric", "Entity Match (p)"), - ("ER_RelationMatchMetric", "Relation Match (p)"), - # TE metrics - ("TE_ExpectedEntityLinkMetric", "Expected Entity Link (p)"), - ("TE_ExpectedRelationLinkMetric", "Expected Relation Link (p)"), -] - -METRIC_NAME_MAP_PRETTY = {k: v for k, v in METRIC_NAME_INDEX_PRETTY} -SEM_METRIC_LONG_NAMES = {v: k for k, v in SEM_METRIC_SHORT_NAMES.items()} diff --git a/experiments/moviekg/src/moviekg/paper/helpers/__init__.py b/experiments/moviekg/src/moviekg/paper/helpers/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/experiments/moviekg/src/moviekg/paper/helpers/agggregate.py b/experiments/moviekg/src/moviekg/paper/helpers/agggregate.py deleted file mode 100644 index adff60e..0000000 --- a/experiments/moviekg/src/moviekg/paper/helpers/agggregate.py +++ /dev/null @@ -1,224 +0,0 @@ -import pandas as pd -import json -import numpy as np -from moviekg.paper.config import SEM_METRIC_SHORT_NAMES -from moviekg.paper.helpers.helpers import load_metrics_from_file -from moviekg.config import OUTPUT_ROOT - -def agg_duration_over_stages_per_pipeline(metric_df): - # group by pipeline and stage and take mean of normalized - metric_df = metric_df[metric_df["metric"] == "duration"] - # print(metric_df) - metric_df = metric_df.groupby(["pipeline"])["value"].sum().reset_index() - # add stage column = stage 3 - metric_df["stage"] = "stage_3"# - metric_df["metric"] = "duration" - - # print(metric_df.to_string()) - return metric_df - -def norm_min(min, value): - return (min/value) - -def norm_max(max, value): - return 1 / (max/value) - -def get_average_f1_source_entity_f1(df: pd.DataFrame): - df = df[df["metric"] == "SourceEntityPrecisionMetric"] - - for row in df.itertuples(): - details = json.loads(row.details) - expected_entities_count = details["expected_entities_count"] - found_entities_count = details["found_entities_count"] - overlapping_entities_count = details["overlapping_entities_count"] - possible_duplicates_count = details["possible_duplicates_count"] - overlapping_entities_strict_count = details["overlapping_entities_strict_count"] - - # print(f"pipeline={row.pipeline}, stage={row.stage}, expected_entities_count={expected_entities_count}, found_entities_count={found_entities_count}, overlapping_entities_count={overlapping_entities_count}, possible_duplicates_count={possible_duplicates_count}, overlapping_entities_strict_count={overlapping_entities_strict_count}") - precision = overlapping_entities_strict_count / overlapping_entities_count - precision = precision if precision <= 1.0 else 1.0 - recall = overlapping_entities_count / expected_entities_count - recall = recall if recall <= 1.0 else 1.0 - f1 = 2 * (precision * recall) / (precision + recall) - df.loc[row.Index, "normalized"] = f1 - - df = df[["pipeline", "normalized"]] - - # save as csv - - # calculate the average of the metrics - df = df.groupby("pipeline").mean().reset_index() - # set as value for normalized and stage_3 - df["stage"] = "stage_3" - df["metric"] = "SourceEntityF1Metric" - df["value"] = df["normalized"] - df = df[["pipeline", "stage", "metric", "value"]] - - return df - -def aggregate_reference_metrics(df: pd.DataFrame): - metrics = [ - "ReferenceTripleAlignmentMetricSoftEV", - "SourceEntityPrecisionMetric", - ] - - source_entity_f1_df = get_average_f1_source_entity_f1(df) - df = pd.concat([df, source_entity_f1_df]) - - df = df[df["metric"].isin(metrics)] - # if metric is ReferenceTripleAlignmentMetricSoftEV get details["f1"] and set normalized to it - - df.loc[df["metric"] == "ReferenceTripleAlignmentMetricSoftEV", "normalized"] = df[df["metric"] == "ReferenceTripleAlignmentMetricSoftEV"]["details"].apply(lambda x: json.loads(x)["f1_score"]) - - df = df[["pipeline", "stage", "metric", "normalized"]] - - new_rows = [] - for pipeline in df["pipeline"].unique(): - new_rows.append({ - "pipeline": pipeline, - "stage": "stage_3", - "metric": "EntityMatchingMetric", - "normalized": 0.85 - }) - new_rows.append({ - "pipeline": pipeline, - "stage": "stage_3", - "metric": "OntologyMatchingMetric", - "normalized": 0.75 - }) - new_rows.append({ - "pipeline": pipeline, - "stage": "stage_3", - "metric": "EntityLinkingMetric", - "normalized": 0.44 - }) - - df = pd.concat([df, pd.DataFrame(new_rows)]) - - - # for each pipeline and stage = stage_3, calculate the average of the metrics - df = df[df["stage"] == "stage_3"] - - return df - -def aggregate_efficiency_metrics(df: pd.DataFrame): - metrics = ["duration", "memory_peak"] - df = df[df["metric"].isin(metrics)] - df = df[["pipeline", "stage", "metric", "value"]] - # for duration aggregate sum the values for each pipeline and stage - - duration_df = agg_duration_over_stages_per_pipeline(df) - # remove duration - df = df[df["metric"] != "duration"] - df = pd.concat([df, duration_df]) - - df["stage"] = "stage_3" - - def get_min_for_metric(metric): - return df[df["metric"] == metric]["value"].min() - - def get_max_for_metric(metric): - return df[df["metric"] == metric]["value"].max() - - for metric in df["metric"].unique(): - min_val = get_min_for_metric(metric) - max_val = get_max_for_metric(metric) - df.loc[df["metric"] == metric, "normalized"] = norm_min(min_val, df["value"]) - - return df - - -def aggregate_semantic_metrics(df: pd.DataFrame): - metrics = list(SEM_METRIC_SHORT_NAMES.keys()) - df = df[df["metric"].isin(metrics)] - df = df[["pipeline", "stage", "metric", "normalized"]] - # for each pipeline and stage = stage_3, calculate the average of the metrics - df = df[df["stage"] == "stage_3"] - - return df - -def aggregate_size_metrics(df: pd.DataFrame): - metrics = ["entity_count", "triple_count"] - df = df[df["metric"].isin(metrics)] - df = df[["pipeline", "stage", "metric", "value"]] - # for each pipeline and stage = stage_3, calculate the average of the metrics - df = df[df["stage"] == "stage_3"] - - # Pivot to compute density per pipeline - wide = df.pivot(index="pipeline", columns="metric", values="value") - - # Compute density = triple_count / entity_count (guard against zero/NaN) - denom = wide["entity_count"] - numer = wide["triple_count"] - density = np.where((denom > 0) & np.isfinite(denom), numer / denom, np.nan) - wide["density"] = density - - - # Return to long format: (pipeline, metric, value) - df = (wide.reset_index() - .melt(id_vars="pipeline", var_name="metric", value_name="value")) - - - def _normalize(group: pd.DataFrame): - vmax = group["value"].max() - vmin = group["value"].min() - - invert_normalization = False - if group.name == "density": - invert_normalization = True - - if invert_normalization: - group["normalized"] = norm_min(vmin, group["value"]) # largest→0, smallest→1 - else: - group["normalized"] = norm_max(vmax, group["value"]) #(group["value"] - vmin) / (vmax - vmin) # smallest→0, largest→1 - - return group - - df = df.groupby("metric", group_keys=False).apply(_normalize) - - return df - -def mean_scores(df, column_name): - df = df[["pipeline", "normalized"]] - # calculate the average of the metrics - df = df.groupby("pipeline").mean().reset_index() - # rename normalized to semantic - df = df.rename(columns={"normalized": column_name}) - return df - -def aggregate_ranking_df(): - metric_df = load_metrics_from_file(OUTPUT_ROOT / "all_metrics.csv") - - # # replace pipeline name with name_mapping - # metric_df["pipeline"] = metric_df["pipeline"].map(map_pipeline_name) - - # only pipelines where name contains 2 "_" chars - # metric_df = metric_df[metric_df["pipeline"].str.count("_") == 2] TODO - - norm_semantic_df = aggregate_semantic_metrics(metric_df) - norm_semantic_df = norm_semantic_df[["pipeline", "metric", "normalized"]] - agg_semantic_df = mean_scores(norm_semantic_df, "semantic") - - norm_reference_df = aggregate_reference_metrics(metric_df) - norm_reference_df = norm_reference_df[["pipeline", "metric", "normalized"]] - # print(norm_reference_df.to_string()) - agg_reference_df = mean_scores(norm_reference_df, "reference") - - norm_efficiency_df = aggregate_efficiency_metrics(metric_df) - # print(norm_efficiency_df) - norm_efficiency_df = norm_efficiency_df[["pipeline", "metric", "normalized"]] - agg_efficiency_df = mean_scores(norm_efficiency_df, "efficiency") - - norm_size_df = aggregate_size_metrics(metric_df) - norm_size_df = norm_size_df[["pipeline", "metric", "normalized"]] - agg_size_df = mean_scores(norm_size_df, "size") - - norm_df = pd.merge(norm_semantic_df, norm_reference_df, on=["pipeline", "metric", "normalized"], how="outer") - norm_df = pd.merge(norm_df, norm_efficiency_df, on=["pipeline", "metric", "normalized"], how="outer") - norm_df = pd.merge(norm_df, norm_size_df, on=["pipeline", "metric", "normalized"], how="outer") - - agg_df = pd.merge(agg_semantic_df, agg_reference_df, on=["pipeline"], how="left") - agg_df = pd.merge(agg_df, agg_efficiency_df, on=["pipeline"], how="left") - agg_df = pd.merge(agg_df, agg_size_df, on=["pipeline"], how="left") - - return norm_df, agg_df \ No newline at end of file diff --git a/experiments/moviekg/src/moviekg/paper/helpers/getter.py b/experiments/moviekg/src/moviekg/paper/helpers/getter.py deleted file mode 100644 index c5889a4..0000000 --- a/experiments/moviekg/src/moviekg/paper/helpers/getter.py +++ /dev/null @@ -1,557 +0,0 @@ - -import pandas as pd -from collections import defaultdict -import json -from typing import List, Callable - - -type pipeline_name = str -type stage_name = str -type metric_name = str -type metric_value = float -type pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]] -type pipeline_stage_metric_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]] - -""" -Helper file to map final metrics as kgpipe.evaluation... still in progress - -Each getter returns a nested dictionary of pipeline, stage, metric_name -{ - "pipeline": { - "stage": { - "metric_name": value - } - } -} -""" - -# Util - -def dict_for_metric_name(df: pd.DataFrame, metric_name: str, row_name: str = "value") -> pipeline_stage_dict: - df = df[df["metric"] == metric_name] - metric_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for index, row in df.iterrows(): - metric_dict[row["pipeline"]][row["stage"]] = row[row_name] - return metric_dict - -# Statistical metrics - -def sta_entity_count(df: pd.DataFrame): - # only pipeline, stage, value - return dict_for_metric_name(df, "entity_count") - -def sta_fact_count(df: pd.DataFrame): - return dict_for_metric_name(df, "triple_count") - -def sta_type_count(df: pd.DataFrame): - return dict_for_metric_name(df, "class_count") - -def sta_relation_count(df: pd.DataFrame): - return dict_for_metric_name(df, "relation_count") - -def sta_shallow_entity_count(df: pd.DataFrame): - return dict_for_metric_name(df, "shallow_entity_count") - -def sta_denisity(df: pd.DataFrame): - fact_count = sta_fact_count(df) - entity_count = sta_entity_count(df) - - metric_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for pipeline, stage_dict in fact_count.items(): - for stage, value in stage_dict.items(): - metric_dict[pipeline][stage] = value / entity_count[pipeline][stage] - - return metric_dict - -def sta_duration(df: pd.DataFrame): - return dict_for_metric_name(df, "duration") - -# def sta_memory_peak(df: pd.DataFrame): -# return dict_for_metric_name(df, "memory_peak") - -# Semantic metrics - -def sem_disjoint_domain(df: pd.DataFrame): - return dict_for_metric_name(df, "disjoint_domain", "normalized") - -def sem_incorrect_relation_direction(df: pd.DataFrame): - return dict_for_metric_name(df, "incorrect_relation_direction", "normalized") - -def sem_incorrect_relation_cardinality(df: pd.DataFrame): - return dict_for_metric_name(df, "incorrect_relation_cardinality", "normalized") - -def sem_incorrect_relation_range(df: pd.DataFrame): - return dict_for_metric_name(df, "incorrect_relation_range", "normalized") - -def sem_incorrect_relation_domain(df: pd.DataFrame): - return dict_for_metric_name(df, "incorrect_relation_domain", "normalized") - -def sem_incorrect_datatype(df: pd.DataFrame): - return dict_for_metric_name(df, "incorrect_datatype", "normalized") - -def sem_incorrect_datatype_format(df: pd.DataFrame): - return dict_for_metric_name(df, "incorrect_datatype_format", "normalized") - -# Reference metrics -def ref_kg_f1(df: pd.DataFrame): - df = df[df["metric"] == "ReferenceTripleAlignmentMetricSoftEV"] - - res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for row in df.itertuples(): - details = json.loads(row.details) - # print(details) - f1 = details.get("f1_score", -1) - res[row.pipeline][row.stage] = f1 - return res - -def ref_kg_p(df: pd.DataFrame): - df = df[df["metric"] == "ReferenceTripleAlignmentMetricSoftEV"] - - res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for row in df.itertuples(): - details = json.loads(row.details) - # print(details) - p = details["precision"] - res[row.pipeline][row.stage] = p - return res - -def ref_kg_r(df: pd.DataFrame): - df = df[df["metric"] == "ReferenceTripleAlignmentMetricSoftE"] - res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for row in df.itertuples(): - details = json.loads(row.details) - # print(details) - r = details["recall"] - res[row.pipeline][row.stage] = r - return res - -def ref_source_entity_f1(df: pd.DataFrame) -> pipeline_stage_dict: - df = df[df["metric"] == "SourceEntityPrecisionMetric"] - res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for row in df.itertuples(): - details = json.loads(row.details) - expected_entities_count = details["expected_entities_count"] - found_entities_count = details["found_entities_count"] - overlapping_entities_count = details["overlapping_entities_count"] - possible_duplicates_count = details["possible_duplicates_count"] - overlapping_entities_strict_count = details["overlapping_entities_strict_count"] - - precision = overlapping_entities_strict_count / overlapping_entities_count - precision = precision if precision <= 1.0 else 1.0 - recall = overlapping_entities_count / expected_entities_count - recall = recall if recall <= 1.0 else 1.0 - f1 = 2 * (precision * recall) / (precision + recall) - df.loc[row.Index, "normalized"] = f1 - res[row.pipeline][row.stage] = f1 - return res - -def ref_source_entity_p(df: pd.DataFrame): - df = df[df["metric"] == "SourceEntityPrecisionMetric"] - res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for row in df.itertuples(): - details = json.loads(row.details) - precision = details["overlapping_entities_strict_count"] / details["overlapping_entities_count"] - res[row.pipeline][row.stage] = precision - return res - -def ref_source_entity_r(df: pd.DataFrame): - df = df[df["metric"] == "SourceEntityPrecisionMetric"] - res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for row in df.itertuples(): - details = json.loads(row.details) - recall = details["overlapping_entities_count"] / details["expected_entities_count"] - res[row.pipeline][row.stage] = recall - return res - -def ref_source_typed_entity_fn(df: pd.DataFrame): - df = df[df["metric"] == "SourceTypedEntityCoverageMetric"] - res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for row in df.itertuples(): - details = json.loads(row.details) - # print(details) - fn = details.get("fn", -1) - res[row.pipeline][row.stage] = fn - return res - -def ref_source_typed_entity_p(df: pd.DataFrame): - df = df[df["metric"] == "SourceTypedEntityCoverageMetric"] - res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for row in df.itertuples(): - details = json.loads(row.details) - # print(details) - precision = details.get("precision", -1) - res[row.pipeline][row.stage] = precision - return res - -def ref_source_typed_entity_r(df: pd.DataFrame): - df = df[df["metric"] == "SourceTypedEntityCoverageMetric"] - res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for row in df.itertuples(): - details = json.loads(row.details) - recall = details.get("recall", -1) - res[row.pipeline][row.stage] = recall - return res - -def ref_entity_matching_f1(df: pd.DataFrame): - df = df[df["metric"] == "ER_EntityMatchMetric"] - - res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for row in df.itertuples(): - details = json.loads(row.details) - if "error" in details: - res[row.pipeline][row.stage] = -1 - continue - # print(details) - tp = details["true_seed_match_cnt"] - fp = details["false_seed_match_cnt"] - fn = details["false_missing_seed_match_cnt"] - f1 = 2 * tp / (2 * tp + fp + fn) - res[row.pipeline][row.stage] = f1 - return res - -def ref_entity_matching_p(df: pd.DataFrame): - df = df[df["metric"] == "ER_EntityMatchMetric"] - - res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for row in df.itertuples(): - details = json.loads(row.details) - if "error" in details: - res[row.pipeline][row.stage] = -1 - continue - # print(details) - tp = details["true_seed_match_cnt"] - fp = details["false_seed_match_cnt"] - fn = details["false_missing_seed_match_cnt"] - precision = tp / (tp + fp) - res[row.pipeline][row.stage] = precision - return res - -def ref_entity_matching_r(df: pd.DataFrame): - df = df[df["metric"] == "ER_EntityMatchMetric"] - res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for row in df.itertuples(): - details = json.loads(row.details) - if "error" in details: - res[row.pipeline][row.stage] = -1 - continue - # print(details) - tp = details["true_seed_match_cnt"] - fp = details["false_seed_match_cnt"] - fn = details["false_missing_seed_match_cnt"] - recall = tp / (tp + fn) - res[row.pipeline][row.stage] = recall - return res - -RM_DEFAULT_FN=24 # 23 + label - -def ref_relation_matching_f1(df: pd.DataFrame): - df = df[df["metric"] == "ER_RelationMatchMetric"] - res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for row in df.itertuples(): - details = json.loads(row.details) - if "error" in details: - res[row.pipeline][row.stage] = -1 - continue - # print(details) - tp = details["true_relation_match_cnt"] - fp = details["false_relation_match_cnt"] - fn = RM_DEFAULT_FN - (tp+fp) # details.get("false_missing_relation_match_cnt", 0) - f1 = 2 * tp / (2 * tp + fp + fn) - res[row.pipeline][row.stage] = f1 - return res - - -def ref_relation_matching_p(df: pd.DataFrame): - df = df[df["metric"] == "ER_RelationMatchMetric"] - res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for row in df.itertuples(): - details = json.loads(row.details) - if "error" in details: - res[row.pipeline][row.stage] = -1 - continue - # print(details) - tp = details["true_relation_match_cnt"] - fp = details["false_relation_match_cnt"] - fn = RM_DEFAULT_FN - (tp+fp) # details.get("false_missing_relation_match_cnt", 0) - print(f"tp, fp, fn for {row.pipeline} {row.stage}: {tp}, {fp}, {fn}") - precision = tp / (tp + fp) if (tp + fp) > 0 else 0 - res[row.pipeline][row.stage] = precision - return res - - -def ref_relation_matching_r(df: pd.DataFrame): - df = df[df["metric"] == "ER_RelationMatchMetric"] - res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for row in df.itertuples(): - details = json.loads(row.details) - if "error" in details: - res[row.pipeline][row.stage] = -1 - continue - # print(details) - tp = details["true_relation_match_cnt"] - fp = details["false_relation_match_cnt"] - fn = RM_DEFAULT_FN - (tp+fp) # details.get("false_missing_relation_match_cnt", 0) - recall = tp / (tp + fn) if (tp + fn) > 0 else 0 - res[row.pipeline][row.stage] = recall - return res - -def ref_entity_linking_r(df: pd.DataFrame): - df = df[df["metric"] == "TE_ExpectedEntityLinkMetric"] - res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for row in df.itertuples(): - details = json.loads(row.details) - if "error" in details: - res[row.pipeline][row.stage] = -1 - continue - # print(details) - tp = details["true_link_cnt"] - fp = details["false_link_cnt"] - fn = details["false_missing_link_cnt"] - r = tp / (tp + fn) if (tp + fn) > 0 else 0 - res[row.pipeline][row.stage] = r - return res - -def ref_json_entity_matching_f1(df: pd.DataFrame): - df = df[df["metric"] == "JsonEntityMatchingMetric"] - res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for row in df.itertuples(): - details = json.loads(row.details) - if "error" in details: - res[row.pipeline][row.stage] = -1 - continue - res[row.pipeline][row.stage] = details["f1_score"] - return res - -def ref_json_entity_matching_p(df: pd.DataFrame): - df = df[df["metric"] == "JsonEntityMatchingMetric"] - res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for row in df.itertuples(): - details = json.loads(row.details) - if "error" in details: - res[row.pipeline][row.stage] = -1 - continue - res[row.pipeline][row.stage] = details["precision"] - return res - -def ref_json_entity_matching_r(df: pd.DataFrame): - df = df[df["metric"] == "JsonEntityMatchingMetric"] - res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for row in df.itertuples(): - details = json.loads(row.details) - if "error" in details: - res[row.pipeline][row.stage] = -1 - continue - res[row.pipeline][row.stage] = details["recall"] - return res - -def ref_json_entity_linking_r(df: pd.DataFrame): - df = df[df["metric"] == "JsonEntityLinkingMetric"] - res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for row in df.itertuples(): - details = json.loads(row.details) - if "error" in details: - res[row.pipeline][row.stage] = -1 - continue - res[row.pipeline][row.stage] = details["recall"] - return res - -TABLE_DISPLAY_NAMES = { - # Statistical metrics - sta_entity_count.__name__ : "EC", - sta_fact_count.__name__: "FC", - sta_type_count.__name__: "TC", - sta_relation_count.__name__: "RC", - sta_shallow_entity_count.__name__: "SEC", - sta_denisity.__name__: "D", - sta_duration.__name__: "T", - # sta_memory_peak.__name__: "M", - # Semantic metrics - sem_disjoint_domain.__name__: "ODT", - sem_incorrect_relation_direction.__name__: "ORD", - sem_incorrect_relation_cardinality.__name__: "OCA", - sem_incorrect_relation_range.__name__: "OR", - sem_incorrect_relation_domain.__name__: "OD", - sem_incorrect_datatype.__name__: "OLT", - sem_incorrect_datatype_format.__name__: "OLF", - # Reference metrics - ref_kg_f1.__name__: "RTC", - ref_kg_p.__name__: "RTC-SoftE", - ref_kg_r.__name__: "RTC-SoftE-R", - ref_source_entity_f1.__name__: "VSEC", - ref_source_entity_p.__name__: "VSEC-P", - ref_source_entity_r.__name__: "VSEC-R", - ref_source_typed_entity_p.__name__: "VSEC-P-TE", - ref_source_typed_entity_r.__name__: "VSEC-R-TE", - ref_source_typed_entity_fn.__name__: "VSEC-FN-TE", - ref_entity_matching_f1.__name__: "ER-EM", - ref_entity_matching_p.__name__: "ER-EM-P", - ref_entity_matching_r.__name__: "ER-EM-R", - ref_relation_matching_f1.__name__: "ER-RM", - ref_relation_matching_p.__name__: "ER-RM-P", - ref_relation_matching_r.__name__: "ER-RM-R", - ref_entity_linking_r.__name__: "TE-EEL", - ref_json_entity_matching_f1.__name__: "JSON-EM", - ref_json_entity_matching_p.__name__: "JSON-EM-P", - ref_json_entity_matching_r.__name__: "JSON-EM-R", - ref_json_entity_linking_r.__name__: "JSON-EL", -} - -def dict_of_metrics(df: pd.DataFrame, metric_getters: List[Callable[[pd.DataFrame], dict]]) -> pipeline_stage_metric_dict: - """ - # call the getter functions for each metric name not the dict_for_metric_name - """ - - # Create a 3-level nested defaultdict: pipeline -> stage -> metric_name -> value - metric_dict = defaultdict(lambda: defaultdict(dict)) - - for metric_getter in metric_getters: - metric_name = metric_getter.__name__ # the metric name (e.g., "sta_entity_count") - metric_data = metric_getter(df) # returns pipeline->stage->value - - if metric_data is None: - continue - - for pipeline, stage_dict in metric_data.items(): - for stage, value in stage_dict.items(): - metric_dict[pipeline][stage][metric_name] = value - - return metric_dict - -def get_pipeline_stage_metric_dict(df: pd.DataFrame, metric_names: List[str]) -> pipeline_stage_metric_dict: - """ - # call the getter functions for each metric name not the dict_for_metric_name - """ - return dict_of_metrics(df, [globals()[f"{metric_name.lower()}"] for metric_name in TABLE_DISPLAY_NAMES.keys()]) - - -def normalize_min_best(values: List[float], value: float) -> float: - def norm_min(min, value): - return (min/value) - return norm_min(min(values), value) - - -def normalize_max_best(values: List[float], value: float) -> float: - # print(f"values: {values}, value: {value}") - def norm_max(max, value): - return 1 / (max/value) - return norm_max(max(values), value) - - -def normalize_metric(psmd: pipeline_stage_metric_dict, metric_name: str, stages: List[str], func: Callable[[list[float], float], float]) -> pipeline_stage_metric_dict: - values_for_metric = [] - - pipelines_to_normalize = [] - stages_to_normalize = [] - - - for pipeline, stage_dict in psmd.items(): - pipelines_to_normalize.append(pipeline) - for stage, metric_dict in stage_dict.items(): - if stage not in stages or metric_name not in metric_dict: - continue - stages_to_normalize.append(stage) - values_for_metric.append(metric_dict[metric_name]) - - for pipeline in pipelines_to_normalize: - for stage in stages_to_normalize: - if metric_name not in psmd[pipeline][stage]: - continue - value = psmd[pipeline][stage][metric_name] - if metric_name == sta_fact_count.__name__: - if pipeline in ["json_llm_mapping_v1", "text_llm_triple_extract_v1"]: - values_for_metric=[65000] - else: - values_for_metric=[340000] - print(f"setting max ec for norm {pipeline} {values_for_metric}") - psmd[pipeline][stage][metric_name+"_norm"] = func(values_for_metric, value) - - return psmd - -def update_task_selected_task_metric(psmd: pipeline_stage_metric_dict, metric_name: str) -> pipeline_stage_metric_dict: - - for pipleine, stage_dict in psmd.items(): - if pipleine in ["reference", "seed"]: - continue - for stage, metric_dict in stage_dict.items(): - entity_matching_f1 = metric_dict.get(ref_entity_matching_f1.__name__, -1) - relation_matching_f1 = metric_dict.get(ref_relation_matching_f1.__name__, -1) - entity_linking_r = metric_dict.get(ref_entity_linking_r.__name__, -1) - json_entity_matching_f1 = metric_dict.get(ref_json_entity_matching_f1.__name__, -1) - json_entity_linking_r = metric_dict.get(ref_json_entity_linking_r.__name__, -1) - - if json_entity_matching_f1 != -1: - metric_dict[metric_name] = json_entity_matching_f1 - metric_dict[metric_name+"_spec"] = "JSON ER" - elif entity_matching_f1 != -1: - metric_dict[metric_name] = (entity_matching_f1 + relation_matching_f1) / 2 - metric_dict[metric_name+"_spec"] = "RDF ER" - elif json_entity_linking_r != -1: - metric_dict[metric_name] = json_entity_linking_r - metric_dict[metric_name+"_spec"] = "JSON EL" - else: - metric_dict[metric_name] = entity_linking_r - metric_dict[metric_name+"_spec"] = "TE" - - return psmd - -def agg_avg(values: list[float]) -> float: - return sum(values) / len(values) - -def agg_sum(values: list[float]) -> float: - return sum(values) - -def agg_metric_over_stages(psmd: pipeline_stage_metric_dict, metric_name: str, suffix: str, agg_func: Callable[[list[float]], float]) -> pipeline_stage_metric_dict: - - values_for_metric_by_pipeline = defaultdict[pipeline_name, list[float]](lambda: []) - pipelines_to_agg = [] - stages_to_agg = [] - - for pipeline, stage_dict in psmd.items(): - if pipeline in ["reference", "seed"]: - continue - pipelines_to_agg.append(pipeline) - for stage, metric_dict in stage_dict.items(): - if metric_name not in metric_dict: - continue - stages_to_agg.append(stage) - values_for_metric_by_pipeline[pipeline].append(metric_dict[metric_name]) - - for pipeline in pipelines_to_agg: - try: - psmd[pipeline]["stage_3"][metric_name+suffix] = agg_func(values_for_metric_by_pipeline[pipeline]) - except Exception as e: - print(f"Error aggregating metric {metric_name} for pipeline {pipeline}: {e}") - print(values_for_metric_by_pipeline[pipeline]) - psmd[pipeline]["stage_3"][metric_name+suffix] = 0 - return psmd - -def apply_selected_updates(psmd: pipeline_stage_metric_dict) -> pipeline_stage_metric_dict: - normalize_metric(psmd, "sta_entity_count", ["stage_3"], normalize_max_best) - update_task_selected_task_metric(psmd, "ref_selected_task_metric") - agg_metric_over_stages(psmd, "ref_selected_task_metric", "_avg", agg_avg) - agg_metric_over_stages(psmd, "sta_duration", "_sum", agg_sum) - agg_metric_over_stages(psmd, "ref_source_entity_f1", "_avg", agg_avg) - agg_metric_over_stages(psmd, "ref_kg_f1", "_avg", agg_avg) - return psmd - -def test_getter(): - from pathlib import Path - from moviekg.paper.helpers.helpers import load_metrics_from_file - print(TABLE_DISPLAY_NAMES.keys()) - df = load_metrics_from_file(Path("/home/marvin/project/data/out/large") / "all_metrics.csv") - psmd = dict_of_metrics(df, [globals()[f"{metric_name.lower()}"] for metric_name in TABLE_DISPLAY_NAMES.keys()]) - - - apply_selected_updates(psmd) - - for pipeline, stage_dict in psmd.items(): - for stage, metric_dict in stage_dict.items(): - if pipeline in ["reference", "seed"]: - continue - # print(f"{pipeline} {stage} {metric_dict['ref_selected_task_metric']} {metric_dict['ref_selected_task_metric_spec']}") - if "stage_3" == stage: - # print(f"{pipeline} {stage} {metric_dict['ref_selected_task_metric_agg']}") - print(pipeline) - print(stage) - print(json.dumps(metric_dict, indent=4)) - print("--------------------------------") \ No newline at end of file diff --git a/experiments/moviekg/src/moviekg/paper/helpers/helpers.py b/experiments/moviekg/src/moviekg/paper/helpers/helpers.py deleted file mode 100644 index 2dd3200..0000000 --- a/experiments/moviekg/src/moviekg/paper/helpers/helpers.py +++ /dev/null @@ -1,777 +0,0 @@ -from matplotlib.font_manager import font_scalings -import pandas as pd -import numpy as np -import matplotlib.pyplot as plt -import seaborn as sns -import json -from matplotlib.patches import Patch -from matplotlib.ticker import ScalarFormatter -from typing import Dict -import re -import pandas as pd - -import pandas as pd -from typing import List, Optional - -from moviekg.paper.config import HEADERS, main_classes -from moviekg.pipelines.test_inc_ssp import pipeline_types, llm_pipeline_types - - -def load_metrics_from_file(file_path): - # print("Loading metrics from file: ", file_path) - df = pd.read_csv(file_path, names=HEADERS, skiprows=1) - return df - -def plot_growth_v1(df, metrics): - """ - df: pandas DataFrame with columns: - pipeline, stage, aspect, metric, value, normalized, details - metrics: list[str] of metric names to plot - - Generates a subplot for each metric. - Each subplot has x-axis: stage, y-axis: value. - Each pipeline's value is a grouped bar at each stage. - Returns (fig, axes). - """ - required_cols = {"pipeline", "stage", "aspect", "metric", "value", "normalized", "details"} - missing = required_cols - set(df.columns) - if missing: - raise ValueError(f"DataFrame is missing required columns: {sorted(missing)}") - - if not isinstance(metrics, (list, tuple)) or len(metrics) == 0: - raise ValueError("`metrics` must be a non-empty list of metric names.") - - # Only keep rows for requested metrics - plot_df = df[df["metric"].isin(metrics)].copy() - if plot_df.empty: - raise ValueError("No rows found for the requested metrics.") - - # Create subplots - n_metrics = len(metrics) - fig, axes = plt.subplots(n_metrics, 1, figsize=(10, max(3.5, 2.8 * n_metrics)), squeeze=False) - axes = axes.ravel() - - # Overall (stable) pipeline order: alphabetical for consistency - all_pipelines = sorted(plot_df["pipeline"].dropna().unique().tolist()) - - for ax, metric in zip(axes, metrics): - mdf = plot_df[plot_df["metric"] == metric].copy() - if mdf.empty: - ax.set_visible(False) - continue - - # Preserve stage order as first-appearance order for this metric - stage_order = pd.Index(mdf["stage"].dropna().astype(str)).drop_duplicates().tolist() - if not stage_order: - ax.set_visible(False) - continue - - # Pivot to stage x pipeline = values - pivot = ( - mdf.assign(stage=pd.Categorical(mdf["stage"].astype(str), categories=stage_order, ordered=True)) - .pivot_table( - index="stage", - columns="pipeline", - values="value", - aggfunc="sum", - ) - .reindex(columns=all_pipelines) # ensure consistent pipeline order - .sort_index() - ) - - # If some pipelines/stages don't exist, fill with 0 (or use NaN if you prefer gaps) - vals = pivot.fillna(0.0).values - stages = pivot.index.astype(str).tolist() - pipelines = pivot.columns.astype(str).tolist() - - n_stages = len(stages) - n_pipes = max(1, len(pipelines)) - - x = np.arange(n_stages, dtype=float) - total_width = 0.8 - bar_w = total_width / n_pipes - - # Center the grouped bars around each stage tick - start = x - (total_width / 2) + (bar_w / 2) - - for i, pipe in enumerate(pipelines): - y = pivot[pipe].fillna(0.0).to_numpy() - ax.bar(start + i * bar_w, y, width=bar_w, label=pipe) - - ax.set_title(str(metric)) - ax.set_xlabel("stage") - ax.set_ylabel("value") - ax.set_xticks(x) - ax.set_xticklabels(stages, rotation=0, ha="center") - - # Only show legend if multiple pipelines - if n_pipes > 1: - ax.legend(title="pipeline", frameon=False, ncols=min(3, n_pipes)) - ax.grid(axis="y", linestyle=":", linewidth=0.7, alpha=0.6) - - fig.tight_layout() - return fig, axes - -# --- Hardcoded pipeline colors (light/dark for solos; mid-tone for combined) -PALETTE = { - # JSON solo - "json_a": "#9ecae1", "json_b": "#1f77b4", "json_c": "21f77b4", - # "json_baseA": "#9ecae1", - # RDF solo - "rdf_a": "#a1d99b", "rdf_b": "#2ca02c", "rdf_c": "#3ca02c", - # TEXT solo - "text_a": "#fdd0a2", "text_b": "#ff7f0e", "text_c": "#ff7f0e", - - # JSON mixed → violet - "json_rdf_text": "#756bb1", "json_text_rdf": "#756bb1", - # RDF mixed → teal - "rdf_json_text": "#1c9099", "rdf_text_json": "#1c9099", - # TEXT mixed → red-brown - "text_json_rdf": "#d95f0e", "text_rdf_json": "#d95f0e", -} - -PALETTE_2 = { - # JSON solo - "JSON_base": "#9ecae1", - # "json_baseA": "#9ecae1", - # RDF solo - "RDF_base": "#a1d99b", "RDF_llm": "#2ca02c", - # TEXT solo - "TEXT_base": "#fdd0a2", - - # JSON mixed → violet - "json_rdf_text": "#756bb1", "json_text_rdf": "#756bb1", - # RDF mixed → teal - "rdf_json_text": "#1c9099", "rdf_text_json": "#1c9099", - # TEXT mixed → red-brown - "text_json_rdf": "#d95f0e", "text_rdf_json": "#d95f0e", -} - - -HUE_ORDER = [ - "json_a","json_b","json_rdf_text","json_text_rdf", - "rdf_a","rdf_b","rdf_json_text","rdf_text_json", - "text_a","text_b","text_json_rdf","text_rdf_json" -] -HUE_ORDER_2 = [ - "RDF_base","RDF_llm","rdf_json_text","rdf_text_json", - "JSON_base","json_rdf_text","json_text_rdf", - "TEXT_base","text_json_rdf","text_rdf_json" -] - -def plot_growth(df, metrics, kind="bar", references={}): - """ - df: pandas DataFrame with columns: - pipeline, stage, aspect, metric, value, normalized, details - metrics: list[str] of metric names to plot - kind: "bar" or "line" - - Generates a facet plot (subplot per metric). - Each subplot has x-axis: stage, y-axis: value, - with different pipelines distinguished by color. - """ - required_cols = {"pipeline", "stage", "aspect", "metric", "value", "normalized", "details"} - missing = required_cols - set(df.columns) - if missing: - raise ValueError(f"DataFrame is missing required columns: {sorted(missing)}") - - if not metrics: - raise ValueError("`metrics` must be a non-empty list of metric names.") - - # Filter to requested metrics - plot_df = df[df["metric"].isin(metrics)].copy() - if plot_df.empty: - raise ValueError("No rows found for the requested metrics.") - - # Consistent style - sns.set(style="whitegrid") - - stage_order = list(dict.fromkeys(plot_df["stage"])) - - # sns.set_context("notebook", font_scale=1.2) - - # Facet grid WITHOUT hue to avoid legend kwarg collisions - g = sns.FacetGrid( - plot_df, - col="metric", - col_wrap=len(metrics), - height=len(metrics)*1.6, - aspect=1.5, - sharey=False, - col_order=metrics, - legend_out=False, - ) - - if kind != "bar": - raise ValueError("`kind` must be 'bar' for per-bar labels.") - - # Draw grouped bars with hue specified inside map_dataframe - g.map_dataframe( - sns.barplot, - x="stage", - y="value", - hue="pipeline", - hue_order=HUE_ORDER, - palette=PALETTE, - order=stage_order, - dodge=True, - errorbar=None - ) - - - try: - g._legend.remove() - except Exception: - pass - - # build a single combined legend below everything - handles, labels = g.axes[0].get_legend_handles_labels() - g.fig.legend( - handles, labels, - loc="lower center", - ncol=min(6, len(labels)), # 6 items per row (→ 2 rows for 12 pipelines) - bbox_to_anchor=(0.5, -0.1), # adjust vertical offset - frameon=False - ) - - for ax_idx, ax in enumerate(g.axes.flat): - - # remove x axis label - ax.set_xlabel("") - - # numbers 1 to 3 - for stage_idx in range(1, 4): - value, nvalue, details = get_reference_value(df, metrics[ax_idx], "stage_"+str(stage_idx)) - # print(metrics[ax_idx], value) - xpos = stage_idx - if stage_idx == 0: - ax.axhline(value, ls="--", color="red") - else: - ax.axhline(value, ls="--", color="black") - - for ax in g.axes.flat: - ax.set_xlabel("") - # tidy up axes - ax.set_xticks(range(len(stage_order))) - ax.set_xticklabels(stage_order) - ax.yaxis.set_major_formatter(ScalarFormatter(useMathText=True)) - ax.ticklabel_format(style='sci', axis='y', scilimits=(0,0)) - ax.grid(True, axis="y", linestyle="--", alpha=0.3) - ax.margins(x=0.02) - - return g - -def _stage_sort_key(s): - """ - Convert 'stage_3' -> 3 for natural sorting; unknown formats go to +inf. - """ - m = re.search(r"(\d+)$", str(s)) - return int(m.group(1)) if m else float("inf") - -def _shorten_iri(iri): - """ - Turn 'http://kg.org/ontology/Person' -> 'Person' for cleaner legends. - """ - return str(iri).rstrip("/").split("/")[-1] - -def _flatten_to_df(nested): - """ - nested: dict like { - 'rdf_a': {'stage_1': {'iri': count, ...}, ...}, - 'reference': {...}, - ... - } - Returns a tidy DataFrame with columns: - Pipeline, Stage, Class, Actual, Expected - """ - - # Split out reference (Expected) from others (Actual) - if "reference" not in nested: - raise ValueError("Input must contain a 'reference' key with expected counts.") - ref = nested["reference"] - pipelines = {k: v for k, v in nested.items() if k != "reference"} - - # Collect all stages/classes across data to ensure aligned zeros - all_stages = sorted( - {s for d in nested.values() for s in d.keys()}, - key=_stage_sort_key - ) - - - - all_classes = sorted( - {c for d in nested.values() for s in d.values() for c in s.keys()} - ) - - - - # Build rows - rows = [] - for pipe, pdata in pipelines.items(): - for stage in all_stages: - for cls in all_classes: - actual = pdata.get(stage, {}).get(cls, 0) - expected = ref.get(stage, {}).get(cls, 0) - if cls not in main_classes: - cls = "Other" - rows.append({ - "Pipeline": pipe, - "Stage": stage, - "Class": cls, - "Actual": actual, - "Expected": expected, - "Class Short": _shorten_iri(cls), - }) - return pd.DataFrame(rows), [ _shorten_iri(c) for c in all_classes ], all_stages, list(pipelines.keys()) - -import pandas as pd -import matplotlib.pyplot as plt -from matplotlib.patches import Patch -import seaborn as sns - -def plot_actual_expected_stacked(df, - pipeline_order=None, - stage_order=None, - class_order=None, - col_wrap=3, - height=4, - suptitle="Actual vs Expected (stacked by Class) per Pipeline & Stage"): - # --- prep --- - df = df.copy() - # ensure numeric & fill NAs - for col in ["Actual", "Expected"]: - df[col] = pd.to_numeric(df[col], errors="coerce").fillna(0) - - # Use Class Short as plotting label (cleaner legend) - if "Class Short" not in df.columns: - df["Class Short"] = df["Class"] - - # Default orders (preserve first-seen order) - if pipeline_order is None: - pipeline_order = list(pd.unique(df["Pipeline"])) - if stage_order is None: - stage_order = list(pd.unique(df["Stage"])) - if class_order is None: - class_order = list(pd.unique(df["Class Short"])) - - # aggregate once - gdf = ( - df.groupby(["Pipeline", "Stage", "Class Short"], as_index=False) - .agg(Actual=("Actual","sum"), Expected=("Expected","sum")) - ) - - # full grid to align missing combos to 0 - full_index = pd.MultiIndex.from_product( - [pipeline_order, stage_order], names=["Pipeline","Stage"] - ) - - # pivots: (Pipeline, Stage) × Class - actual = (gdf.pivot_table(index=["Pipeline","Stage"], columns="Class Short", - values="Actual", aggfunc="sum") - .reindex(full_index) - .reindex(columns=class_order) - .fillna(0)) - expected = (gdf.pivot_table(index=["Pipeline","Stage"], columns="Class Short", - values="Expected", aggfunc="sum") - .reindex(full_index) - .reindex(columns=class_order) - .fillna(0)) - - # --- plot --- - sns.set(style="whitegrid") - n_pipes = len(pipeline_order) - ncols = min(col_wrap, n_pipes) - nrows = (n_pipes + ncols - 1) // ncols - fig, axes = plt.subplots(nrows, ncols, figsize=(ncols*height*1.6, nrows*height), squeeze=False, constrained_layout=True) - axes = axes.flatten() - - # palettes - blues = sns.color_palette("Blues", n_colors=max(3, len(class_order))) - oranges = sns.color_palette("Oranges", n_colors=max(3, len(class_order))) - color_map_actual = {cls: blues[i % len(blues)] for i, cls in enumerate(class_order)} - color_map_expected = {cls: oranges[i % len(oranges)] for i, cls in enumerate(class_order)} - - width = 0.4 - for ax, pipeline in zip(axes, pipeline_order): - act = actual.loc[pipeline] # index=Stage, cols=Class Short - exp = expected.loc[pipeline] # index=Stage, cols=Class Short - - x = range(len(stage_order)) - - # stacked bars - bottom_a = [0.0]*len(stage_order) - bottom_e = [0.0]*len(stage_order) - - for cls in class_order: - a_vals = act[cls].to_numpy() - e_vals = exp[cls].to_numpy() - - ax.bar([xi - 0.2 for xi in x], a_vals, width=width, bottom=bottom_a, color=color_map_actual[cls], edgecolor="none", label="Actual") - ax.bar([xi + 0.2 for xi in x], e_vals, width=width, bottom=bottom_e, color=color_map_expected[cls], edgecolor="none", label="Expected") - - # update bottoms - bottom_a = [b + v for b, v in zip(bottom_a, a_vals)] - bottom_e = [b + v for b, v in zip(bottom_e, e_vals)] - - # cosmetics - ax.set_title(pipeline) - ax.set_xticks(list(x)) - ax.set_xticklabels(stage_order) - ax.set_xlabel("Stage") - ax.set_ylabel("Count") - ax.grid(axis="y", linestyle=":", linewidth=0.7, alpha=0.6) - - # hide any unused axes - for j in range(len(pipeline_order), len(axes)): - fig.delaxes(axes[j]) - - # legend - handles = ( - [Patch(facecolor=color_map_actual[c], label=f"{c} • Actual") for c in class_order] + - [Patch(facecolor=color_map_expected[c], label=f"{c} • Expected") for c in class_order] - ) - - # legend (robust placement) - ncol_leg = min(4, len(handles)) - nrows_leg = int(np.ceil(len(handles) / ncol_leg)) - - leg = fig.legend( - handles=handles, - loc="lower center", - ncol=ncol_leg, - bbox_to_anchor=(0.5, 0.02), # inside the figure, just above bottom - frameon=False - ) - - # Title inside the top of the figure - fig.suptitle(suptitle, y=0.99, fontsize=14) - - # Give the legend guaranteed space at the bottom, proportional to its rows - # (works alongside constrained_layout) - plt.subplots_adjust(bottom=0.08 + 0.05 * max(0, nrows_leg - 1)) - - return fig - - -def plot_expected_actual_from_nested( - nested, - col_wrap=3, - height=4, - suptitle="Actual vs Expected (stacked by Class) per Pipeline & Stage" -): - """ - nested: dict structured like the user's example. - Creates one subplot per pipeline. For each Stage on that subplot, - draws two stacked bars (Actual & Expected), each stacked by Class. - """ - - df, class_labels, stage_order, pipeline_order = _flatten_to_df(nested) - - # We’ll use the *short* class labels for stacking order & legend - classes = class_labels - - # Prepare nice style - sns.set(style="whitegrid") - g = sns.FacetGrid( - df, - col="Pipeline", - col_wrap=col_wrap, - height=height, - sharey=True, - col_order=pipeline_order - ) - - df[['Actual','Expected']] = df[['Actual','Expected']].fillna(0) - - # Aggregate by Pipeline, Stage, Class, and Class Short - df = ( - df.groupby(['Pipeline', 'Stage', 'Class', 'Class Short'], as_index=False) - .agg({'Actual': 'sum', 'Expected': 'sum'}) - ) - - return plot_actual_expected_stacked(df, pipeline_order, stage_order, ["Other", "Person", "Company", "Film"], col_wrap, height, suptitle) - - -def plot_class_occurence(df): - """ - df: pandas dataframe with columns: pipeline, stage, aspect, metric, value, normalized, details - """ - - # filter df for metrics - df = df[df["metric"].isin(["class_occurrence"])] - # filter details contains unique_classes - df = df[df["details"].str.contains("unique_classes")] - # remove duration column - df = df.drop(columns=["duration"]) - # filter not seed pipeline - df = df[df["pipeline"] != "seed"] - - - class_counts_by_stage_by_pipeline = {} - # for each row - for index, row in df.iterrows(): - details = json.loads(row["details"]) - classes = details["classes"] - if row["pipeline"] not in class_counts_by_stage_by_pipeline: - class_counts_by_stage_by_pipeline[row["pipeline"]] = {} - # skip stage 0 - if row["stage"] == "stage_0": - continue - if row["stage"] not in class_counts_by_stage_by_pipeline[row["pipeline"]]: - class_counts_by_stage_by_pipeline[row["pipeline"]][row["stage"]] = {} - for class_name, count in classes.items(): - if class_name not in class_counts_by_stage_by_pipeline[row["pipeline"]][row["stage"]]: - class_counts_by_stage_by_pipeline[row["pipeline"]][row["stage"]][class_name] = 0 - class_counts_by_stage_by_pipeline[row["pipeline"]][row["stage"]][class_name] += count - - # remove stage_0 - class_counts_by_stage_by_pipeline = {k: v for k, v in class_counts_by_stage_by_pipeline.items() if k != "stage_0"} - - return plot_expected_actual_from_nested(class_counts_by_stage_by_pipeline, col_wrap=2, height=4, suptitle="Actual vs Reference by Stage • Stacked by Class") - - -def rank_pipeline_stage(group_df, metric_names, metric_weights): - weights = pd.Series(metric_weights, index=metric_names) - vals = ( - group_df.set_index("metric")["normalized"] - .reindex(metric_names) # align order - .astype(float) - ) - return float((vals * weights).sum()/len(vals)) - -def rank_metrics_apply(df, metric_names, metric_weights): - dff = df[df["metric"].isin(metric_names)] - return ( - dff.groupby(["pipeline", "stage"]) - .apply(lambda g: rank_pipeline_stage(g, metric_names, metric_weights)) - .rename("score") - .reset_index() - ) - - -def rank_metrics( - df: pd.DataFrame, - metric_names: List[str], - metric_weights: List[float], - *, - agg: str = "mean", - fill_missing: Optional[float] = 0.0, - score_col: str = "score", -) -> pd.DataFrame: - """ - Compute a weighted score per (pipeline, stage) using normalized metric values. - - Parameters - ---------- - df : DataFrame - Must include columns: pipeline, stage, metric, normalized - (other columns are ignored). - metric_names : list of str - Names of metrics to include, in the same order as their weights. - metric_weights : list of float - Weights aligned to metric_names. - agg : {"mean","sum","max","min"}, default "mean" - If there are duplicate rows per (pipeline, stage, metric), how to aggregate. - fill_missing : float or None, default 0.0 - Value to fill when a metric is missing for a (pipeline, stage). - Use None to leave as NaN (then the final score may be NaN). - score_col : str, default "score" - Name of the output score column. - - Returns - ------- - DataFrame with columns: pipeline, stage, - """ - if len(metric_names) != len(metric_weights): - raise ValueError("metric_names and metric_weights must have the same length") - - # Keep only what we need - dff = df.loc[df["metric"].isin(metric_names), ["pipeline", "stage", "metric", "normalized"]] - - # Aggregate duplicates per (pipeline, stage, metric) - agg_map = {"mean": "mean", "sum": "sum", "max": "max", "min": "min"} - if agg not in agg_map: - raise ValueError(f'agg must be one of {list(agg_map)}') - pivot = dff.pivot_table( - index=["pipeline", "stage"], - columns="metric", - values="normalized", - aggfunc=agg_map[agg], - ) - - # Enforce column order and align with weights - pivot = pivot.reindex(columns=metric_names) - if fill_missing is not None: - pivot = pivot.fillna(fill_missing) - - weights = pd.Series(metric_weights, index=metric_names) - scores = pivot.dot(weights).rename(score_col) - - return scores.reset_index() - -def get_reference_value(df, metric_name, stage): - df = df[df["metric"] == metric_name] - df = df[df["stage"] == stage] - df = df[df["pipeline"] == "reference"] - # print(df.to_string()) - value = df["value"].values[0] - nvalue = df["normalized"].values[0] - details = json.loads(df["details"].values[0]) - return value, nvalue, details - - -def get_reference_class_counts(df) -> Dict[str, Dict[str, int]]: - df = df[df["pipeline"] == "reference"] - reference_stage_class_count: Dict[str, Dict[str, int]] = defaultdict(lambda: defaultdict(int)) - df = df[df["metric"] == "class_occurrence"] - for stage in df["stage"].unique(): - df_stage = df[df["stage"] == stage] - details = json.loads(df_stage["details"].values[0]) - class_counts = details["classes"] - for class_name, count in class_counts.items(): - reference_stage_class_count[stage][class_name.split("/")[-1]] += count - - return reference_stage_class_count - -# def subplot_source_entity_integration(df): -# pass - -from collections import defaultdict - -def plot_class_occurence_new(df, reference_stage_class_count, classes): - - df = df[df["metric"] == "class_occurrence"] - - - pipeline_stage_class_count = defaultdict(lambda: defaultdict(lambda: defaultdict(int))) - - rows = [] - - # for each pipeline and stage - for pipeline in df["pipeline"].unique(): - for stage in df["stage"].unique(): - df_pipeline_stage = df[df["pipeline"] == pipeline] - df_pipeline_stage = df_pipeline_stage[df_pipeline_stage["stage"] == stage] - details = json.loads(df_pipeline_stage["details"].values[0]) - class_counts = details["classes"] - for class_name, count in class_counts.items(): - if class_name not in classes: - class_name = "Other" - pipeline_stage_class_count[pipeline][stage][class_name] += count - - pretty_pipeline_names = { - "json_a": "JSON_base", - "json_c": "JSON_llm", - "rdf_a": "RDF_base", - "rdf_c": "RDF_llm", - "text_a": "TEXT_base", - "text_c": "TEXT_llm", - "json_llm_mapping_v1": "JSON_llm", - "rdf_llm_schema_align_v1": "RDF_llm", - "text_llm_triple_extract_v1": "TEXT_llm", - } - - # convert dict of dict to rows - for pipeline, stage_class_count in pipeline_stage_class_count.items(): - for stage, class_count in stage_class_count.items(): - for class_name, count in class_count.items(): - rows.append({"pipeline": pretty_pipeline_names.get(pipeline, pipeline), "stage": stage, "class": class_name.split("/")[-1], "count": count}) - - # df: pipeline, stage, class, count - df = pd.DataFrame(rows) - df = df[df["class"] != "Other"] - - classes_short = [class_name.split("/")[-1] for class_name in classes] - - sns.set(style="whitegrid") - - stage_order = list(dict.fromkeys(df["stage"])) - g = sns.FacetGrid( - df, - col="class", - col_wrap=3, - height=3, - aspect=1.2, - sharey=False, - col_order=classes_short #+["Other"], # preserve requested order - ) - g.map_dataframe( - sns.barplot, - x="stage", - y="count", - hue="pipeline", - hue_order=HUE_ORDER_2, - palette=PALETTE_2, - order=stage_order, - dodge=True, - errorbar=None - ) - - - for ax_idx, ax in enumerate(g.axes.flat): - class_idx = ax_idx - class_name = classes_short[class_idx] - - # remove x axis label - ax.set_xlabel("") - - for stage, class_counts in reference_stage_class_count.items(): - xpos = int(stage.split("_")[1]) - if stage == "stage_0": - ax.axhline(class_counts[class_name], ls="--", color="red") - else: - ax.axhline(class_counts[class_name], ls="--", color="black") - - - # g.add_legend() - - if g.legend is not None: - g.legend.remove() - - # build a combined legend below everything - handles, labels = g.axes[0].get_legend_handles_labels() - g.fig.legend( - handles, labels, - loc="lower center", - ncol=min(6, len(labels)), # split across columns - bbox_to_anchor=(0.5, -0.15) # push below the grid - ) - - # make space at bottom so legend isn’t cut off - g.fig.subplots_adjust(bottom=0.2) - - plt.subplots_adjust(top=0.88) - - # g.savefig("class_occurence_new.png") - - return g - - -def plot_class_occ_4_bar_chart(df): - metrics = ["class_occurrence"] - stages = ["stage_1", "stage_2", "stage_3"] - all_reference_values = {} - for metric in metrics: - for stage in stages: - value, nvalue, details = get_reference_value(df, metric, stage) - all_reference_values[metric] = { - "value": value, - "nvalue": nvalue, - "details": details - } - - reference_stage_class_count = get_reference_class_counts(df) - - # remove seed and reference pipeline - df = df[df["pipeline"] != "seed"] - df = df[df["pipeline"] != "json_b"] - df = df[df["pipeline"] != "rdf_b"] - df = df[df["pipeline"] != "text_b"] - df = df[df["pipeline"] != "reference"] - - # subplot_source_entity_integration(df) - - classes = ["http://kg.org/ontology/Film", "http://kg.org/ontology/Person", "http://kg.org/ontology/Company"] - - - return plot_class_occurence_new(df, reference_stage_class_count, classes) diff --git a/experiments/moviekg/src/moviekg/paper/helpers/ranking.py b/experiments/moviekg/src/moviekg/paper/helpers/ranking.py deleted file mode 100644 index ba9102a..0000000 --- a/experiments/moviekg/src/moviekg/paper/helpers/ranking.py +++ /dev/null @@ -1,119 +0,0 @@ -import pandas as pd -from collections import defaultdict -from typing import Any, Mapping, List, Dict - -from moviekg.config import OUTPUT_ROOT -from moviekg.paper.helpers.getter import ( - pipeline_stage_metric_dict, pipeline_name, metric_name, metric_value, - TABLE_DISPLAY_NAMES, - normalize_metric, normalize_min_best, normalize_max_best, - sta_fact_count, sta_denisity, sta_duration, #memory_peak is not considered - ref_kg_p, ref_source_entity_f1, - sem_disjoint_domain, sem_incorrect_relation_direction, sem_incorrect_relation_range, sem_incorrect_relation_domain, sem_incorrect_datatype, sem_incorrect_datatype_format -) - -type pipeline_agg = Mapping[pipeline_name, float] - -def agg_metrics(psmd: pipeline_stage_metric_dict, metric_names: List[metric_name]) -> pipeline_agg: - values_by_pipeline: Dict[pipeline_name, List[metric_value]] = defaultdict[pipeline_name, List[metric_value]](lambda: []) - for pipeline, stage_dict in psmd.items(): - if pipeline in ["reference", "seed"]: - continue - for stage, metric_dict in stage_dict.items(): - if stage not in ["stage_3"]: # only stage 3 is considered - continue - for metric_name in metric_names: - if metric_name in metric_dict: - values_by_pipeline[pipeline].append(metric_dict[metric_name]) - else: - print(f"pipeline: {pipeline}") - print(f"stage: {stage}") - print(f"metric_names: {metric_names}") - print(f"metric_dict: {metric_dict}") - raise ValueError(f"Metric {metric_name} not found in metric_names") - - res: pipeline_agg = defaultdict[pipeline_name, float](lambda: 0.0) - - for pipeline, values in values_by_pipeline.items(): - filtered_values = [value for value in values if value >= 0] - res[pipeline] = sum(filtered_values) / len(filtered_values) - print(pipeline) - print(" |\t".join(metric_names)) - print(" |\t".join([ str(value) for value in values_by_pipeline[pipeline]])) - print("="+str(res[pipeline])) - print("--------------------------------") - - return res - -def _rank_and_save2csv(weights: dict, outfile_stem: str, psmd: pipeline_stage_metric_dict, round_digits: int = 3) -> None: - - # psmd = normalize_metric(psmd, sta_fact_count.__name__, ["stage_3"], normalize_max_best) - psmd = normalize_metric(psmd, sta_denisity.__name__, ["stage_3"], normalize_max_best) - psmd = normalize_metric(psmd, sta_fact_count.__name__, ["stage_3"], normalize_max_best) - sta_metric_names = [sta_denisity.__name__+"_norm", sta_fact_count.__name__+"_norm"] - sta_agg = agg_metrics(psmd, sta_metric_names) - - sem_metric_names = [ - sem_disjoint_domain.__name__, sem_incorrect_relation_direction.__name__, - sem_incorrect_relation_range.__name__, sem_incorrect_relation_domain.__name__, - sem_incorrect_datatype.__name__, sem_incorrect_datatype_format.__name__] - sem_agg = agg_metrics(psmd, sem_metric_names) - - ref_metric_names = [ref_kg_p.__name__, ref_source_entity_f1.__name__+"_avg", "ref_selected_task_metric_avg"] - ref_agg = agg_metrics(psmd, ref_metric_names) - - psmd = normalize_metric(psmd, sta_duration.__name__+"_sum", ["stage_3"], normalize_min_best) - eff_metric_names = [sta_duration.__name__+"_sum_norm"] - eff_agg = agg_metrics(psmd, eff_metric_names) - - import json - json.dump(psmd, open(OUTPUT_ROOT / f"paper/{outfile_stem}_psmd.json", "w"), indent=4) - - df_rows = [] - - for pipeline, value in sem_agg.items(): - df_rows.append( - { - "pipeline": pipeline, - "semantic": round(value, round_digits), - "reference": round(ref_agg[pipeline], round_digits), - "size": round(sta_agg[pipeline], round_digits), - "efficiency": round(eff_agg[pipeline], round_digits) - } - ) - - - df = pd.DataFrame(df_rows) - - cols = ["size", "semantic", "reference", "efficiency"] - # Ensure we only use known columns; fill missing weights with 0.0 - w = pd.Series(weights).reindex(cols, fill_value=0.0) - - # Compute combined score - df = df[["pipeline"] + cols].copy() - df["combined"] = (df[cols] * w).sum(axis=1).round(round_digits) - - print(df.to_string()) - - # Sort & save (keep default index=True to match original behavior) - out = df[["pipeline", "combined"]].sort_values(by="combined", ascending=False) - out.to_csv(OUTPUT_ROOT / f"paper/{outfile_stem}.csv", sep="\t") - -# TODO cleanup -# def _rank_and_save(weights: dict, outfile_stem: str, df: pd.DataFrame, round_digits: int = 3) -> None: -# """ -# Compute weighted 'combined' score and save a TSV sorted by 'combined'. -# Uses the same behavior as your original functions (round to 3, keep default index in CSV). -# """ -# cols = ["size", "semantic", "reference", "efficiency"] -# # Ensure we only use known columns; fill missing weights with 0.0 -# w = pd.Series(weights).reindex(cols, fill_value=0.0) - -# # Compute combined score -# df = df[["pipeline"] + cols].copy() -# df["combined"] = (df[cols] * w).sum(axis=1).round(round_digits) - -# # Sort & save (keep default index=True to match original behavior) -# out = df[["pipeline", "combined"]].sort_values(by="combined", ascending=False) -# out.to_csv(OUTPUT_ROOT / f"paper/{outfile_stem}.csv", sep="\t") - diff --git a/experiments/moviekg/src/moviekg/paper/test_figtab.py b/experiments/moviekg/src/moviekg/paper/test_figtab.py deleted file mode 100644 index 6eb685c..0000000 --- a/experiments/moviekg/src/moviekg/paper/test_figtab.py +++ /dev/null @@ -1,779 +0,0 @@ -import json -import pandas as pd -from pathlib import Path -from collections import defaultdict - -from moviekg.config import OUTPUT_DIR, DATASET_SELECT -from moviekg.paper.helpers.agggregate import agg_duration_over_stages_per_pipeline -from moviekg.paper.helpers.getter import get_pipeline_stage_metric_dict, TABLE_DISPLAY_NAMES, apply_selected_updates -from moviekg.paper.helpers.helpers import load_metrics_from_file, plot_growth, plot_class_occ_4_bar_chart -from moviekg.paper.helpers.ranking import _rank_and_save2csv -from moviekg.paper.config import ( - name_mapping, METRIC_NAME_MAP, SEM_METRIC_SHORT_NAMES, - METRIC_NAME_INDEX_PRETTY, METRIC_NAME_MAP_PRETTY, SEM_METRIC_LONG_NAMES -) - - -# === Preamble === -if not OUTPUT_DIR: - raise ValueError("OUTPUT_DIR is not set") -if not DATASET_SELECT: - raise ValueError("DATASET_SELECT is not set") - -OUTPUT_ROOT = Path(OUTPUT_DIR) / DATASET_SELECT -(OUTPUT_ROOT / "paper").mkdir(parents=True, exist_ok=True) - -PIPLEINE_NAME_MAP = { - "json_rdf_text": "JRT", - "json_text_rdf": "JTR", - "rdf_json_text": "RJT", - "rdf_text_json": "RTJ", - "text_json_rdf": "TJR", - "text_rdf_json": "TRJ", - "json_a": "J_A", - "json_b": "J_B", - "json_c": "J_C", - "json_llm_mapping_v1": "J_C", - "json_baseA": "J_baseA", - "rdf_a": "R_A", - "rdf_b": "R_B", - "rdf_c": "R_C", - "rdf_llm_schema_align_v1": "R_C", - "text_a": "T_A", - "text_b": "T_B", - "text_c": "T_C", - "text_llm_triple_extract_v1": "T_C", - } - -def map_pipeline_name_pretty(pipeline_name): - return PIPLEINE_NAME_MAP.get(pipeline_name, pipeline_name) - -# === Helper Functions === - -def map_pipeline_name(pipeline_name): - return name_mapping.get(pipeline_name, pipeline_name) - - -def map_metric_name(metric_name): - return METRIC_NAME_MAP.get(metric_name, metric_name) - - -def add_REI_precision(metric_df): - # REI_fscore = 2 * (precision * recall) / (precision + recall) - source_entity_coverage_metric_soft = metric_df[metric_df["metric"] == "SourceEntityCoverageMetricSoft"] - - additional_rows = [] - for index, row in source_entity_coverage_metric_soft.iterrows(): - details = json.loads(row["details"]) - #"{""expected_entities_count"": 2758, ""found_entities_count"": 3099, ""overlapping_entities_count"": 53}" - - expected_entities_count = details["expected_entities_count"] - #found_entities_count = details["found_entities_count"] - overlapping_entities_count = details["overlapping_entities_count"] - - tp = overlapping_entities_count if overlapping_entities_count <= expected_entities_count else expected_entities_count - fp = overlapping_entities_count - tp if overlapping_entities_count > tp else 0 - precision = tp / (tp + fp) - - additional_rows.append( - {"pipeline": row["pipeline"], - "stage": row["stage"], - "metric": "REI_precision", - "aspect": "reference", - "normalized": precision, - "value": precision, - "details": row["details"]}) - - additional_df = pd.DataFrame(additional_rows) - return pd.concat([metric_df, additional_df]) - -def extract_class_occurence_df(df): - - classes = ["http://kg.org/ontology/Film", "http://kg.org/ontology/Person", "http://kg.org/ontology/Company"] - - - pipeline_stage_class_count = defaultdict(lambda: defaultdict(lambda: defaultdict(int))) - - # for each pipeline and stage - for pipeline in df["pipeline"].unique(): - for stage in df["stage"].unique(): - df_pipeline_stage = df[df["pipeline"] == pipeline] - df_pipeline_stage = df_pipeline_stage[df_pipeline_stage["stage"] == stage] - try: - details = json.loads(df_pipeline_stage["details"].values[0]) - class_counts = details["classes"] - for class_name, count in class_counts.items(): - if class_name not in classes: - class_name = "Other" - pipeline_stage_class_count[pipeline][stage][class_name] += count - except: - print(f"Error loading details for {pipeline} {stage}") - # print(df_pipeline_stage["details"].values[0]) - - rows = [] - for pipeline, stage_class_count in pipeline_stage_class_count.items(): - for stage, class_count in stage_class_count.items(): - for class_name, count in class_count.items(): - rows.append({"pipeline": pipeline, "stage": stage, "metric": class_name.split("/")[-1], "score": count}) - - return pd.DataFrame(rows) - - - -def map_metric_name_pretty(metric_name): - return METRIC_NAME_MAP_PRETTY.get(metric_name, metric_name) # TODO: remove this - -def get_statistics_df(df): - # only pipeline, stage, metric, normalized - - class_occurence_df = df[df["metric"] == "class_occurrence"] - class_count_df = extract_class_occurence_df(class_occurence_df) - - # print(class_count_df) - - df = df[df["aspect"] == "statistical"] - metircs = ["entity_count", "relation_count", "triple_count", "class_count", "duration", "loose_entity_count", "shallow_entity_count"] - df = df[df["metric"].isin(metircs)] - - df = df[["pipeline", "stage", "metric", "value"]] - df["score"] = df["value"].round(2) - - # union df and class_count_df - df = pd.concat([df, class_count_df]) - df[["pipeline"]] = df[["pipeline"]].map(map_pipeline_name) - - # rename metric to short name - df["metric"] = df["metric"].map(map_metric_name_pretty) - - # make each metric a column - df = df.pivot(index=["pipeline", "stage"], columns="metric", values="score") - df = df.reset_index() - - - return df - -def get_semantic_df(df): - # only pipeline, stage, metric, normalized - df = df[df["aspect"] == "semantic"] - df = df[["pipeline", "stage", "metric", "normalized"]] - - metrics = list(SEM_METRIC_SHORT_NAMES.keys()) - df = df[df["metric"].isin(metrics)] - - df["score"] = df["normalized"].round(2) - - # rename metric to short name - df["metric"] = df["metric"].map(map_metric_name_pretty) - - # make each metric a column - df = df.pivot(index=["pipeline", "stage"], columns="metric", values="score") - df = df.reset_index() - - return df - -def get_reference_df(df): - # TODO metric names and selection - # only pipeline, stage, metric, normalized - df = df[df["aspect"] == "reference"] - df = add_REI_precision(df) - - df = df[["pipeline", "stage", "metric", "normalized"]] - - metrics = [ - "ReferenceTripleAlignmentMetricSoftEV", - "ReferenceTripleAlignmentMetricSoftE", - "ReferenceTripleAlignmentMetric", - # "ReferenceClassCoverageMetric", - "SourceEntityCoverageMetric", - "SourceEntityCoverageMetricSoft", - "REI_precision", - "TE_ExpectedEntityLinkMetric", - "TE_ExpectedRelationLinkMetric", - "ER_EntityMatchMetric", - "ER_RelationMatchMetric", - ] - - df = df[df["metric"].isin(metrics)] - - df["score"] = df["normalized"].round(2) - - # rename metric to short name - df["metric"] = df["metric"].map(map_metric_name_pretty) - - # make each metric a column - df = df.pivot(index=["pipeline", "stage"], columns="metric", values="score") - df = df.reset_index() - - - return df - -# === Tests === - -def test_wide_table_smoth(): - """ - Stores all metrics in a wide table format. - """ - - metric_df = load_metrics_from_file(OUTPUT_ROOT / "all_metrics.csv") - - # replace pipeline name with name_mapping - metric_df["pipeline"] = metric_df["pipeline"].map(map_pipeline_name) - - - # statistics_df - statistics_df = get_statistics_df(metric_df) - # semantic_df - semantic_df = get_semantic_df(metric_df) - # reference_df - reference_df = get_reference_df(metric_df) - - # join all of them on pipeline and stage - df = pd.merge(statistics_df, semantic_df, on=["pipeline", "stage"], how="left") - df = pd.merge(df, reference_df, on=["pipeline", "stage"], how="left") - - # colum order - df = df[["pipeline", "stage"] + [v for k, v in METRIC_NAME_INDEX_PRETTY]] - # print(df) - - df.to_csv(OUTPUT_ROOT / "paper/test_wide_table_smoth.csv", sep="\t") - - -def test_table_with_statistic_metrics(): - metric_df = load_metrics_from_file(OUTPUT_ROOT / "all_metrics.csv") - metrics = ["entity_count", "relation_count", "triple_count", "class_count", "loose_entity_count", "shallow_entity_count"] - - # filter for metrics - metric_df = metric_df[["pipeline", "stage", "metric", "value"]] - duration_df = agg_duration_over_stages_per_pipeline(metric_df) - duration_df = duration_df[["pipeline", "stage", "metric", "value"]] - metric_df = metric_df[metric_df["metric"].isin(metrics)] - - metric_df = pd.concat([metric_df, duration_df]) - - metric_df["metric"] = metric_df["metric"].map(map_metric_name) - metric_df["pipeline"] = metric_df["pipeline"].map(map_pipeline_name_pretty) - # only stage = stage_3 - metric_df = metric_df[metric_df["stage"] == "stage_3"] - - # Assuming your dataframe is called df - pivot_df = metric_df.pivot_table( - index=["pipeline", "stage"], # rows - columns="metric", # pivoted column - values="value" # values to fill - ).reset_index() - - # (Optional) Flatten the column index if needed - pivot_df.columns.name = None # remove "metric" header - - # column selection and order Pipeline FC EC RC TC Time - pivot_df = pivot_df[["pipeline", "FC", "EC", "RC", "TC", "SEC", "Time"]] - # save as TSV - output_path = OUTPUT_ROOT / "paper/test_tab_2_statistic_metrics.csv" - pivot_df.to_csv(output_path, sep="\t") - - -def test_table_with_semantic_metrics(): - metric_df = load_metrics_from_file(OUTPUT_ROOT / "all_metrics.csv") - # replace pipeline name with name_mapping - metric_df["pipeline"] = metric_df["pipeline"].map(map_pipeline_name_pretty) - # remove details colums - local_metric_df = metric_df.drop(columns=["details"]) - - # only stage = stage_1 and aspect = statistical - stage_3_df = local_metric_df[local_metric_df["stage"] == "stage_3"] - statistical_df = stage_3_df[stage_3_df["aspect"] == "semantic"] - # statistical_df["pipeline"] = statistical_df["pipeline"].map(map_pipeline_name_pretty) - - # print all available metric names - print(statistical_df["metric"].unique()) - - # rename metric to short name and remove metrics that are not in SEM_METRIC_SHORT_NAMES - statistical_df = statistical_df[statistical_df["metric"].isin(list(SEM_METRIC_SHORT_NAMES.keys()))] - statistical_df["metric"] = statistical_df["metric"].map(SEM_METRIC_SHORT_NAMES) - - # format normalized value to 2 decimal places - statistical_df["normalized"] = statistical_df["normalized"].round(3) - - # only stage = stage_3 - statistical_df = statistical_df[statistical_df["stage"] == "stage_3"] - - # make CSV with, x axis: pipeline, y axis: metric_name, cell: value - # Pivot the table: index=metric, columns=pipeline, values=value - pivot_df = statistical_df.pivot(index="metric", columns="pipeline", values="normalized") - # transpose the table - pivot_df = pivot_df.T - - # assume you have a dict SEM_METRIC_LONG_NAMES mapping short->long - long_name_row = {col: SEM_METRIC_LONG_NAMES.get(col, col) for col in pivot_df.columns} - pivot_df = pd.concat([pd.DataFrame([long_name_row], index=["metric_long_name"]), pivot_df]) - - # column selection and order pipeline 𝑂𝐷𝑇 𝑂𝐷 𝑂𝑅 𝑂𝑅𝐷 𝑂𝐿𝑇 𝑂𝐿𝐹 𝑂𝐴𝑣𝑔 - - output_path = OUTPUT_ROOT / "paper/test_tab_3_ssp_semantic_eval.csv" - pivot_df.to_csv(output_path, sep="\t") - -def test_table_with_matching_metrics(): - from moviekg.paper.helpers.getter import TABLE_DISPLAY_NAMES, get_pipeline_stage_metric_dict, ref_entity_matching_f1, ref_relation_matching_f1, ref_json_entity_matching_f1 - - metric_df = load_metrics_from_file(OUTPUT_ROOT / "all_metrics.csv") - metric_df["pipeline"] = metric_df["pipeline"].map(map_pipeline_name_pretty) - metrics = [metric for metric in list(TABLE_DISPLAY_NAMES.keys()) if metric in [ref_entity_matching_f1.__name__, ref_relation_matching_f1.__name__, ref_json_entity_matching_f1.__name__]] - - metric_dict = get_pipeline_stage_metric_dict(metric_df, metrics) - - df_rows = [] - for pipeline, stage_dict in metric_dict.items(): - for stage, metric_dict in stage_dict.items(): - rdf_em_f1 = metric_dict.get(ref_entity_matching_f1.__name__, -1) - json_em_f1 = metric_dict.get(ref_json_entity_matching_f1.__name__, -1) - em_f1 = -1 - if rdf_em_f1 != -1: - em_f1 = rdf_em_f1 - elif json_em_f1 != -1: - em_f1 = json_em_f1 - - rdf_rm_f1 = metric_dict.get(ref_relation_matching_f1.__name__, -1) - json_el_r = -1 # metric_dict.get(ref.__name__, -1) - rm_f1 = -1 - if rdf_rm_f1 != -1: - rm_f1 = rdf_rm_f1 - elif json_el_r != -1: - rm_f1 = json_el_r - - df_rows.append({"pipeline": pipeline, "stage": stage, "EM_f1": em_f1, "RM_f1": rm_f1}) - - # remove -1 rows - df_rows = [row for row in df_rows if row["EM_f1"] != -1 and row["RM_f1"] != -1] - - df = pd.DataFrame(df_rows) - # df = df.pivot(index=["pipeline", "stage"], columns="metric", values="value") - # df = df.reset_index() - output_path = OUTPUT_ROOT / "paper/test_tab_4_matching_metrics.csv" - df.to_csv(output_path, sep="\t") - -def test_table_with_matching_metrics_pr(): - from moviekg.paper.helpers.getter import ( - TABLE_DISPLAY_NAMES, get_pipeline_stage_metric_dict, - ref_entity_matching_f1, ref_entity_matching_p, ref_entity_matching_r, - ref_relation_matching_f1, ref_relation_matching_p, ref_relation_matching_r, - ref_json_entity_matching_f1, ref_json_entity_matching_p, ref_json_entity_matching_r - ) - - - - metric_df = load_metrics_from_file(OUTPUT_ROOT / "all_metrics.csv") - metric_df["pipeline"] = metric_df["pipeline"].map(map_pipeline_name_pretty) - metrics = [ - ref_entity_matching_p.__name__, ref_entity_matching_r.__name__, - ref_relation_matching_p.__name__, ref_relation_matching_r.__name__, - ref_json_entity_matching_p.__name__, ref_json_entity_matching_r.__name__ - ] - - psmd = get_pipeline_stage_metric_dict(metric_df, metrics) - - df_rows = [] - for pipeline, stage_dict in psmd.items(): - for stage, metric_dict in stage_dict.items(): - rdf_em_p = metric_dict.get(ref_entity_matching_p.__name__, -1) - rdf_em_r = metric_dict.get(ref_entity_matching_r.__name__, -1) - json_em_p = metric_dict.get(ref_json_entity_matching_p.__name__, -1) - json_em_r = metric_dict.get(ref_json_entity_matching_r.__name__, -1) - em_p = -1 - em_r = -1 - if rdf_em_p != -1: - em_p = rdf_em_p - em_r = rdf_em_r - elif json_em_p != -1: - em_p = json_em_p - em_r = json_em_r - - # print(json.dumps(metric_dict, indent=4)) - # print("--------------------------------") - - rdf_rm_p = metric_dict.get(ref_relation_matching_p.__name__, -1) - rdf_rm_r = metric_dict.get(ref_relation_matching_r.__name__, -1) - json_rm_p = metric_dict.get(ref_relation_matching_p.__name__, -1) - json_rm_r = metric_dict.get(ref_relation_matching_r.__name__, -1) - - rm_p = -1 - rm_r = -1 - if rdf_rm_p != -1: - rm_p = rdf_rm_p - rm_r = rdf_rm_r - elif json_rm_p != -1: - rm_p = json_rm_p - rm_r = json_rm_r - - df_rows.append({"pipeline": pipeline, "stage": stage, "EM_p": em_p, "EM_r": em_r, "RM_p": rm_p, "RM_r": rm_r}) - - # remove -1 rows - df_rows = [row for row in df_rows if row["EM_p"] != -1 and row["EM_r"] != -1 and row["RM_p"] != -1 and row["RM_r"] != -1] - - df = pd.DataFrame(df_rows) - # df = df.pivot(index=["pipeline", "stage"], columns="metric", values="value") - # df = df.reset_index() - output_path = OUTPUT_ROOT / "paper/test_tab_4_matching_metrics_pr.csv" - df.to_csv(output_path, sep="\t") - -def test_table_with_linking_metrics(): - from moviekg.paper.helpers.getter import TABLE_DISPLAY_NAMES, get_pipeline_stage_metric_dict, ref_entity_linking_r, ref_json_entity_linking_r - - metric_df = load_metrics_from_file(OUTPUT_ROOT / "all_metrics.csv") - metric_df["pipeline"] = metric_df["pipeline"].map(map_pipeline_name_pretty) - metrics = [metric for metric in list(TABLE_DISPLAY_NAMES.keys()) if metric in [ref_entity_linking_r.__name__, ref_json_entity_linking_r.__name__]] - - metric_dict = get_pipeline_stage_metric_dict(metric_df, metrics) - - df_rows = [] - for pipeline, stage_dict in metric_dict.items(): - for stage, metric_dict in stage_dict.items(): - rdf_el_r = metric_dict.get(ref_entity_linking_r.__name__, -1) - json_el_r = metric_dict.get(ref_json_entity_linking_r.__name__, -1) - el_r = -1 - if rdf_el_r != -1: - el_r = rdf_el_r - elif json_el_r != -1: - el_r = json_el_r - - df_rows.append({"pipeline": pipeline, "stage": stage, "EL_r": el_r}) - - # remove -1 rows - df_rows = [row for row in df_rows if row["EL_r"] != -1] - - df = pd.DataFrame(df_rows) - # df = df.pivot(index=["pipeline", "stage"], columns="metric", values="value") - # df = df.reset_index() - output_path = OUTPUT_ROOT / "paper/test_tab_5_linking_metrics.csv" - df.to_csv(output_path, sep="\t") - - -def test_table_6(): - """ - External KG R @inc (film) - EC (no Seed) REI @inc (film) - Pipeline | f1@1 f1@2 f1@3 p@3 | f1@1 f@2 f@3 - """ - metric_df = load_metrics_from_file(OUTPUT_ROOT / "all_metrics.csv") - metric_df["pipeline"] = metric_df["pipeline"].map(map_pipeline_name_pretty) - from moviekg.paper.helpers.getter import ( - get_pipeline_stage_metric_dict, ref_kg_f1, ref_kg_p, ref_kg_r, ref_source_entity_f1, ref_source_entity_p, ref_source_entity_r - ) - - metrics = [ - ref_kg_f1.__name__, ref_kg_p.__name__, ref_kg_r.__name__, ref_source_entity_f1.__name__, ref_source_entity_p.__name__, ref_source_entity_r.__name__ - ] - - psmd = get_pipeline_stage_metric_dict(metric_df, metrics) - # import json - # json.dump(psmd, open(OUTPUT_ROOT / "paper/test_tab_6_metrics.json", "w"), indent=4) - - rows = [] - - round_to = 2 - - for pipeline, stage_dict in psmd.items(): - if pipeline in ["reference", "seed"]: - continue - kg_p = [0, 0, 0] - kg_r = [0, 0, 0] - se_p = [0, 0, 0] - se_r = [0, 0, 0] - - - for stage, metric_dict in stage_dict.items(): - kg_p[int(stage.split("_")[1]) - 1] = round(metric_dict.get(ref_kg_p.__name__, -1), round_to) - kg_r[int(stage.split("_")[1]) - 1] = round(metric_dict.get(ref_kg_r.__name__, -1), round_to) - se_p[int(stage.split("_")[1]) - 1] = round(metric_dict.get(ref_source_entity_p.__name__, -1), round_to) - se_r[int(stage.split("_")[1]) - 1] = round(metric_dict.get(ref_source_entity_r.__name__, -1), round_to) - - rows.append({ - "pipeline": pipeline, - "kg_p@1": kg_p[0], "kg_r@1": kg_r[0], "kg_p@2": kg_p[1], "kg_r@2": kg_r[1], "kg_p@3": kg_p[2], "kg_r@3": kg_r[2], - "se_p@1": se_p[0], "se_r@1": se_r[0], "se_p@2": se_p[1], "se_r@2": se_r[1], "se_p@3": se_p[2], "se_r@3": se_r[2]}) - - df = pd.DataFrame(rows) - output_path = OUTPUT_ROOT / "paper/test_tab_6_reference_alignment.csv" - df.to_csv(output_path, sep="\t") - -def test_table_with_reference_overlap_metrics(): - # "Pipeline Inc. P R F1 ∼P ∼F ∼F1" - metric_df = load_metrics_from_file(OUTPUT_ROOT / "all_metrics.csv") - - # replace pipeline name with name_mapping - metric_df["pipeline"] = metric_df["pipeline"].map(map_pipeline_name) - - metric_names = ["ReferenceTripleAlignmentMetricSoftEV", "ReferenceTripleAlignmentMetricSoftE", "ReferenceTripleAlignmentMetric"] - names_map = { - "ReferenceTripleAlignmentMetricSoftEV": "soft_ev_", - "ReferenceTripleAlignmentMetricSoftE": "soft_e_", - "ReferenceTripleAlignmentMetric": "strict_", - } - - # filter for pipeline in pipeline_types - # global metric_df - # apply filter function - # only stage = stage_1 - metric_df = metric_df[metric_df["stage"] == "stage_3"] - metric_df = metric_df[metric_df["metric"].isin(metric_names)] - metric_df["metric"] = metric_df["metric"].map(names_map) - # order by stage and pipeline - # print(metric_df.pivot_table(index=["pipeline", "metric"], values="normalized", aggfunc="mean")) - - # extract precision, recall from details.json - metric_df["p"] = metric_df["details"].apply(lambda x: json.loads(x)["precision"] if "precision" in json.loads(x) else 0) - metric_df["r"] = metric_df["details"].apply(lambda x: json.loads(x)["recall"] if "recall" in json.loads(x) else 0) - # renmae value to f1 - metric_df["f1"] = metric_df["normalized"] - - # only pipline, metric, p, r, f1 - metric_df = metric_df[["pipeline", "metric", "p", "r", "f1"]] - - # result - df_wide = metric_df.pivot( - index="pipeline", - columns="metric", - values=["p", "r", "f1"] - ) - - # flatten MultiIndex columns - df_wide.columns = [f"{m if m!='' else ''}{k}" for k, m in df_wide.columns] - df_wide = df_wide.reset_index() - - # sort columns by name - df_wide = df_wide[sorted(df_wide.columns)] - # normalize values to 2 decimal places for all coluns except pipeline - df_wide.iloc[:, 1:] = df_wide.iloc[:, 1:].round(2) - - output_path = OUTPUT_ROOT / "paper/test_reference_alignment" - df_wide.to_csv(output_path, sep="\t") - -def test_figure_with_kg_growth(): - metric_df = load_metrics_from_file(OUTPUT_ROOT / "all_metrics.csv") - # remove reference stage_0 - metric_df["pipeline"] = metric_df["pipeline"].replace("json_b2", "json_b") - - metric_df = metric_df[metric_df["stage"] != "stage_0"] - - # filter for pipeline in pipeline_types - # global metric_df - # metric_df = filter_msp_and_reference(metric_df) - sorted_metric_df = metric_df.sort_values(by=["stage", "pipeline"]) - g = plot_growth(sorted_metric_df, metrics=["entity_count", "triple_count"], kind="bar") - g.fig.subplots_adjust(wspace=0.1) - # save as png - g.savefig(OUTPUT_ROOT / "paper/test_fig_both_growth.png") - - -def test_figure_with_entity_class_occurence(): - metric_df = load_metrics_from_file(OUTPUT_ROOT / "all_metrics.csv") - g = plot_class_occ_4_bar_chart(metric_df) - g.savefig(OUTPUT_ROOT / "paper/test_fig_msp_type_reference.png") - - -# Preset weight configs (kept exactly as used in your original code) -PRESETS = { - "equal": { - "size": 0.25, "semantic": 0.25, "reference": 0.25, "efficiency": 0.25 - }, - # Quantity-focused (your code used 0.5, 0.1, 0.1, 0.3) - "quantity_focused": { - "size": 0.5, "semantic": 0.1, "reference": 0.1, "efficiency": 0.3 - }, - # Quality-focused (your code used 0.0, 0.5, 0.5, 0.0) - "quality_focused": { - "size": 0.0, "semantic": 0.5, "reference": 0.5, "efficiency": 0.0 - }, - # Reference-alignment focused (your code used 0.0, 0.2, 0.8, 0.0) - "reference_alignment_focused": { - "size": 0.0, "semantic": 0.2, "reference": 0.8, "efficiency": 0.0 - }, - "efficiency_oriented": { - "size": 0.2, "semantic": 0.2, "reference": 0.2, "efficiency": 0.4 - }, -} - -psmd_df = load_metrics_from_file(OUTPUT_ROOT / "all_metrics.csv") -psmd = get_pipeline_stage_metric_dict(psmd_df, TABLE_DISPLAY_NAMES.keys()) -psmd = apply_selected_updates(psmd) - -# TODO cleanup -# norm_df, agg_df = aggregate_ranking_df() -# def test_rank_save_norm_df(): -# norm_df["normalized"] = norm_df["normalized"].round(2) -# # to format pipeline, metric_name1... metric_nameN, normalized -# wide = norm_df.pivot(index="pipeline", columns="metric", values="normalized").reset_index() -# wide.to_csv(OUTPUT_ROOT / "paper/test_rank_norm_df.csv", sep="\t") - -# c1 size, c2 sem, c3 ref, c4 eff -def test_rank_equal(): - # _rank_and_save(PRESETS["equal"], "test_rank_equal", agg_df) - _rank_and_save2csv(PRESETS["equal"], "test_rank_equal", psmd) - -def test_rank_quantity_focused(): - #_rank_and_save(PRESETS["quantity_focused"], "test_rank_quantity_focused", agg_df) - _rank_and_save2csv(PRESETS["quantity_focused"], "test_rank_quantity_focused", psmd) - -def test_rank_quality_focused(): - #_rank_and_save(PRESETS["quality_focused"], "test_rank_quality_focused", agg_df) - _rank_and_save2csv(PRESETS["quality_focused"], "test_rank_quality_focused", psmd) - -def test_rank_reference_alignment_focused(): - #_rank_and_save(PRESETS["reference_alignment_focused"], "test_rank_reference_alignment_focused", agg_df) - _rank_and_save2csv(PRESETS["reference_alignment_focused"], "test_rank_reference_alignment_focused", psmd) - -def test_rank_efficiency_oriented(): - #_rank_and_save(PRESETS["efficiency_oriented"], "test_rank_efficiency_oriented", agg_df) - _rank_and_save2csv(PRESETS["efficiency_oriented"], "test_rank_efficiency_oriented", psmd) - -def test_full_ranking_table(): - """ - for each rank table read it and then concatenate them into one table joining on the index - for example: - test_rank_equal.csv: - pipeline combined - 0 json_rdf_text 0.855084 - 1 json_text_rdf 0.867719 - 2 rdf_json_text 0.855081 - 3 rdf_text_json 0.867721 - 4 text_json_rdf 0.864522 - 5 text_rdf_json 0.864522 - test_rank_quantity_focused.csv: - pipeline combined - 0 rdf_json_text 0.950847 - 1 text_rdf_json 0.940847 - 2 json_text_rdf 0.93847 - 3 rdf_text_json 0.920847 - 4 json_rdf_text 0.910847 - 5 text_json_rdf 0.900847 - - the result should be: - pipeline combined - 0 json_rdf_text 0.855084 rdf_json_text_0.950847 - 1 json_text_rdf 0.867719 text_rdf_json_0.940847 - 2 rdf_json_text 0.855081 json_text_rdf_0.93847 - 3 rdf_text_json 0.867721 rdf_text_json_0.920847 - 4 text_json_rdf 0.864522 json_rdf_text_0.910847 - 5 text_rdf_json 0.864522 text_json_rdf_0.900847 - - rename the "combined" column for each to the name of the file - """ - - ranking_files = [ - "test_rank_equal.csv", - "test_rank_quantity_focused.csv", - "test_rank_quality_focused.csv", - "test_rank_reference_alignment_focused.csv", - "test_rank_efficiency_oriented.csv" - ] - - - ranking_files = [OUTPUT_ROOT / "paper" / file for file in ranking_files] - - # Base frame with fixed ranks 0..5 (top to bottom) - result = pd.DataFrame({"rank": range(15)}) - # result = pd.DataFrame() - - for file in ranking_files: - name = Path(file).stem # e.g., "test_rank_equal" - df = pd.read_csv(file, sep="\t") - # Ensure we have at least 6 rows; if more, keep top-6; if fewer, allow NaNs - # df = df.head(6).reset_index(drop=True) - - # pipeline name != reference and reset index - df = df[df["pipeline"] != "reference"] - df["pipeline"] = df["pipeline"].map(PIPLEINE_NAME_MAP) - df = df.reset_index(drop=True) - - - # Build two columns for this file: pipeline + score - sub = pd.DataFrame({ - "rank": df.index, - f"{name.split(".")[0]}_pipe": df["pipeline"], - f"{name.split(".")[0]}_score": df["combined"] - }) - - # Join on rank to keep rows aligned 0..5 - result = result.merge(sub, on="rank", how="left") - - # Make 'rank' the index if you prefer, or keep as a column - result = result.set_index("rank") - - result.to_csv(OUTPUT_ROOT / "paper/test_tab_7_full_ranking_table.csv", sep="\t") - -def test_new_ranking_table(): - """ - """ - from moviekg.paper.helpers.ranking import _rank_and_save3csv - df =_rank_and_save3csv("test_rank_new", psmd) - df["pipeline"] = df["pipeline"].map(PIPLEINE_NAME_MAP) - df.to_csv(OUTPUT_ROOT / "paper/test_tab_8_new_ranking_table.csv", sep="\t") - # metric_df = load_metrics_from_file(OUTPUT_ROOT / "all_metrics.csv") - # metric_df["pipeline"] = metric_df["pipeline"].map(map_pipeline_name_pretty) - # # metric_df = metric_df[metric_df["stage"] == "stage_3"] - # # metric_df = metric_df[metric_df["metric"].isin(TABLE_DISPLAY_NAMES.keys())] - # # metric_df = metric_df[metric_df["pipeline"] != "reference"] - # # metric_df = metric_df.reset_index(drop=True) - # # metric_df = metric_df.pivot(index="pipeline", columns="metric", values="normalized") - # # metric_df = metric_df.reset_index() - # metric_df.to_csv(OUTPUT_ROOT / "paper/test_tab_8_new_ranking_table.csv", sep="\t") - - -def test_new_quality_table(): - - metric_df = load_metrics_from_file(OUTPUT_ROOT / "all_metrics.csv") - metric_df["pipeline"] = metric_df["pipeline"].map(map_pipeline_name_pretty) - from moviekg.paper.helpers.getter import ( - get_pipeline_stage_metric_dict, - sta_entity_count, sta_fact_count, sta_type_count, sta_relation_count, sta_shallow_entity_count, sta_denisity, sta_duration, - ref_kg_f1, ref_kg_p, ref_kg_r, - ref_source_entity_f1, ref_source_entity_p, ref_source_entity_r, - ref_source_typed_entity_r, ref_source_typed_entity_p, ref_source_typed_entity_fn, - sem_disjoint_domain, sem_incorrect_relation_direction, sem_incorrect_relation_cardinality, sem_incorrect_relation_range, sem_incorrect_relation_domain, sem_incorrect_datatype, sem_incorrect_datatype_format, - ) - - metrics = [ - sta_entity_count.__name__, sta_fact_count.__name__, sta_type_count.__name__, sta_relation_count.__name__, sta_shallow_entity_count.__name__, sta_denisity.__name__, sta_duration.__name__, - ref_kg_f1.__name__, ref_kg_p.__name__, - ref_kg_r.__name__, ref_source_entity_f1.__name__, - ref_source_entity_p.__name__, ref_source_entity_r.__name__, - ref_source_typed_entity_r.__name__, ref_source_typed_entity_p.__name__, ref_source_typed_entity_fn.__name__, - sem_disjoint_domain.__name__, sem_incorrect_relation_direction.__name__, sem_incorrect_relation_cardinality.__name__, sem_incorrect_relation_range.__name__, sem_incorrect_relation_domain.__name__, sem_incorrect_datatype.__name__, sem_incorrect_datatype_format.__name__, - ] - - psmd = get_pipeline_stage_metric_dict(metric_df, metrics) - # import json - # json.dump(psmd, open(OUTPUT_ROOT / "paper/test_tab_6_metrics.json", "w"), indent=4) - - rows = [] - - round_to = 3 - - for pipeline, stage_dict in psmd.items(): - if pipeline in ["reference", "seed"]: - continue - - for stage, metric_dict in stage_dict.items(): - ec = round(metric_dict.get(sta_entity_count.__name__, -1), round_to) - kg_p = round(metric_dict.get(ref_kg_p.__name__, -1), round_to) - kg_r = round(metric_dict.get(ref_kg_r.__name__, -1), round_to) - se_p = round(metric_dict.get(ref_source_entity_p.__name__, -1), round_to) - se_r= round(metric_dict.get(ref_source_entity_r.__name__, -1), round_to) - ste_p = round(metric_dict.get(ref_source_typed_entity_p.__name__, -1), round_to) - ste_r = round(metric_dict.get(ref_source_typed_entity_r.__name__, -1), round_to) - ste_fn = round(metric_dict.get(ref_source_typed_entity_fn.__name__, -1), round_to) - o_dt = round(metric_dict.get(sem_disjoint_domain.__name__, -1), round_to) - o_d = round(metric_dict.get(sem_incorrect_relation_domain.__name__, -1), round_to) - o_r = round(metric_dict.get(sem_incorrect_relation_range.__name__, -1), round_to) - o_rd = round(metric_dict.get(sem_incorrect_relation_direction.__name__, -1), round_to) - o_lt = round(metric_dict.get(sem_incorrect_datatype.__name__, -1), round_to) - o_lf = round(metric_dict.get(sem_incorrect_datatype_format.__name__, -1), round_to) - - rows.append({ - "pipeline": pipeline, "stage": stage, - "EC": ec, - "kg_p": kg_p, "kg_r": kg_r, "se_p": se_p, "se_r": se_r, "ste_p": ste_p, "ste_r": ste_r, "ste_fn": ste_fn, - "O_DT": o_dt, "O_D": o_d, "O_R": o_r, "O_RD": o_rd, "O_LT": o_lt, "O_LF": o_lf - }) - - df = pd.DataFrame(rows) - df.to_csv(OUTPUT_ROOT / "paper/test_tab_9_new_quality_table.csv", sep="\t") \ No newline at end of file diff --git a/experiments/moviekg/src/moviekg/paper/test_ranksens.py b/experiments/moviekg/src/moviekg/paper/test_ranksens.py deleted file mode 100644 index e53f284..0000000 --- a/experiments/moviekg/src/moviekg/paper/test_ranksens.py +++ /dev/null @@ -1,162 +0,0 @@ -import re -import numpy as np -import pandas as pd -import matplotlib.pyplot as plt -from itertools import product - -# ========================= -# 1) Data -# ========================= -data = [ - ("T_C", 0.824, 0.367, 0.332), - ("R_A", 0.996, 0.993, 0.994), - ("TJR", 0.980, 0.980, 0.793), - ("RJT", 0.981, 0.967, 0.849), - ("TRJ", 0.980, 0.967, 0.808), - ("JRT", 0.982, 0.980, 0.838), - ("J_A", 0.938, 0.976, 0.988), - ("T_B", 0.893, 0.555, 0.580), - ("J_B", 0.968, 0.961, 0.788), - ("JTR", 0.981, 0.980, 0.806), - ("J_C", 0.993, 0.751, 0.851), - ("R_B", 0.993, 0.982, 0.962), - ("RTJ", 0.979, 0.967, 0.845), - ("R_C", 0.996, 0.984, 0.993), - ("T_A", 0.986, 0.526, 0.590), -] -df = pd.DataFrame(data, columns=["pipeline", "semantic", "correctness", "coverage"]) - -# ========================= -# 2) Define cohorts -# ========================= -# Single-source pipelines: "R_A", "J_B", "T_C", etc. -single_re = re.compile(r"^[RJT]_[A-Z]$") - -df["is_single"] = df["pipeline"].apply(lambda s: bool(single_re.match(s))) -df["source_type"] = df["pipeline"].apply(lambda s: s[0]) # 'R', 'J', 'T' - -single_df = df[df["is_single"]].copy() -multi_df = df[~df["is_single"]].copy() # e.g., "TJR", "RJT", ... - -# Cohort dict: RDF-only, JSON-only, TEXT-only, and Multi-source -cohorts = { - "RDF-only (R_*)": single_df[single_df["source_type"] == "R"].copy(), - "JSON-only (J_*)": single_df[single_df["source_type"] == "J"].copy(), - "Text-only (T_*)": single_df[single_df["source_type"] == "T"].copy(), - "Multi-source (no underscore)": multi_df.copy(), -} - -# ========================= -# 3) Weight grid on simplex -# ========================= -# Weights are (w_sem, w_cor, w_cov) with w_sum=1 and w_i>=0 -STEP = 0.05 # set to 0.1 for fewer points -vals = np.round(np.arange(0, 1 + 1e-9, STEP), 10) - -weights = [] -for w in product(vals, repeat=3): - if abs(sum(w) - 1.0) < 1e-9: - weights.append(w) -weights = np.array(weights) # (N, 3) -print(f"Weight grid: step={STEP}, N={len(weights)} points") - -# ========================= -# 4) Sensitivity computation -# ========================= -def sensitivity_summary(cohort_df: pd.DataFrame, weights: np.ndarray) -> pd.DataFrame: - """ - Returns per-pipeline: - - wins: how many weight points where it ranks #1 - - win_fraction - - avg_rank - - avg_score (mean across weights) - """ - if cohort_df.empty: - return pd.DataFrame() - - M = cohort_df[["semantic", "correctness", "coverage"]].to_numpy() # (m,3) - scores = weights @ M.T # (N,m) - - # winner counts - winner_idx = np.argmax(scores, axis=1) - winners = cohort_df["pipeline"].iloc[winner_idx].to_numpy() - win_counts = pd.Series(winners).value_counts().reindex(cohort_df["pipeline"]).fillna(0).astype(int) - - # rank matrix: rank 1 = best - order = scores.argsort(axis=1)[:, ::-1] - rank_matrix = np.empty_like(order) - for i in range(order.shape[0]): - rank_matrix[i, order[i]] = np.arange(1, M.shape[0] + 1) - - summary = pd.DataFrame({ - "wins": win_counts.values, - "win_fraction": (win_counts.values / len(weights)), - "avg_rank": rank_matrix.mean(axis=0), - "avg_score": scores.mean(axis=0), - }, index=cohort_df["pipeline"].values) - - summary = summary.sort_values(["win_fraction", "avg_rank"], ascending=[False, True]) - return summary - -all_summaries = {name: sensitivity_summary(cdf, weights) for name, cdf in cohorts.items()} - -# Print summaries -for name, summ in all_summaries.items(): - print("\n" + "=" * 80) - print(name) - if summ.empty: - print("(empty cohort)") - else: - print(summ) - -# ========================= -# 5) Plots (VLDB-friendly) -# ========================= -# A) Win-fraction bars for each cohort -# for name, summ in all_summaries.items(): -# if summ.empty: -# continue -# plt.figure(figsize=(9, 3.8)) -# plt.bar(summ.index, summ["win_fraction"].values) -# plt.xticks(rotation=45, ha="right") -# plt.ylabel("Win fraction (#1 over weight grid)") -# plt.title(f"{name} — winner sensitivity (step={STEP})") -# plt.tight_layout() -# plt.show() - -# B) Average-rank bars for each cohort -# for name, summ in all_summaries.items(): -# if summ.empty: -# continue -# plt.figure(figsize=(9, 3.8)) -# plt.bar(summ.index, summ["avg_rank"].values) -# plt.xticks(rotation=45, ha="right") -# plt.ylabel("Average rank (lower is better)") -# plt.title(f"{name} — average rank over weight grid") -# plt.tight_layout() -# plt.show() - -# ========================= -# 6) Optional: a compact “paper table” per cohort -# ========================= -paper_tables = {} -for name, summ in all_summaries.items(): - if summ.empty: - continue - paper_tables[name] = summ[["win_fraction", "avg_rank"]].copy() - -print("\n" + "=" * 80) -print("Compact paper tables (win_fraction, avg_rank):") -for name, t in paper_tables.items(): - print("\n---", name, "---") - print(t) - -# ========================= -# 7) Optional: export to CSV (uncomment if you want files) -# ========================= -# for name, summ in all_summaries.items(): -# if summ.empty: -# continue -# safe_name = re.sub(r"[^A-Za-z0-9]+", "_", name).strip("_") -# summ.to_csv(f"sensitivity_{safe_name}.csv") -# print("Wrote CSV files.") \ No newline at end of file diff --git a/experiments/moviekg/src/moviekg/pipelines/helpers.py b/experiments/moviekg/src/moviekg/pipelines/helpers.py index fab8ee3..d47c2e5 100644 --- a/experiments/moviekg/src/moviekg/pipelines/helpers.py +++ b/experiments/moviekg/src/moviekg/pipelines/helpers.py @@ -7,7 +7,7 @@ from kgpipe.generation.loaders import build_from_conf from kgpipe.datasets.multipart_multisource import Dataset -from moviekg.datasets.pipe_out import PipeOut, StageOut +from kgpipe.io.pipe_out import PipeOut, StageOut from moviekg.config import dataset, catalog @@ -70,7 +70,12 @@ def run_helper( tmp_dir = stage_dir / "tmp" tmp_dir.mkdir(parents=True, exist_ok=True) - pipeline = build_from_conf(pipeline_conf, target_data, tmp_dir.as_posix()) + pipeline = build_from_conf( + name=pipeline_name, + conf=pipeline_conf, + target_data=target_data, + data_dir=tmp_dir.as_posix(), + ) stage_dir.mkdir(parents=True, exist_ok=True) diff --git a/mkdocs.yml b/mkdocs.yml index 7a41041..f1400c0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -32,6 +32,7 @@ site_dir: site nav: - Home: index.md - Quickstart: quickstart.md + - KGI-Bench (benchmark site): /kgibench/ - Concepts: - Tasks: tasks.md - Pipelines: pipelines.md @@ -48,5 +49,5 @@ nav: - Experiments: - Reproduce MovieKG: reproduce.md - Other: - - Migration: migration.md + - Adoption (integrating existing pipelines): adoption.md - View/UI: view.md diff --git a/src/kgpipe/cli/eval_new.py b/src/kgpipe/cli/eval_new.py index 3c9b442..a86f141 100644 --- a/src/kgpipe/cli/eval_new.py +++ b/src/kgpipe/cli/eval_new.py @@ -9,6 +9,8 @@ from kgpipe_eval.metrics.statistics import CountMetric from kgpipe_eval.metrics.duplicates import DuplicateMetric from kgpipe_eval.metrics.entity_alignment import EntityAlignmentMetric +from kgpipe_eval.metrics.triple_alignment import TripleAlignmentMetric +from kgpipe_eval.metrics.consistency_violations import DisjointDomainMetric, DomainMetric, RangeMetric, RelationDirectionMetric, DatatypeMetric, DatatypeFormatMetric from kgpipe_eval.utils.kg_utils import KgManager from kgpipe_eval.utils.metric_utils import MeasurementKey, parse_eval_results, write_eval_csv from kgpipe_eval.config.manager import load_metric_configs, write_default_config_yaml @@ -68,6 +70,13 @@ def _available_metric_instances() -> dict[str, Any]: "CountMetric": CountMetric(), "DuplicateMetric": DuplicateMetric(), "EntityAlignmentMetric": EntityAlignmentMetric(), + "TripleAlignmentMetric": TripleAlignmentMetric(), + "DisjointDomainMetric": DisjointDomainMetric(), + "DomainMetric": DomainMetric(), + "RangeMetric": RangeMetric(), + "RelationDirectionMetric": RelationDirectionMetric(), + "DatatypeMetric": DatatypeMetric(), + "DatatypeFormatMetric": DatatypeFormatMetric(), } def _normalize_key(k: str) -> str: @@ -78,6 +87,35 @@ def _metric_key(metric: Any) -> str: return getattr(metric, "key", metric.__class__.__name__) +def _metric_description(metric: Any) -> str: + cls = metric.__class__ + desc = getattr(cls, "description", None) + if desc: + return str(desc).strip() + if cls.__doc__: + return cls.__doc__.strip().split("\n")[0] + compute_doc = cls.compute.__doc__ + if compute_doc: + return compute_doc.strip().split("\n")[0] + return "—" + + +def _render_available_metrics_table() -> None: + metrics = _available_metric_instances() + table = Table(title="Available metrics (eval-new)") + table.add_column("Name", style="cyan") + table.add_column("Description", style="green") + + for name in sorted(metrics.keys()): + table.add_row(name, _metric_description(metrics[name])) + + console.print(table) + console.print( + f"[dim]{len(metrics)} metric(s). " + "Pass one or more with `eval-new run -m `.[/dim]" + ) + + def _build_confs_for_selected_metrics( selected_metric_instances: list[Any], loaded_confs: dict[str, Any], @@ -167,6 +205,14 @@ def eval_new_cmd() -> None: """ +@eval_new_cmd.command(name="list") +def list_metrics_cmd() -> None: + """ + List all metrics available to `eval-new run`. + """ + _render_available_metrics_table() + + @eval_new_cmd.command(name="run") @click.argument("kg_paths", nargs=-1, type=click.Path(exists=True)) @click.option( diff --git a/src/kgpipe/io/__init__.py b/src/kgpipe/io/__init__.py new file mode 100644 index 0000000..fe16459 --- /dev/null +++ b/src/kgpipe/io/__init__.py @@ -0,0 +1,2 @@ +__all__ = [] + diff --git a/src/kgpipe/io/pipe_out.py b/src/kgpipe/io/pipe_out.py new file mode 100644 index 0000000..e19bcd5 --- /dev/null +++ b/src/kgpipe/io/pipe_out.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from pathlib import Path +from typing import List, Optional + +from pydantic import BaseModel + +from kgpipe.common.models import KgPipePlan, KgStageReport + + +class TaskOut(BaseModel): + """ + Output artifacts produced by a single task within a stage. + """ + + task_name: str + output: List[Path] + + +class StageOut(BaseModel): + """ + Output artifacts for one incremental stage. + """ + + root: Path + stage_name: str + tasks: List[TaskOut] + resultKG: Optional[Path] = None + plan: Optional[KgPipePlan] = None + report: KgStageReport + + @property + def stage_index(self) -> int: + """ + Extract stage number from `stage_` directory name. + """ + return int(self.stage_name.split("_", 1)[1]) + + +class PipeOut(BaseModel): + """ + Output artifacts for a full incremental pipeline run directory containing stage_* subdirs. + """ + + root: Path + pipeline_name: str + stages: List[StageOut] + resultKG: Optional[Path] = None + + +def _stage_paths(run_dir: Path) -> list[Path]: + stage_paths = [p for p in run_dir.iterdir() if p.is_dir() and p.name.startswith("stage_")] + stage_paths.sort(key=lambda p: int(p.name.split("_", 1)[1])) + return stage_paths + + +def _resolve_stage_result_kg(stage_dir: Path) -> Path: + """ + Prefer `result_eval.nt` (evaluation-ready), fallback to `result.nt`. + """ + candidates = [ + # stage_dir / "result_eval.nt", + stage_dir / "result.nt", + ] + for c in candidates: + if c.exists(): + return c + # Keep the legacy default for downstream tools that expect result.nt even if not created yet. + return stage_dir / "result.nt" + + +def load_stage_out(stage_dir: Path) -> StageOut: + """ + Load stage outputs from a `stage_` directory produced by KGpipe incremental runs. + """ + stage_name = stage_dir.name + + plan_path = stage_dir / "exec-plan.json" + report_path = stage_dir / "exec-report.json" + + if not plan_path.exists(): + raise FileNotFoundError(f"Missing exec plan: {plan_path}") + if not report_path.exists(): + raise FileNotFoundError(f"Missing exec report: {report_path}") + + stage_plan = KgPipePlan.model_validate_json(plan_path.read_text()) + + stage_tasks: list[TaskOut] = [] + for step in stage_plan.steps: + stage_tasks.append( + TaskOut( + task_name=step.task, + output=[stage_dir / f"{output.path}" for output in step.output], + ) + ) + + stage_report = KgStageReport.model_validate_json(report_path.read_text()) + + return StageOut( + root=stage_dir, + stage_name=stage_name, + tasks=stage_tasks, + resultKG=_resolve_stage_result_kg(stage_dir), + plan=stage_plan, + report=stage_report, + ) + + +def load_pipe_out(run_dir: Path) -> PipeOut: + """ + Load a pipeline run output directory that contains `stage_*` directories. + """ + run_dir = Path(run_dir) + stages = [load_stage_out(p) for p in _stage_paths(run_dir)] + + return PipeOut( + root=run_dir, + pipeline_name=run_dir.name, + stages=stages, + resultKG=_resolve_stage_result_kg(run_dir) if (run_dir / "result.nt").exists() else (run_dir / "result.nt"), + ) + diff --git a/src/kgpipe_eval/metrics/consistency_violations.py b/src/kgpipe_eval/metrics/consistency_violations.py index 6e0ddd4..7bbc1ae 100644 --- a/src/kgpipe_eval/metrics/consistency_violations.py +++ b/src/kgpipe_eval/metrics/consistency_violations.py @@ -1,14 +1,50 @@ -from kgpipe_eval.api import Metric +from kgpipe_eval.api import Metric, MetricResult, Measurement from pydantic import BaseModel, model_validator, ConfigDict from kgpipe.common import KG from pathlib import Path from kgpipe_eval.utils.kg_utils import TripleGraph +from typing import Dict, Set, Optional + +from rdflib import URIRef, RDF, Literal, Graph, XSD +from rdflib.query import Result, ResultRow + +from kgcore.api.ontology import Ontology, OntologyUtil +from tqdm import tqdm + +def get_ontology_graph(ontology_path: Optional[Path], kg: KG) -> Graph: + if ontology_path is not None: + return Graph().parse(ontology_path) + elif kg is not None: + return kg.get_ontology_graph() + + +def enrich_type_information(graph: Graph, ontology: Ontology, type_property: URIRef = RDF.type) -> Graph: + type_dict = {} + + new_graph = Graph() + + for s, p, o in graph: + domain, range = ontology.get_domain_range(str(p)) + if domain and isinstance(s, URIRef): + if str(s) not in type_dict: + type_dict[str(s)] = [] + type_dict[str(s)].append(str(domain)) + if range and isinstance(o, URIRef): + if str(o) not in type_dict: + type_dict[str(o)] = [] + type_dict[str(o)].append(str(range)) + new_graph.add((s, p, o)) + + for uri, types in type_dict.items(): + for type in types: + new_graph.add((URIRef(uri), type_property, URIRef(type))) + return new_graph class ConsistencyViolationsConfig(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) - reference_kg: KG - ontology_path: Path + reference_kg: Optional[KG] = None + ontology_path: Optional[Path] = None @model_validator(mode="after") def _require_reference_kg_or_ontology_path(self): @@ -18,27 +54,554 @@ def _require_reference_kg_or_ontology_path(self): class DisjointDomainMetric(Metric): def compute(self, kg: TripleGraph, config: ConsistencyViolationsConfig): - pass + """Compute disjoint domain score.""" + + raw_graph: Graph = kg.get_graph() + ontology_graph: Graph = get_ontology_graph(config.ontology_path, config.reference_kg) + ontology = OntologyUtil.load_ontology_from_graph(ontology_graph) + graph = enrich_type_information(raw_graph, ontology) + + for s, p, o in ontology_graph.triples((None, None, None)): + graph.add((s, p, o)) + + # Get all disjoint domains + disjoint_domains_qr: Result = graph.query( + """ + SELECT DISTINCT ?subject + WHERE { + ?subject a ?disjointDomain1 . + ?subject a ?disjointDomain2 . + ?disjointDomain1 owl:disjointWith ?disjointDomain2 . + } + """ + ) + subjects_with_disjoint_domains = set([row["subject"] for row in disjoint_domains_qr if isinstance(row, ResultRow)]) + + subjects = set([str(s) for s in graph.subjects()]) + + return MetricResult( + metric=self, + measurements=[ + Measurement(name="subjects_with_disjoint_domains", value=len(subjects_with_disjoint_domains), unit="number"), + Measurement(name="subjects", value=len(subjects), unit="number"), + Measurement(name="normalized_score", value=1.0 - (len(subjects_with_disjoint_domains) / len(subjects)), unit="ratio"), + ], + summary=f"Number of subjects with disjoint domains: {len(subjects_with_disjoint_domains)}", + ) class DomainMetric(Metric): def compute(self, kg: TripleGraph, config: ConsistencyViolationsConfig): - pass + """Compute incorrect relation domain score. + + TODO: check if this is correct for increment eval if namespace changes to former generic namespace not seed + """ + + raw_graph: Graph = kg.get_graph() + ontology_graph: Graph = get_ontology_graph(config.ontology_path, config.reference_kg) + ontology = OntologyUtil.load_ontology_from_graph(ontology_graph) + graph = enrich_type_information(raw_graph, ontology) + + # disjoint class by class + disjoint_class_by_class : Dict[str, Set[str]] = {} + for class_ in ontology.classes: + if class_.disjointWith is not None: + disjoint_class_by_class[class_.uri] = class_.disjointWith + else: + disjoint_class_by_class[class_.uri] = set() + + + def is_subject_type(o, type): + # print(o, type) + if isinstance(o, URIRef): + types = [str(t) for _, _, t in graph.triples((o, RDF.type, None))] + return type in types and not any(str(other_type) in disjoint_class_by_class.get(str(type), set()) for other_type in types) + elif isinstance(o, Literal): + return o.datatype == type + else: + return False + + domain_by_property = {} + for property in ontology.properties: + if property.domain is not None: + domain_by_property[property.uri] = property.domain.uri + else: + print(f"Property {property.uri} has no domain") + domain_by_property[property.uri] = "TODO" + + incorrect_relation_domain = 0 + correct_relation_domain = 0 + + for s, p, o in graph.triples((None, None, None)): + if str(p) in domain_by_property: + if is_subject_type(s, domain_by_property[str(p)]): + correct_relation_domain += 1 + else: + incorrect_relation_domain += 1 + + if incorrect_relation_domain + correct_relation_domain > 0: + normalized_score = 1.0 - (incorrect_relation_domain / (incorrect_relation_domain + correct_relation_domain)) + else: + normalized_score = 0.0 + + return MetricResult( + metric=self, + measurements=[ + Measurement(name="incorrect_relation_domain", value=incorrect_relation_domain, unit="number"), + Measurement(name="correct_relation_domain", value=correct_relation_domain, unit="number"), + Measurement(name="normalized_score", value=normalized_score, unit="ratio"), + ], + summary=f"Number of incorrect relation domain: {incorrect_relation_domain}", + # name=self.name, + # value=incorrect_relation_domain, + # normalized_score=normalized_score, + # details={"incorrect_relation_domain": incorrect_relation_domain, "correct_relation_domain": correct_relation_domain}, + # aspect=self.aspect + ) class RangeMetric(Metric): + def compute(self, kg: TripleGraph, config: ConsistencyViolationsConfig): - pass + """Compute incorrect relation range score.""" + + raw_graph: Graph = kg.get_graph() + ontology_graph: Graph = get_ontology_graph(config.ontology_path, config.reference_kg) + ontology : Ontology= OntologyUtil.load_ontology_from_graph(ontology_graph) + graph = enrich_type_information(raw_graph, ontology) + + # disjoint class by class + disjoint_class_by_class : Dict[str, Set[str]] = {} + for class_ in ontology.classes: + if class_.disjointWith is not None: + disjoint_class_by_class[class_.uri] = class_.disjointWith + else: + disjoint_class_by_class[class_.uri] = set() + + def is_object_type(o, type): + # print(o, type) + if isinstance(o, URIRef): + types = [str(t) for s, p, t in graph.triples((o, RDF.type, None))] + # if str(type) not in types: + # print(f"Incorrect relation range {types} of {o} for property {p} with range {types}") + return str(type) in types and not any(str(other_type) in disjoint_class_by_class.get(str(type), set()) for other_type in types) + elif isinstance(o, Literal): + datatype = o.datatype + if not datatype: + datatype = str(XSD.string) + return str(datatype) == str(type) + else: + return False + + + range_by_property = {} + for property in ontology.properties: + if property.range is not None: + range_by_property[property.uri] = property.range.uri + else: + # print(f"Property {property.uri} has no range") + range_by_property[property.uri] = None + + incorrect_relation_range = 0 + correct_relation_range = 0 + + for s, p, o in graph.triples((None, None, None)): + if str(p) in range_by_property: + if is_object_type(o, range_by_property[str(p)]): + correct_relation_range += 1 + else: + # print(f"Incorrect relation range {o if isinstance(o, URIRef) else o.datatype} for property {p} with range {range_by_property[str(p)]}") + incorrect_relation_range += 1 + + normalized_score = 1.0 - (incorrect_relation_range / (incorrect_relation_range + correct_relation_range)) if incorrect_relation_range + correct_relation_range > 0 else 1.0 + """Compute incorrect relation range score.""" + return MetricResult( + metric=self, + measurements=[ + Measurement(name="incorrect_relation_range", value=incorrect_relation_range, unit="number"), + Measurement(name="correct_relation_range", value=correct_relation_range, unit="number"), + Measurement(name="normalized_score", value=normalized_score, unit="ratio"), + ], + summary=f"Number of incorrect relation range: {incorrect_relation_range}", + # name=self.name, + # value=incorrect_relation_range, + # normalized_score=normalized_score, + # details={"incorrect_relation_range": incorrect_relation_range, "correct_relation_range": correct_relation_range}, + # aspect=self.aspect + ) class RelationDirectionMetric(Metric): def compute(self, kg: TripleGraph, config: ConsistencyViolationsConfig): - pass + """Compute incorrect relation direction score.""" + + raw_graph: Graph = kg.get_graph() + ontology_graph: Graph = get_ontology_graph(config.ontology_path, config.reference_kg) + ontology = OntologyUtil.load_ontology_from_graph(ontology_graph) + graph = enrich_type_information(raw_graph, ontology) + + if len(ontology_graph) == 0: + ontology_graph = graph + print(f"INFO: ontology_graph is empty, using graph instead") + + # TODO use ontology implementation from framework + predicate_defs_sr = ontology_graph.query( + """ + SELECT DISTINCT ?predicate ?domain ?range + WHERE { + ?predicate rdfs:domain ?domain . + ?predicate rdfs:range ?range . + } + """ + ) + + # def check_type(uri, type): + # result = graph.query( + # """ + # SELECT ?uri + # WHERE { + # ?uri a ?type . + # } + # """, + # initBindings={"uri": uri, "type": type} + # ) + # return len(result) > 0 + + predicate_defs = {} + for row in predicate_defs_sr: + predicate_defs[str(row["predicate"])] = (str(row["domain"]), str(row["range"])) + + incorrect_relation_direction = 0 + correct_relation_direction = 0 + + entity_types = {} + for s, p, o in graph.triples((None, RDF.type, None)): + if str(s) not in entity_types: + entity_types[str(s)] = [] + entity_types[str(s)].append(str(o)) + + for s, p, o in tqdm(graph, desc="Checking relation direction"): + if str(s) not in entity_types: + continue + if str(p) in predicate_defs: + domain, range = predicate_defs[str(p)] + + if isinstance(o, URIRef): + if not str(s) in entity_types: + # print(f"Skipping s {s} because it is not in entity_types") + continue + if not str(o) in entity_types: + # print(f"Skipping o {o} because it is not in entity_types") + continue + if domain in entity_types[str(s)] and range in entity_types[str(o)]: + correct_relation_direction += 1 + if domain in entity_types[str(o)] and range in entity_types[str(s)]: + incorrect_relation_direction += 1 + + # print("incorrect_relation_direction", incorrect_relation_direction) + # print("correct_relation_direction", correct_relation_direction) + + if incorrect_relation_direction + correct_relation_direction > 0: + normalized_score = incorrect_relation_direction / (incorrect_relation_direction + correct_relation_direction) + normalized_score = 1.0 - normalized_score + else: + normalized_score = 0.0 + + return MetricResult( + metric=self, + measurements=[ + Measurement(name="incorrect_relation_direction", value=incorrect_relation_direction, unit="number"), + Measurement(name="correct_relation_direction", value=correct_relation_direction, unit="number"), + Measurement(name="normalized_score", value=normalized_score, unit="ratio"), + ], + summary=f"Number of incorrect relation direction: {incorrect_relation_direction}", + # name=self.name, + # value=incorrect_relation_direction, + # normalized_score=normalized_score, + # details={ + # "incorrect_relation_direction": incorrect_relation_direction, + # "correct_relation_direction": correct_relation_direction, + # "possible_relations": predicate_defs, + # "size_ontology_graph": len(ontology_graph) + # }, + # aspect=self.aspect + ) class DatatypeMetric(Metric): def compute(self, kg: TripleGraph, config: ConsistencyViolationsConfig): - pass + """Compute incorrect datatype score.""" + + raw_graph: Graph = kg.get_graph() + ontology_graph: Graph = get_ontology_graph(config.ontology_path, config.reference_kg) + ontology = OntologyUtil.load_ontology_from_graph(ontology_graph) + graph = enrich_type_information(raw_graph, ontology) + + def is_object_type(o, type): + # print(o, type) + if isinstance(o, URIRef): + types = [str(t) for s, p, t in graph.triples((o, RDF.type, None))] + # if str(type) not in types: + # print(f"Incorrect relation range {types} of {o} for property {p} with range {types}") + return str(type) in types + elif isinstance(o, Literal): + datatype = o.datatype + if not datatype: + datatype = str(XSD.string) + return str(datatype) == str(type) + else: + return False + + # def is_object_type(o, type): + # # print(o, type) + # if isinstance(o, URIRef): + # types = [str(t) for s, p, t in graph.triples((o, RDF.type, None))] + # return type in types + # elif isinstance(o, Literal): + # return str(o.datatype) == type + # else: + # return False + + range_by_property = {} + for property in ontology.properties: + if property.range is not None: + range_by_property[property.uri] = property.range.uri + else: + print(f"Property {property.uri} has no range") + range_by_property[property.uri] = "TODO" + + incorrect_datatype = 0 + correct_datatype = 0 + + for s, p, o in graph.triples((None, None, None)): + if str(p) in range_by_property: + if isinstance(o, Literal): + if not str(p) in range_by_property or is_object_type(o, range_by_property[str(p)]): + correct_datatype += 1 + else: + incorrect_datatype += 1 + # print(f"Incorrect datatype {o.datatype} for property {p} with range {range_by_property[str(p)]}") + + normalized_score = 1.0 - (incorrect_datatype / (incorrect_datatype + correct_datatype)) if incorrect_datatype + correct_datatype > 0 else 0.0 + + return MetricResult( + metric=self, + measurements=[ + Measurement(name="incorrect_datatype", value=incorrect_datatype, unit="number"), + Measurement(name="correct_datatype", value=correct_datatype, unit="number"), + Measurement(name="normalized_score", value=normalized_score, unit="ratio"), + ], + summary=f"Number of incorrect datatype: {incorrect_datatype}", + # name=self.name, + # value=incorrect_datatype, + # normalized_score=1.0 - (incorrect_datatype / (incorrect_datatype + correct_datatype)) if incorrect_datatype + correct_datatype > 0 else 0.0, + # details={"incorrect_datatype": incorrect_datatype, "correct_datatype": correct_datatype}, + # aspect=self.aspect + ) class DatatypeFormatMetric(Metric): def compute(self, kg: TripleGraph, config: ConsistencyViolationsConfig): - pass + """Compute incorrect datatype format score.""" + + from kgpipe.evaluation.aspects.func.datatype_validator import validate_datatype + + raw_graph: Graph = kg.get_graph() + ontology_graph: Graph = get_ontology_graph(config.ontology_path, config.reference_kg) + ontology = OntologyUtil.load_ontology_from_graph(ontology_graph) + graph = enrich_type_information(raw_graph, ontology) + + def is_object_type(o, type): + # print(o, type) + if isinstance(o, URIRef): + types = [str(t) for s, p, t in graph.triples((o, RDF.type, None))] + return type in types + elif isinstance(o, Literal): + return str(o.datatype) == type + else: + return False + + range_by_property = {} + for property in ontology.properties: + if property.range is not None: + range_by_property[property.uri] = property.range.uri + else: + print(f"Property {property.uri} has no range") + range_by_property[property.uri] = "TODO" + + incorrect_datatype = 0 + correct_datatype = 0 + + for s, p, o in graph.triples((None, None, None)): + if str(p) in range_by_property: + if isinstance(o, Literal): + if str(p) in range_by_property: + if validate_datatype(str(o), range_by_property[str(p)]): + # print(f"Correct datatype {o.datatype} for property {p} and value {o} with range {range_by_property[str(p)]}") + correct_datatype += 1 + else: + # print(f"Incorrect datatype {p} \'{o}\' {range_by_property[str(p)]}") + incorrect_datatype += 1 + else: + print(f"Property {p} has no range") + # if not str(p) in range_by_property: + # print(f"Property {p} has no range") + # # or validate_datatype(str(o), range_by_property[str(p)]): + # # print(f"Correct datatype {o.datatype} for property {p} and value {o} with range {range_by_property[str(p)]}") + # correct_datatype += 1 + # else: + # incorrect_datatype += 1 + + if incorrect_datatype + correct_datatype > 0: + normalized_score = 1.0 - (incorrect_datatype / (incorrect_datatype + correct_datatype)) + else: + normalized_score = 0.0 + + return MetricResult( + metric=self, + measurements=[ + Measurement(name="incorrect_datatype", value=incorrect_datatype, unit="number"), + Measurement(name="correct_datatype", value=correct_datatype, unit="number"), + Measurement(name="normalized_score", value=normalized_score, unit="ratio"), + ], + summary=f"Number of incorrect datatype: {incorrect_datatype}", + # name=self.name, + # value=incorrect_datatype, + # normalized_score=normalized_score, + # details={"incorrect_datatype": incorrect_datatype, "correct_datatype": correct_datatype}, + # aspect=self.aspect + ) + + +# @Registry.metric() +# class OntologyClassCoverageMetric(Metric): +# """Check if the KG has correct class coverage.""" +# def __init__(self): +# super().__init__( +# name="ontology_class_coverage", +# description="Check if the KG has correct class coverage", +# aspect=EvaluationAspect.SEMANTIC +# ) + +# def compute(self, kg: KG, config: SemanticConfig, **kwargs) -> MetricResult: +# """Compute ontology class coverage score.""" + +# raw_graph: Graph = kg.get_graph() +# ontology_graph: Graph = kg.get_ontology_graph() +# ontology = OntologyUtil.load_ontology_from_graph(ontology_graph) +# graph = enrich_type_information(raw_graph, ontology) + +# expected_classes = set([c.uri for c in ontology.classes if not c.uri.startswith(str(OWL))]) + +# found_classes = set(str(o) for s, p, o in graph.triples((None, RDF.type, None)) if not str(o).startswith(str(OWL))) + +# true_positive = len(expected_classes & found_classes) +# false_positive = len(found_classes - expected_classes) +# false_negative = len(expected_classes - found_classes) + +# precision = true_positive / (true_positive + false_positive) if true_positive + false_positive > 0 else 0.0 +# recall = true_positive / (true_positive + false_negative) if true_positive + false_negative > 0 else 0.0 +# f1_score = 2 * precision * recall / (precision + recall) if precision + recall > 0 else 0.0 + +# return MetricResult( +# name=self.name, +# value=true_positive, +# normalized_score=f1_score, +# details={"true_positive": true_positive, "false_positive": false_positive, "false_negative": false_negative}, +# aspect=self.aspect +# ) + +# @Registry.metric() +# class OntologyRelationCoverageMetric(Metric): +# """Check if the KG has correct relation coverage.""" +# def __init__(self): +# super().__init__( +# name="ontology_relation_coverage", +# description="Check if the KG has correct relation coverage", +# aspect=EvaluationAspect.SEMANTIC +# ) + +# def compute(self, kg: KG, config: SemanticConfig, **kwargs) -> MetricResult: +# """Compute ontology relation coverage score.""" + +# raw_graph: Graph = kg.get_graph() +# ontology_graph: Graph = kg.get_ontology_graph() +# ontology = OntologyUtil.load_ontology_from_graph(ontology_graph) +# graph = enrich_type_information(raw_graph, ontology) + +# NOT_FILTER: List[str] = [str(OWL), str(RDF), str(RDFS)] + +# expected_relations = set([r.uri for r in ontology.properties]) +# expected_relations = set([r for r in expected_relations if not any(filter(lambda x: r.startswith(x), NOT_FILTER))]) + +# # print(expected_relations) + +# found_relations = set(str(p) for _, p, _ in graph.triples((None, None, None))) +# def filter_relation(r): +# return any(filter(lambda x: r.startswith(x), NOT_FILTER)) +# found_relations = set([r for r in found_relations if not filter_relation(r)]) + +# # print(found_relations) + +# true_positive = len(expected_relations & found_relations) +# false_positive = len(found_relations - expected_relations) +# false_negative = len(expected_relations - found_relations) + +# precision = true_positive / (true_positive + false_positive) if true_positive + false_positive > 0 else 0.0 +# recall = true_positive / (true_positive + false_negative) if true_positive + false_negative > 0 else 0.0 +# f1_score = 2 * precision * recall / (precision + recall) if precision + recall > 0 else 0.0 + +# return MetricResult( +# name=self.name, +# value=true_positive, +# normalized_score=f1_score, +# details={"true_positive": true_positive, "false_positive": false_positive, "false_negative": false_negative, "missing": (expected_relations - found_relations)}, +# aspect=self.aspect +# ) + +# @Registry.metric() +# class OntologyPropertyCoverageMetric(Metric): +# """Check if the KG has correct property coverage.""" +# def __init__(self): +# super().__init__( +# name="ontology_property_coverage", +# description="Check if the KG has correct property coverage", +# aspect=EvaluationAspect.SEMANTIC +# ) + +# def compute(self, kg: KG, config: SemanticConfig, **kwargs) -> MetricResult: +# """Compute ontology property coverage score.""" +# return MetricResult( +# name=self.name, +# value=0.0, +# normalized_score=1.0, +# details={"error": "Not implemented"}, +# aspect=self.aspect +# ) + +# @Registry.metric() +# class OntologyNamespaceCoverageMetric(Metric): +# """Check if the KG has correct namespace coverage.""" +# def __init__(self): +# super().__init__( +# name="ontology_namespace_coverage", +# description="Check if the KG has correct namespace coverage", +# aspect=EvaluationAspect.SEMANTIC +# ) + +# def compute(self, kg: KG, config: SemanticConfig, **kwargs) -> MetricResult: +# """Compute ontology namespace coverage score.""" + +# # graph = kg.get_graph() +# # ontology_graph = kg.get_ontology_graph() +# # if len(ontology_graph) == 0: +# # ontology_graph = graph + +# # ontology = OntologyUtil.load_ontology_from_graph(ontology_graph) + + +# return MetricResult( +# name=self.name, +# value=0.0, +# normalized_score=1.0, +# details={"error": "Not implemented"}, +# aspect=self.aspect +# ) # class OntologyClassCoverageMetric(): # pass @@ -47,4 +610,55 @@ def compute(self, kg: TripleGraph, config: ConsistencyViolationsConfig): # pass # class OntologyNamespaceCoverageMetric(): -# pass \ No newline at end of file +# pass + +# Cardinality Metric + # """Compute incorrect relation cardinality score.""" + + # raw_graph: Graph = kg.get_graph() + # ontology_graph: Graph = kg.get_ontology_graph() + # ontology = OntologyUtil.load_ontology_from_graph(ontology_graph) + # graph = enrich_type_information(raw_graph, ontology) + # if len(ontology_graph) == 0: + # ontology_graph = graph + + # cardinality_by_property = {} + # property_cardinalities: Dict[str, Dict[str, int]] = defaultdict(lambda: defaultdict(int)) + # properties_in_graph = set() + + # for s, p, o in graph.triples((None, None, None)): + # properties_in_graph.add(str(p)) + + # for property in properties_in_graph: + # cardinality_by_property[property] = get_property_cardinality(ontology_graph, property) + + # # print(cardinality_by_property) + # # print(property_cardinalities) + + # for s, p, o in graph.triples((None, None, None)): + # if str(p) in cardinality_by_property: + # if str(s) in property_cardinalities[str(p)]: + # property_cardinalities[str(p)][str(s)] += 1 + # else: + # property_cardinalities[str(p)][str(s)] = 1 + + # incorrect_cardinality = 0 + # correct_cardinality = 0 + + # for property, cardinality in property_cardinalities.items(): + # min, max = cardinality_by_property[property] + # for subject, count in cardinality.items(): + # if count > max: + # incorrect_cardinality += 1 + # elif count < min: + # incorrect_cardinality += 1 + # else: + # correct_cardinality += 1 + + # return MetricResult( + # name=self.name, + # value=incorrect_cardinality, + # normalized_score=1.0 - (incorrect_cardinality / (incorrect_cardinality + correct_cardinality)) if incorrect_cardinality + correct_cardinality > 0 else 0.0, + # details={"incorrect_cardinality": incorrect_cardinality, "correct_cardinality": correct_cardinality}, + # aspect=self.aspect + # ) \ No newline at end of file diff --git a/src/kgpipe_eval/utils/kg_utils.py b/src/kgpipe_eval/utils/kg_utils.py index 1032af2..8d0317d 100644 --- a/src/kgpipe_eval/utils/kg_utils.py +++ b/src/kgpipe_eval/utils/kg_utils.py @@ -113,6 +113,15 @@ def _graph(self) -> Graph: else: raise ValueError(f"Unsupported KG type: {type(self.kg)}") + def get_graph(self) -> Graph: + return self._graph() + + def get_ontology_graph(self) -> Graph: + if isinstance(self.kg, KG): + return self.kg.get_ontology_graph() + else: + raise ValueError(f"Unsupported KG type: {type(self.kg)}") + def triples(self, triple_pattern: TriplePattern) -> Iterable[Triple]: g = self._graph() # RDFLib yields (s, p, o) as Identifiers From 4c89c762425975b23950d080ec889e850eac9223 Mon Sep 17 00:00:00 2001 From: Marvin Hofer Date: Sat, 6 Jun 2026 16:46:20 +0200 Subject: [PATCH 69/96] Revise README.md with updated KGI-Bench links Updated links to KGI-Bench documentation and added a new link for KGI-Bench-Movie. --- experiments/moviekg/README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/experiments/moviekg/README.md b/experiments/moviekg/README.md index 6fbf591..4363cab 100644 --- a/experiments/moviekg/README.md +++ b/experiments/moviekg/README.md @@ -4,8 +4,9 @@ This directory contains **MovieKG pipeline definitions and execution helpers** f pipelines with KGpipe. Evaluation of the produced KGs is now handled in the **KGI-Bench** repository (Movie benchmark). See: -- `KGI-Bench/docs/reproduce.md` -- `KGI-Bench/docs/cli.md` (includes `kgibench evaluate --benchmark movie ...`) +- [KGI-Bench](https://github.com/ScaDS/KGI-Bench) +- [KGI-Bench-Movie](https://github.com/ScaDS/KGI-Bench/tree/main/benchmarks/kgi-bench-movie) +- [KGI-Bench/docs/cli.md](https://scads.github.io/KGI-Bench/#cli) (includes `kgibench evaluate --benchmark movie ...`) ## What’s in here @@ -119,4 +120,4 @@ Pipeline outputs are written under `$OUTPUT_DIR/$DATASET_SELECT// │   │   └── tmp/ │ ├── json_alt[... trunc] └── medium[... trunc] -``` \ No newline at end of file +``` From d76e9822e91f3a01f4a09fc421cfbeed61c724fb Mon Sep 17 00:00:00 2001 From: Marvin Date: Sat, 6 Jun 2026 16:55:53 +0200 Subject: [PATCH 70/96] docu: gh-page workflow --- .github/workflows/docs.yml | 57 ++++++++++++++++++++++++++++++ docs/{README.md => create-docs.md} | 0 2 files changed, 57 insertions(+) create mode 100644 .github/workflows/docs.yml rename docs/{README.md => create-docs.md} (100%) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..dfae2a4 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,57 @@ +name: docs + +on: + push: + branches: [ "main" ] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install docs dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[docs]" + pip install -e "KGI-Bench/.[docs]" + + - name: Build site + run: | + mkdocs build --strict + (cd KGI-Bench && mkdocs build --strict) + + # Publish KGI-Bench site under /kgibench/ + mkdir -p site/kgibench + cp -r KGI-Bench/site/* site/kgibench/ + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: site + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 + diff --git a/docs/README.md b/docs/create-docs.md similarity index 100% rename from docs/README.md rename to docs/create-docs.md From ff6360d1a36c68cb2c42720c0578bbeeb7aeb74b Mon Sep 17 00:00:00 2001 From: Marvin Date: Mon, 8 Jun 2026 12:19:37 +0200 Subject: [PATCH 71/96] docs + moviekg: mkdocs fix; updated zenodo download links; Makefile fix --- .github/workflows/docs.yml | 10 +--------- experiments/moviekg/Makefile | 18 ++++++++++-------- experiments/moviekg/env | 2 +- mkdocs.yml | 2 +- 4 files changed, 13 insertions(+), 19 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index dfae2a4..92a01d0 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -28,16 +28,9 @@ jobs: run: | python -m pip install --upgrade pip pip install -e ".[docs]" - pip install -e "KGI-Bench/.[docs]" - name: Build site - run: | - mkdocs build --strict - (cd KGI-Bench && mkdocs build --strict) - - # Publish KGI-Bench site under /kgibench/ - mkdir -p site/kgibench - cp -r KGI-Bench/site/* site/kgibench/ + run: mkdocs build --strict - name: Upload artifact uses: actions/upload-pages-artifact@v3 @@ -54,4 +47,3 @@ jobs: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 - diff --git a/experiments/moviekg/Makefile b/experiments/moviekg/Makefile index c220a8c..e3fcdf7 100644 --- a/experiments/moviekg/Makefile +++ b/experiments/moviekg/Makefile @@ -1,6 +1,6 @@ .PHONY: -DATASET_URL := https://zenodo.org/record/17246358/files/inc_movie_kg_datasets.tar.gz?download=1 +ZENODO_RECORD := 17246357 BASE_DIR := ./data # === Main === @@ -68,7 +68,9 @@ clean: $(BASE_DIR)/datasets.tar.gz: @mkdir -p $(BASE_DIR) - @cd $(BASE_DIR) && wget $(DATASET_URL) -O datasets.tar.gz + @cd $(BASE_DIR) && wget "$$(curl -sL https://zenodo.org/api/records/$(ZENODO_RECORD) \ + | jq -r '.files[] | select(.key=="inc_movie_kg_datasets.tar.gz") | .links.self')" \ + -O datasets.tar.gz $(BASE_DIR)/datasets/.extracted: $(BASE_DIR)/datasets.tar.gz @mkdir -p $(BASE_DIR)/datasets @@ -83,10 +85,10 @@ datasets-eval: # === RDF === test-rdf-base: - pytest -s -v src/moviekg/pipelines/test_inc_ssp.py -k rdf_base + pytest -s -v src/moviekg/pipelines/test_inc_ssp.py -k rdf_a test-rdf-alt: - pytest -s -v src/moviekg/pipelines/test_inc_ssp.py -k rdf_alt + pytest -s -v src/moviekg/pipelines/test_inc_ssp.py -k rdf_b test-rdf-llm: pytest -s -v src/moviekg/pipelines/test_inc_ssp.py -k rdf_llm @@ -94,10 +96,10 @@ test-rdf-llm: # === JSON === test-json-base: - pytest -s -v src/moviekg/pipelines/test_inc_ssp.py -k json_base + pytest -s -v src/moviekg/pipelines/test_inc_ssp.py -k json_a test-json-alt: - pytest -s -v src/moviekg/pipelines/test_inc_ssp.py -k json_alt + pytest -s -v src/moviekg/pipelines/test_inc_ssp.py -k json_b test-json-llm: pytest -v -s src/moviekg/pipelines/test_inc_ssp.py -k json_llm @@ -105,10 +107,10 @@ test-json-llm: # === TEXT === test-text-base: - pytest -s -v src/moviekg/pipelines/test_inc_ssp.py -k text_base + pytest -s -v src/moviekg/pipelines/test_inc_ssp.py -k text_a test-text-alt: - pytest -s -v src/moviekg/pipelines/test_inc_ssp.py -k text_alt + pytest -s -v src/moviekg/pipelines/test_inc_ssp.py -k text_b test-text-llm: pytest -s -v src/moviekg/pipelines/test_inc_ssp.py -k text_llm diff --git a/experiments/moviekg/env b/experiments/moviekg/env index 92445d4..cb9e376 100644 --- a/experiments/moviekg/env +++ b/experiments/moviekg/env @@ -1,7 +1,7 @@ PIPELINE_CONFIG=pipeline.conf DATASET_SELECT=small -ONTOLOGY_PATH=./data/datasets/movie-ontology.ttl +ONTOLOGY_PATH=./data/datasets/film_10k/ontology.ttl OUTPUT_DIR=./data/results/ DATASET_SMALL=./data/datasets/film_100 diff --git a/mkdocs.yml b/mkdocs.yml index f1400c0..1fafb7d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -32,7 +32,7 @@ site_dir: site nav: - Home: index.md - Quickstart: quickstart.md - - KGI-Bench (benchmark site): /kgibench/ + - KGI-Bench (benchmark site): https://scads.github.io/KGI-Bench/ - Concepts: - Tasks: tasks.md - Pipelines: pipelines.md From 2bda809d0786cb52d0950df128dd41f18838c118 Mon Sep 17 00:00:00 2001 From: Marvin Date: Mon, 8 Jun 2026 12:26:30 +0200 Subject: [PATCH 72/96] update mkdocs --- docs/index.md | 2 +- docs/reproduce.md | 4 ++-- mkdocs.yml | 2 ++ 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/index.md b/docs/index.md index b506b88..4a8a452 100644 --- a/docs/index.md +++ b/docs/index.md @@ -34,7 +34,7 @@ cd experiments/examples - Define tasks: [Task specification](tasks.md) - Build and run pipelines: [Pipelines](pipelines.md) - Configure runs and task parameters: [Configuration](configuration.md) and [Parameters](parameters.md) -- Evaluate generated KGs: [Evaluation](evaluation.md) and [Metrics](metrics/) +- Evaluate generated KGs: [Evaluation](evaluation.md) and [Metrics](metrics/metrics.md) - Understand the internal “PipeKG”: [Meta KG](metakg.md) ## Other Links diff --git a/docs/reproduce.md b/docs/reproduce.md index 70b1d69..1d9349b 100644 --- a/docs/reproduce.md +++ b/docs/reproduce.md @@ -1,7 +1,7 @@ # Rep Experiments (Deprecated but working) -Guidelines to run the [experiments](../experiments) -- see also [moviekg](../experiments/moviekg/README.md) +Guidelines to run the [experiments](https://github.com/ScaDS/KGpipe/tree/main/experiments) +- see also [moviekg](https://github.com/ScaDS/KGpipe/blob/main/experiments/moviekg/README.md) ## Overview diff --git a/mkdocs.yml b/mkdocs.yml index 1fafb7d..cafa4aa 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -51,3 +51,5 @@ nav: - Other: - Adoption (integrating existing pipelines): adoption.md - View/UI: view.md + - Building docs: create-docs.md + - Migration (renamed): migration.md From ddc1f4ae80ef48d481b75df5ec67da3a9df25164 Mon Sep 17 00:00:00 2001 From: Marvin Date: Tue, 9 Jun 2026 15:22:30 +0200 Subject: [PATCH 73/96] deps: update optional dependencies --- Dockerfile | 2 +- pyproject.toml | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 06cc466..75ccec3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,6 @@ RUN --mount=type=cache,target=/root/.cache/uv \ COPY . . RUN --mount=type=cache,target=/root/.cache/uv \ - uv pip install -e . + uv pip install -e ".[ml,cpu]" ENTRYPOINT ["kgpipe"] diff --git a/pyproject.toml b/pyproject.toml index 13cbf96..8705d87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,8 @@ dependencies = [ "streamlit-elements>=0.1.0", "uvicorn>=0.41.0", "fastapi>=0.135.1", + "tqdm>=4.67.1", + "scipy>=1.16.2", ] [project.optional-dependencies] From 046dee82b8678a6cc7540409883695d8b0647700 Mon Sep 17 00:00:00 2001 From: Marvin Date: Mon, 15 Jun 2026 17:57:18 +0200 Subject: [PATCH 74/96] exp(params): new package layout --- .../param-opti/src/kgpipe_search/__init__.py | 0 .../param-opti/src/kgpipe_search/estimate.py | 8 +++++++ .../src/kgpipe_search/evaluation.py | 0 .../param-opti/src/kgpipe_search/sample.py | 0 .../param-opti/src/kgpipe_search/search.py | 8 +++++++ .../src/kgpipe_search/test_experiments.py | 7 ++++++ .../src/kgpipe_search/test_features.py | 24 +++++++++++++++++++ 7 files changed, 47 insertions(+) create mode 100644 experiments/param-opti/src/kgpipe_search/__init__.py create mode 100644 experiments/param-opti/src/kgpipe_search/estimate.py create mode 100644 experiments/param-opti/src/kgpipe_search/evaluation.py create mode 100644 experiments/param-opti/src/kgpipe_search/sample.py create mode 100644 experiments/param-opti/src/kgpipe_search/search.py create mode 100644 experiments/param-opti/src/kgpipe_search/test_experiments.py create mode 100644 experiments/param-opti/src/kgpipe_search/test_features.py diff --git a/experiments/param-opti/src/kgpipe_search/__init__.py b/experiments/param-opti/src/kgpipe_search/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/experiments/param-opti/src/kgpipe_search/estimate.py b/experiments/param-opti/src/kgpipe_search/estimate.py new file mode 100644 index 0000000..47a46d0 --- /dev/null +++ b/experiments/param-opti/src/kgpipe_search/estimate.py @@ -0,0 +1,8 @@ + + + +def wilson_score_interval(): + pass + +def wallen_score_interval(): + pass \ No newline at end of file diff --git a/experiments/param-opti/src/kgpipe_search/evaluation.py b/experiments/param-opti/src/kgpipe_search/evaluation.py new file mode 100644 index 0000000..e69de29 diff --git a/experiments/param-opti/src/kgpipe_search/sample.py b/experiments/param-opti/src/kgpipe_search/sample.py new file mode 100644 index 0000000..e69de29 diff --git a/experiments/param-opti/src/kgpipe_search/search.py b/experiments/param-opti/src/kgpipe_search/search.py new file mode 100644 index 0000000..1424304 --- /dev/null +++ b/experiments/param-opti/src/kgpipe_search/search.py @@ -0,0 +1,8 @@ + + + +def neighborhood_optimization(): + pass + +def bayesian_optimization(): + pass \ No newline at end of file diff --git a/experiments/param-opti/src/kgpipe_search/test_experiments.py b/experiments/param-opti/src/kgpipe_search/test_experiments.py new file mode 100644 index 0000000..78e3142 --- /dev/null +++ b/experiments/param-opti/src/kgpipe_search/test_experiments.py @@ -0,0 +1,7 @@ + + + + +TEXT_SEARCH_SPACE = {} +JSON_SEARCH_SPACE = {} +RDF_SEARCH_SPACE = {} diff --git a/experiments/param-opti/src/kgpipe_search/test_features.py b/experiments/param-opti/src/kgpipe_search/test_features.py new file mode 100644 index 0000000..14d8ba8 --- /dev/null +++ b/experiments/param-opti/src/kgpipe_search/test_features.py @@ -0,0 +1,24 @@ + + + +# TODO see requirements specification + + +def test_sample_space(): + pass + +def test_neighborhood_search(): + pass + +def test_bayesian_optimization(): + pass + +def test_random_search(): + pass + +def test_grid_search(): + pass + +def test_hyperparameter_tuning(): + pass + From 18ecae2eb55f06e66190bfe544efd38835276bad Mon Sep 17 00:00:00 2001 From: Marvin Hofer Date: Tue, 7 Jul 2026 21:04:41 +0200 Subject: [PATCH 75/96] feat(discovery): improved discovery of external modules --- src/kgpipe/cli/discover.py | 86 ++++++++++---- src/kgpipe/cli/exec.py | 50 ++++++++ src/kgpipe/cli/main.py | 2 + src/kgpipe/common/discovery.py | 202 +++++++++++++++++---------------- 4 files changed, 222 insertions(+), 118 deletions(-) create mode 100644 src/kgpipe/cli/exec.py diff --git a/src/kgpipe/cli/discover.py b/src/kgpipe/cli/discover.py index 1c03cbe..f2a6531 100644 --- a/src/kgpipe/cli/discover.py +++ b/src/kgpipe/cli/discover.py @@ -16,15 +16,74 @@ discover_entry_points, discover_local_modules, get_registered_tasks, - get_registered_pipelines, - get_registered_metrics, - get_registered_evaluators, ) # Initialize Rich console for pretty output console = Console() +def _function_path(func) -> str: + return f"{func.__module__}.{func.__qualname__}" + + +def _component_name(factory) -> str: + try: + obj = factory() + return getattr(obj, "name", factory.__name__) + except Exception: + return factory.__name__ + + +def _show_component_table(title: str, items: list[tuple[str, str]]) -> None: + if not items: + return + + table = Table(title=title) + table.add_column("Name", style="cyan") + table.add_column("Function Path", style="green") + + for name, path in sorted(items, key=lambda item: item[0].lower()): + table.add_row(name, path) + + console.print(table) + console.print() + + +def _show_discovered_components() -> None: + """Display discovered components with names and function paths.""" + from kgpipe.common.registry import Registry + + tasks = get_registered_tasks() + pipelines = Registry.list("pipeline") + metrics = Registry.list("metric") + evaluators = Registry.list("evaluator") + + console.print() + console.print("[bold blue]Discovered Components:[/bold blue]") + console.print( + f"Tasks: {len(tasks)}, Pipelines: {len(pipelines)}, " + f"Metrics: {len(metrics)}, Evaluators: {len(evaluators)}" + ) + console.print() + + _show_component_table( + "Tasks", + [(task.name, _function_path(task.function)) for task in tasks], + ) + _show_component_table( + "Pipelines", + [(_component_name(factory), _function_path(factory)) for factory in pipelines], + ) + _show_component_table( + "Metrics", + [(_component_name(factory), _function_path(factory)) for factory in metrics], + ) + _show_component_table( + "Evaluators", + [(_component_name(factory), _function_path(factory)) for factory in evaluators], + ) + + def discover_package(package_name: str) -> bool: """ Discover and register components from a package by name. @@ -85,7 +144,7 @@ def discover_module_path(module_path: str) -> bool: "module_paths", multiple=True, type=click.Path(exists=True), - help="Path(s) to module directory or file to discover", + help="Path(s) to module directory or file to discover (searches recursively)", ) @click.option( "--all", @@ -170,22 +229,5 @@ def discover_cmd( # Show discovered components if requested if show_results: - console.print() - console.print("[bold blue]Discovered Components:[/bold blue]") - - tasks = get_registered_tasks() - pipelines = get_registered_pipelines() - metrics = get_registered_metrics() - evaluators = get_registered_evaluators() - - table = Table(title="Registered Components") - table.add_column("Type", style="cyan") - table.add_column("Count", style="green") - - table.add_row("Tasks", str(len(tasks))) - table.add_row("Pipelines", str(len(pipelines))) - table.add_row("Metrics", str(len(metrics))) - table.add_row("Evaluators", str(len(evaluators))) - - console.print(table) + _show_discovered_components() diff --git a/src/kgpipe/cli/exec.py b/src/kgpipe/cli/exec.py new file mode 100644 index 0000000..135aed2 --- /dev/null +++ b/src/kgpipe/cli/exec.py @@ -0,0 +1,50 @@ +# Exec pipeline + +import click +from pathlib import Path + +@click.command() +@click.argument("pipeline", type=str) +@click.option( + "-c", + "--config", + type=click.Path(path_type=Path, exists=True, dir_okay=False), + required=False, + help="Path to the config file.", +) +@click.option( + "--config-json", + type=str, + required=False, + help="JSON string of the config.", +) +@click.option( + "--discover", + type=click.Path(path_type=Path, exists=True, file_okay=False), + default=None, + help="Directory to discover additional packages or modules.", +) +@click.option( + "--mode", + type=click.Choice(['local', 'docker', 'swarm']), + default="local", + help="Execution mode.", +) +def exec_cmd(pipeline: str, config: Path, discover: Path | None): + """ + Execute a pipeline. + + PIPELINE: Name of the pipeline to execute + CONFIG: Path to the config file + DISCOVER: Path to the directory to discover additional packages or modules + """ + + if mode == "local": + execute_pipeline_local(pipeline, config, discover) + elif mode == "docker": + execute_pipeline_docker(pipeline, config, discover) + elif mode == "swarm": + execute_pipeline_swarm(pipeline, config, discover) + else: + raise ValueError(f"Invalid mode: {mode}") +# TODO implement \ No newline at end of file diff --git a/src/kgpipe/cli/main.py b/src/kgpipe/cli/main.py index 6f3e0c0..0c3692a 100644 --- a/src/kgpipe/cli/main.py +++ b/src/kgpipe/cli/main.py @@ -21,6 +21,7 @@ from .task import task_cmd from .discover import discover_cmd from .eval_new import eval_new_cmd +from .exec import exec_cmd # from .rank import rank_cmd # Initialize Rich console for pretty output console = Console() @@ -83,6 +84,7 @@ def cli(ctx: click.Context, config: Optional[str], verbose: bool, quiet: bool): cli.add_command(task_cmd) cli.add_command(discover_cmd) cli.add_command(eval_new_cmd) +cli.add_command(exec_cmd) # cli.add_command(rank_cmd) if __name__ == "__main__": diff --git a/src/kgpipe/common/discovery.py b/src/kgpipe/common/discovery.py index d4feb68..4b8de0b 100644 --- a/src/kgpipe/common/discovery.py +++ b/src/kgpipe/common/discovery.py @@ -7,7 +7,6 @@ import importlib import importlib.util -import pkgutil import sys from pathlib import Path from typing import List, Dict, Any, Optional, Callable @@ -96,13 +95,99 @@ def discover_installed_packages() -> None: pass +def _resolve_import_root(module_path: Path) -> tuple[Path, str] | None: + """Return (sys.path root, dotted module prefix) for the longest matching sys.path entry.""" + module_path = module_path.resolve() + best_match: tuple[Path, str] | None = None + best_len = -1 + + for sys_path_entry in sys.path: + if not sys_path_entry: + continue + try: + sys_path = Path(sys_path_entry).resolve() + relative = module_path.relative_to(sys_path) + if len(sys_path.parts) > best_len: + best_match = (sys_path, ".".join(relative.parts)) + best_len = len(sys_path.parts) + except (ValueError, OSError): + continue + + return best_match + + +def _find_package_source_root(module_path: Path) -> Path | None: + """Find the directory that should be on sys.path for package imports.""" + module_path = module_path.resolve() + if module_path.is_file(): + module_path = module_path.parent + + for parent in [module_path, *module_path.parents]: + try: + relative = module_path.relative_to(parent) + except ValueError: + break + if not relative.parts: + continue + + top_package = parent / relative.parts[0] + if top_package.is_dir() and (top_package / "__init__.py").exists(): + if not (parent / "__init__.py").exists(): + return parent + + return None + + +def _module_name_for_path(py_file: Path, scan_root: Path) -> str: + """Build a dotted module name for a Python file under scan_root.""" + py_file = py_file.resolve() + scan_root = scan_root.resolve() + + import_root = _resolve_import_root(scan_root) + if import_root: + sys_path_root, _ = import_root + relative = py_file.relative_to(sys_path_root) + return ".".join(relative.with_suffix("").parts) + + package_src = _find_package_source_root(scan_root) + if package_src: + path_str = str(package_src) + if path_str not in sys.path: + sys.path.insert(0, path_str) + relative = py_file.relative_to(package_src) + return ".".join(relative.with_suffix("").parts) + + relative = py_file.relative_to(scan_root) + return ".".join(relative.with_suffix("").parts) + + +def _import_python_module(py_file: Path, module_name: str) -> None: + """Import a module by name, falling back to loading directly from a file path.""" + try: + importlib.import_module(module_name) + logger.info(f"Successfully discovered module: {module_name}") + return + except Exception as e: + logger.debug(f"Could not import {module_name}: {e}") + + try: + spec = importlib.util.spec_from_file_location(module_name, py_file) + if spec and spec.loader: + module = importlib.util.module_from_spec(spec) + sys.modules.setdefault(module_name, module) + spec.loader.exec_module(module) + logger.info(f"Successfully discovered module from file: {py_file} ({module_name})") + except Exception as e: + logger.warning(f"Error discovering module {py_file} ({module_name}): {e}") + + def discover_local_modules(module_path: Path) -> None: """ Discover components from local modules. This function can handle: - Python files (.py) - imports the file as a module - - Directories - scans for Python files and imports them + - Directories - recursively scans for Python files and imports them - Paths in sys.path - converts to relative module names Args: @@ -111,102 +196,27 @@ def discover_local_modules(module_path: Path) -> None: if not module_path.exists(): logger.warning(f"Module path does not exist: {module_path}") return - - # Resolve to absolute path + module_path = module_path.resolve() - - # Handle file paths - if module_path.is_file() and module_path.suffix == '.py': - try: - # Use importlib.util to load from file path - module_name = module_path.stem - spec = importlib.util.spec_from_file_location(module_name, module_path) - if spec and spec.loader: - module = importlib.util.module_from_spec(spec) - # Execute the module to trigger registration - spec.loader.exec_module(module) - logger.info(f"Successfully discovered module from file: {module_path}") - except Exception as e: - logger.error(f"Error discovering module from file {module_path}: {e}") + + if module_path.is_file(): + if module_path.suffix != ".py" or module_path.name == "__init__.py": + return + py_files = [module_path] + scan_root = module_path.parent + elif module_path.is_dir(): + py_files = sorted( + py_file + for py_file in module_path.rglob("*.py") + if py_file.name != "__init__.py" + ) + scan_root = module_path + else: return - - # Handle directory paths - if module_path.is_dir(): - # Check if this directory (or its resolved path) is in sys.path - path_str = str(module_path) - path_in_sys_path = path_str in sys.path - # Also check resolved paths - if not path_in_sys_path: - for sys_path_entry in sys.path: - try: - if Path(sys_path_entry).resolve() == module_path: - path_in_sys_path = True - break - except Exception: - continue - - if path_in_sys_path: - # Directory is in sys.path, so we can import modules from it directly - # Scan for Python files in the directory - for py_file in module_path.glob("*.py"): - if py_file.name == "__init__.py": - continue - try: - module_name = py_file.stem - spec = importlib.util.spec_from_file_location(module_name, py_file) - if spec and spec.loader: - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - logger.info(f"Successfully discovered module: {module_name}") - except Exception as e: - logger.warning(f"Error discovering module {py_file}: {e}") - else: - # Try to import the directory as a package - # First, check if we can find it relative to sys.path entries - for sys_path_entry in sys.path: - try: - sys_path = Path(sys_path_entry).resolve() - try: - # Check if module_path is a subdirectory of sys_path - relative_path = module_path.relative_to(sys_path) - if relative_path: - # Convert to module name - module_name = str(relative_path).replace('/', '.').replace('\\', '.') - # Try to import it - module = importlib.import_module(module_name) - logger.info(f"Successfully discovered package: {module_name}") - # Also scan for Python files in the directory - for py_file in module_path.glob("*.py"): - if py_file.name == "__init__.py": - continue - try: - file_module_name = f"{module_name}.{py_file.stem}" - file_module = importlib.import_module(file_module_name) - logger.info(f"Successfully discovered module: {file_module_name}") - except Exception as e: - logger.debug(f"Could not import {py_file.stem} from {module_name}: {e}") - return - except ValueError: - # Not a subdirectory, continue - continue - except Exception: - continue - - # If not found relative to sys.path, try direct import with absolute path conversion - # This is a fallback that may not work, but we try it - logger.debug(f"Directory {module_path} not found relative to sys.path, attempting direct scan") - for py_file in module_path.glob("*.py"): - if py_file.name == "__init__.py": - continue - try: - module_name = py_file.stem - spec = importlib.util.spec_from_file_location(module_name, py_file) - if spec and spec.loader: - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - logger.info(f"Successfully discovered module from file: {py_file}") - except Exception as e: - logger.warning(f"Error discovering module {py_file}: {e}") + + for py_file in py_files: + module_name = _module_name_for_path(py_file, scan_root) + _import_python_module(py_file, module_name) def get_registered_tasks() -> List[Any]: From becafe9751e465a0dcb0674c74f0427643f7e1b2 Mon Sep 17 00:00:00 2001 From: Marvin Hofer Date: Tue, 7 Jul 2026 21:05:03 +0200 Subject: [PATCH 76/96] feat(eval): added ranking impl for metrics results --- src/kgpipe_eval/utils/score_utils.py | 349 +++++++++++++++++++++++++++ 1 file changed, 349 insertions(+) create mode 100644 src/kgpipe_eval/utils/score_utils.py diff --git a/src/kgpipe_eval/utils/score_utils.py b/src/kgpipe_eval/utils/score_utils.py new file mode 100644 index 0000000..7980706 --- /dev/null +++ b/src/kgpipe_eval/utils/score_utils.py @@ -0,0 +1,349 @@ +from __future__ import annotations + +import json +import math +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, List, Mapping, Sequence + +from kgpipe_eval.api import MetricResult +from kgpipe_eval.utils.metric_utils import MeasurementKey, parse_eval_results + +JsonMapping = Mapping[str, Any] +MeasurementLookup = Mapping[MeasurementKey, Any] + + +@dataclass(frozen=True) +class ResolvedMeasurement: + metric: str + measurement: str + value: float + weight: float = 1.0 + transform: str | None = None + + +@dataclass(frozen=True) +class SubgroupScore: + name: str + score: float + measurements: tuple[ResolvedMeasurement, ...] = () + + +@dataclass(frozen=True) +class AggregateScore: + final_score: float + subgroups: dict[str, SubgroupScore] = field(default_factory=dict) + + +_AGGREGATIONS = frozenset({"mean", "weighted_mean", "min", "max", "geometric_mean", "product"}) +_TRANSFORMS = frozenset({None, "identity", "invert", "one_minus"}) + + +def _as_float(value: Any, *, context: str) -> float: + if isinstance(value, bool): + raise ValueError(f"{context}: boolean values are not supported") + if isinstance(value, (int, float)): + return float(value) + raise ValueError(f"{context}: expected numeric value, got {type(value).__name__}") + + +def _apply_transform(value: float, transform: str | None) -> float: + if transform in (None, "identity"): + return value + if transform in ("invert", "one_minus"): + return 1.0 - value + raise ValueError(f"Unsupported transform: {transform!r}") + + +def _aggregate(values: Sequence[float], method: str, weights: Sequence[float] | None = None) -> float: + if not values: + raise ValueError("Cannot aggregate an empty list of values") + + if method == "mean": + return sum(values) / len(values) + + if method == "weighted_mean": + if weights is None: + raise ValueError("weighted_mean requires weights") + if len(weights) != len(values): + raise ValueError("weighted_mean requires one weight per value") + total_weight = sum(weights) + if total_weight <= 0: + raise ValueError("weighted_mean requires positive total weight") + return sum(v * w for v, w in zip(values, weights)) / total_weight + + if method == "min": + return min(values) + + if method == "max": + return max(values) + + if method == "product": + result = 1.0 + for value in values: + result *= value + return result + + if method == "geometric_mean": + if any(v < 0 for v in values): + raise ValueError("geometric_mean requires non-negative values") + if any(v == 0 for v in values): + return 0.0 + return math.exp(sum(math.log(v) for v in values) / len(values)) + + raise ValueError(f"Unsupported aggregation method: {method!r}") + + +def _parse_measurement_ref(item: Any, *, subgroup: str) -> tuple[str, str, float, str | None]: + if isinstance(item, str): + if "." in item: + metric, measurement = item.split(".", 1) + elif ":" in item: + metric, measurement = item.split(":", 1) + else: + raise ValueError( + f"subgroup {subgroup!r}: measurement ref {item!r} must be " + "'MetricName.measurement' or 'MetricName:measurement'" + ) + return metric, measurement, 1.0, None + + if not isinstance(item, Mapping): + raise ValueError(f"subgroup {subgroup!r}: measurement item must be a mapping or string") + + metric = item.get("metric") + measurement = item.get("measurement") + if not isinstance(metric, str) or not metric: + raise ValueError(f"subgroup {subgroup!r}: measurement item missing 'metric'") + if not isinstance(measurement, str) or not measurement: + raise ValueError(f"subgroup {subgroup!r}: measurement item missing 'measurement'") + + weight = item.get("weight", 1.0) + transform = item.get("transform") + if not isinstance(weight, (int, float)): + raise ValueError(f"subgroup {subgroup!r}: weight for {metric}.{measurement} must be numeric") + if transform is not None and not isinstance(transform, str): + raise ValueError(f"subgroup {subgroup!r}: transform for {metric}.{measurement} must be a string") + if transform not in _TRANSFORMS: + raise ValueError(f"subgroup {subgroup!r}: unsupported transform {transform!r}") + + return metric, measurement, float(weight), transform + + +def _lookup_measurement( + measurements: MeasurementLookup, + *, + metric: str, + measurement: str, + subgroup: str, +) -> Any: + for key, value in measurements.items(): + if key.metric == metric and key.measurement == measurement: + return value + raise KeyError( + f"subgroup {subgroup!r}: measurement {metric}.{measurement} not found in eval results" + ) + + +def _resolve_subgroup( + name: str, + subgroup_cfg: JsonMapping, + measurements: MeasurementLookup, +) -> SubgroupScore: + if not isinstance(subgroup_cfg, Mapping): + raise ValueError(f"subgroup {name!r}: config must be an object") + + items = subgroup_cfg.get("measurements", subgroup_cfg.get("items", [])) + if not isinstance(items, list) or not items: + raise ValueError(f"subgroup {name!r}: 'measurements' must be a non-empty list") + + aggregation = subgroup_cfg.get("aggregation", subgroup_cfg.get("type", "mean")) + if not isinstance(aggregation, str) or aggregation not in _AGGREGATIONS: + raise ValueError(f"subgroup {name!r}: unsupported aggregation {aggregation!r}") + + default_transform = subgroup_cfg.get("transform") + if default_transform is not None and default_transform not in _TRANSFORMS: + raise ValueError(f"subgroup {name!r}: unsupported transform {default_transform!r}") + + resolved: list[ResolvedMeasurement] = [] + values: list[float] = [] + weights: list[float] = [] + + for item in items: + metric, measurement, weight, item_transform = _parse_measurement_ref(item, subgroup=name) + transform = item_transform if item_transform is not None else default_transform + raw_value = _lookup_measurement( + measurements, + metric=metric, + measurement=measurement, + subgroup=name, + ) + value = _apply_transform( + _as_float(raw_value, context=f"{name}.{metric}.{measurement}"), + transform, + ) + resolved.append( + ResolvedMeasurement( + metric=metric, + measurement=measurement, + value=value, + weight=weight, + transform=transform, + ) + ) + values.append(value) + weights.append(weight) + + score = _aggregate(values, aggregation, weights if aggregation == "weighted_mean" else None) + return SubgroupScore(name=name, score=score, measurements=tuple(resolved)) + + +def aggregate_scores( + measurements: MeasurementLookup | Sequence[Mapping[str, Any]] | Path | str, + config: JsonMapping, +) -> AggregateScore: + """ + Aggregate eval measurements into named subgroups, then into a final score. + + Config schema (dict / JSON): + + { + "subgroups": { + "coverage": { + "measurements": [ + {"metric": "EntityAlignmentMetric", "measurement": "recall"}, + {"metric": "TripleAlignmentMetric", "measurement": "recall", "weight": 2.0} + ], + "aggregation": "mean" + }, + "correctness": { + "measurements": [ + "EntityAlignmentMetric.precision", + "TripleAlignmentMetric.precision" + ], + "aggregation": "mean" + }, + "cleanliness": { + "measurements": [ + {"metric": "DuplicateMetric", "measurement": "duplicates_ratio", "transform": "invert"} + ], + "aggregation": "mean" + } + }, + "final": { + "aggregation": "weighted_mean", + "weights": { + "coverage": 0.4, + "correctness": 0.4, + "cleanliness": 0.2 + } + } + } + + Measurement refs may be objects or shorthand strings like ``MetricName.measurement``. + Supported subgroup/final aggregations: mean, weighted_mean, min, max, geometric_mean, product. + Supported transforms: identity (default), invert / one_minus (``1 - value``). + """ + lookup = _coerce_measurement_lookup(measurements) + + subgroups_cfg = config.get("subgroups") + if not isinstance(subgroups_cfg, Mapping) or not subgroups_cfg: + raise ValueError("config must contain a non-empty 'subgroups' object") + + subgroup_scores: dict[str, SubgroupScore] = {} + for name, subgroup_cfg in subgroups_cfg.items(): + if not isinstance(name, str) or not name: + raise ValueError("subgroup names must be non-empty strings") + subgroup_scores[name] = _resolve_subgroup(name, subgroup_cfg, lookup) + + final_cfg = config.get("final", {}) + if not isinstance(final_cfg, Mapping): + raise ValueError("config 'final' must be an object") + + final_aggregation = final_cfg.get("aggregation", final_cfg.get("type", "weighted_mean")) + if not isinstance(final_aggregation, str) or final_aggregation not in _AGGREGATIONS: + raise ValueError(f"unsupported final aggregation {final_aggregation!r}") + + subgroup_names = list(subgroup_scores.keys()) + subgroup_values = [subgroup_scores[name].score for name in subgroup_names] + + final_weights_cfg = final_cfg.get("weights") + if final_aggregation == "weighted_mean": + if not isinstance(final_weights_cfg, Mapping): + raise ValueError("final weighted_mean requires a 'weights' object") + final_weights = [float(final_weights_cfg.get(name, 0.0)) for name in subgroup_names] + elif isinstance(final_weights_cfg, Mapping): + final_weights = [float(final_weights_cfg.get(name, 1.0)) for name in subgroup_names] + else: + final_weights = None + + final_score = _aggregate( + subgroup_values, + final_aggregation, + final_weights if final_aggregation == "weighted_mean" else None, + ) + return AggregateScore(final_score=final_score, subgroups=subgroup_scores) + + +def aggregate_scores_from_json( + eval_results_path: Path | str, + config: JsonMapping | Path | str, +) -> AggregateScore: + """Load eval results and config from JSON files and compute the aggregate score.""" + measurements = parse_eval_results(Path(eval_results_path)) + resolved_config = _coerce_config(config) + return aggregate_scores(measurements, resolved_config) + +def aggregate_scores_from_results( + results: List[MetricResult], + config: JsonMapping | Path | str, +) -> AggregateScore: + lookup: dict[MeasurementKey, Any] = {} + for result in results: + metric = getattr(result.metric, "key", result.metric.__class__.__name__) + for measurement in result.measurements: + lookup[ + MeasurementKey( + metric=metric, + measurement=measurement.name, + unit=measurement.unit or "", + ) + ] = measurement.value + resolved_config = _coerce_config(config) + return aggregate_scores(lookup, resolved_config) + +def _coerce_measurement_lookup( + measurements: MeasurementLookup | Sequence[Mapping[str, Any]] | Path | str, +) -> dict[MeasurementKey, Any]: + if isinstance(measurements, (str, Path)): + return parse_eval_results(Path(measurements)) + + if isinstance(measurements, Sequence) and not isinstance(measurements, (str, bytes, bytearray)): + if measurements and isinstance(measurements[0], Mapping) and "metric" in measurements[0]: + out: dict[MeasurementKey, Any] = {} + for entry in measurements: + if not isinstance(entry, Mapping): + continue + metric = entry.get("metric") + if not isinstance(metric, str): + continue + for m in entry.get("measurements", []): + if not isinstance(m, Mapping): + continue + name = m.get("name") + unit = m.get("unit") or "" + if isinstance(name, str) and name: + out[MeasurementKey(metric=metric, measurement=name, unit=str(unit))] = m.get("value") + return out + return dict(measurements) # type: ignore[arg-type] + + return dict(measurements) + + +def _coerce_config(config: JsonMapping | Path | str) -> JsonMapping: + if isinstance(config, (str, Path)): + path = Path(config) + loaded = json.loads(path.read_text()) + if not isinstance(loaded, Mapping): + raise ValueError(f"{path} must contain a JSON object") + return loaded + return config From 727592360c7462f444e9d11846ef26bebb8c2249 Mon Sep 17 00:00:00 2001 From: Marvin Hofer Date: Tue, 7 Jul 2026 21:06:29 +0200 Subject: [PATCH 77/96] exp(params): refactored parameter optimization/search experiment structure and scope --- experiments/param-opti/.gitignore | 3 +- experiments/param-opti/Agent.md | 31 - experiments/param-opti/README.md | 144 +--- experiments/param-opti/experiment.ipynb | 53 ++ experiments/param-opti/input/Parameters.md | 17 - .../input/am_light/parameters.properties | 3 - .../param-opti/input/am_light/repo.url | 1 - .../param-opti/input/corenlp_openie/repo.url | 1 - experiments/param-opti/input/paris/cli.txt | 10 - experiments/param-opti/input/paris/repo.url | 1 - .../param-opti/input/valentine/repo.url | 1 - experiments/param-opti/run_experiment.py | 27 - experiments/param-opti/run_qap_mock.py | 26 - .../param-opti/spec/implementation_state.md | 123 --- experiments/param-opti/spec/llmextractor.md | 154 ---- experiments/param-opti/src/experiment.py | 396 +++++++++ .../configuration.py} | 487 ++++------- .../src/kgpipe_search/definitions.py | 205 +++++ .../dev}/__init__.py | 0 .../dev/docker}/__init__.py | 0 .../src/kgpipe_search/dev/docker/copy.py | 262 ++++++ .../src/kgpipe_search/dev/docker/mounts.py | 21 + .../src/kgpipe_search/dev/docker/swarm.py | 463 +++++++++++ .../src/kgpipe_search/dev/execution.py | 137 ++++ .../dev}/tasks/__init__.py | 0 .../dev}/tasks/agreementmaker.py | 0 .../dev}/tasks/base_linker.py | 0 .../dev}/tasks/base_linker_lib.py | 0 .../dev}/tasks/base_matcher.py | 0 .../dev}/tasks/base_matcher_lib.py | 0 .../dev}/tasks/corenlp.py | 0 .../dev}/tasks/corenlp_lip.py | 0 .../dev}/tasks/formats.py | 0 .../dev}/tasks/fusion.py | 5 +- .../dev}/tasks/fusion_lib.py | 0 .../dev}/tasks/genie.py | 0 .../dev}/tasks/genie_lib.py | 0 .../dev}/tasks/jedai.py | 0 .../dev/tasks/llm_extract_lib.py | 18 + .../dev/tasks/llm_mapping_lib.py | 0 .../dev}/tasks/matching_helpers.py | 0 .../dev}/tasks/paris.py | 7 +- .../dev}/tasks/paris_lib.py | 0 .../dev}/tasks/select_lib.py | 0 .../dev}/tasks/spotlight.py | 0 .../dev}/tasks/spotlight_lib.py | 0 .../dev}/tasks/text_helpers.py | 0 .../param-opti/src/kgpipe_search/estimate.py | 11 +- .../src/kgpipe_search/evaluation.py | 94 +++ .../param-opti/src/kgpipe_search/sample.py | 17 + .../param-opti/src/kgpipe_search/search.py | 457 ++++++++++- .../src/kgpipe_search/test/__init__.py | 0 .../src/kgpipe_search/test/conftest.py | 31 + .../kgpipe_search/test/test_configuration.py | 188 +++++ .../src/kgpipe_search/test/test_execution.py | 140 ++++ .../test/test_execution_docker.py | 476 +++++++++++ .../kgpipe_search/test/test_experiments.py | 118 +++ .../kgpipe_search/{ => test}/test_features.py | 6 +- .../src/kgpipe_search/test/test_search.py | 112 +++ .../src/kgpipe_search/test_experiments.py | 7 - .../param-opti/src/param_opti/__init__.py | 13 - .../param-opti/src/param_opti/__main__.py | 161 ---- .../param-opti/src/param_opti/experiment.py | 767 ------------------ .../pipeline_selection/test_configuration.py | 26 - .../src/param_opti/pipeline_util.py | 5 - .../param-opti/src/param_opti/search.py | 17 - experiments/param-opti/src/param_opti/tool.py | 92 --- .../rdf_sampled_pipeline_configs.json | 147 ---- .../text_sampled_pipeline_configs.json | 153 ---- experiments/param-opti/src/qap/sge_metrics.py | 17 - .../param-opti/src/qap/test_eval_pipelines.py | 122 --- .../param-opti/src/qap/test_exec_pipelines.py | 202 ----- .../param-opti/src/qap/test_ref_based.py | 205 ----- .../param-opti/src/qap/test_sge_based.py | 36 - .../param-opti/src/qap_mock/__init__.py | 15 - .../param-opti/src/qap_mock/__main__.py | 57 -- .../param-opti/src/qap_mock/experiments.py | 204 ----- experiments/param-opti/src/qap_mock/models.py | 37 - .../param-opti/src/qap_mock/objectives.py | 226 ------ .../param-opti/src/qap_mock/pipeline_util.py | 414 ---------- experiments/param-opti/src/qap_mock/search.py | 138 ---- .../param-opti/src/qap_mock/search_space.py | 143 ---- experiments/param-opti/src/qap_mock/stats.py | 58 -- 83 files changed, 3371 insertions(+), 4137 deletions(-) delete mode 100644 experiments/param-opti/Agent.md create mode 100644 experiments/param-opti/experiment.ipynb delete mode 100644 experiments/param-opti/input/Parameters.md delete mode 100644 experiments/param-opti/input/am_light/parameters.properties delete mode 100644 experiments/param-opti/input/am_light/repo.url delete mode 100644 experiments/param-opti/input/corenlp_openie/repo.url delete mode 100644 experiments/param-opti/input/paris/cli.txt delete mode 100644 experiments/param-opti/input/paris/repo.url delete mode 100644 experiments/param-opti/input/valentine/repo.url delete mode 100644 experiments/param-opti/run_experiment.py delete mode 100644 experiments/param-opti/run_qap_mock.py delete mode 100644 experiments/param-opti/spec/implementation_state.md delete mode 100644 experiments/param-opti/spec/llmextractor.md create mode 100644 experiments/param-opti/src/experiment.py rename experiments/param-opti/src/{qap/test_conf_pipelines.py => kgpipe_search/configuration.py} (50%) create mode 100644 experiments/param-opti/src/kgpipe_search/definitions.py rename experiments/param-opti/src/{param_opti/pipeline_selection => kgpipe_search/dev}/__init__.py (100%) rename experiments/param-opti/src/{qap => kgpipe_search/dev/docker}/__init__.py (100%) create mode 100644 experiments/param-opti/src/kgpipe_search/dev/docker/copy.py create mode 100644 experiments/param-opti/src/kgpipe_search/dev/docker/mounts.py create mode 100644 experiments/param-opti/src/kgpipe_search/dev/docker/swarm.py create mode 100644 experiments/param-opti/src/kgpipe_search/dev/execution.py rename experiments/param-opti/src/{param_opti => kgpipe_search/dev}/tasks/__init__.py (100%) rename experiments/param-opti/src/{param_opti => kgpipe_search/dev}/tasks/agreementmaker.py (100%) rename experiments/param-opti/src/{param_opti => kgpipe_search/dev}/tasks/base_linker.py (100%) rename experiments/param-opti/src/{param_opti => kgpipe_search/dev}/tasks/base_linker_lib.py (100%) rename experiments/param-opti/src/{param_opti => kgpipe_search/dev}/tasks/base_matcher.py (100%) rename experiments/param-opti/src/{param_opti => kgpipe_search/dev}/tasks/base_matcher_lib.py (100%) rename experiments/param-opti/src/{param_opti => kgpipe_search/dev}/tasks/corenlp.py (100%) rename experiments/param-opti/src/{param_opti => kgpipe_search/dev}/tasks/corenlp_lip.py (100%) rename experiments/param-opti/src/{param_opti => kgpipe_search/dev}/tasks/formats.py (100%) rename experiments/param-opti/src/{param_opti => kgpipe_search/dev}/tasks/fusion.py (91%) rename experiments/param-opti/src/{param_opti => kgpipe_search/dev}/tasks/fusion_lib.py (100%) rename experiments/param-opti/src/{param_opti => kgpipe_search/dev}/tasks/genie.py (100%) rename experiments/param-opti/src/{param_opti => kgpipe_search/dev}/tasks/genie_lib.py (100%) rename experiments/param-opti/src/{param_opti => kgpipe_search/dev}/tasks/jedai.py (100%) create mode 100644 experiments/param-opti/src/kgpipe_search/dev/tasks/llm_extract_lib.py create mode 100644 experiments/param-opti/src/kgpipe_search/dev/tasks/llm_mapping_lib.py rename experiments/param-opti/src/{param_opti => kgpipe_search/dev}/tasks/matching_helpers.py (100%) rename experiments/param-opti/src/{param_opti => kgpipe_search/dev}/tasks/paris.py (96%) rename experiments/param-opti/src/{param_opti => kgpipe_search/dev}/tasks/paris_lib.py (100%) rename experiments/param-opti/src/{param_opti => kgpipe_search/dev}/tasks/select_lib.py (100%) rename experiments/param-opti/src/{param_opti => kgpipe_search/dev}/tasks/spotlight.py (100%) rename experiments/param-opti/src/{param_opti => kgpipe_search/dev}/tasks/spotlight_lib.py (100%) rename experiments/param-opti/src/{param_opti => kgpipe_search/dev}/tasks/text_helpers.py (100%) create mode 100644 experiments/param-opti/src/kgpipe_search/test/__init__.py create mode 100644 experiments/param-opti/src/kgpipe_search/test/conftest.py create mode 100644 experiments/param-opti/src/kgpipe_search/test/test_configuration.py create mode 100644 experiments/param-opti/src/kgpipe_search/test/test_execution.py create mode 100644 experiments/param-opti/src/kgpipe_search/test/test_execution_docker.py create mode 100644 experiments/param-opti/src/kgpipe_search/test/test_experiments.py rename experiments/param-opti/src/kgpipe_search/{ => test}/test_features.py (50%) create mode 100644 experiments/param-opti/src/kgpipe_search/test/test_search.py delete mode 100644 experiments/param-opti/src/kgpipe_search/test_experiments.py delete mode 100644 experiments/param-opti/src/param_opti/__init__.py delete mode 100644 experiments/param-opti/src/param_opti/__main__.py delete mode 100644 experiments/param-opti/src/param_opti/experiment.py delete mode 100644 experiments/param-opti/src/param_opti/pipeline_selection/test_configuration.py delete mode 100644 experiments/param-opti/src/param_opti/pipeline_util.py delete mode 100644 experiments/param-opti/src/param_opti/search.py delete mode 100644 experiments/param-opti/src/param_opti/tool.py delete mode 100644 experiments/param-opti/src/qap/fixtures/rdf_sampled_pipeline_configs.json delete mode 100644 experiments/param-opti/src/qap/fixtures/text_sampled_pipeline_configs.json delete mode 100644 experiments/param-opti/src/qap/sge_metrics.py delete mode 100644 experiments/param-opti/src/qap/test_eval_pipelines.py delete mode 100644 experiments/param-opti/src/qap/test_exec_pipelines.py delete mode 100644 experiments/param-opti/src/qap/test_ref_based.py delete mode 100644 experiments/param-opti/src/qap/test_sge_based.py delete mode 100644 experiments/param-opti/src/qap_mock/__init__.py delete mode 100644 experiments/param-opti/src/qap_mock/__main__.py delete mode 100644 experiments/param-opti/src/qap_mock/experiments.py delete mode 100644 experiments/param-opti/src/qap_mock/models.py delete mode 100644 experiments/param-opti/src/qap_mock/objectives.py delete mode 100644 experiments/param-opti/src/qap_mock/pipeline_util.py delete mode 100644 experiments/param-opti/src/qap_mock/search.py delete mode 100644 experiments/param-opti/src/qap_mock/search_space.py delete mode 100644 experiments/param-opti/src/qap_mock/stats.py diff --git a/experiments/param-opti/.gitignore b/experiments/param-opti/.gitignore index a5521ea..3895a6e 100644 --- a/experiments/param-opti/.gitignore +++ b/experiments/param-opti/.gitignore @@ -4,4 +4,5 @@ output_qap_mock/ testdata/ tmp/ data/ -data \ No newline at end of file +data +backlog/ \ No newline at end of file diff --git a/experiments/param-opti/Agent.md b/experiments/param-opti/Agent.md deleted file mode 100644 index 06c4e09..0000000 --- a/experiments/param-opti/Agent.md +++ /dev/null @@ -1,31 +0,0 @@ -# Tool Parameter Extraction - -An experiment to extract configuration (hyper)parameters from tools that perform data integration tasks and cluster them to show common options. - -The extraction and clustering code is under src/kgpipe_parameters -The experiment code using this is under experimets/param-opti - -Trace the implementation state under spec/ -Reuse its state and extend it for each new feature. -Tack issues under spec/fix_needed.md - -# Implementation Requirements -- Implement parameter extractors from - - docker doc - - python lib - - cli help - - http api doc - - repo Readme.md/Doc -- The extractors can use LLMs or rules/regex patterns -- Find similar parameters between the single tools implementing a cluster strategy - - using sentence transformer embeddings - - prompting llms with preselected terms -- Visualize the clusters - -# Success Criteria -- simple tests for each extractors -- a working experiment in experiments/param-opti -- a table with configuration parameters -- A vizualization output - -I want you to check missing features and just implement the next feature required now. \ No newline at end of file diff --git a/experiments/param-opti/README.md b/experiments/param-opti/README.md index edc5def..d7dcbb1 100644 --- a/experiments/param-opti/README.md +++ b/experiments/param-opti/README.md @@ -1,149 +1,17 @@ -# Parameter Optimization Experiment +# Pipeline Search and Optimization This experiment extracts and analyzes configuration parameters from open-source data integration tools using the `kgpipe_parameters` extraction module. -## Paper mock experiments (Quality-Aware Pipelines) - -This directory also contains a **self-contained mock** of the experiments described in `Quality_Aware_Pipelines.pdf` (Section 6, “Experimental Evaluation”). - -- **What it is**: a small simulation of (a) a pipeline configuration space (implementations + thresholds), (b) a “true” end-to-end quality objective (accuracy/coverage/consistency aggregated), (c) an approximate quality estimator \( \hat{Q} \), and (d) search strategies (Default, Random Search, Quality-Aware Search). -- **What it is not**: it does **not** run KGpipe or reproduce the paper’s numbers. It’s meant as a scaffolding to iterate on the experimental protocol and factor out cleaner subpackages later. - -### Run the mock experiments - -From `experiments/param-opti`: - -```bash -python3 run_qap_mock.py all -python3 run_qap_mock.py exp1 # search effectiveness (Table-2-like) -python3 run_qap_mock.py exp2 # estimation reliability (corr/MAE/top-k) -python3 run_qap_mock.py exp3 # impl-only vs param-only vs joint -``` - -Outputs are written to `output_qap_mock/` (JSON). - -#### “Mock → real” execution mode - -The `qap_mock` package can now execute **real KGpipe tasks** (instead of purely simulated formulas) when dependencies are installed. - -- **Install dependencies** (from repo root): - -```bash -python3 -m pip install -e . -``` - -- **Enable docker-backed tasks** (PARIS, CoreNLP) for richer pipelines: - -```bash -export QAP_MOCK_USE_DOCKER=1 -``` - -Without `QAP_MOCK_USE_DOCKER=1`, `qap_mock` will use non-docker fallbacks where available (e.g., union-only RDF fusion and a lightweight pattern-based IE) so the experiment harness stays runnable. - -## Directory Structure - -``` -param-opti/ -├── input/ # Tool definitions -│ ├── paris/ -│ │ ├── repo.url # Git repository URL -│ │ └── cli.txt # CLI help output (optional) -│ └── corenlp_openie/ -│ └── repo.url -├── repos/ # Cloned repositories (auto-populated) -├── output/ # Extraction results (JSON) -├── output_qap_mock/ # Mock paper experiment results (JSON) -├── src/ -│ └── param_opti/ # Experiment code -└── run_experiment.py # Main entry point -``` - ## Usage -### Run full experiment -```bash -# From kgpipe root (with venv activated) -cd experiments/param-opti -python run_experiment.py -``` - -### Run for specific tool - -```bash -python run_experiment.py --tool paris -python run_experiment.py --tool paris corenlp_openie -``` - -### Skip repository cloning - -```bash -python run_experiment.py --no-clone -``` - -### Use LLM-based extraction (requires kgpipe_llm) - -```bash -python run_experiment.py --use-llm -``` +## -## Adding New Tools +## Contribution -1. Create a folder in `input/` with the tool name -2. Add `repo.url` with the Git repository URL -3. Optionally add `cli.txt` with CLI help output -4. Optionally add `config.json` for additional settings: - -```json -{ - "language": "python", - "main_files": ["src/main.py", "cli.py"] -} +Linting check ``` - -## Output Format - -Results are saved as JSON files in `output/`: - -```json -{ - "tool_name": "paris", - "timestamp": "2024-...", - "sources": [ - { - "source_type": "cli", - "file_path": "input/paris/cli.txt", - "parameters_count": 5 - } - ], - "parameters": [ - { - "name": "threshold", - "native_keys": ["--threshold"], - "description": "Matching threshold", - "type_hint": "float", - "default_value": 0.5, - "_source": "cli" - } - ], - "summary": { - "total_parameters": 5, - "total_sources": 1, - "total_errors": 0 - } -} +.venv/bin/ruff check --fix experiments/param-opti/src/kgpipe_search ``` -A `_summary.json` file is also generated with aggregate statistics. - - -# Configuration Apsects - -1. Task Assignment: Selecting -2. Task Tunning -3. - - -# Notes - -../../.venv/bin/pytest -s --show-capture=no src/qap/test_exec_pipelines.py -k "test_rdf_pipeline_from_saved_sampled_configs" \ No newline at end of file +The `kgpipe_search.dev` module will be migrated into the core KGpipe API. \ No newline at end of file diff --git a/experiments/param-opti/experiment.ipynb b/experiments/param-opti/experiment.ipynb new file mode 100644 index 0000000..2acd687 --- /dev/null +++ b/experiments/param-opti/experiment.ipynb @@ -0,0 +1,53 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "2d5609eb", + "metadata": {}, + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8a1d7641", + "metadata": {}, + "outputs": [], + "source": [ + "# imports\n", + "import src.kgpipe_search\n", + "from src.kgpipe_search.configuration import enumerate_exhaustive_pipeline_config_snapshots" + ] + }, + { + "cell_type": "markdown", + "id": "7a82e13b", + "metadata": {}, + "source": [ + "Enumerate all pipeline configs" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4eff5d27", + "metadata": {}, + "outputs": [], + "source": [ + "enumerate_exhaustive_pipeline_config_snapshots()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/experiments/param-opti/input/Parameters.md b/experiments/param-opti/input/Parameters.md deleted file mode 100644 index 2dac3e7..0000000 --- a/experiments/param-opti/input/Parameters.md +++ /dev/null @@ -1,17 +0,0 @@ - -# Entity Matching -Algo -Cluster -Threshold - -# Ontology Matching -Algo -Cluster -Threshold - -# Entity Linking - -# Relation Linking - -# Fusion -Method \ No newline at end of file diff --git a/experiments/param-opti/input/am_light/parameters.properties b/experiments/param-opti/input/am_light/parameters.properties deleted file mode 100644 index 9a296d7..0000000 --- a/experiments/param-opti/input/am_light/parameters.properties +++ /dev/null @@ -1,3 +0,0 @@ -# manual file for parameters -similarity_threshold=0.7 -similarity_threshold_mapping=SIMILARITY_THRESHOLD diff --git a/experiments/param-opti/input/am_light/repo.url b/experiments/param-opti/input/am_light/repo.url deleted file mode 100644 index 7d29f7d..0000000 --- a/experiments/param-opti/input/am_light/repo.url +++ /dev/null @@ -1 +0,0 @@ -https://github.com/AgreementMakerLight/AML-Project.git \ No newline at end of file diff --git a/experiments/param-opti/input/corenlp_openie/repo.url b/experiments/param-opti/input/corenlp_openie/repo.url deleted file mode 100644 index 9ccaf57..0000000 --- a/experiments/param-opti/input/corenlp_openie/repo.url +++ /dev/null @@ -1 +0,0 @@ -https://github.com/stanfordnlp/CoreNLP.git \ No newline at end of file diff --git a/experiments/param-opti/input/paris/cli.txt b/experiments/param-opti/input/paris/cli.txt deleted file mode 100644 index edeca1b..0000000 --- a/experiments/param-opti/input/paris/cli.txt +++ /dev/null @@ -1,10 +0,0 @@ -Paris - -You can specify a file that has no content. -PARIS will ask for the necessary data and store it in . - -Paris - -Shorthand for the previous form. - -Paris diff --git a/experiments/param-opti/input/paris/repo.url b/experiments/param-opti/input/paris/repo.url deleted file mode 100644 index d3f2731..0000000 --- a/experiments/param-opti/input/paris/repo.url +++ /dev/null @@ -1 +0,0 @@ -https://github.com/dig-team/PARIS.git \ No newline at end of file diff --git a/experiments/param-opti/input/valentine/repo.url b/experiments/param-opti/input/valentine/repo.url deleted file mode 100644 index 9a4dda0..0000000 --- a/experiments/param-opti/input/valentine/repo.url +++ /dev/null @@ -1 +0,0 @@ -https://github.com/delftdata/valentine.git \ No newline at end of file diff --git a/experiments/param-opti/run_experiment.py b/experiments/param-opti/run_experiment.py deleted file mode 100644 index e9c5d9f..0000000 --- a/experiments/param-opti/run_experiment.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python3 -""" -Quick script to run the parameter extraction experiment. - -This script can be run directly from the param-opti directory: - python run_experiment.py - python run_experiment.py --tool paris - python run_experiment.py --no-clone -""" - -import sys -from pathlib import Path - -# Add src to path -src_path = Path(__file__).parent / "src" -sys.path.insert(0, str(src_path)) - -# Also ensure kgpipe is importable -kgpipe_src = Path(__file__).parent.parent.parent / "src" -sys.path.insert(0, str(kgpipe_src)) - -from param_opti.__main__ import main - -if __name__ == "__main__": - sys.exit(main()) - - diff --git a/experiments/param-opti/run_qap_mock.py b/experiments/param-opti/run_qap_mock.py deleted file mode 100644 index 162042f..0000000 --- a/experiments/param-opti/run_qap_mock.py +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env python3 -""" -Quick runner for the "Quality Aware Knowledge Graph Pipeline Configurations" -paper mock experiments. - -Run from this directory: - python run_qap_mock.py exp1 - python run_qap_mock.py exp2 - python run_qap_mock.py exp3 - python run_qap_mock.py all -""" - -import sys -from pathlib import Path - -# Add local experiment src + project src to path -exp_src_path = Path(__file__).parent / "src" -repo_src_path = Path(__file__).resolve().parents[2] / "src" -sys.path.insert(0, str(exp_src_path)) -sys.path.insert(0, str(repo_src_path)) - -from qap_mock.__main__ import main # noqa: E402 - -if __name__ == "__main__": - raise SystemExit(main()) - diff --git a/experiments/param-opti/spec/implementation_state.md b/experiments/param-opti/spec/implementation_state.md deleted file mode 100644 index d837e66..0000000 --- a/experiments/param-opti/spec/implementation_state.md +++ /dev/null @@ -1,123 +0,0 @@ -# Implementation State - -Last updated: 2026-02-20 - -## Parameter Extractors - -| Source Type | Regex | LLM | Tests | Module | -|-----------------|-------|-----|-------|---------------------------------| -| CLI help | ✅ | ✅ | ✅ | `extractors/cli.py` | -| Python lib | ✅ | ✅ | ✅ | `extractors/python_lib.py` | -| HTTP API doc | ✅ | ✅ | ✅ | `extractors/http_api.py` | -| Docker doc | ✅ | ✅ | ✅ | `extractors/docker.py` | -| Repo README/Doc | ✅ | ✅ | ✅ | `extractors/readme_doc.py` | - -All extractors live under `src/kgpipe_parameters/extraction/extractors/`. - -## Core Infrastructure - -| Component | Status | Location | -|----------------------------|--------|-----------------------------------------------| -| Models | ✅ | `extraction/models.py` | -| Base classes | ✅ | `extraction/base.py` | -| Regex patterns | ✅ | `extraction/patterns.py` | -| Utilities | ✅ | `extraction/utils.py` | -| ParameterMiner | ✅ | `extraction/param_miner.py` | -| Auto source detect | ✅ | `param_miner._detect_source_type()` | -| Keyword chunk filter | ✅ | `extraction/chunk_filter.py` | - -## Keyword Chunk Filter - -Keyword-based pre-filter that scores chunks before they reach any extractor. -Counts parameter-signal keywords per language/file-type and skips files below -a configurable threshold. No embeddings, zero extra dependencies. - -| Language / Type | Keywords cover | Threshold | -|-----------------|---------------------------------------------------------|-----------| -| Python | argparse, click, dataclass, Field, os.environ, … | 2 | -| Java | @Option, @Parameter, getProperty, Properties, @Value,… | 1 | -| .properties | `=`, `:` | 1 | -| XML | ` ExtractionResult -``` - -## How LLMExtractor Works - -### 1. Initialization (`base.py`) - -```python -class LLMExtractor(BaseExtractor): - def __init__(self, source_type: SourceType, llm_client=None): - self.llm_client = llm_client - if llm_client is None: - from kgpipe_llm.common.core import get_client_from_env - self.llm_client = get_client_from_env() -``` - -If no `llm_client` is passed, the constructor tries to auto-create one via `get_client_from_env()`, which reads these environment variables: - -| Variable | Purpose | -|------------------------|-----------------------------------------| -| `LLM_ENDPOINT_URL` | API endpoint (Ollama or OpenAI-compat) | -| `DEFAULT_LLM_MODEL_NAME` | Model name (`gemma3:27B`, `gpt-4o`, …)| -| `OLLAMA_TOKEN` | Token for Ollama API | -| `OPENAI_TOKEN` | Token for OpenAI API | -| `LLM_SEED` | Optional reproducibility seed | -| `CONTEXT_WINDOW` | Max context window (default 16384) | - -The client auto-detects whether to use the **OpenAI** or **Ollama** backend based on the model name. - -### 2. Prompt Construction (`_create_prompt`) - -Each LLM extractor overrides `_create_prompt()` with a source-type-specific prompt template. For example, `LLMCLIExtractor`: - -``` -Extract all configuration parameters from the following CLI help output. -For each parameter, identify: -- Parameter name (normalized, without -- or -) -- Native keys/flags (--flag, -f, etc.) -- Description -- Type (if mentioned) -- Default value (if mentioned) -- Whether it's required or optional - -CLI Help Output: -{source} - -Return a JSON object with a 'parameters' array. Each parameter should have: -name, native_keys, description, type_hint, default_value, required. -``` - -Each source type adapts the prompt to mention the kind of content it expects (code blocks for README, ENV/ARG for Docker, function signatures for Python, etc.). - -### 3. Structured Output via Pydantic Schema - -The `extract()` method defines a Pydantic schema inline and passes it to `send_prompt()`: - -```python -class ParameterSchema(BaseModel): - name: str - native_keys: List[str] - description: Optional[str] = None - type_hint: Optional[str] = None - default_value: Optional[Union[str, int, float, bool]] = None - required: bool = False - -class ExtractionSchema(BaseModel): - parameters: List[ParameterSchema] - -response = self.llm_client.send_prompt(prompt, ExtractionSchema) -``` - -`LLMClient.send_prompt()` uses the Pydantic model's JSON schema to enforce structured output: -- **OpenAI backend**: uses tool/function calling (`openai_call_with_tool`) to get schema-conformant JSON. -- **Ollama backend**: passes the schema in the `format` field so the model outputs valid JSON. - -The response is always a `dict` with a `"parameters"` key containing a list of parameter objects. - -### 4. Response Parsing - -The returned dict is iterated and each entry is converted to a `RawParameter`: - -```python -for param_data in response["parameters"]: - raw_param = RawParameter( - name=normalize_parameter_name(param_data["name"]), - native_keys=param_data.get("native_keys", []), - description=param_data.get("description"), - type_hint=param_data.get("type_hint"), - default_value=param_data.get("default_value"), - required=param_data.get("required", False), - source=source[:200], - provenance={"method": "llm"}, - ) -``` - -The result is wrapped in an `ExtractionResult` with `extraction_method=ExtractionMethod.LLM`. - -### 5. Error Handling - -All LLM extractors catch exceptions and return an empty `ExtractionResult` with the error message in the `errors` list. This ensures a failed LLM call never crashes the extraction pipeline. - -## How ParameterMiner Uses LLM Extractors - -In `ParameterMiner.extract_parameters()`, the `method` argument controls dispatch: - -| Method | Behavior | -|-------------------------|-------------------------------------------------------| -| `ExtractionMethod.REGEX`| Always use the regex extractor. | -| `ExtractionMethod.LLM` | Always use the LLM extractor (raises if no client). | -| `ExtractionMethod.AUTO` | Try regex first. If it returns 0 parameters **and** an `llm_client` is available, fall back to the LLM extractor. | - -The experiment runner (`run_experiment.py --use-llm`) sets `use_llm=True`, which provides an `llm_client` to the `ParameterMiner`, enabling the AUTO fallback. - -## Existing LLM Extractors - -| Class | Source Type | Prompt Focus | -|--------------------------|-------------|-----------------------------------------------| -| `LLMCLIExtractor` | CLI | Flags, options, defaults from help output | -| `LLMPythonExtractor` | Python | Function params, class attrs, env vars | -| `LLMHTTPExtractor` | HTTP API | Query/path/body/header params from specs | -| `LLMDockerExtractor` | Docker | ENV, ARG, volumes, ports | -| `LLMReadmeDocExtractor` | README | Flags, env vars, config keys, tunable values | - -## Testing - -LLM extractors are tested with a mock client (`conftest.py::mock_llm_client`) that returns a canned response, so tests run without a live LLM endpoint. - diff --git a/experiments/param-opti/src/experiment.py b/experiments/param-opti/src/experiment.py new file mode 100644 index 0000000..b3670b6 --- /dev/null +++ b/experiments/param-opti/src/experiment.py @@ -0,0 +1,396 @@ +#!/usr/bin/env python3 +""" +Run and evaluate pipeline configs from fixture files. + +Example: + python experiment.py \\ + --seed data/bench/.../seed/data.nt \\ + --source data/bench/.../sources/rdf/data.nt \\ + --reference data/bench/.../reference/data_agg.nt \\ + --ontology data/bench/.../ontology.ttl \\ + --pipeline-type rdf \\ + --configs exhaustive +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import types +from dataclasses import asdict, is_dataclass +from importlib import import_module +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional + +from kgpipe.common import Data, DataFormat, KgPipe +from kgpipe_search.configuration import ( + load_rdf_exhaustive_pipeline_configs, + load_rdf_sampled_pipeline_configs, + load_text_exhaustive_pipeline_configs, + load_text_sampled_pipeline_configs, + pipeline_config_snapshot_key, + print_pipeline_config_short, + task_keys_from_pipeline_config, +) +from kgpipe_search.definitions import ( + PipelineConfig, + RDF_SEARCH_SPACE, + TEXT_SEARCH_SPACE, +) +from kgpipe_search.evaluation import evaluate_pipeline + + +def _install_param_opti_shim() -> None: + if "param_opti" in sys.modules: + return + + param_opti = types.ModuleType("param_opti") + tasks = types.ModuleType("param_opti.tasks") + + for lib in ( + "base_linker_lib", + "base_matcher_lib", + "paris_lib", + "fusion_lib", + "spotlight_lib", + "corenlp_lip", + "genie_lib", + ): + module = import_module(f"kgpipe_search.dev.tasks.{lib}") + setattr(tasks, lib, module) + sys.modules[f"param_opti.tasks.{lib}"] = module + + param_opti.tasks = tasks + sys.modules["param_opti"] = param_opti + sys.modules["param_opti.tasks"] = tasks + + +_install_param_opti_shim() + + +def _to_jsonable(value: Any) -> Any: + if is_dataclass(value): + return {k: _to_jsonable(v) for k, v in asdict(value).items()} + if isinstance(value, dict): + return {k: _to_jsonable(v) for k, v in value.items()} + if isinstance(value, list): + return [_to_jsonable(v) for v in value] + if isinstance(value, Path): + return str(value) + return value + + +def _set_ontology_env(ontology_path: Optional[Path]) -> None: + if ontology_path is None: + return + if not ontology_path.exists(): + raise FileNotFoundError(f"Ontology file not found: {ontology_path}") + os.environ["ONTOLOGY_PATH"] = str(ontology_path.resolve()) + + +def _validate_input_path(path: Path, label: str) -> Path: + resolved = path.resolve() + if not resolved.exists(): + raise FileNotFoundError(f"{label} not found: {resolved}") + return resolved + + +def _load_pipeline_configs( + *, + pipeline_type: str, + configs: str, + configs_fixture: Optional[Path], +) -> List[PipelineConfig]: + loaders: Dict[str, Dict[str, Callable[[], List[PipelineConfig]]]] = { + "rdf": { + "sampled": load_rdf_sampled_pipeline_configs, + "exhaustive": load_rdf_exhaustive_pipeline_configs, + }, + "text": { + "sampled": load_text_sampled_pipeline_configs, + "exhaustive": load_text_exhaustive_pipeline_configs, + }, + } + + if pipeline_type not in loaders: + raise ValueError(f"Unsupported pipeline type {pipeline_type!r}") + if configs not in loaders[pipeline_type]: + raise ValueError(f"Unsupported configs mode {configs!r}") + + loader = loaders[pipeline_type][configs] + loaded = loader(configs_fixture) if configs_fixture is not None else loader() + if not loaded: + raise ValueError( + f"No pipeline configs loaded for pipeline_type={pipeline_type!r}, configs={configs!r}. " + "Generate fixtures with the configuration tests first." + ) + return loaded + + +def run_rdf_pipeline( + pipeline_config: PipelineConfig, + *, + seed_path: Path, + source_path: Path, + result_path: Path, + tasks_tmp_dir: Path, + run_name: str, +) -> Path: + tasks_tmp_dir.mkdir(parents=True, exist_ok=True) + result_path.parent.mkdir(parents=True, exist_ok=True) + + pipeline = KgPipe( + tasks=pipeline_config.tasks, + seed=Data(path=seed_path, format=DataFormat.RDF_NTRIPLES), + data_dir=tasks_tmp_dir, + name=run_name, + ) + + pipeline.build( + stable_files=True, + configCatalog=pipeline_config.config_catalog, + source=Data(path=source_path, format=DataFormat.RDF_NTRIPLES), + result=Data(path=result_path, format=DataFormat.RDF_NTRIPLES), + ) + pipeline.run(configCatalog=pipeline_config.config_catalog, stable_files_override=False) + return result_path + + +def run_text_pipeline( + pipeline_config: PipelineConfig, + *, + seed_path: Path, + source_path: Path, + result_path: Path, + tasks_tmp_dir: Path, + run_name: str, +) -> Path: + tasks_tmp_dir.mkdir(parents=True, exist_ok=True) + result_path.parent.mkdir(parents=True, exist_ok=True) + + pipeline = KgPipe( + tasks=pipeline_config.tasks, + seed=Data(path=seed_path, format=DataFormat.RDF_NTRIPLES), + data_dir=tasks_tmp_dir, + name=run_name, + ) + + pipeline.build( + stable_files=True, + configCatalog=pipeline_config.config_catalog, + source=Data(path=source_path, format=DataFormat.TEXT), + result=Data(path=result_path, format=DataFormat.RDF_NTRIPLES), + ) + pipeline.run(configCatalog=pipeline_config.config_catalog, stable_files_override=False) + return result_path + + +def run_all_configs( + *, + seed_path: Path, + source_path: Path, + reference_path: Path, + ontology_path: Optional[Path], + output_dir: Path, + pipeline_type: str, + configs: str, + configs_fixture: Optional[Path], + start: int, + limit: Optional[int], + results_path: Optional[Path], +) -> List[Dict[str, Any]]: + _set_ontology_env(ontology_path) + + search_space = RDF_SEARCH_SPACE if pipeline_type == "rdf" else TEXT_SEARCH_SPACE + run_pipeline = run_rdf_pipeline if pipeline_type == "rdf" else run_text_pipeline + + pipeline_configs = _load_pipeline_configs( + pipeline_type=pipeline_type, + configs=configs, + configs_fixture=configs_fixture, + ) + + end = len(pipeline_configs) if limit is None else min(len(pipeline_configs), start + limit) + selected = pipeline_configs[start:end] + + output_dir.mkdir(parents=True, exist_ok=True) + run_results: List[Dict[str, Any]] = [] + + print(f"Running {len(selected)} pipeline config(s) [{start}:{end})") + print(f"seed: {seed_path}") + print(f"source: {source_path}") + print(f"reference: {reference_path}") + print(f"output_dir: {output_dir}") + + for offset, pipeline_config in enumerate(selected, start=start): + run_name = f"config_{offset:04d}" + result_path = output_dir / f"{run_name}.nt" + tasks_tmp_dir = output_dir / f"{run_name}_tasks_tmp" + config_key = pipeline_config_snapshot_key(pipeline_config, search_space) + task_keys = task_keys_from_pipeline_config(pipeline_config) + + print(f"\n=== config {offset + 1}/{len(pipeline_configs)} ({run_name}) ===") + print_pipeline_config_short(pipeline_config) + + entry: Dict[str, Any] = { + "config_idx": offset, + "task_keys": task_keys, + "config_key": config_key, + "result_path": str(result_path), + "status": "ok", + } + + try: + run_pipeline( + pipeline_config, + seed_path=seed_path, + source_path=source_path, + result_path=result_path, + tasks_tmp_dir=tasks_tmp_dir, + run_name=run_name, + ) + aggregate_score = evaluate_pipeline( + pipeline_config, + result_path, + reference_path, + ) + entry["evaluation"] = _to_jsonable(aggregate_score) + print(f"score: {aggregate_score.final_score:.6f}") + except Exception as exc: + entry["status"] = "error" + entry["error"] = f"{type(exc).__name__}: {exc}" + print(f"failed: {entry['error']}") + + run_results.append(entry) + + if results_path is not None: + results_path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "pipeline_type": pipeline_type, + "configs": configs, + "seed": str(seed_path), + "source": str(source_path), + "reference": str(reference_path), + "ontology": str(ontology_path) if ontology_path is not None else None, + "output_dir": str(output_dir), + "start": start, + "limit": limit, + "results": run_results, + } + results_path.write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(f"\nWrote results to {results_path}") + + succeeded = sum(1 for item in run_results if item["status"] == "ok") + print(f"\nFinished: {succeeded}/{len(run_results)} succeeded") + return run_results + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Execute and evaluate pipeline configs from fixture files.", + ) + parser.add_argument("--seed", type=Path, required=True, help="Path to seed knowledge graph") + parser.add_argument("--source", type=Path, required=True, help="Path to source input graph/text") + parser.add_argument( + "--reference", + type=Path, + required=True, + help="Path to reference knowledge graph used for evaluation", + ) + parser.add_argument( + "--ontology", + type=Path, + default=None, + help="Optional ontology path (sets ONTOLOGY_PATH for matchers)", + ) + parser.add_argument( + "--output-dir", + type=Path, + default=Path("data/tmp/pipeline_runs"), + help="Directory for pipeline outputs and task temp files", + ) + parser.add_argument( + "--pipeline-type", + choices=["rdf", "text"], + default="rdf", + help="Pipeline family to run", + ) + parser.add_argument( + "--configs", + choices=["sampled", "exhaustive"], + default="exhaustive", + help="Which fixture set to execute", + ) + parser.add_argument( + "--configs-fixture", + type=Path, + default=None, + help="Optional path to a custom configs fixture JSON file", + ) + parser.add_argument( + "--start", + type=int, + default=0, + help="Start index into the loaded config list", + ) + parser.add_argument( + "--limit", + type=int, + default=None, + help="Maximum number of configs to run (default: all from --start)", + ) + parser.add_argument( + "--results", + type=Path, + default=None, + help="Optional path to write a JSON summary of all runs", + ) + return parser + + +def main(argv: Optional[List[str]] = None) -> int: + args = build_parser().parse_args(argv) + + if args.start < 0: + raise SystemExit("--start must be >= 0") + if args.limit is not None and args.limit <= 0: + raise SystemExit("--limit must be > 0") + + seed_path = _validate_input_path(args.seed, "Seed graph") + source_path = _validate_input_path(args.source, "Source input") + reference_path = _validate_input_path(args.reference, "Reference graph") + ontology_path = ( + _validate_input_path(args.ontology, "Ontology") + if args.ontology is not None + else None + ) + + run_results = run_all_configs( + seed_path=seed_path, + source_path=source_path, + reference_path=reference_path, + ontology_path=ontology_path, + output_dir=args.output_dir, + pipeline_type=args.pipeline_type, + configs=args.configs, + configs_fixture=args.configs_fixture, + start=args.start, + limit=args.limit, + results_path=args.results, + ) + + failed = sum(1 for item in run_results if item["status"] != "ok") + return 1 if failed else 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except FileNotFoundError as exc: + print(f"error: {exc}", file=sys.stderr) + raise SystemExit(2) from exc diff --git a/experiments/param-opti/src/qap/test_conf_pipelines.py b/experiments/param-opti/src/kgpipe_search/configuration.py similarity index 50% rename from experiments/param-opti/src/qap/test_conf_pipelines.py rename to experiments/param-opti/src/kgpipe_search/configuration.py index 89fe3ee..cff1cbb 100644 --- a/experiments/param-opti/src/qap/test_conf_pipelines.py +++ b/experiments/param-opti/src/kgpipe_search/configuration.py @@ -1,177 +1,24 @@ from typing import List, Dict, Any, Optional -import itertools -import json -import random -from kgpipe.common import KgPipe, Data, DataFormat, Registry +from kgpipe.common import KgTask from kgpipe.common.model.configuration import ConfigurationProfile, ParameterBinding -from kgpipe.common.model.task import KgTask -from pydantic import BaseModel - -from param_opti.tasks.paris import paris_graph_alignment_task, paris_entity_alignment_task, paris_ontology_matching_task -from param_opti.tasks.fusion import fusion_first_value_task -from param_opti.tasks.base_linker import relation_linker_label_alias_embedding_transformer_task, entity_linker_label_alias_embedding_transformer_task -from param_opti.tasks.base_matcher import ( - graph_alignment_label_alias_embedding_transformer_task, - relation_matcher_label_alias_embedding_transformer_task, - entity_matcher_label_alias_embedding_transformer_task, +from kgpipe_search.definitions import ( + PipelineLayout, + PipelineConfig, + RDF_SAMPLED_PIPELINE_CONFIGS_FIXTURE, + RDF_EXHAUSTIVE_PIPELINE_CONFIGS_FIXTURE, + TEXT_SAMPLED_PIPELINE_CONFIGS_FIXTURE, + TEXT_EXHAUSTIVE_PIPELINE_CONFIGS_FIXTURE, + _RDF_PIPELINE_CONFIG_SNAPSHOT_VERSION, + _RDF_EXHAUSTIVE_PIPELINE_CONFIG_SNAPSHOT_VERSION, + _TEXT_PIPELINE_CONFIG_SNAPSHOT_VERSION, + _TEXT_EXHAUSTIVE_PIPELINE_CONFIG_SNAPSHOT_VERSION, ) -from param_opti.tasks.corenlp import corenlp_text_extraction_task -from param_opti.tasks.genie import genie_text_extraction_task -from param_opti.tasks.spotlight import spotlight_entity_linking_task -from param_opti.tasks.matching_helpers import aggregate_matching_results_task -from param_opti.tasks.text_helpers import aggregate_entity_linking_task, aggregate_relation_linking_task -from param_opti.tasks.text_helpers import generate_rdf_from_text_results_task -from param_opti.tasks.select_lib import select_first_value_task -from kgpipe.generation.loaders import build_from_conf +import json +import random +import itertools from pathlib import Path -# for given tasks and config parameters, generate a pipeline (KGpipe) - -tmp_base_dir = Path("tmp") -if not tmp_base_dir.exists(): - tmp_base_dir.mkdir(parents=True, exist_ok=True) - -RDF_SAMPLED_PIPELINE_CONFIGS_FIXTURE = Path(__file__).resolve().parent / "fixtures" / "rdf_sampled_pipeline_configs.json" -_RDF_PIPELINE_CONFIG_SNAPSHOT_VERSION = 1 - -TEXT_SAMPLED_PIPELINE_CONFIGS_FIXTURE = Path(__file__).resolve().parent / "fixtures" / "text_sampled_pipeline_configs.json" -_TEXT_PIPELINE_CONFIG_SNAPSHOT_VERSION = 1 - - -class PipelineConfig(BaseModel): - tasks: List[KgTask] - config_catalog: Dict[str, ConfigurationProfile] - -RDF_SEARCH_SPACE = { - "graph_alignment_label_alias_embedding_transformer_task": { - "category": ["ontology_matching", "entity_matching", "aggregate_matching_results"], - "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2", "infloat/e5-base-v2"], - "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], - }, - "relation_matcher_label_alias_embedding_transformer_task": { - "category": ["ontology_matching"], - "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2", "infloat/e5-base-v2"], - "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], - }, - "entity_matcher_label_alias_embedding_transformer_task": { - "category": ["entity_matching"], - "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2", "infloat/e5-base-v2"], - "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], - }, - "paris_ontology_matching_task": { - "category": ["ontology_matching"], - "ontology_matching_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], - }, - "paris_entity_alignment_task": { - "category": ["entity_matching"], - "entity_matching_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], - }, - "paris_graph_alignment_task": { - "category": ["ontology_matching", "entity_matching", "aggregate_matching_results"], - "entity_matching_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], - "relation_matching_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], - }, - "aggregate_matching_results_task": { - "category": ["aggregate_matching_results"], - }, - "fusion_first_value_task": { - "category": ["fusion"], - # "fusion_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], - }, - "relation_linker_label_alias_embedding_transformer_task": { - "category": ["entity_linking"], - "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2", "infloat/e5-base-v2"], - "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], - }, - "entity_linker_label_alias_embedding_transformer_task": { - "category": "entity_linking", - "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2", "infloat/e5-base-v2"], - "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], - }, -} - -TEXT_SEARCH_SPACE = { - "corenlp_text_extraction_task": { - "category": ["information_extraction"], - # does not have config parameters - }, - "genie_text_extraction_task": { - "category": ["information_extraction"], - # does not have config parameters - }, - "spotlight_entity_linking_task": { - "category": ["entity_linking"], - "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], - }, - "relation_linker_label_alias_embedding_transformer_task": { - "category": ["relation_linking"], - "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2", "infloat/e5-base-v2"], - "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], - }, - "entity_linker_label_alias_embedding_transformer_task": { - "category": ["entity_linking"], - "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2", "infloat/e5-base-v2"], - "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], - }, - "aggregate_entity_linking_task": { - "category": ["aggregate_entity_linking"], - }, - "aggregate_relation_linking_task": { - "category": ["aggregate_relation_linking"], - }, - "generate_rdf_from_text_results_task": { - "category": ["construct_rdf"], - }, - "select_first_value_task": { - "category": ["fusion"], - }, -} - -TEXT_TASK_DICT = { - "corenlp_text_extraction_task": corenlp_text_extraction_task, - "genie_text_extraction_task": genie_text_extraction_task, - "spotlight_entity_linking_task": spotlight_entity_linking_task, - "relation_linker_label_alias_embedding_transformer_task": relation_linker_label_alias_embedding_transformer_task, - "entity_linker_label_alias_embedding_transformer_task": entity_linker_label_alias_embedding_transformer_task, - "select_first_value_task": select_first_value_task, - "aggregate_entity_linking_task": aggregate_entity_linking_task, - "aggregate_relation_linking_task": aggregate_relation_linking_task, - "generate_rdf_from_text_results_task": generate_rdf_from_text_results_task, -} - -RDF_TASK_DICT = { - "graph_alignment_label_alias_embedding_transformer_task": graph_alignment_label_alias_embedding_transformer_task, - "relation_matcher_label_alias_embedding_transformer_task": relation_matcher_label_alias_embedding_transformer_task, - "entity_matcher_label_alias_embedding_transformer_task": entity_matcher_label_alias_embedding_transformer_task, - "paris_ontology_matching_task": paris_ontology_matching_task, - "paris_entity_alignment_task": paris_entity_alignment_task, - "paris_graph_alignment_task": paris_graph_alignment_task, - "fusion_first_value_task": fusion_first_value_task, - "relation_linker_label_alias_embedding_transformer_task": relation_linker_label_alias_embedding_transformer_task, - "entity_linker_label_alias_embedding_transformer_task": entity_linker_label_alias_embedding_transformer_task, - "aggregate_matching_results_task": aggregate_matching_results_task, - # "fusion_union_task": fusion_union_task, -} - - - -task_dict = {**TEXT_TASK_DICT, **RDF_TASK_DICT} - -for task_name, task in RDF_TASK_DICT.items(): - Registry.add_task(task_name, task) - -class PipelineLayout(BaseModel): - """ - allowed task categories in the pipeline - """ - allowed_task_categories: List[str] - -TEXT_PIPELINE_LAYOUT = PipelineLayout( - allowed_task_categories=["information_extraction", "entity_linking", "aggregate_entity_linking", "relation_linking", "aggregate_relation_linking", "construct_rdf", "fusion"] -) +from kgpipe_search.definitions import task_dict -RDF_PIPELINE_LAYOUT = PipelineLayout( - allowed_task_categories=["ontology_matching", "entity_matching", "aggregate_matching_results", "fusion"] -) def _task_categories_list(search_space: Dict[str, Dict[str, Any]], task_name: str) -> List[str]: raw = search_space.get(task_name, {}).get("category") @@ -324,15 +171,40 @@ def load_text_sampled_pipeline_configs(path: Optional[Path] = None) -> List[Pipe return [pipeline_config_from_snapshot(item) for item in raw["samples"]] +def load_rdf_exhaustive_pipeline_configs(path: Optional[Path] = None) -> List[PipelineConfig]: + fixture_path = path or RDF_EXHAUSTIVE_PIPELINE_CONFIGS_FIXTURE + raw = json.loads(fixture_path.read_text(encoding="utf-8")) + if raw.get("version") != _RDF_EXHAUSTIVE_PIPELINE_CONFIG_SNAPSHOT_VERSION: + raise ValueError( + f"Unsupported rdf exhaustive configs snapshot version {raw.get('version')!r}; " + f"expected {_RDF_EXHAUSTIVE_PIPELINE_CONFIG_SNAPSHOT_VERSION}" + ) + return [pipeline_config_from_snapshot(item) for item in raw["samples"]] + + +def load_text_exhaustive_pipeline_configs(path: Optional[Path] = None) -> List[PipelineConfig]: + fixture_path = path or TEXT_EXHAUSTIVE_PIPELINE_CONFIGS_FIXTURE + raw = json.loads(fixture_path.read_text(encoding="utf-8")) + if raw.get("version") != _TEXT_EXHAUSTIVE_PIPELINE_CONFIG_SNAPSHOT_VERSION: + raise ValueError( + f"Unsupported text exhaustive configs snapshot version {raw.get('version')!r}; " + f"expected {_TEXT_EXHAUSTIVE_PIPELINE_CONFIG_SNAPSHOT_VERSION}" + ) + return [pipeline_config_from_snapshot(item) for item in raw["samples"]] + + # TODO rules for valid pipeline config: def sample_valid_pipeline_config( search_space: Dict[str, Dict[str, Any]], pipeline_layout: PipelineLayout, + *, + rng: Optional[random.Random] = None, ) -> PipelineConfig: """ Randomly sample a valid pipeline config from the search space, respecting the order of categories in the pipeline layout. """ + draw = rng.choice if rng is not None else random.choice tasks: List[KgTask] = [] config_catalog: Dict[str, ConfigurationProfile] = {} covered_categories: set[str] = set() @@ -366,7 +238,7 @@ def sample_valid_pipeline_config( f"categories {sorted(covered_categories)}. Adjust search_space or pipeline_layout." ) - task_key = random.choice(eligible_task_names) + task_key = draw(eligible_task_names) task = task_dict[task_key] covered_categories.update(_task_categories_list(search_space, task_key)) tasks.append(task) @@ -387,7 +259,124 @@ def sample_valid_pipeline_config( if not config_values: raise ValueError(f"Empty search space for {task_key}.{config_name}") - config_value = random.choice(config_values) + config_value = draw(config_values) + name_parts.append(f"{config_name}={config_value}") + bindings.append( + ParameterBinding( + parameter=_get_param(task.config_spec, config_name), + value=config_value, + ) + ) + + if bindings: + config_catalog[task.name] = ConfigurationProfile( + name=f"{task.name}_" + ",".join(name_parts), + definition=task.config_spec, + bindings=bindings, + ) + + return PipelineConfig(tasks=tasks, config_catalog=config_catalog) + + +_PIPELINE_CONFIG_SNAPSHOT_FILE_VERSION = 1 + + +def save_pipeline_config_snapshot( + path: Path, + pipeline_config: PipelineConfig, + *, + task_keys: Optional[List[str]] = None, +) -> None: + keys = task_keys or task_keys_from_pipeline_config(pipeline_config) + payload = { + "version": _PIPELINE_CONFIG_SNAPSHOT_FILE_VERSION, + "snapshot": pipeline_config_to_snapshot(keys, pipeline_config), + } + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +def load_pipeline_config_snapshot(path: Path) -> PipelineConfig: + raw = json.loads(path.read_text(encoding="utf-8")) + if raw.get("version") != _PIPELINE_CONFIG_SNAPSHOT_FILE_VERSION: + raise ValueError( + f"Unsupported pipeline config snapshot file version {raw.get('version')!r}; " + f"expected {_PIPELINE_CONFIG_SNAPSHOT_FILE_VERSION}" + ) + return pipeline_config_from_snapshot(raw["snapshot"]) + + +def task_keys_from_pipeline_config(pipeline_config: PipelineConfig) -> List[str]: + keys: List[str] = [] + for task in pipeline_config.tasks: + for task_key, registered in task_dict.items(): + if registered is task or registered.name == task.name: + keys.append(task_key) + break + else: + raise ValueError(f"Unknown task {task.name!r}") + return keys + + +def pipeline_config_snapshot_key( + pipeline_config: PipelineConfig, + search_space: Dict[str, Dict[str, Any]], +) -> str: + task_keys = task_keys_from_pipeline_config(pipeline_config) + snapshot = pipeline_config_to_snapshot(task_keys, pipeline_config) + return json.dumps(snapshot, sort_keys=True) + + +def build_pipeline_config_for_task_combo( + search_space: Dict[str, Dict[str, Any]], + task_name_combo: List[str], + *, + rng: random.Random, + template: Optional[PipelineConfig] = None, +) -> PipelineConfig: + """ + Build a pipeline config for a fixed task combo. + Reuses parameter profiles from template when the task key is unchanged. + """ + template_keys = ( + task_keys_from_pipeline_config(template) if template is not None else [] + ) + tasks: List[KgTask] = [] + config_catalog: Dict[str, ConfigurationProfile] = {} + + for index, task_key in enumerate(task_name_combo): + task = task_dict[task_key] + tasks.append(task) + + if ( + template is not None + and index < len(template_keys) + and template_keys[index] == task_key + ): + profile = template.config_catalog.get(task.name) + if profile is not None: + config_catalog[task.name] = profile + continue + + if getattr(task, "config_spec", None) is None: + continue + + bindings: List[ParameterBinding] = [] + name_parts: List[str] = [] + for config_name, config_values in search_space[task_key].items(): + if config_name == "category": + continue + if not isinstance(config_values, list): + raise TypeError( + f"Search space values must be lists; got {task_key}.{config_name}={type(config_values)}" + ) + if not config_values: + raise ValueError(f"Empty search space for {task_key}.{config_name}") + + config_value = rng.choice(config_values) name_parts.append(f"{config_name}={config_value}") bindings.append( ParameterBinding( @@ -405,6 +394,7 @@ def sample_valid_pipeline_config( return PipelineConfig(tasks=tasks, config_catalog=config_catalog) + def print_pipeline_config_short(pipeline_config: PipelineConfig): """ print the pipeline config in a short format @@ -551,158 +541,3 @@ def _task_param_assignments(task_key: str) -> List[Dict[str, Any]]: print("TOTAL expected configs:", total_expected) print("TOTAL generated snapshots:", len(all_snapshots)) return all_snapshots - - - -def test_sample_valid_rdf_pipeline_config(): - pipeline_layout = PipelineLayout( - allowed_task_categories=["ontology_matching", "entity_matching", "aggregate_matching_results", "fusion"] - ) - pipeline_config = sample_valid_pipeline_config(RDF_SEARCH_SPACE, pipeline_layout) - print_pipeline_config_short(pipeline_config) - -def test_enumerate_all_valid_rdf_task_combinations_no_config_sampling(): - print("enumerate_all_valid_rdf_task_combinations_no_config_sampling") - combos = enumerate_valid_task_combinations(RDF_SEARCH_SPACE, RDF_PIPELINE_LAYOUT) - - for combo in combos: - print(combo) - - # With current SEARCH_SPACE: - # - ontology_matching can be satisfied by paris_ontology_matching_task, paris_entity_alignment_task, paris_graph_alignment_task - # - entity_matching can be satisfied by paris_entity_alignment_task, paris_graph_alignment_task (and may be skipped if already covered) - # - fusion must be satisfied by fusion_first_value_task - # expected = { - # ("paris_ontology_matching_task", "paris_entity_alignment_task", "fusion_first_value_task"), - # ("paris_ontology_matching_task", "paris_graph_alignment_task", "fusion_first_value_task"), - # ("paris_graph_alignment_task", "fusion_first_value_task"), - # } - - # assert set(tuple(c) for c in combos) == expected - - - -def test_enumerate_all_valid_rdf_task_combinations_with_config_sampling(): - print("enumerate_all_valid_rdf_task_combinations_with_config_sampling") - n = 1 - rng = random.Random(0) - - combos = enumerate_valid_task_combinations(RDF_SEARCH_SPACE, RDF_PIPELINE_LAYOUT) - - total_config_count = 0 - snapshots: List[Dict[str, Any]] = [] - - for combo in combos: - print() - print("combo:", combo) - for i in range(n): - total_config_count += 1 - print(f"sample {total_config_count}/{len(combos) * n}") - pipeline_config = sample_config_catalog_for_task_combo( - RDF_SEARCH_SPACE, combo, rng=rng - ) - - print_pipeline_config_short(pipeline_config) - snapshots.append(pipeline_config_to_snapshot(combo, pipeline_config)) - - RDF_SAMPLED_PIPELINE_CONFIGS_FIXTURE.parent.mkdir(parents=True, exist_ok=True) - RDF_SAMPLED_PIPELINE_CONFIGS_FIXTURE.write_text( - json.dumps( - {"version": _RDF_PIPELINE_CONFIG_SNAPSHOT_VERSION, "samples": snapshots}, - indent=2, - sort_keys=True, - ) - + "\n", - encoding="utf-8", - ) - - -def test_sample_valid_text_pipeline_config(): - pipeline_config = sample_valid_pipeline_config(TEXT_SEARCH_SPACE, TEXT_PIPELINE_LAYOUT) - print_pipeline_config_short(pipeline_config) - - -def test_enumerate_all_valid_text_task_combinations_no_config_sampling(): - print("enumerate_all_valid_text_task_combinations_no_config_sampling") - combos = enumerate_valid_task_combinations(TEXT_SEARCH_SPACE, TEXT_PIPELINE_LAYOUT) - for combo in combos: - print(combo) - -def test_enumerate_all_valid_text_task_combinations_with_config_sampling(): - print("enumerate_all_valid_text_task_combinations_with_config_sampling") - n = 1 - rng = random.Random(0) - - combos = enumerate_valid_task_combinations(TEXT_SEARCH_SPACE, TEXT_PIPELINE_LAYOUT) - - total_config_count = 0 - snapshots: List[Dict[str, Any]] = [] - - for combo in combos: - print() - print("combo:", combo) - for i in range(n): - total_config_count += 1 - print(f"sample {total_config_count}/{len(combos) * n}") - pipeline_config = sample_config_catalog_for_task_combo( - TEXT_SEARCH_SPACE, combo, rng=rng - ) - print_pipeline_config_short(pipeline_config) - snapshots.append(pipeline_config_to_snapshot(combo, pipeline_config)) - - TEXT_SAMPLED_PIPELINE_CONFIGS_FIXTURE.parent.mkdir(parents=True, exist_ok=True) - TEXT_SAMPLED_PIPELINE_CONFIGS_FIXTURE.write_text( - json.dumps( - {"version": _TEXT_PIPELINE_CONFIG_SNAPSHOT_VERSION, "samples": snapshots}, - indent=2, - sort_keys=True, - ) - + "\n", - encoding="utf-8", - ) - -def test_enumerate_all_valid_text_task_combinations_with_config_sampling_exhaustive(): - print("enumerate_all_valid_text_task_combinations_with_config_sampling_exhaustive") - all_snapshots = enumerate_exhaustive_pipeline_config_snapshots( - TEXT_SEARCH_SPACE, TEXT_PIPELINE_LAYOUT - ) - serialized = [json.dumps(s, sort_keys=True) for s in all_snapshots] - assert len(set(serialized)) == len(serialized) - - -def test_enumerate_all_valid_rdf_task_combinations_with_config_sampling_exhaustive(): - print("enumerate_all_valid_rdf_task_combinations_with_config_sampling_exhaustive") - all_snapshots = enumerate_exhaustive_pipeline_config_snapshots( - RDF_SEARCH_SPACE, RDF_PIPELINE_LAYOUT - ) - serialized = [json.dumps(s, sort_keys=True) for s in all_snapshots] - assert len(set(serialized)) == len(serialized) - - - -# def test_rdf_pipeline_from_config(): -# pipeline_config = sample_valid_pipeline_config(RDF_SEARCH_SPACE, PipelineLayout(allowed_task_categories=["entity_matching", "fusion"])) - -# seed_path = tmp_base_dir / "seed.nt" -# source_path = tmp_base_dir / "source.nt" -# result_path = tmp_base_dir / "result.nt" -# tasks_tmp_dir = tmp_base_dir / "tasks_tmp" -# tasks_tmp_dir.mkdir(parents=True, exist_ok=True) - -# # Ensure inputs exist for pipeline execution. -# seed_path.write_text(" .\n") -# source_path.write_text(" .\n") - -# pipeline = KgPipe( -# tasks=pipeline_config.tasks, -# seed=Data(path=seed_path, format=DataFormat.RDF_NTRIPLES), -# data_dir=tasks_tmp_dir, -# name="test_pipeline") - -# pipeline.build( -# stable_files=True, -# configCatalog=pipeline_config.config_catalog, -# source=Data(path=source_path, format=DataFormat.RDF_NTRIPLES), -# result=Data(path=result_path, format=DataFormat.RDF_NTRIPLES)) - -# pipeline.run(configCatalog=pipeline_config.config_catalog, stable_files_override=True) \ No newline at end of file diff --git a/experiments/param-opti/src/kgpipe_search/definitions.py b/experiments/param-opti/src/kgpipe_search/definitions.py new file mode 100644 index 0000000..348c04b --- /dev/null +++ b/experiments/param-opti/src/kgpipe_search/definitions.py @@ -0,0 +1,205 @@ +from pydantic import BaseModel +from typing import List, Dict, Optional +from kgpipe.common import KgTask +from kgpipe.common.model.configuration import ConfigurationProfile +from pathlib import Path + +class PipelineLayout(BaseModel): + """ + allowed task categories in the pipeline + """ + allowed_task_categories: List[str] + + +class PipelineConfig(BaseModel): + tasks: List[KgTask] + config_catalog: Dict[str, ConfigurationProfile] + result_path: Optional[Path] = None + + + +RDF_SAMPLED_PIPELINE_CONFIGS_FIXTURE = Path(__file__).resolve().parent.parent / "data" / "fixtures" / "rdf_sampled_pipeline_configs.json" +_RDF_PIPELINE_CONFIG_SNAPSHOT_VERSION = 1 + +RDF_EXHAUSTIVE_PIPELINE_CONFIGS_FIXTURE = Path(__file__).resolve().parent.parent / "data" / "fixtures" / "rdf_exhaustive_pipeline_configs.json" +_RDF_EXHAUSTIVE_PIPELINE_CONFIG_SNAPSHOT_VERSION = 1 + +TEXT_SAMPLED_PIPELINE_CONFIGS_FIXTURE = Path(__file__).resolve().parent.parent / "data" / "fixtures" / "text_sampled_pipeline_configs.json" +_TEXT_PIPELINE_CONFIG_SNAPSHOT_VERSION = 1 + +TEXT_EXHAUSTIVE_PIPELINE_CONFIGS_FIXTURE = Path(__file__).resolve().parent.parent / "data" / "fixtures" / "text_exhaustive_pipeline_configs.json" +_TEXT_EXHAUSTIVE_PIPELINE_CONFIG_SNAPSHOT_VERSION = 1 + +TEXT_PIPELINE_LAYOUT = PipelineLayout( + allowed_task_categories=["information_extraction", "entity_linking", "aggregate_entity_linking", "relation_linking", "aggregate_relation_linking", "construct_rdf", "fusion"] +) + +RDF_PIPELINE_LAYOUT = PipelineLayout( + allowed_task_categories=["ontology_matching", "entity_matching", "aggregate_matching_results", "fusion"] +) + +RDF_LAYOUT = [ + "graph_alignment" + "relation_matching" + "entity_matching" + "aggregate_matching" +] + + +RDF_SEARCH_SPACE = { + "graph_alignment_label_alias_embedding_transformer_task": { + "category": ["ontology_matching", "entity_matching", "aggregate_matching_results"], + "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2", "intfloat/e5-base-v2"], + "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], + }, + "relation_matcher_label_alias_embedding_transformer_task": { + "category": ["ontology_matching"], + "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2", "intfloat/e5-base-v2"], + "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], + }, + "entity_matcher_label_alias_embedding_transformer_task": { + "category": ["entity_matching"], + "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2", "intfloat/e5-base-v2"], + "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], + }, + "paris_ontology_matching_task": { + "category": ["ontology_matching"], + "ontology_matching_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], + }, + "paris_entity_alignment_task": { + "category": ["entity_matching"], + "entity_matching_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], + }, + "paris_graph_alignment_task": { + "category": ["ontology_matching", "entity_matching", "aggregate_matching_results"], + "entity_matching_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], + "relation_matching_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], + }, + "aggregate_matching_results_task": { + "category": ["aggregate_matching_results"], + }, + "fusion_first_value_task": { + "category": ["fusion"], + # "fusion_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], + }, + "relation_linker_label_alias_embedding_transformer_task": { + "category": ["entity_linking"], + "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2", "intfloat/e5-base-v2"], + "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], + }, + "entity_linker_label_alias_embedding_transformer_task": { + "category": "entity_linking", + "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2", "intfloat/e5-base-v2"], + "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], + }, +} + +RDF_BASELINE_CONFIG = { + "profiles": { + "paris_graph_alignment_task": { + "bindings": [ + { + "parameter": "entity_matching_threshold", + "value": 0.9 + }, + { + "parameter": "relation_matching_threshold", + "value": 0.5 + } + ], + "profile_name": "paris_graph_alignment_entity_matching_threshold=0.9,relation_matching_threshold=0.5" + } + }, + "task_keys": [ + "paris_graph_alignment_task", + "fusion_first_value_task" + ] + } + +TEXT_LAYOUT = [ + "information_extraction" + "entity_linking" + "relation_linking" + "fusion" +] + +TEXT_SEARCH_SPACE = { + "corenlp_text_extraction_task": { + "category": ["information_extraction"], + # does not have config parameters + }, + "genie_text_extraction_task": { + "category": ["information_extraction"], + # does not have config parameters + }, + "spotlight_entity_linking_task": { + "category": ["entity_linking"], + "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], + }, + "relation_linker_label_alias_embedding_transformer_task": { + "category": ["relation_linking"], + "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2", "intfloat/e5-base-v2"], + "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], + }, + "entity_linker_label_alias_embedding_transformer_task": { + "category": ["entity_linking"], + "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2", "intfloat/e5-base-v2"], + "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], + }, + "aggregate_entity_linking_task": { + "category": ["aggregate_entity_linking"], + }, + "aggregate_relation_linking_task": { + "category": ["aggregate_relation_linking"], + }, + "generate_rdf_from_text_results_task": { + "category": ["construct_rdf"], + }, + "select_first_value_task": { + "category": ["fusion"], + }, +} + +from kgpipe_search.dev.tasks.paris import paris_graph_alignment_task, paris_entity_alignment_task, paris_ontology_matching_task +from kgpipe_search.dev.tasks.fusion import fusion_first_value_task +from kgpipe_search.dev.tasks.base_linker import relation_linker_label_alias_embedding_transformer_task, entity_linker_label_alias_embedding_transformer_task +from kgpipe_search.dev.tasks.base_matcher import ( + graph_alignment_label_alias_embedding_transformer_task, + relation_matcher_label_alias_embedding_transformer_task, + entity_matcher_label_alias_embedding_transformer_task, +) +from kgpipe_search.dev.tasks.corenlp import corenlp_text_extraction_task +from kgpipe_search.dev.tasks.genie import genie_text_extraction_task +from kgpipe_search.dev.tasks.spotlight import spotlight_entity_linking_task +from kgpipe_search.dev.tasks.matching_helpers import aggregate_matching_results_task +from kgpipe_search.dev.tasks.text_helpers import aggregate_entity_linking_task, aggregate_relation_linking_task +from kgpipe_search.dev.tasks.text_helpers import generate_rdf_from_text_results_task +from kgpipe_search.dev.tasks.select_lib import select_first_value_task + +TEXT_TASK_DICT = { + "corenlp_text_extraction_task": corenlp_text_extraction_task, + "genie_text_extraction_task": genie_text_extraction_task, + "spotlight_entity_linking_task": spotlight_entity_linking_task, + "relation_linker_label_alias_embedding_transformer_task": relation_linker_label_alias_embedding_transformer_task, + "entity_linker_label_alias_embedding_transformer_task": entity_linker_label_alias_embedding_transformer_task, + "select_first_value_task": select_first_value_task, + "aggregate_entity_linking_task": aggregate_entity_linking_task, + "aggregate_relation_linking_task": aggregate_relation_linking_task, + "generate_rdf_from_text_results_task": generate_rdf_from_text_results_task, +} + +RDF_TASK_DICT = { + "graph_alignment_label_alias_embedding_transformer_task": graph_alignment_label_alias_embedding_transformer_task, + "relation_matcher_label_alias_embedding_transformer_task": relation_matcher_label_alias_embedding_transformer_task, + "entity_matcher_label_alias_embedding_transformer_task": entity_matcher_label_alias_embedding_transformer_task, + "paris_ontology_matching_task": paris_ontology_matching_task, + "paris_entity_alignment_task": paris_entity_alignment_task, + "paris_graph_alignment_task": paris_graph_alignment_task, + "fusion_first_value_task": fusion_first_value_task, + "relation_linker_label_alias_embedding_transformer_task": relation_linker_label_alias_embedding_transformer_task, + "entity_linker_label_alias_embedding_transformer_task": entity_linker_label_alias_embedding_transformer_task, + "aggregate_matching_results_task": aggregate_matching_results_task, + # "fusion_union_task": fusion_union_task, +} + +task_dict = {**TEXT_TASK_DICT, **RDF_TASK_DICT} \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/pipeline_selection/__init__.py b/experiments/param-opti/src/kgpipe_search/dev/__init__.py similarity index 100% rename from experiments/param-opti/src/param_opti/pipeline_selection/__init__.py rename to experiments/param-opti/src/kgpipe_search/dev/__init__.py diff --git a/experiments/param-opti/src/qap/__init__.py b/experiments/param-opti/src/kgpipe_search/dev/docker/__init__.py similarity index 100% rename from experiments/param-opti/src/qap/__init__.py rename to experiments/param-opti/src/kgpipe_search/dev/docker/__init__.py diff --git a/experiments/param-opti/src/kgpipe_search/dev/docker/copy.py b/experiments/param-opti/src/kgpipe_search/dev/docker/copy.py new file mode 100644 index 0000000..9fc4918 --- /dev/null +++ b/experiments/param-opti/src/kgpipe_search/dev/docker/copy.py @@ -0,0 +1,262 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Literal, Protocol, Sequence + +from kgpipe_search.mounts import ScratchMount + +CopyStrategyName = Literal["hdfs", "bind"] + +DEFAULT_HADOOP_CONF_HOST = "/etc/hadoop/conf" +DEFAULT_HADOOP_CONF_CONTAINER = "/etc/hadoop/conf" +MINIMAL_HADOOP_CONF_CONTAINER = "/tmp/kgpipe-hadoop-conf" +DEFAULT_HDFS_PORT = 9000 +HOSTS_MOUNT_SOURCE = "/etc/hosts" +HOSTS_MOUNT_TARGET = "/etc/hosts" + + +def _normalize_namenode(namenode: str, *, default_port: int = DEFAULT_HDFS_PORT) -> str: + raw = namenode.strip().rstrip("/") + if raw.startswith("hdfs://"): + authority, _, path = raw[len("hdfs://") :].partition("/") + host = authority + else: + host, _, path = raw.partition("/") + path = f"/{path}" if path else "" + + if ":" not in host: + host = f"{host}:{default_port}" + + return f"hdfs://{host}{path}" + + +def _hosts_mount() -> dict[str, str]: + return { + "Type": "bind", + "Source": HOSTS_MOUNT_SOURCE, + "Target": HOSTS_MOUNT_TARGET, + "ReadOnly": True, + } + + +@dataclass(frozen=True) +class CopyTarget: + """Destination path for a copy strategy (e.g. HDFS directory).""" + + path: str + + +@dataclass(frozen=True) +class CopyContext: + """Source location on the node where the experiment wrote outputs.""" + + run_name: str + node_id: str + scratch: ScratchMount + + +@dataclass(frozen=True) +class CopyJobPlan: + image: str + command: Sequence[str] + env: dict[str, str] = field(default_factory=dict) + mounts: list[dict[str, str]] = field(default_factory=list) + + +class CopyStrategy(Protocol): + def build_job(self, *, context: CopyContext, destination: CopyTarget) -> CopyJobPlan: + ... + + +@dataclass(frozen=True) +class BindCopyStrategy: + """ + Copy scratch outputs to a host bind-mounted directory using `cp`. + + `destination.path` must be a **host path on every node** (same path), + e.g. an NFS mountpoint or any shared filesystem mounted consistently. + """ + + image: str = "alpine:3.20" + dest_container_path: str = "/dst" + scratch_container_path: str | None = None + + def build_job(self, *, context: CopyContext, destination: CopyTarget) -> CopyJobPlan: + scratch_root = self.scratch_container_path or context.scratch.container_path + src = f"{scratch_root}/{context.run_name}" + dst = f"{self.dest_container_path}/{context.run_name}" + + command = [ + "sh", + "-lc", + ( + "set -euo pipefail; " + f"test -d {src!r}; " + f"mkdir -p {dst!r}; " + f"cp -a {src!r}/. {dst!r}/; " + f"echo copied to {dst!r}" + ), + ] + + mounts: list[dict[str, str]] = [ + context.scratch.to_mount(), + { + "Type": "bind", + "Source": destination.path, + "Target": self.dest_container_path, + }, + ] + + env = { + "KGPIPE_RUN_ID": context.run_name, + "KGPIPE_SCRATCH": scratch_root, + "KGPIPE_BIND_DEST": destination.path, + } + + return CopyJobPlan(image=self.image, command=command, env=env, mounts=mounts) + + +def _hdfs_path(namenode: str | None, path: str, *, default_port: int = DEFAULT_HDFS_PORT) -> str: + normalized = path.rstrip("/") + if normalized.startswith("hdfs://"): + return normalized + if namenode is None: + return normalized + rel = normalized if normalized.startswith("/") else f"/{normalized}" + return f"{_normalize_namenode(namenode, default_port=default_port)}{rel}" + + +def _minimal_hadoop_conf_script(*, namenode: str, conf_dir: str) -> str: + nn = _normalize_namenode(namenode) + return ( + f"mkdir -p {conf_dir!r}; " + f"cat > {conf_dir!r}/core-site.xml <<'EOF'\n" + "\n" + "\n" + "\n" + " \n" + " fs.defaultFS\n" + f" {nn}\n" + " \n" + "\n" + "EOF\n" + f"export HADOOP_CONF_DIR={conf_dir!r}" + ) + + +@dataclass(frozen=True) +class HdfsCopyStrategy: + """ + Copy node-local scratch outputs to HDFS using the hdfs CLI. + + Configure either: + - `namenode` (+ optional `user`) for a minimal client setup, or + - `hadoop_conf_host` to mount cluster config from the node. + """ + + image: str = "apache/hadoop:3.3.6" + namenode: str | None = None + user: str | None = None + hadoop_conf_host: str | None = None + hadoop_conf_container: str = DEFAULT_HADOOP_CONF_CONTAINER + minimal_conf_container: str = MINIMAL_HADOOP_CONF_CONTAINER + mount_node_hosts: bool = True + hdfs_port: int = DEFAULT_HDFS_PORT + extra_env: dict[str, str] = field(default_factory=dict) + + def build_job(self, *, context: CopyContext, destination: CopyTarget) -> CopyJobPlan: + src = f"{context.scratch.container_path}/{context.run_name}" + hdfs_dst = _hdfs_path(self.namenode, destination.path, default_port=self.hdfs_port) + hdfs_dst = f"{hdfs_dst}/{context.run_name}" + + setup_parts: list[str] = [] + env: dict[str, str] = { + "KGPIPE_RUN_ID": context.run_name, + "KGPIPE_SCRATCH": context.scratch.container_path, + "KGPIPE_HDFS_DEST": hdfs_dst, + **self.extra_env, + } + + if self.user: + env["HADOOP_USER_NAME"] = self.user + + if self.namenode is not None: + setup_parts.append( + _minimal_hadoop_conf_script( + namenode=_normalize_namenode(self.namenode, default_port=self.hdfs_port), + conf_dir=self.minimal_conf_container, + ) + ) + elif self.hadoop_conf_host: + env["HADOOP_CONF_DIR"] = self.hadoop_conf_container + + setup = " && ".join(setup_parts) + prefix = f"{setup} && " if setup else "" + + command = [ + "bash", + "-lc", + ( + f"set -euo pipefail; " + f"{prefix}" + f"test -d {src!r}; " + f"hdfs dfs -mkdir -p {hdfs_dst!r}; " + f"hdfs dfs -put -f {src!r}/. {hdfs_dst!r}/; " + f"echo copied to {hdfs_dst!r}" + ), + ] + + mounts = [context.scratch.to_mount()] + if self.mount_node_hosts: + mounts.append(_hosts_mount()) + if self.namenode is None and self.hadoop_conf_host: + mounts.append( + { + "Type": "bind", + "Source": self.hadoop_conf_host, + "Target": self.hadoop_conf_container, + "ReadOnly": True, + } + ) + + return CopyJobPlan(image=self.image, command=command, env=env, mounts=mounts) + + +def copy_strategy_from_config(cfg: dict | str) -> CopyStrategy: + if isinstance(cfg, str): + cfg = {"type": cfg} + strategy_type = cfg.get("type", "hdfs") + if strategy_type in {"bind", "local", "cp"}: + return BindCopyStrategy( + image=cfg.get("image", "alpine:3.20"), + dest_container_path=cfg.get("dest_container_path", "/dst"), + scratch_container_path=cfg.get("scratch_container_path"), + ) + if strategy_type == "hdfs": + namenode = cfg.get("namenode") + hadoop_conf_host = cfg.get("hadoop_conf_host") + if hadoop_conf_host is None and namenode is None: + hadoop_conf_host = DEFAULT_HADOOP_CONF_HOST + + return HdfsCopyStrategy( + image=cfg.get("image", "apache/hadoop:3.3.6"), + namenode=namenode, + user=cfg.get("user"), + hadoop_conf_host=hadoop_conf_host, + hadoop_conf_container=cfg.get( + "hadoop_conf_container", DEFAULT_HADOOP_CONF_CONTAINER + ), + minimal_conf_container=cfg.get( + "minimal_conf_container", MINIMAL_HADOOP_CONF_CONTAINER + ), + mount_node_hosts=cfg.get("mount_node_hosts", True), + hdfs_port=int(cfg.get("hdfs_port", DEFAULT_HDFS_PORT)), + extra_env=cfg.get("extra_env", {}), + ) + raise ValueError(f"unsupported copy strategy: {strategy_type!r}") + + +def copy_target_from_config(cfg: dict | str) -> CopyTarget: + if isinstance(cfg, str): + return CopyTarget(path=cfg) + return CopyTarget(path=cfg["path"]) diff --git a/experiments/param-opti/src/kgpipe_search/dev/docker/mounts.py b/experiments/param-opti/src/kgpipe_search/dev/docker/mounts.py new file mode 100644 index 0000000..51638aa --- /dev/null +++ b/experiments/param-opti/src/kgpipe_search/dev/docker/mounts.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from dataclasses import dataclass + +DEFAULT_SCRATCH_HOST = "/local/d1/docker-scratch" +DEFAULT_SCRATCH_CONTAINER = "/local/d1/docker-scratch" + + +@dataclass(frozen=True) +class ScratchMount: + """Bind-mount a host scratch directory into the container.""" + + host_path: str = DEFAULT_SCRATCH_HOST + container_path: str = DEFAULT_SCRATCH_CONTAINER + + def to_mount(self) -> dict[str, str]: + return { + "Type": "bind", + "Source": self.host_path, + "Target": self.container_path, + } diff --git a/experiments/param-opti/src/kgpipe_search/dev/docker/swarm.py b/experiments/param-opti/src/kgpipe_search/dev/docker/swarm.py new file mode 100644 index 0000000..61c7d11 --- /dev/null +++ b/experiments/param-opti/src/kgpipe_search/dev/docker/swarm.py @@ -0,0 +1,463 @@ +from __future__ import annotations + +import json +import threading +import time +import uuid +from dataclasses import dataclass +from typing import Any, Dict, Literal, Optional, Sequence, Tuple + +from docker import DockerClient +from docker.errors import APIError, NotFound +from docker.models.services import Service +from docker.types import RestartPolicy, ServiceMode + +from kgpipe_search.copy import CopyContext, CopyStrategy, CopyTarget +from kgpipe_search.mounts import ( + ScratchMount, +) +@dataclass(frozen=True) +class SwarmJobResult: + service_id: str + service_name: str + run_name: str + node_id: Optional[str] + state: Literal["complete", "failed", "shutdown", "rejected", "orphaned"] + exit_code: Optional[int] + logs: str + + +@dataclass(frozen=True) +class SwarmRunResult: + job: SwarmJobResult + copy: SwarmJobResult | None = None + + +ResultFormat = Literal["float", "json", "logs", "exit_code"] + + +@dataclass(frozen=True) +class ResultSpec: + """Describes what to return from a finished container job.""" + + format: ResultFormat = "float" + json_key: str | None = "result" + require_exit_code: int | None = 0 + + +def _json_lines(logs: str) -> list[Any]: + parsed: list[Any] = [] + for line in logs.splitlines(): + line = line.strip() + if not line: + continue + try: + parsed.append(json.loads(line)) + except json.JSONDecodeError: + continue + return parsed + + +def _value_from_json(obj: Any, key: str | None) -> Any: + if key is None: + return obj + if not isinstance(obj, dict): + raise ValueError(f"expected JSON object to read key {key!r}, got {type(obj).__name__}") + if key not in obj: + raise ValueError(f"JSON object has no key {key!r}") + return obj[key] + + +def extract_job_result(job: SwarmJobResult, spec: ResultSpec | None = None) -> Any: + """Extract the requested value from a finished Swarm job.""" + spec = spec or ResultSpec() + + if spec.require_exit_code is not None and job.exit_code != spec.require_exit_code: + raise RuntimeError( + f"expected exit code {spec.require_exit_code}, got {job.exit_code} " + f"(state={job.state})" + ) + + if spec.format == "exit_code": + if job.exit_code is None: + raise ValueError("exit code unavailable") + return job.exit_code + + if spec.format == "logs": + return job.logs + + if spec.format == "json": + lines = _json_lines(job.logs) + if not lines: + raise ValueError("no JSON found in logs") + return _value_from_json(lines[-1], spec.json_key) + + if spec.format == "float": + return float(_parse_scalar_from_logs(job.logs, key=spec.json_key)) + + raise ValueError(f"unsupported result format: {spec.format!r}") + + +def _parse_scalar_from_logs(logs: str, *, key: str | None = "result") -> float | int | str: + stripped = logs.strip() + if not stripped: + raise ValueError("empty logs") + + for obj in reversed(_json_lines(stripped)): + value = _value_from_json(obj, key) + if isinstance(value, (int, float)): + return value + if isinstance(value, str): + try: + return float(value) + except ValueError as e: + raise ValueError(f"JSON key {key!r} is not numeric: {value!r}") from e + raise ValueError(f"JSON key {key!r} is not numeric: {value!r}") + + return float(stripped.split()[-1]) + + +def parse_job_result(logs: str) -> float: + """Parse a float result from container stdout logs.""" + return float(_parse_scalar_from_logs(logs)) + + +class SwarmManager: + """ + Minimal Swarm "job runner" that launches one-shot services. + + Key feature: enforce a *per-node* cap by pinning each job to a node that has + fewer than `max_per_node` active tasks with our label. + """ + + DEFAULT_APP_LABEL_KEY = "kgpipe.job" + + def __init__(self, *, app_label_key: str = DEFAULT_APP_LABEL_KEY, app_label_value: str = "1"): + self.client = DockerClient.from_env() + self._label_key = app_label_key + self._label_value = app_label_value + self._schedule_lock = threading.Lock() + + def active_node_ids(self) -> list[str]: + node_ids: list[str] = [] + for node in self._nodes(): + node_id = node.get("ID") + if not node_id: + continue + + status_state = (((node.get("Status") or {}).get("State")) or "").lower() + availability = (((node.get("Spec") or {}).get("Availability")) or "").lower() + if status_state != "ready": + continue + if availability and availability != "active": + continue + + node_ids.append(node_id) + return node_ids + + def _nodes(self) -> Sequence[dict]: + return self.client.api.nodes() + + def _active_task_counts_by_node(self) -> Dict[str, int]: + """ + Count active tasks for our app label, grouped by NodeID. + + "Active" includes tasks that are accepted/starting/running/preparing, + i.e. still occupying a container slot. + """ + tasks = self.client.api.tasks( + filters={ + "label": [f"{self._label_key}={self._label_value}"], + "desired-state": ["running"], + } + ) + counts: Dict[str, int] = {} + for t in tasks: + node_id = t.get("NodeID") + if not node_id: + continue + st = (((t.get("Status") or {}).get("State")) or "").lower() + if st in {"new", "pending", "assigned", "accepted", "preparing", "starting", "running"}: + counts[node_id] = counts.get(node_id, 0) + 1 + return counts + + def _pick_node_with_capacity(self, *, max_per_node: int) -> Optional[str]: + nodes = self._nodes() + if not nodes: + return None + + counts = self._active_task_counts_by_node() + + eligible: list[Tuple[str, int]] = [] + for n in nodes: + node_id = n.get("ID") + if not node_id: + continue + + status_state = (((n.get("Status") or {}).get("State")) or "").lower() + availability = (((n.get("Spec") or {}).get("Availability")) or "").lower() + if status_state != "ready": + continue + if availability and availability != "active": + continue + + eligible.append((node_id, counts.get(node_id, 0))) + + if not eligible: + return None + + eligible.sort(key=lambda x: x[1]) + node_id, used = eligible[0] + if used >= max_per_node: + return None + return node_id + + def run_job( + self, + *, + image: str, + command: Optional[Sequence[str]] = None, + args: Optional[Sequence[str]] = None, + env: Optional[Dict[str, str]] = None, + parameter: Optional[str] = None, + node_id: Optional[str] = None, + max_per_node: int = 1, + timeout_s: int = 60 * 60, + poll_interval_s: float = 1.0, + cleanup: bool = True, + extra_labels: Optional[Dict[str, str]] = None, + name_prefix: str = "kgpipe-exp", + run_name: Optional[str] = None, + scratch: ScratchMount | None = None, + mounts: Optional[list[dict]] = None, + ) -> SwarmJobResult: + """ + Launch a one-shot service (1 replica), wait for completion, fetch logs. + + The `parameter` is passed via env var `KGPIPE_PARAM` by default. + When `scratch` is set, the host scratch directory is bind-mounted and + `KGPIPE_RUN_ID` is set to `run_name` (default: job id) for per-run subdirs. + """ + if max_per_node <= 0: + raise ValueError("max_per_node must be >= 1") + + job_id = uuid.uuid4().hex[:12] + run_id = run_name or job_id + service_name = f"{name_prefix}-{job_id}" + + labels = { + self._label_key: self._label_value, + "kgpipe.job_id": job_id, + } + if extra_labels: + labels.update(extra_labels) + + env_list: list[str] = [] + if env: + env_list.extend([f"{k}={v}" for k, v in env.items()]) + if parameter is not None: + env_list.append(f"KGPIPE_PARAM={parameter}") + if scratch is not None: + env_list.append(f"KGPIPE_RUN_ID={run_id}") + env_list.append(f"KGPIPE_SCRATCH={scratch.container_path}") + + service_mounts = list(mounts or []) + if scratch is not None: + service_mounts.append(scratch.to_mount()) + + deadline = time.time() + timeout_s + last_err: Optional[Exception] = None + + service: Optional[Service] = None + pinned_node_id: Optional[str] = None + + while time.time() < deadline and service is None: + with self._schedule_lock: + if node_id is not None: + counts = self._active_task_counts_by_node() + if counts.get(node_id, 0) >= max_per_node: + pinned_node_id = None + else: + pinned_node_id = node_id + else: + pinned_node_id = self._pick_node_with_capacity(max_per_node=max_per_node) + + if pinned_node_id is None: + pass + else: + mode = ServiceMode("replicated", replicas=1) + try: + service = self.client.services.create( + image=image, + command=list(command) if command else None, + args=list(args) if args else None, + env=env_list or None, + mounts=service_mounts or None, + name=service_name, + mode=mode, + labels=labels, + restart_policy=RestartPolicy(condition="none"), + constraints=[f"node.id=={pinned_node_id}"], + container_labels=labels, + ) + except APIError as e: + last_err = e + service = None + pinned_node_id = None + + if service is None: + time.sleep(poll_interval_s) + + if service is None: + raise RuntimeError("Unable to schedule job before timeout") from last_err + + try: + result = self._wait_service_done( + service, + timeout_s=max(1, int(deadline - time.time())), + poll_interval_s=poll_interval_s, + ) + finally: + if cleanup: + try: + service.remove() + except Exception: + pass + + return SwarmJobResult( + service_id=service.id, + service_name=service_name, + run_name=run_id, + node_id=pinned_node_id, + state=result["state"], + exit_code=result.get("exit_code"), + logs=result.get("logs", ""), + ) + + def copy_results( + self, + job: SwarmJobResult, + *, + scratch: ScratchMount, + strategy: CopyStrategy, + destination: CopyTarget, + max_per_node: int = 1, + timeout_s: int = 60 * 60, + poll_interval_s: float = 1.0, + cleanup: bool = True, + name_prefix: str = "kgpipe-copy", + ) -> SwarmJobResult: + """ + Launch a copy service on the same node as `job`, moving scratch outputs + to `destination` using the given copy strategy (e.g. HDFS). + """ + if job.node_id is None: + raise ValueError("cannot copy results: source job has no node_id") + if job.state != "complete" or job.exit_code != 0: + raise RuntimeError( + f"cannot copy results from unsuccessful job " + f"(state={job.state}, exit_code={job.exit_code})" + ) + + plan = strategy.build_job( + context=CopyContext( + run_name=job.run_name, + node_id=job.node_id, + scratch=scratch, + ), + destination=destination, + ) + + return self.run_job( + image=plan.image, + command=list(plan.command), + env=plan.env, + mounts=plan.mounts, + node_id=job.node_id, + run_name=job.run_name, + max_per_node=max_per_node, + timeout_s=timeout_s, + poll_interval_s=poll_interval_s, + cleanup=cleanup, + name_prefix=name_prefix, + ) + + def run_job_with_copy( + self, + *, + copy_strategy: CopyStrategy, + copy_destination: CopyTarget, + scratch: ScratchMount, + copy_on_success: bool = True, + **job_kwargs: Any, + ) -> SwarmRunResult: + """Run an experiment job and optionally copy its scratch outputs afterward.""" + job = self.run_job(scratch=scratch, **job_kwargs) + if not copy_on_success: + return SwarmRunResult(job=job) + + if job.state != "complete" or job.exit_code != 0: + return SwarmRunResult(job=job) + + copy_job = self.copy_results( + job, + scratch=scratch, + strategy=copy_strategy, + destination=copy_destination, + max_per_node=job_kwargs.get("max_per_node", 1), + timeout_s=job_kwargs.get("timeout_s", 60 * 60), + poll_interval_s=job_kwargs.get("poll_interval_s", 1.0), + cleanup=job_kwargs.get("cleanup", True), + ) + return SwarmRunResult(job=job, copy=copy_job) + + def _wait_service_done( + self, service: Service, *, timeout_s: int, poll_interval_s: float + ) -> Dict[str, Any]: + deadline = time.time() + timeout_s + + def collect_logs() -> str: + try: + raw = service.logs(stdout=True, stderr=True) + if raw is None: + return "" + if isinstance(raw, (bytes, bytearray)): + return bytes(raw).decode("utf-8", errors="replace") + chunks: list[bytes] = [] + for c in raw: + if isinstance(c, (bytes, bytearray)): + chunks.append(bytes(c)) + else: + chunks.append(str(c).encode("utf-8", errors="replace")) + return b"".join(chunks).decode("utf-8", errors="replace") + except Exception: + return "" + + while time.time() < deadline: + try: + tasks = service.tasks() + except (APIError, NotFound): + return {"state": "orphaned", "exit_code": None, "logs": ""} + + if not tasks: + time.sleep(poll_interval_s) + continue + + t = tasks[0] + status = (t.get("Status") or {}) + state = (status.get("State") or "").lower() + + if state in {"complete", "failed", "shutdown", "rejected"}: + exit_code = None + container_status = status.get("ContainerStatus") or {} + if "ExitCode" in container_status: + exit_code = container_status.get("ExitCode") + + logs = collect_logs() + + return {"state": state, "exit_code": exit_code, "logs": logs} + + time.sleep(poll_interval_s) + + logs = collect_logs() + return {"state": "shutdown", "exit_code": None, "logs": logs} diff --git a/experiments/param-opti/src/kgpipe_search/dev/execution.py b/experiments/param-opti/src/kgpipe_search/dev/execution.py new file mode 100644 index 0000000..4752e5b --- /dev/null +++ b/experiments/param-opti/src/kgpipe_search/dev/execution.py @@ -0,0 +1,137 @@ +# Wrapper for pipeline execution and evaluation + +from __future__ import annotations + +from typing import Any, Dict + +from kgpipe_search.copy import ( + copy_strategy_from_config, + copy_target_from_config, +) +from kgpipe_search.mounts import ( + DEFAULT_SCRATCH_CONTAINER, + DEFAULT_SCRATCH_HOST, + ScratchMount, +) +from kgpipe_search.swarm import ResultSpec, SwarmManager, extract_job_result + +type KG = str +type Source = str + +type ConfigSpace = Dict[str, Any] +type Config = Dict[str, Any] + +type EvaluationResult = float + +_swarm = SwarmManager() + + +def _result_spec_from_config(config: Config) -> ResultSpec: + require_exit_code = config.get("require_exit_code", 0) + if require_exit_code == "any": + require_exit_code = None + + return ResultSpec( + format=config.get("result_format", "float"), + json_key=config.get("result_key", "result"), + require_exit_code=require_exit_code, + ) + + +def _scratch_from_config(config: Config) -> ScratchMount | None: + scratch = config.get("scratch") + if scratch is None: + return None + if isinstance(scratch, ScratchMount): + return scratch + if isinstance(scratch, str): + return ScratchMount(host_path=scratch) + if isinstance(scratch, dict): + return ScratchMount( + host_path=scratch.get("host_path", DEFAULT_SCRATCH_HOST), + container_path=scratch.get("container_path", DEFAULT_SCRATCH_CONTAINER), + ) + raise ValueError(f"invalid scratch config: {scratch!r}") + + +def execute_pipeline(kg: KG, source: Source, config: Config) -> float: + pass + + +def execute_pipeline_docker(kg: KG, source: Source, config: Config) -> float: + pass + + +def execute_pipeline_docker_swarm(kg: KG, source: Source, config: Config) -> Any: + """ + Execute a single experiment in Swarm and return a result. + + Expected `config` keys (minimal): + - image: str (required) + - parameter: str (optional) passed via `KGPIPE_PARAM` + + Result handling: + - result_format: "float" | "json" | "logs" | "exit_code" (default "float") + - result_key: JSON field to read for "float"/"json" (default "result") + - require_exit_code: expected exit code (default 0); use "any" to skip check + + Scratch (optional): + - scratch: host path str, or dict with host_path/container_path + - run_name: per-run subdirectory under scratch (default: auto job id) + + Copy (optional, requires scratch): + - copy: destination path str, or dict with strategy/destination + HDFS strategy supports either namenode+user or hadoop_conf_host + + Float format contract: + - Container prints JSON with a numeric `result` field, or a bare float + as the last token in logs. + """ + scratch = _scratch_from_config(config) + copy_cfg = config.get("copy") + job_kwargs = { + "image": config["image"], + "command": config.get("command"), + "args": config.get("args"), + "env": config.get("env"), + "parameter": config.get("parameter"), + "max_per_node": int(config.get("max_per_node", 1)), + "timeout_s": int(config.get("timeout_s", 60 * 60)), + "extra_labels": {"kgpipe.kg": str(kg), "kgpipe.source": str(source)}, + "run_name": config.get("run_name"), + "scratch": scratch, + "mounts": config.get("mounts"), + } + + if copy_cfg is not None: + if scratch is None: + raise ValueError("copy requires scratch to be configured") + if isinstance(copy_cfg, str): + strategy = copy_strategy_from_config({"type": "hdfs"}) + destination = copy_target_from_config(copy_cfg) + else: + strategy = copy_strategy_from_config(copy_cfg.get("strategy", {"type": "hdfs"})) + destination = copy_target_from_config( + copy_cfg.get("destination", copy_cfg.get("path")) + ) + run = _swarm.run_job_with_copy( + copy_strategy=strategy, + copy_destination=destination, + scratch=scratch, + **job_kwargs, + ) + res = run.job + if run.copy is not None and run.copy.exit_code not in (0, None): + raise RuntimeError( + f"Swarm copy stage failed (state={run.copy.state}, exit_code={run.copy.exit_code})" + ) + else: + res = _swarm.run_job(**job_kwargs) + + try: + return extract_job_result(res, _result_spec_from_config(config)) + except (ValueError, RuntimeError) as e: + raise RuntimeError( + f"Swarm job result extraction failed " + f"(state={res.state}, exit_code={res.exit_code})" + ) from e diff --git a/experiments/param-opti/src/param_opti/tasks/__init__.py b/experiments/param-opti/src/kgpipe_search/dev/tasks/__init__.py similarity index 100% rename from experiments/param-opti/src/param_opti/tasks/__init__.py rename to experiments/param-opti/src/kgpipe_search/dev/tasks/__init__.py diff --git a/experiments/param-opti/src/param_opti/tasks/agreementmaker.py b/experiments/param-opti/src/kgpipe_search/dev/tasks/agreementmaker.py similarity index 100% rename from experiments/param-opti/src/param_opti/tasks/agreementmaker.py rename to experiments/param-opti/src/kgpipe_search/dev/tasks/agreementmaker.py diff --git a/experiments/param-opti/src/param_opti/tasks/base_linker.py b/experiments/param-opti/src/kgpipe_search/dev/tasks/base_linker.py similarity index 100% rename from experiments/param-opti/src/param_opti/tasks/base_linker.py rename to experiments/param-opti/src/kgpipe_search/dev/tasks/base_linker.py diff --git a/experiments/param-opti/src/param_opti/tasks/base_linker_lib.py b/experiments/param-opti/src/kgpipe_search/dev/tasks/base_linker_lib.py similarity index 100% rename from experiments/param-opti/src/param_opti/tasks/base_linker_lib.py rename to experiments/param-opti/src/kgpipe_search/dev/tasks/base_linker_lib.py diff --git a/experiments/param-opti/src/param_opti/tasks/base_matcher.py b/experiments/param-opti/src/kgpipe_search/dev/tasks/base_matcher.py similarity index 100% rename from experiments/param-opti/src/param_opti/tasks/base_matcher.py rename to experiments/param-opti/src/kgpipe_search/dev/tasks/base_matcher.py diff --git a/experiments/param-opti/src/param_opti/tasks/base_matcher_lib.py b/experiments/param-opti/src/kgpipe_search/dev/tasks/base_matcher_lib.py similarity index 100% rename from experiments/param-opti/src/param_opti/tasks/base_matcher_lib.py rename to experiments/param-opti/src/kgpipe_search/dev/tasks/base_matcher_lib.py diff --git a/experiments/param-opti/src/param_opti/tasks/corenlp.py b/experiments/param-opti/src/kgpipe_search/dev/tasks/corenlp.py similarity index 100% rename from experiments/param-opti/src/param_opti/tasks/corenlp.py rename to experiments/param-opti/src/kgpipe_search/dev/tasks/corenlp.py diff --git a/experiments/param-opti/src/param_opti/tasks/corenlp_lip.py b/experiments/param-opti/src/kgpipe_search/dev/tasks/corenlp_lip.py similarity index 100% rename from experiments/param-opti/src/param_opti/tasks/corenlp_lip.py rename to experiments/param-opti/src/kgpipe_search/dev/tasks/corenlp_lip.py diff --git a/experiments/param-opti/src/param_opti/tasks/formats.py b/experiments/param-opti/src/kgpipe_search/dev/tasks/formats.py similarity index 100% rename from experiments/param-opti/src/param_opti/tasks/formats.py rename to experiments/param-opti/src/kgpipe_search/dev/tasks/formats.py diff --git a/experiments/param-opti/src/param_opti/tasks/fusion.py b/experiments/param-opti/src/kgpipe_search/dev/tasks/fusion.py similarity index 91% rename from experiments/param-opti/src/param_opti/tasks/fusion.py rename to experiments/param-opti/src/kgpipe_search/dev/tasks/fusion.py index 0c6eec3..21a37ad 100644 --- a/experiments/param-opti/src/param_opti/tasks/fusion.py +++ b/experiments/param-opti/src/kgpipe_search/dev/tasks/fusion.py @@ -3,6 +3,7 @@ from kgpipe.common.model.configuration import ConfigurationProfile from kgpipe.common.model.configuration import ConfigurationDefinition, Parameter, ParameterType from kgpipe.common.models import TaskInput, TaskOutput, KgTask, DataFormat +from kgpipe.common import Registry def fusion_first_value_function( inputs: TaskInput, outputs: TaskOutput, config: ConfigurationProfile | None = None @@ -35,6 +36,7 @@ def fusion_first_value_function( # ] # ) ) +Registry.add_task(fusion_first_value_task.name, fusion_first_value_task) def fusion_union_function(inputs: TaskInput, outputs: TaskOutput): # touch output file @@ -45,4 +47,5 @@ def fusion_union_function(inputs: TaskInput, outputs: TaskOutput): function=fusion_union_function, input_spec={"source": DataFormat.RDF_NTRIPLES, "target": DataFormat.RDF_NTRIPLES, "matches": DataFormat.ER_JSON}, output_spec={"output": DataFormat.RDF_NTRIPLES}, -) \ No newline at end of file +) +Registry.add_task(fusion_union_task.name, fusion_union_task) \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/tasks/fusion_lib.py b/experiments/param-opti/src/kgpipe_search/dev/tasks/fusion_lib.py similarity index 100% rename from experiments/param-opti/src/param_opti/tasks/fusion_lib.py rename to experiments/param-opti/src/kgpipe_search/dev/tasks/fusion_lib.py diff --git a/experiments/param-opti/src/param_opti/tasks/genie.py b/experiments/param-opti/src/kgpipe_search/dev/tasks/genie.py similarity index 100% rename from experiments/param-opti/src/param_opti/tasks/genie.py rename to experiments/param-opti/src/kgpipe_search/dev/tasks/genie.py diff --git a/experiments/param-opti/src/param_opti/tasks/genie_lib.py b/experiments/param-opti/src/kgpipe_search/dev/tasks/genie_lib.py similarity index 100% rename from experiments/param-opti/src/param_opti/tasks/genie_lib.py rename to experiments/param-opti/src/kgpipe_search/dev/tasks/genie_lib.py diff --git a/experiments/param-opti/src/param_opti/tasks/jedai.py b/experiments/param-opti/src/kgpipe_search/dev/tasks/jedai.py similarity index 100% rename from experiments/param-opti/src/param_opti/tasks/jedai.py rename to experiments/param-opti/src/kgpipe_search/dev/tasks/jedai.py diff --git a/experiments/param-opti/src/kgpipe_search/dev/tasks/llm_extract_lib.py b/experiments/param-opti/src/kgpipe_search/dev/tasks/llm_extract_lib.py new file mode 100644 index 0000000..eac369e --- /dev/null +++ b/experiments/param-opti/src/kgpipe_search/dev/tasks/llm_extract_lib.py @@ -0,0 +1,18 @@ +from transformers import AutoTokenizer, AutoModelForSeq2SeqLM + +def test_llm_extract(): + # Load the Flan-T5 Large checkpoint (780M parameters) + tokenizer = AutoTokenizer.from_pretrained("google/flan-t5-large") + model = AutoModelForSeq2SeqLM.from_pretrained("google/flan-t5-large") + + prompt = """Extract the organizations and locations from the following text: + "Sarah flew from Berlin to Leipzig to attend a workshop at the university." """ + + # Encode the prompt and generate extraction + inputs = tokenizer(prompt, return_tensors="pt") + outputs = model.generate(**inputs, max_length=50) + + # Decode the output + extracted_info = tokenizer.decode(outputs[0], skip_special_tokens=True) + print(extracted_info) + diff --git a/experiments/param-opti/src/kgpipe_search/dev/tasks/llm_mapping_lib.py b/experiments/param-opti/src/kgpipe_search/dev/tasks/llm_mapping_lib.py new file mode 100644 index 0000000..e69de29 diff --git a/experiments/param-opti/src/param_opti/tasks/matching_helpers.py b/experiments/param-opti/src/kgpipe_search/dev/tasks/matching_helpers.py similarity index 100% rename from experiments/param-opti/src/param_opti/tasks/matching_helpers.py rename to experiments/param-opti/src/kgpipe_search/dev/tasks/matching_helpers.py diff --git a/experiments/param-opti/src/param_opti/tasks/paris.py b/experiments/param-opti/src/kgpipe_search/dev/tasks/paris.py similarity index 96% rename from experiments/param-opti/src/param_opti/tasks/paris.py rename to experiments/param-opti/src/kgpipe_search/dev/tasks/paris.py index 85e2b27..04e9eba 100644 --- a/experiments/param-opti/src/param_opti/tasks/paris.py +++ b/experiments/param-opti/src/kgpipe_search/dev/tasks/paris.py @@ -1,4 +1,4 @@ -from kgpipe.common import TaskInput, TaskOutput, KgTask, DataFormat, Data +from kgpipe.common import TaskInput, TaskOutput, KgTask, DataFormat, Data, Registry from kgpipe.common.model.configuration import ConfigurationProfile, ConfigurationDefinition, Parameter, ParameterType from pathlib import Path @@ -72,18 +72,19 @@ def paris_graph_alignment_function(inputs: TaskInput, outputs: TaskOutput, confi ) paris_graph_alignment_task = KgTask( - name="paris_graph_alignment", + name="paris_graph_alignment_task", function=paris_graph_alignment_function, input_spec={"source": DataFormat.RDF_NTRIPLES, "target": DataFormat.RDF_NTRIPLES}, output_spec={"output": DataFormat.ER_JSON}, config_spec=ConfigurationDefinition( - name="paris_graph_alignment", + name="paris_graph_alignment_task", parameters=[ Parameter(name="entity_matching_threshold", native_keys=["--entity-matching-threshold"], datatype=ParameterType.number, default_value=0.5, required=True, allowed_values=[0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]), Parameter(name="relation_matching_threshold", native_keys=["--relation-matching-threshold"], datatype=ParameterType.number, default_value=0.5, required=True, allowed_values=[0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]), ] ) ) +Registry.add_task(paris_graph_alignment_task.name, paris_graph_alignment_task) def paris_ontology_matching_function(inputs: TaskInput, outputs: TaskOutput, config: ConfigurationProfile): """ diff --git a/experiments/param-opti/src/param_opti/tasks/paris_lib.py b/experiments/param-opti/src/kgpipe_search/dev/tasks/paris_lib.py similarity index 100% rename from experiments/param-opti/src/param_opti/tasks/paris_lib.py rename to experiments/param-opti/src/kgpipe_search/dev/tasks/paris_lib.py diff --git a/experiments/param-opti/src/param_opti/tasks/select_lib.py b/experiments/param-opti/src/kgpipe_search/dev/tasks/select_lib.py similarity index 100% rename from experiments/param-opti/src/param_opti/tasks/select_lib.py rename to experiments/param-opti/src/kgpipe_search/dev/tasks/select_lib.py diff --git a/experiments/param-opti/src/param_opti/tasks/spotlight.py b/experiments/param-opti/src/kgpipe_search/dev/tasks/spotlight.py similarity index 100% rename from experiments/param-opti/src/param_opti/tasks/spotlight.py rename to experiments/param-opti/src/kgpipe_search/dev/tasks/spotlight.py diff --git a/experiments/param-opti/src/param_opti/tasks/spotlight_lib.py b/experiments/param-opti/src/kgpipe_search/dev/tasks/spotlight_lib.py similarity index 100% rename from experiments/param-opti/src/param_opti/tasks/spotlight_lib.py rename to experiments/param-opti/src/kgpipe_search/dev/tasks/spotlight_lib.py diff --git a/experiments/param-opti/src/param_opti/tasks/text_helpers.py b/experiments/param-opti/src/kgpipe_search/dev/tasks/text_helpers.py similarity index 100% rename from experiments/param-opti/src/param_opti/tasks/text_helpers.py rename to experiments/param-opti/src/kgpipe_search/dev/tasks/text_helpers.py diff --git a/experiments/param-opti/src/kgpipe_search/estimate.py b/experiments/param-opti/src/kgpipe_search/estimate.py index 47a46d0..04727d6 100644 --- a/experiments/param-opti/src/kgpipe_search/estimate.py +++ b/experiments/param-opti/src/kgpipe_search/estimate.py @@ -1,8 +1,11 @@ +from typing import Tuple +import numpy as np -def wilson_score_interval(): - pass +def wilson_interval(p: float, n: int) -> Tuple[float, float]: + z = 1.96 + return p ± np.sqrt(p * (1 - p) / n) * z -def wallen_score_interval(): - pass \ No newline at end of file +def wald_interval(p: float, n: int) -> Tuple[float, float]: + return p ± np.sqrt(p * (1 - p) / n) * z \ No newline at end of file diff --git a/experiments/param-opti/src/kgpipe_search/evaluation.py b/experiments/param-opti/src/kgpipe_search/evaluation.py index e69de29..61e3d41 100644 --- a/experiments/param-opti/src/kgpipe_search/evaluation.py +++ b/experiments/param-opti/src/kgpipe_search/evaluation.py @@ -0,0 +1,94 @@ +from kgpipe_eval.evaluator import Evaluator +from kgpipe_eval.utils.kg_utils import KgLike, KgManager +from kgpipe_eval.utils.score_utils import aggregate_scores_from_json, aggregate_scores_from_results +from kgpipe_search.definitions import PipelineConfig + +aggregation_config = { + "subgroups": { + "coverage": { + "measurements": [ + {"metric": "EntityAlignmentMetric", "measurement": "recall"}, + {"metric": "TripleAlignmentMetric", "measurement": "recall"} + ], + "aggregation": "mean" + }, + "correctness": { + "measurements": [ + "EntityAlignmentMetric.precision", + "TripleAlignmentMetric.precision" + ], + "aggregation": "mean" + }, + # "consistency": { + # "measurements": [ + # {"metric": "ConsistencyMetric", "measurement": "consistency_score"} + # ], + # "aggregation": "mean" + # }, + # "cleanliness": { + # "measurements": [ + # {"metric": "DuplicateMetric", "measurement": "duplicates_ratio", "transform": "invert"} + # ], + # "aggregation": "mean" + # } + }, + "final": { + "aggregation": "weighted_mean", + "weights": { + "coverage": 0.5, + "correctness": 0.5, + # "cleanliness": 0.2 + } + } +} + +def test_aggregate_results(): + result = aggregate_scores_from_json('data/eval_results.json', aggregation_config) + print(f'Final score: {result.final_score:.6f}') + for name, sg in result.subgroups.items(): + print(f' {name}: {sg.score:.6f}') + for m in sg.measurements: + print(f' {m.metric}.{m.measurement} = {m.value:.6f}') + +def evaluate_pipeline(pipeline_config: PipelineConfig, result_kg: KgLike, reference_kg: KgLike): + from kgpipe_eval.metrics.triple_alignment import TripleAlignmentMetric, TripleAlignmentConfig + from kgpipe_eval.metrics.entity_alignment import EntityAlignmentMetric, EntityAlignmentConfig + + entity_alignment_config = EntityAlignmentConfig( + method="label_embedding", + reference_kg=reference_kg, + verified_entities_path=None, + verified_entities_delimiter="\t", + entity_sim_threshold=0.95 + ) + + triple_alignment_config = TripleAlignmentConfig( + reference_kg=reference_kg, + entity_alignment_config=entity_alignment_config, + value_sim_threshold=0.5, + cache_literal_embeddings=True + ) + + result_graph = KgManager.load_kg(result_kg) + try: + results = Evaluator().run(result_graph, [TripleAlignmentMetric(), EntityAlignmentMetric()], { + "TripleAlignmentMetric": triple_alignment_config, + "EntityAlignmentMetric": entity_alignment_config + }) + finally: + KgManager.unload_kg(result_graph) + + return aggregate_scores_from_results(results, aggregation_config) + + +import random + +def dummy_evaluate_pipeline(pipeline_config: PipelineConfig, result_kg: KgLike, reference_kg: KgLike): + return random.uniform(0.5, 1.0) # 0.5 to 1.0 + +def _execute_pipeline(pipeline_config: PipelineConfig): + pass + +def execute_and_dummy_evaluate_pipeline(pipeline_config: PipelineConfig): + result = _execute_pipeline(pipeline_config) + return dummy_evaluate_pipeline(pipeline_config, None, None) \ No newline at end of file diff --git a/experiments/param-opti/src/kgpipe_search/sample.py b/experiments/param-opti/src/kgpipe_search/sample.py index e69de29..c0628d1 100644 --- a/experiments/param-opti/src/kgpipe_search/sample.py +++ b/experiments/param-opti/src/kgpipe_search/sample.py @@ -0,0 +1,17 @@ + +class TripleSampleIterator: + + def __init__(self): + + def __iter__(self): + return self + + def __next__(self) -> List[Tuple[str, str, str]]: + if self.budget <= 0: + raise StopIteration + self.budget -= 1 + return self.next() + + def next(self) -> List[Tuple[str, str, str]]: + return random.sample(self.config_space, self.budget) + diff --git a/experiments/param-opti/src/kgpipe_search/search.py b/experiments/param-opti/src/kgpipe_search/search.py index 1424304..9e1e618 100644 --- a/experiments/param-opti/src/kgpipe_search/search.py +++ b/experiments/param-opti/src/kgpipe_search/search.py @@ -1,8 +1,457 @@ +from __future__ import annotations +import math +import random +from dataclasses import dataclass +from typing import Any, Callable, Dict, List, Literal, Optional, Set, Tuple +from kgpipe.common.model.configuration import ConfigurationProfile, ParameterBinding +from kgpipe_search.configuration import ( + build_pipeline_config_for_task_combo, + enumerate_valid_task_combinations, + pipeline_config_snapshot_key, + sample_valid_pipeline_config, + task_keys_from_pipeline_config, +) +from kgpipe_search.definitions import PipelineConfig, PipelineLayout -def neighborhood_optimization(): - pass +Observation = Tuple[float, PipelineConfig] +EvaluateFn = Callable[[PipelineConfig], float] +SearchStrategy = Literal["random", "neighborhood", "bayesian"] -def bayesian_optimization(): - pass \ No newline at end of file + +@dataclass +class SearchRun: + strategy: SearchStrategy + history: List[Observation] + budget: int + decisions: List[str] + + +def _top_k(history: List[Observation], k: int) -> List[Observation]: + ranked = sorted(history, key=lambda item: item[0], reverse=True) + return ranked[: max(1, min(k, len(ranked)))] + + +def _parameter_neighbors( + anchor: PipelineConfig, + search_space: Dict[str, Dict[str, Any]], +) -> List[PipelineConfig]: + anchor_keys = task_keys_from_pipeline_config(anchor) + neighbors: List[PipelineConfig] = [] + + for task, task_key in zip(anchor.tasks, anchor_keys): + profile = anchor.config_catalog.get(task.name) + if profile is None: + continue + + for binding in profile.bindings: + param_name = binding.parameter.name + domain = search_space.get(task_key, {}).get(param_name) + if not isinstance(domain, list): + continue + + for value in domain: + if value == binding.value: + continue + + new_catalog = dict(anchor.config_catalog) + new_bindings: List[ParameterBinding] = [] + name_parts: List[str] = [] + for current in profile.bindings: + chosen = value if current.parameter.name == param_name else current.value + new_bindings.append( + ParameterBinding(parameter=current.parameter, value=chosen) + ) + name_parts.append(f"{current.parameter.name}={chosen}") + + new_catalog[task.name] = ConfigurationProfile( + name=f"{task.name}_" + ",".join(name_parts), + definition=profile.definition, + bindings=new_bindings, + ) + neighbors.append( + PipelineConfig(tasks=list(anchor.tasks), config_catalog=new_catalog) + ) + + return neighbors + + +def _implementation_neighbors( + anchor: PipelineConfig, + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, + rng: random.Random, +) -> List[PipelineConfig]: + anchor_keys = task_keys_from_pipeline_config(anchor) + neighbors: List[PipelineConfig] = [] + + for combo in enumerate_valid_task_combinations(search_space, pipeline_layout): + if len(combo) != len(anchor_keys): + continue + if sum(left != right for left, right in zip(anchor_keys, combo)) != 1: + continue + neighbors.append( + build_pipeline_config_for_task_combo( + search_space, + combo, + rng=rng, + template=anchor, + ) + ) + + return neighbors + + +def neighbors_at_distance_one( + anchor: PipelineConfig, + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, + rng: random.Random, +) -> List[PipelineConfig]: + seen: Set[str] = set() + neighbors: List[PipelineConfig] = [] + + for candidate in ( + _parameter_neighbors(anchor, search_space) + + _implementation_neighbors(anchor, search_space, pipeline_layout, rng) + ): + key = pipeline_config_snapshot_key(candidate, search_space) + if key in seen: + continue + seen.add(key) + neighbors.append(candidate) + + return neighbors + + +def sample_unevaluated_config( + rng: random.Random, + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, + evaluated_keys: Set[str], + *, + max_attempts: int = 500, +) -> PipelineConfig: + for _ in range(max_attempts): + candidate = sample_valid_pipeline_config( + search_space, + pipeline_layout, + rng=rng, + ) + key = pipeline_config_snapshot_key(candidate, search_space) + if key not in evaluated_keys: + return candidate + + raise RuntimeError("Failed to sample an unevaluated configuration") + + +def select_next_random_config( + rng: random.Random, + history: List[Observation], + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, + evaluated_keys: Set[str], +) -> PipelineConfig: + del history + return sample_unevaluated_config( + rng, + search_space, + pipeline_layout, + evaluated_keys, + ) + + +def select_next_neighborhood_config( + rng: random.Random, + history: List[Observation], + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, + evaluated_keys: Set[str], + *, + k: int = 3, + rho: float = 0.2, +) -> Tuple[PipelineConfig, str]: + if not history or rng.random() < rho: + return ( + sample_unevaluated_config( + rng, + search_space, + pipeline_layout, + evaluated_keys, + ), + "explore", + ) + + anchors = _top_k(history, k) + anchor_score, anchor_config = rng.choice(anchors) + neighborhood = neighbors_at_distance_one( + anchor_config, + search_space, + pipeline_layout, + rng, + ) + + unevaluated = [ + candidate + for candidate in neighborhood + if pipeline_config_snapshot_key(candidate, search_space) not in evaluated_keys + ] + if unevaluated: + return rng.choice(unevaluated), f"neighborhood(anchor_score={anchor_score:.4f})" + + return ( + sample_unevaluated_config( + rng, + search_space, + pipeline_layout, + evaluated_keys, + ), + "explore(fallback)", + ) + + +def _config_distance( + left: PipelineConfig, + right: PipelineConfig, + search_space: Dict[str, Dict[str, Any]], +) -> float: + if pipeline_config_snapshot_key(left, search_space) == pipeline_config_snapshot_key( + right, search_space + ): + return 0.0 + + left_keys = task_keys_from_pipeline_config(left) + right_keys = task_keys_from_pipeline_config(right) + distance = float(sum(a != b for a, b in zip(left_keys, right_keys))) + if len(left_keys) != len(right_keys): + distance += abs(len(left_keys) - len(right_keys)) + + left_params = { + (task.name, binding.parameter.name): binding.value + for task in left.tasks + for binding in (left.config_catalog.get(task.name).bindings if left.config_catalog.get(task.name) else []) + } + right_params = { + (task.name, binding.parameter.name): binding.value + for task in right.tasks + for binding in (right.config_catalog.get(task.name).bindings if right.config_catalog.get(task.name) else []) + } + + all_param_keys = set(left_params) | set(right_params) + for key in all_param_keys: + if left_params.get(key) != right_params.get(key): + distance += 1.0 + + return distance + + +def _predict_with_uncertainty( + candidate: PipelineConfig, + history: List[Observation], + search_space: Dict[str, Dict[str, Any]], +) -> Tuple[float, float]: + weights: List[float] = [] + scores: List[float] = [] + + for score, observed in history: + distance = _config_distance(candidate, observed, search_space) + if distance == 0.0: + return score, 0.0 + weights.append(math.exp(-distance)) + scores.append(score) + + if not weights: + return 0.75, 1.0 + + total_weight = sum(weights) + mean = sum(score * weight for score, weight in zip(scores, weights)) / total_weight + uncertainty = 1.0 / (1.0 + total_weight) + return mean, uncertainty + + +def _acquisition(mean: float, uncertainty: float, *, beta: float = 0.5) -> float: + return mean + beta * uncertainty + + +def select_next_bayesian_config( + rng: random.Random, + history: List[Observation], + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, + evaluated_keys: Set[str], + *, + init_random: int = 3, + pool_size: int = 32, + beta: float = 0.5, +) -> Tuple[PipelineConfig, str]: + if len(history) < init_random: + return ( + sample_unevaluated_config( + rng, + search_space, + pipeline_layout, + evaluated_keys, + ), + "init_random", + ) + + candidates: List[PipelineConfig] = [] + for _ in range(pool_size): + candidates.append( + sample_unevaluated_config( + rng, + search_space, + pipeline_layout, + evaluated_keys, + ) + ) + + best_candidate = candidates[0] + best_acquisition = float("-inf") + best_prediction = 0.0 + best_uncertainty = 0.0 + + for candidate in candidates: + mean, uncertainty = _predict_with_uncertainty(candidate, history, search_space) + score = _acquisition(mean, uncertainty, beta=beta) + if score > best_acquisition: + best_acquisition = score + best_candidate = candidate + best_prediction = mean + best_uncertainty = uncertainty + + return ( + best_candidate, + f"acquisition(pred={best_prediction:.4f},unc={best_uncertainty:.4f},a={best_acquisition:.4f})", + ) + + +def run_search( + strategy: SearchStrategy, + budget: int, + evaluate_fn: EvaluateFn, + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, + *, + rng: Optional[random.Random] = None, + k: int = 3, + rho: float = 0.2, + init_random: int = 3, + pool_size: int = 32, + beta: float = 0.5, +) -> SearchRun: + draw = rng or random.Random() + history: List[Observation] = [] + evaluated_keys: Set[str] = set() + decisions: List[str] = [] + + for _ in range(budget): + if strategy == "random": + candidate = select_next_random_config( + draw, + history, + search_space, + pipeline_layout, + evaluated_keys, + ) + decision = "sample" + elif strategy == "neighborhood": + candidate, decision = select_next_neighborhood_config( + draw, + history, + search_space, + pipeline_layout, + evaluated_keys, + k=k, + rho=rho, + ) + elif strategy == "bayesian": + candidate, decision = select_next_bayesian_config( + draw, + history, + search_space, + pipeline_layout, + evaluated_keys, + init_random=init_random, + pool_size=pool_size, + beta=beta, + ) + else: + raise ValueError(f"Unknown search strategy: {strategy!r}") + + key = pipeline_config_snapshot_key(candidate, search_space) + score = evaluate_fn(candidate) + history.append((score, candidate)) + evaluated_keys.add(key) + decisions.append(decision) + + return SearchRun( + strategy=strategy, + history=history, + budget=budget, + decisions=decisions, + ) + + +def random_search( + budget: int, + evaluate_fn: EvaluateFn, + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, + **kwargs: Any, +) -> SearchRun: + return run_search( + "random", + budget, + evaluate_fn, + search_space, + pipeline_layout, + **kwargs, + ) + + +def neighborhood_optimization( + budget: int, + evaluate_fn: EvaluateFn, + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, + *, + k: int = 3, + rho: float = 0.2, + **kwargs: Any, +) -> SearchRun: + return run_search( + "neighborhood", + budget, + evaluate_fn, + search_space, + pipeline_layout, + k=k, + rho=rho, + **kwargs, + ) + + +def bayesian_optimization( + budget: int, + evaluate_fn: EvaluateFn, + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, + *, + init_random: int = 3, + pool_size: int = 32, + beta: float = 0.5, + **kwargs: Any, +) -> SearchRun: + return run_search( + "bayesian", + budget, + evaluate_fn, + search_space, + pipeline_layout, + init_random=init_random, + pool_size=pool_size, + beta=beta, + **kwargs, + ) diff --git a/experiments/param-opti/src/kgpipe_search/test/__init__.py b/experiments/param-opti/src/kgpipe_search/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/experiments/param-opti/src/kgpipe_search/test/conftest.py b/experiments/param-opti/src/kgpipe_search/test/conftest.py new file mode 100644 index 0000000..540da02 --- /dev/null +++ b/experiments/param-opti/src/kgpipe_search/test/conftest.py @@ -0,0 +1,31 @@ +import sys +import types +from importlib import import_module + + +def _install_param_opti_shim() -> None: + if "param_opti" in sys.modules: + return + + param_opti = types.ModuleType("param_opti") + tasks = types.ModuleType("param_opti.tasks") + + for lib in ( + "base_linker_lib", + "base_matcher_lib", + "paris_lib", + "fusion_lib", + "spotlight_lib", + "corenlp_lip", + "genie_lib", + ): + module = import_module(f"kgpipe_search.dev.tasks.{lib}") + setattr(tasks, lib, module) + sys.modules[f"param_opti.tasks.{lib}"] = module + + param_opti.tasks = tasks + sys.modules["param_opti"] = param_opti + sys.modules["param_opti.tasks"] = tasks + + +_install_param_opti_shim() diff --git a/experiments/param-opti/src/kgpipe_search/test/test_configuration.py b/experiments/param-opti/src/kgpipe_search/test/test_configuration.py new file mode 100644 index 0000000..58a0655 --- /dev/null +++ b/experiments/param-opti/src/kgpipe_search/test/test_configuration.py @@ -0,0 +1,188 @@ +from kgpipe_search.definitions import PipelineLayout, RDF_SEARCH_SPACE, RDF_PIPELINE_LAYOUT, TEXT_SEARCH_SPACE, TEXT_PIPELINE_LAYOUT +from kgpipe_search.configuration import ( + sample_valid_pipeline_config, + enumerate_valid_task_combinations, sample_config_catalog_for_task_combo, enumerate_exhaustive_pipeline_config_snapshots, pipeline_config_to_snapshot, + print_pipeline_config_short +) +from kgpipe_search.definitions import RDF_SAMPLED_PIPELINE_CONFIGS_FIXTURE, _RDF_PIPELINE_CONFIG_SNAPSHOT_VERSION +from kgpipe_search.definitions import RDF_EXHAUSTIVE_PIPELINE_CONFIGS_FIXTURE, _RDF_EXHAUSTIVE_PIPELINE_CONFIG_SNAPSHOT_VERSION +from kgpipe_search.definitions import TEXT_SAMPLED_PIPELINE_CONFIGS_FIXTURE, _TEXT_PIPELINE_CONFIG_SNAPSHOT_VERSION +from kgpipe_search.definitions import TEXT_EXHAUSTIVE_PIPELINE_CONFIGS_FIXTURE, _TEXT_EXHAUSTIVE_PIPELINE_CONFIG_SNAPSHOT_VERSION +import json + +def test_sample_valid_rdf_pipeline_config(): + pipeline_layout = PipelineLayout( + allowed_task_categories=["ontology_matching", "entity_matching", "aggregate_matching_results", "fusion"] + ) + pipeline_config = sample_valid_pipeline_config(RDF_SEARCH_SPACE, pipeline_layout) + print_pipeline_config_short(pipeline_config) + +def test_enumerate_all_valid_rdf_task_combinations_no_config_sampling(): + print("enumerate_all_valid_rdf_task_combinations_no_config_sampling") + combos = enumerate_valid_task_combinations(RDF_SEARCH_SPACE, RDF_PIPELINE_LAYOUT) + + for combo in combos: + print(combo) + + # With current SEARCH_SPACE: + # - ontology_matching can be satisfied by paris_ontology_matching_task, paris_entity_alignment_task, paris_graph_alignment_task + # - entity_matching can be satisfied by paris_entity_alignment_task, paris_graph_alignment_task (and may be skipped if already covered) + # - fusion must be satisfied by fusion_first_value_task + # expected = { + # ("paris_ontology_matching_task", "paris_entity_alignment_task", "fusion_first_value_task"), + # ("paris_ontology_matching_task", "paris_graph_alignment_task", "fusion_first_value_task"), + # ("paris_graph_alignment_task", "fusion_first_value_task"), + # } + + # assert set(tuple(c) for c in combos) == expected + + +import random +from typing import List, Dict, Any + +def test_enumerate_all_valid_rdf_task_combinations_with_config_sampling(): + print("enumerate_all_valid_rdf_task_combinations_with_config_sampling") + n = 1 + rng = random.Random(0) + + combos = enumerate_valid_task_combinations(RDF_SEARCH_SPACE, RDF_PIPELINE_LAYOUT) + + total_config_count = 0 + snapshots: List[Dict[str, Any]] = [] + + for combo in combos: + print() + print("combo:", combo) + for i in range(n): + total_config_count += 1 + print(f"sample {total_config_count}/{len(combos) * n}") + pipeline_config = sample_config_catalog_for_task_combo( + RDF_SEARCH_SPACE, combo, rng=rng + ) + + print_pipeline_config_short(pipeline_config) + snapshots.append(pipeline_config_to_snapshot(combo, pipeline_config)) + + RDF_SAMPLED_PIPELINE_CONFIGS_FIXTURE.parent.mkdir(parents=True, exist_ok=True) + RDF_SAMPLED_PIPELINE_CONFIGS_FIXTURE.write_text( + json.dumps( + {"version": _RDF_PIPELINE_CONFIG_SNAPSHOT_VERSION, "samples": snapshots}, + indent=2, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + + +def test_sample_valid_text_pipeline_config(): + pipeline_config = sample_valid_pipeline_config(TEXT_SEARCH_SPACE, TEXT_PIPELINE_LAYOUT) + print_pipeline_config_short(pipeline_config) + + +def test_enumerate_all_valid_text_task_combinations_no_config_sampling(): + print("enumerate_all_valid_text_task_combinations_no_config_sampling") + combos = enumerate_valid_task_combinations(TEXT_SEARCH_SPACE, TEXT_PIPELINE_LAYOUT) + for combo in combos: + print(combo) + +def test_enumerate_all_valid_text_task_combinations_with_config_sampling(): + print("enumerate_all_valid_text_task_combinations_with_config_sampling") + n = 1 + rng = random.Random(0) + + combos = enumerate_valid_task_combinations(TEXT_SEARCH_SPACE, TEXT_PIPELINE_LAYOUT) + + total_config_count = 0 + snapshots: List[Dict[str, Any]] = [] + + for combo in combos: + print() + print("combo:", combo) + for i in range(n): + total_config_count += 1 + print(f"sample {total_config_count}/{len(combos) * n}") + pipeline_config = sample_config_catalog_for_task_combo( + TEXT_SEARCH_SPACE, combo, rng=rng + ) + print_pipeline_config_short(pipeline_config) + snapshots.append(pipeline_config_to_snapshot(combo, pipeline_config)) + + TEXT_SAMPLED_PIPELINE_CONFIGS_FIXTURE.parent.mkdir(parents=True, exist_ok=True) + TEXT_SAMPLED_PIPELINE_CONFIGS_FIXTURE.write_text( + json.dumps( + {"version": _TEXT_PIPELINE_CONFIG_SNAPSHOT_VERSION, "samples": snapshots}, + indent=2, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + +def test_enumerate_all_valid_text_task_combinations_with_config_sampling_exhaustive(): + print("enumerate_all_valid_text_task_combinations_with_config_sampling_exhaustive") + all_snapshots = enumerate_exhaustive_pipeline_config_snapshots( + TEXT_SEARCH_SPACE, TEXT_PIPELINE_LAYOUT + ) + serialized = [json.dumps(s, sort_keys=True) for s in all_snapshots] + assert len(set(serialized)) == len(serialized) + + TEXT_EXHAUSTIVE_PIPELINE_CONFIGS_FIXTURE.parent.mkdir(parents=True, exist_ok=True) + TEXT_EXHAUSTIVE_PIPELINE_CONFIGS_FIXTURE.write_text( + json.dumps( + {"version": _TEXT_EXHAUSTIVE_PIPELINE_CONFIG_SNAPSHOT_VERSION, "samples": all_snapshots}, + indent=2, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + + +def test_enumerate_all_valid_rdf_task_combinations_with_config_sampling_exhaustive(): + print("enumerate_all_valid_rdf_task_combinations_with_config_sampling_exhaustive") + all_snapshots = enumerate_exhaustive_pipeline_config_snapshots( + RDF_SEARCH_SPACE, RDF_PIPELINE_LAYOUT + ) + serialized = [json.dumps(s, sort_keys=True) for s in all_snapshots] + assert len(set(serialized)) == len(serialized) + + RDF_EXHAUSTIVE_PIPELINE_CONFIGS_FIXTURE.parent.mkdir(parents=True, exist_ok=True) + RDF_EXHAUSTIVE_PIPELINE_CONFIGS_FIXTURE.write_text( + json.dumps( + {"version": _RDF_EXHAUSTIVE_PIPELINE_CONFIG_SNAPSHOT_VERSION, "samples": all_snapshots}, + indent=2, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + + + +# def test_rdf_pipeline_from_config(): +# pipeline_config = sample_valid_pipeline_config(RDF_SEARCH_SPACE, PipelineLayout(allowed_task_categories=["entity_matching", "fusion"])) + +# seed_path = tmp_base_dir / "seed.nt" +# source_path = tmp_base_dir / "source.nt" +# result_path = tmp_base_dir / "result.nt" +# tasks_tmp_dir = tmp_base_dir / "tasks_tmp" +# tasks_tmp_dir.mkdir(parents=True, exist_ok=True) + +# # Ensure inputs exist for pipeline execution. +# seed_path.write_text(" .\n") +# source_path.write_text(" .\n") + +# pipeline = KgPipe( +# tasks=pipeline_config.tasks, +# seed=Data(path=seed_path, format=DataFormat.RDF_NTRIPLES), +# data_dir=tasks_tmp_dir, +# name="test_pipeline") + +# pipeline.build( +# stable_files=True, +# configCatalog=pipeline_config.config_catalog, +# source=Data(path=source_path, format=DataFormat.RDF_NTRIPLES), +# result=Data(path=result_path, format=DataFormat.RDF_NTRIPLES)) + +# pipeline.run(configCatalog=pipeline_config.config_catalog, stable_files_override=True) \ No newline at end of file diff --git a/experiments/param-opti/src/kgpipe_search/test/test_execution.py b/experiments/param-opti/src/kgpipe_search/test/test_execution.py new file mode 100644 index 0000000..a5d1f46 --- /dev/null +++ b/experiments/param-opti/src/kgpipe_search/test/test_execution.py @@ -0,0 +1,140 @@ +import os +import random +from pathlib import Path + +import pytest + +from kgpipe.common import Data, DataFormat, KgPipe +from kgpipe_search.configuration import ( + load_pipeline_config_snapshot, + load_rdf_sampled_pipeline_configs, + pipeline_config_snapshot_key, + save_pipeline_config_snapshot, + sample_valid_pipeline_config, +) +from kgpipe_search.definitions import RDF_PIPELINE_LAYOUT, RDF_SEARCH_SPACE + +KGPIPE_ROOT = Path(__file__).resolve().parents[5] +FALLBACK_TEST_DATA = KGPIPE_ROOT / "src/kgpipe_tasks/test/test_data/rdf" + +tmp_base_dir = Path("data/tmp/rdf_pipelines") +tmp_base_dir.mkdir(parents=True, exist_ok=True) + +SEED_PATH = Path("data/input_final/target_kg/graph.nt") +SOURCE_PATH = Path("data/input_final/rdf_source/graph.nt") +ONTOLOGY_PATH = Path("data/input_final/target_kg/ontology.ttl") +FALLBACK_SEED_PATH = FALLBACK_TEST_DATA / "target.nt" +FALLBACK_SOURCE_PATH = FALLBACK_TEST_DATA / "source.nt" +FALLBACK_ONTOLOGY_PATH = FALLBACK_TEST_DATA / "ontology.ttl" + + +def _ensure_ontology_env() -> None: + if ONTOLOGY_PATH.exists(): + os.environ["ONTOLOGY_PATH"] = str(ONTOLOGY_PATH) + elif FALLBACK_ONTOLOGY_PATH.exists(): + os.environ["ONTOLOGY_PATH"] = str(FALLBACK_ONTOLOGY_PATH) + + +def _rdf_input_paths(tmp_dir: Path) -> tuple[Path, Path]: + if SEED_PATH.exists() and SOURCE_PATH.exists(): + return SEED_PATH, SOURCE_PATH + if FALLBACK_SEED_PATH.exists() and FALLBACK_SOURCE_PATH.exists(): + return FALLBACK_SEED_PATH, FALLBACK_SOURCE_PATH + + seed_path = tmp_dir / "seed.nt" + source_path = tmp_dir / "source.nt" + seed_path.write_text( + " .\n", + encoding="utf-8", + ) + source_path.write_text( + " .\n", + encoding="utf-8", + ) + return seed_path, source_path + + +def _run_rdf_pipeline_config( + pipeline_config, + *, + tmp_dir: Path, + run_name: str, + result_path: Path, +) -> Path: + _ensure_ontology_env() + seed_path, source_path = _rdf_input_paths(tmp_dir) + tasks_tmp_dir = tmp_dir / f"{run_name}_tasks_tmp" + tasks_tmp_dir.mkdir(parents=True, exist_ok=True) + + pipeline = KgPipe( + tasks=pipeline_config.tasks, + seed=Data(path=seed_path, format=DataFormat.RDF_NTRIPLES), + data_dir=tasks_tmp_dir, + name=run_name, + ) + + pipeline.build( + stable_files=True, + configCatalog=pipeline_config.config_catalog, + source=Data(path=source_path, format=DataFormat.RDF_NTRIPLES), + result=Data(path=result_path, format=DataFormat.RDF_NTRIPLES), + ) + + pipeline.run(configCatalog=pipeline_config.config_catalog, stable_files_override=True) + return result_path + + +@pytest.mark.parametrize("config_idx", range(len(load_rdf_sampled_pipeline_configs()))) +def test_rdf_pipeline_from_saved_sampled_configs(config_idx): + """Runs KGpipe using PipelineConfigs materialized from the JSON fixture.""" + configs = load_rdf_sampled_pipeline_configs() + assert configs, ( + "fixtures/rdf_sampled_pipeline_configs.json is missing or empty; " + "run test_enumerate_all_valid_rdf_task_combinations_with_config_sampling" + ) + + pipeline_config = configs[config_idx] + result_path = _run_rdf_pipeline_config( + pipeline_config, + tmp_dir=tmp_base_dir, + run_name=f"saved_sample_config_idx_{config_idx}", + ) + assert result_path.exists() + + +def test_sample_save_load_and_run_pipeline_config(tmp_path: Path): + sampled_config = sample_valid_pipeline_config( + RDF_SEARCH_SPACE, + RDF_PIPELINE_LAYOUT, + rng=random.Random(42), + ) + original_key = pipeline_config_snapshot_key(sampled_config, RDF_SEARCH_SPACE) + + snapshot_path = tmp_path / "sampled_pipeline_config.json" + save_pipeline_config_snapshot(snapshot_path, sampled_config) + assert snapshot_path.exists() + + loaded_config = load_pipeline_config_snapshot(snapshot_path) + loaded_key = pipeline_config_snapshot_key(loaded_config, RDF_SEARCH_SPACE) + assert loaded_key == original_key + + result_path = _run_rdf_pipeline_config( + loaded_config, + tmp_dir=tmp_path, + run_name="sample_save_load_run", + ) + assert result_path.exists() + + +def test_sample_and_run_pipeline_config(tmp_path: Path): + pipeline_config = sample_valid_pipeline_config( + RDF_SEARCH_SPACE, + RDF_PIPELINE_LAYOUT, + rng=random.Random(43), + ) + result_path = _run_rdf_pipeline_config( + pipeline_config, + tmp_dir=tmp_path, + run_name="sample_and_run", + ) + assert result_path.exists() diff --git a/experiments/param-opti/src/kgpipe_search/test/test_execution_docker.py b/experiments/param-opti/src/kgpipe_search/test/test_execution_docker.py new file mode 100644 index 0000000..a661d13 --- /dev/null +++ b/experiments/param-opti/src/kgpipe_search/test/test_execution_docker.py @@ -0,0 +1,476 @@ +from concurrent.futures import ThreadPoolExecutor, as_completed +import os +import uuid +from pathlib import Path + +import pytest +from docker import DockerClient + +from kgpipe_search.mounts import DEFAULT_SCRATCH_HOST, ScratchMount + + +def _swarm_available() -> tuple[bool, str]: + try: + client = DockerClient.from_env() + info = client.info() + except Exception as e: + return False, f"Docker engine not reachable: {e}" + + swarm = info.get("Swarm") or {} + state = (swarm.get("LocalNodeState") or "").lower() + if state != "active": + return False, f"Docker Swarm not active (LocalNodeState={swarm.get('LocalNodeState')!r})" + + return True, "ok" + + +_TEST_COMMAND = [ + "python", + "-c", + "import os, json, time; " + "time.sleep(30); " + "p=float(os.environ.get('KGPIPE_PARAM','0')); " + "print(json.dumps({'result': p + 1.0}))", +] + + +def test_execute_pipeline_docker_swarm_parses_result(): + ok, reason = _swarm_available() + if not ok: + pytest.skip(reason) + + from kgpipe_search.execution import execute_pipeline_docker_swarm + + # The container prints {"result": float(KGPIPE_PARAM) + 1.0} + param = "1.5" + expected = 2.5 + + result = execute_pipeline_docker_swarm( + kg="dummy", + source="dummy", + config={ + "image": "python:3.12-slim", + "command": _TEST_COMMAND, + "parameter": param, + "max_per_node": 1, + "timeout_s": 120, + }, + ) + + assert result == expected + + +def test_extract_job_result_formats(): + from kgpipe_search.swarm import ResultSpec, SwarmJobResult, extract_job_result + + job = SwarmJobResult( + service_id="svc", + service_name="svc-name", + run_name="run-1", + node_id="node-1", + state="complete", + exit_code=0, + logs='info line\n{"result": 2.5, "label": "ok"}\n', + ) + + assert extract_job_result(job, ResultSpec(format="float")) == 2.5 + assert extract_job_result(job, ResultSpec(format="json", json_key="label")) == "ok" + assert extract_job_result(job, ResultSpec(format="json", json_key=None)) == { + "result": 2.5, + "label": "ok", + } + assert extract_job_result(job, ResultSpec(format="logs")) == job.logs + assert extract_job_result(job, ResultSpec(format="exit_code")) == 0 + + failed = SwarmJobResult( + service_id="svc", + service_name="svc-name", + run_name="run-1", + node_id="node-1", + state="complete", + exit_code=7, + logs="", + ) + assert extract_job_result(failed, ResultSpec(format="exit_code", require_exit_code=None)) == 7 + + +def test_execute_pipeline_docker_swarm_exit_code_result(): + ok, reason = _swarm_available() + if not ok: + pytest.skip(reason) + + from kgpipe_search.execution import execute_pipeline_docker_swarm + + result = execute_pipeline_docker_swarm( + kg="dummy", + source="dummy", + config={ + "image": "python:3.12-slim", + "command": ["python", "-c", "import sys; sys.exit(42)"], + "result_format": "exit_code", + "require_exit_code": 42, + "max_per_node": 1, + "timeout_s": 120, + }, + ) + + assert result == 42 + + +def test_execute_pipeline_docker_swarm_json_result(): + ok, reason = _swarm_available() + if not ok: + pytest.skip(reason) + + from kgpipe_search.execution import execute_pipeline_docker_swarm + + result = execute_pipeline_docker_swarm( + kg="dummy", + source="dummy", + config={ + "image": "python:3.12-slim", + "command": [ + "python", + "-c", + "import json; print(json.dumps({'metrics': {'f1': 0.9}, 'result': 0.9}))", + ], + "result_format": "json", + "result_key": "metrics", + "max_per_node": 1, + "timeout_s": 120, + }, + ) + + assert result == {"f1": 0.9} + + +_SCRATCH_WRITE_COMMAND = [ + "python", + "-c", + "import json, os, pathlib; " + "root = pathlib.Path(os.environ['KGPIPE_SCRATCH']) / os.environ['KGPIPE_RUN_ID']; " + "root.mkdir(parents=True, exist_ok=True); " + "(root / 'done.txt').write_text('ok'); " + "print(json.dumps({'result': str(root / 'done.txt')}))", +] + +_SCRATCH_VERIFY_COMMAND = [ + "python", + "-c", + "import os, pathlib, sys; " + "p = pathlib.Path(os.environ['KGPIPE_SCRATCH']) / os.environ['KGPIPE_RUN_ID'] / 'done.txt'; " + "sys.exit(0 if p.is_file() and p.read_text() == 'ok' else 1)", +] + + +def test_swarm_scratch_mount_writes_per_run_file(): + ok, reason = _swarm_available() + if not ok: + pytest.skip(reason) + + scratch_host = Path(os.environ.get("KGPIPE_SCRATCH_HOST", DEFAULT_SCRATCH_HOST)) + if not scratch_host.is_dir(): + pytest.skip(f"scratch host path does not exist: {scratch_host}") + + from kgpipe_search.swarm import ResultSpec, SwarmManager, extract_job_result + + mgr = SwarmManager() + run_name = f"pytest-{uuid.uuid4().hex[:8]}" + scratch = ScratchMount(host_path=str(scratch_host)) + + res = mgr.run_job( + image="python:3.12-slim", + command=_SCRATCH_WRITE_COMMAND, + run_name=run_name, + scratch=scratch, + max_per_node=1, + timeout_s=120, + name_prefix="kgpipe-scratch", + ) + + assert res.state == "complete" + assert res.exit_code == 0 + assert res.run_name == run_name + assert extract_job_result(res, ResultSpec(format="json", json_key="result")).endswith("done.txt") + + out_path = scratch_host / run_name / "done.txt" + if out_path.is_file(): + assert out_path.read_text() == "ok" + return + + assert res.node_id is not None + verify = mgr.run_job( + image="python:3.12-slim", + command=_SCRATCH_VERIFY_COMMAND, + run_name=run_name, + scratch=scratch, + node_id=res.node_id, + max_per_node=1, + timeout_s=120, + name_prefix="kgpipe-scratch-verify", + ) + assert verify.state == "complete" + assert verify.exit_code == 0 + + +def test_hdfs_copy_strategy_builds_put_command_with_hadoop_conf(): + from kgpipe_search.copy import CopyContext, CopyTarget, HdfsCopyStrategy + + strategy = HdfsCopyStrategy(hadoop_conf_host="/etc/hadoop/conf") + plan = strategy.build_job( + context=CopyContext( + run_name="run-42", + node_id="node-1", + scratch=ScratchMount( + host_path="/local/d1/docker-scratch", + container_path="/local/d1/docker-scratch", + ), + ), + destination=CopyTarget(path="/user/kgpipe/results"), + ) + + assert plan.image == "apache/hadoop:3.3.6" + assert "hdfs dfs -put" in plan.command[-1] + assert "/user/kgpipe/results/run-42" in plan.command[-1] + assert plan.env["KGPIPE_HDFS_DEST"] == "/user/kgpipe/results/run-42" + assert plan.env["HADOOP_CONF_DIR"] == "/etc/hadoop/conf" + assert any(m.get("Source") == "/local/d1/docker-scratch" for m in plan.mounts) + assert any(m.get("Source") == "/etc/hadoop/conf" for m in plan.mounts) + + +def test_hdfs_copy_strategy_builds_put_command_with_namenode_and_user(): + from kgpipe_search.copy import CopyContext, CopyTarget, HdfsCopyStrategy + + strategy = HdfsCopyStrategy(namenode="nn.example:8020", user="alice") + plan = strategy.build_job( + context=CopyContext( + run_name="run-42", + node_id="node-1", + scratch=ScratchMount( + host_path="/local/d1/docker-scratch", + container_path="/local/d1/docker-scratch", + ), + ), + destination=CopyTarget(path="/user/kgpipe/results"), + ) + + script = plan.command[-1] + assert "fs.defaultFS" in script + assert "hdfs://nn.example:8020" in script + assert "hdfs dfs -put" in script + assert plan.env["HADOOP_USER_NAME"] == "alice" + assert plan.env["KGPIPE_HDFS_DEST"] == "hdfs://nn.example:8020/user/kgpipe/results/run-42" + assert "HADOOP_CONF_DIR" not in plan.env + assert any(m.get("Source") == "/etc/hosts" for m in plan.mounts) + assert not any(m.get("Source") == "/etc/hadoop/conf" for m in plan.mounts) + + +def test_bind_copy_strategy_builds_cp_command_and_mounts(): + from kgpipe_search.copy import BindCopyStrategy, CopyContext, CopyTarget + + strategy = BindCopyStrategy() + plan = strategy.build_job( + context=CopyContext( + run_name="run-42", + node_id="node-1", + scratch=ScratchMount( + host_path="/local/d1/docker-scratch", + container_path="/local/d1/docker-scratch", + ), + ), + destination=CopyTarget(path="/u/hadena/shared-data/docker-results"), + ) + + assert plan.image == "alpine:3.20" + assert "cp -a" in plan.command[-1] + assert "run-42" in plan.command[-1] + assert plan.env["KGPIPE_BIND_DEST"] == "/u/hadena/shared-data/docker-results" + assert any(m.get("Source") == "/local/d1/docker-scratch" for m in plan.mounts) + assert any(m.get("Source") == "/u/hadena/shared-data/docker-results" for m in plan.mounts) + +# KGPIPE_HDFS_NAMENODE=athena1.informatik.intern.uni-leipzig.de KGPIPE_HDFS_USER=hadena KGPIPE_HDFS_DEST=/user/kgpipe/results \ +# uv run pytest -k swarm_hdfs_copy_stage -v + +def test_swarm_bind_copy_stage(): + ok, reason = _swarm_available() + if not ok: + pytest.skip(reason) + + bind_dest = os.environ.get("KGPIPE_BIND_DEST_HOST") + if not bind_dest: + pytest.skip("set KGPIPE_BIND_DEST_HOST to run bind copy integration test") + + scratch_host = Path(os.environ.get("KGPIPE_SCRATCH_HOST", DEFAULT_SCRATCH_HOST)) + if not scratch_host.is_dir(): + pytest.skip(f"scratch host path does not exist: {scratch_host}") + + bind_dest_path = Path(bind_dest) + if not bind_dest_path.is_dir(): + pytest.skip(f"bind destination host path does not exist: {bind_dest_path}") + + from kgpipe_search.copy import BindCopyStrategy, CopyTarget + from kgpipe_search.mounts import ScratchMount + from kgpipe_search.swarm import SwarmManager + + mgr = SwarmManager() + run_name = f"pytest-bind-copy-{uuid.uuid4().hex[:8]}" + scratch = ScratchMount(host_path=str(scratch_host)) + strategy = BindCopyStrategy() + run = mgr.run_job_with_copy( + image="python:3.12-slim", + command=_SCRATCH_WRITE_COMMAND, + run_name=run_name, + scratch=scratch, + copy_strategy=strategy, + copy_destination=CopyTarget(path=str(bind_dest_path)), + max_per_node=1, + timeout_s=300, + name_prefix="kgpipe-bind-copy", + ) + assert run.job.state == "complete" + assert run.job.exit_code == 0 + assert run.copy is not None, ( + f"copy stage missing; job logs:\n{run.job.logs}" + ) + assert run.copy.state == "complete", ( + f"copy failed (exit_code={run.copy.exit_code}); copy logs:\n{run.copy.logs}" + ) + assert run.copy.exit_code == 0 + assert run.copy.node_id == run.job.node_id + assert "copied to" in run.copy.logs + + # If the bind dest is shared and visible on this host, check directly. + out_path = bind_dest_path / run_name / "done.txt" + if out_path.is_file(): + assert out_path.read_text() == "ok" + return + + # Otherwise verify on the same node via a follow-up service. + assert run.copy.node_id is not None + verify = mgr.run_job( + image="alpine:3.20", + command=[ + "sh", + "-lc", + f"test -f /dst/{run_name}/done.txt && grep -qx ok /dst/{run_name}/done.txt", + ], + node_id=run.copy.node_id, + mounts=[ + {"Type": "bind", "Source": str(bind_dest_path), "Target": "/dst"}, + ], + max_per_node=1, + timeout_s=120, + name_prefix="kgpipe-bind-copy-verify", + ) + assert verify.state == "complete" + assert verify.exit_code == 0 + + +def test_swarm_hdfs_copy_stage(): + ok, reason = _swarm_available() + if not ok: + pytest.skip(reason) + + hdfs_dest = os.environ.get("KGPIPE_HDFS_DEST") + hdfs_namenode = os.environ.get("KGPIPE_HDFS_NAMENODE") + hdfs_user = os.environ.get("KGPIPE_HDFS_USER") + if not hdfs_dest: + pytest.skip("set KGPIPE_HDFS_DEST to run HDFS copy integration test") + + hadoop_conf = os.environ.get("KGPIPE_HADOOP_CONF", "/etc/hadoop/conf") + if hdfs_namenode is None and not Path(hadoop_conf).is_dir(): + pytest.skip(f"Hadoop config not found: {hadoop_conf}") + + scratch_host = Path(os.environ.get("KGPIPE_SCRATCH_HOST", DEFAULT_SCRATCH_HOST)) + if not scratch_host.is_dir(): + pytest.skip(f"scratch host path does not exist: {scratch_host}") + + from kgpipe_search.copy import CopyTarget, HdfsCopyStrategy + from kgpipe_search.mounts import ScratchMount + from kgpipe_search.swarm import SwarmManager + + if hdfs_namenode: + copy_strategy = HdfsCopyStrategy(namenode=hdfs_namenode, user=hdfs_user) + else: + copy_strategy = HdfsCopyStrategy(hadoop_conf_host=hadoop_conf, user=hdfs_user) + + mgr = SwarmManager() + run_name = f"pytest-hdfs-{uuid.uuid4().hex[:8]}" + scratch = ScratchMount(host_path=str(scratch_host)) + + run = mgr.run_job_with_copy( + image="python:3.12-slim", + command=_SCRATCH_WRITE_COMMAND, + run_name=run_name, + scratch=scratch, + copy_strategy=copy_strategy, + copy_destination=CopyTarget(path=hdfs_dest), + max_per_node=1, + timeout_s=300, + name_prefix="kgpipe-hdfs-write", + ) + + assert run.job.state == "complete" + assert run.job.exit_code == 0 + assert run.copy is not None, ( + f"copy stage missing; job logs:\n{run.job.logs}" + ) + assert run.copy.state == "complete", ( + f"copy failed (exit_code={run.copy.exit_code}); copy logs:\n{run.copy.logs}" + ) + assert run.copy.exit_code == 0 + assert run.copy.node_id == run.job.node_id + assert "copied to" in run.copy.logs + + +#watch -n 1 'for s in $(docker service ls --format "{{.Name}}" | grep "^kgpipe-exp-"); do docker service ps "$s" --format "table {{.Name}}\t{{.Node}}\t{{.CurrentState}}\t{{.Error}}"; done' + +def test_execute_pipeline_docker_swarm_parallel_on_all_nodes(): + ok, reason = _swarm_available() + if not ok: + pytest.skip(reason) + + from kgpipe_search.swarm import SwarmJobResult, SwarmManager, parse_job_result + + mgr = SwarmManager() + node_ids = mgr.active_node_ids() + if not node_ids: + pytest.skip("No active Swarm nodes available") + + params = [str(float(i) + 1.0) for i in range(len(node_ids))] + + def run_job(param: str, target_node_id: str): + res = mgr.run_job( + image="python:3.12-slim", + command=_TEST_COMMAND, + parameter=param, + node_id=target_node_id, + max_per_node=1, + timeout_s=120, + ) + parsed = parse_job_result(res.logs) + return param, target_node_id, res, parsed + + results: list[tuple[str, str, SwarmJobResult, float]] = [] + with ThreadPoolExecutor(max_workers=len(node_ids)) as pool: + futures = [ + pool.submit(run_job, param, node_id) + for param, node_id in zip(params, node_ids, strict=True) + ] + for fut in as_completed(futures): + results.append(fut.result()) + + assert len(results) == len(node_ids) + + assigned_nodes: set[str] = set() + for param, target_node_id, res, parsed in results: + assert res.state == "complete" + assert res.exit_code == 0 + assert parsed == float(param) + 1.0 + assert res.node_id == target_node_id + assigned_nodes.add(target_node_id) + + assert assigned_nodes == set(node_ids), ( + f"expected one job per node ({len(node_ids)} nodes), " + f"but jobs ran on {len(assigned_nodes)} distinct nodes" + ) \ No newline at end of file diff --git a/experiments/param-opti/src/kgpipe_search/test/test_experiments.py b/experiments/param-opti/src/kgpipe_search/test/test_experiments.py new file mode 100644 index 0000000..aaa18d8 --- /dev/null +++ b/experiments/param-opti/src/kgpipe_search/test/test_experiments.py @@ -0,0 +1,118 @@ +import os +import random +from pathlib import Path + +import pytest + +from kgpipe.common import Data, DataFormat, KgPipe +from kgpipe_search.configuration import print_pipeline_config_short +from kgpipe_search.definitions import RDF_PIPELINE_LAYOUT, RDF_SEARCH_SPACE, PipelineConfig +from kgpipe_search.evaluation import evaluate_pipeline +from kgpipe_search.search import random_search + +BUDGET = 10 +SEED = 42 + +ONTOLOGY_PATH = Path("data/bench/moviekg_datasets/film_10k/ontology.ttl") +SEED_PATH = Path("data/bench/moviekg_datasets/film_10k/split_0/kg/seed/data.nt") +REFERENCE_PATH = Path("data/bench/moviekg_datasets/film_10k/split_1/kg/reference/data_agg.nt") +RDF_SOURCE_PATH = Path("data/bench/moviekg_datasets/film_10k/split_0/sources/rdf/data.nt") +RDF_TMP_DIR = Path("data/tmp/rdf_pipelines") + + +def _bench_dataset_available() -> bool: + return all( + path.exists() + for path in (ONTOLOGY_PATH, SEED_PATH, REFERENCE_PATH, RDF_SOURCE_PATH) + ) + + +def _run_rdf_pipeline( + pipeline_config: PipelineConfig, + *, + result_path: Path, + tasks_tmp_dir: Path, + run_name: str, +) -> Path: + tasks_tmp_dir.mkdir(parents=True, exist_ok=True) + result_path.parent.mkdir(parents=True, exist_ok=True) + + pipeline = KgPipe( + tasks=pipeline_config.tasks, + seed=Data(path=SEED_PATH, format=DataFormat.RDF_NTRIPLES), + data_dir=tasks_tmp_dir, + name=run_name, + ) + + pipeline.build( + stable_files=True, + configCatalog=pipeline_config.config_catalog, + source=Data(path=RDF_SOURCE_PATH, format=DataFormat.RDF_NTRIPLES), + result=Data(path=result_path, format=DataFormat.RDF_NTRIPLES), + ) + + pipeline.run(configCatalog=pipeline_config.config_catalog, stable_files_override=False) + return result_path + + +def test_rdf_pipeline_random_search(): + if not _bench_dataset_available(): + pytest.skip("moviekg bench dataset not available under data/bench/moviekg_datasets/film_10k") + + os.environ["ONTOLOGY_PATH"] = str(ONTOLOGY_PATH) + RDF_TMP_DIR.mkdir(parents=True, exist_ok=True) + + trial_counter = {"n": 0} + + def evaluate_fn(pipeline_config: PipelineConfig) -> float: + trial = trial_counter["n"] + trial_counter["n"] += 1 + + result_path = RDF_TMP_DIR / f"random_search_trial_{trial}.nt" + tasks_tmp_dir = RDF_TMP_DIR / f"random_search_trial_{trial}_tasks_tmp" + + _run_rdf_pipeline( + pipeline_config, + result_path=result_path, + tasks_tmp_dir=tasks_tmp_dir, + run_name=f"random_search_trial_{trial}", + ) + + aggregate_score = evaluate_pipeline( + pipeline_config, + result_path, + REFERENCE_PATH, + ) + return aggregate_score.final_score + + run = random_search( + budget=BUDGET, + evaluate_fn=evaluate_fn, + search_space=RDF_SEARCH_SPACE, + pipeline_layout=RDF_PIPELINE_LAYOUT, + rng=random.Random(SEED), + ) + + print("\n=== rdf pipeline random search ===") + print(f"budget: {BUDGET}") + print(f"seed: {SEED}") + + best_score = float("-inf") + for trial, ((score, pipeline_config), decision) in enumerate( + zip(run.history, run.decisions), + start=1, + ): + if score > best_score: + best_score = score + improved = " (new best)" + else: + improved = "" + + print(f"\n--- trial {trial}/{BUDGET} [{decision}] ---") + print_pipeline_config_short(pipeline_config) + print(f"score: {score:.4f}{improved}") + print(f"best so far: {best_score:.4f}") + + assert len(run.history) == BUDGET + assert len(run.decisions) == BUDGET + assert best_score > float("-inf") diff --git a/experiments/param-opti/src/kgpipe_search/test_features.py b/experiments/param-opti/src/kgpipe_search/test/test_features.py similarity index 50% rename from experiments/param-opti/src/kgpipe_search/test_features.py rename to experiments/param-opti/src/kgpipe_search/test/test_features.py index 14d8ba8..6a4b217 100644 --- a/experiments/param-opti/src/kgpipe_search/test_features.py +++ b/experiments/param-opti/src/kgpipe_search/test/test_features.py @@ -7,16 +7,16 @@ def test_sample_space(): pass -def test_neighborhood_search(): +def test_neighborhood_optimization(): pass def test_bayesian_optimization(): pass -def test_random_search(): +def test_random_search(config_space: Dict[str, Dict[str, Any]], budget: int): pass -def test_grid_search(): +def test_grid_search(config_space: Dict[str, Dict[str, Any]], budget: int): pass def test_hyperparameter_tuning(): diff --git a/experiments/param-opti/src/kgpipe_search/test/test_search.py b/experiments/param-opti/src/kgpipe_search/test/test_search.py new file mode 100644 index 0000000..f34f89b --- /dev/null +++ b/experiments/param-opti/src/kgpipe_search/test/test_search.py @@ -0,0 +1,112 @@ +import random + +from kgpipe_search.configuration import print_pipeline_config_short +from kgpipe_search.definitions import RDF_PIPELINE_LAYOUT, RDF_SEARCH_SPACE, PipelineConfig +from kgpipe_search.evaluation import dummy_evaluate_pipeline +from kgpipe_search.search import ( + SearchRun, + bayesian_optimization, + neighborhood_optimization, + random_search, +) + + +def _print_search_path(run: SearchRun, pipeline_layout) -> None: + best_score = float("-inf") + best_config: PipelineConfig | None = None + + print(f"\n=== {run.strategy} search ===") + print(f"budget: {run.budget}") + print(f"layout: {pipeline_layout.allowed_task_categories}") + + for trial, ((score, pipeline_config), decision) in enumerate( + zip(run.history, run.decisions), + start=1, + ): + print(f"\n--- trial {trial}/{run.budget} [{decision}] ---") + print_pipeline_config_short(pipeline_config) + + if score > best_score: + best_score = score + best_config = pipeline_config + improved = " (new best)" + else: + improved = "" + + print(f"score: {score:.4f}{improved}") + print(f"best so far: {best_score:.4f}") + + print("\n=== search summary ===") + print(f"evaluated: {len(run.history)}") + print(f"best score: {best_score:.4f}") + if best_config is not None: + print("best config:") + print_pipeline_config_short(best_config) + + +def _assert_valid_search_run(run: SearchRun) -> None: + assert len(run.history) == run.budget + assert len(run.decisions) == run.budget + + seen_configs: set[str] = set() + for score, pipeline_config in run.history: + assert 0.5 <= score <= 1.0 + assert pipeline_config.tasks + config_repr = repr( + [ + ( + task.name, + tuple( + (binding.parameter.name, binding.value) + for binding in ( + pipeline_config.config_catalog.get(task.name).bindings + if pipeline_config.config_catalog.get(task.name) + else [] + ) + ), + ) + for task in pipeline_config.tasks + ] + ) + assert config_repr not in seen_configs + seen_configs.add(config_repr) + + +def test_dummy_evaluate_pipeline_random_search_strategy(): + run = random_search( + budget=10, + evaluate_fn=dummy_evaluate_pipeline, + search_space=RDF_SEARCH_SPACE, + pipeline_layout=RDF_PIPELINE_LAYOUT, + rng=random.Random(0), + ) + _print_search_path(run, RDF_PIPELINE_LAYOUT) + _assert_valid_search_run(run) + + +def test_dummy_evaluate_pipeline_neighborhood_search_strategy(): + run = neighborhood_optimization( + budget=10, + evaluate_fn=dummy_evaluate_pipeline, + search_space=RDF_SEARCH_SPACE, + pipeline_layout=RDF_PIPELINE_LAYOUT, + k=3, + rho=0.2, + rng=random.Random(1), + ) + _print_search_path(run, RDF_PIPELINE_LAYOUT) + _assert_valid_search_run(run) + + +def test_dummy_evaluate_pipeline_bayesian_search_strategy(): + run = bayesian_optimization( + budget=10, + evaluate_fn=dummy_evaluate_pipeline, + search_space=RDF_SEARCH_SPACE, + pipeline_layout=RDF_PIPELINE_LAYOUT, + init_random=3, + pool_size=16, + rng=random.Random(2), + ) + _print_search_path(run, RDF_PIPELINE_LAYOUT) + _assert_valid_search_run(run) diff --git a/experiments/param-opti/src/kgpipe_search/test_experiments.py b/experiments/param-opti/src/kgpipe_search/test_experiments.py deleted file mode 100644 index 78e3142..0000000 --- a/experiments/param-opti/src/kgpipe_search/test_experiments.py +++ /dev/null @@ -1,7 +0,0 @@ - - - - -TEXT_SEARCH_SPACE = {} -JSON_SEARCH_SPACE = {} -RDF_SEARCH_SPACE = {} diff --git a/experiments/param-opti/src/param_opti/__init__.py b/experiments/param-opti/src/param_opti/__init__.py deleted file mode 100644 index 0feb54e..0000000 --- a/experiments/param-opti/src/param_opti/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -""" -Parameter Optimization Experiment Package. - -This package provides tools for extracting and analyzing configuration parameters -from open-source data integration tools. -""" - -from .experiment import ParameterExtractionExperiment -from .tool import ToolDefinition - -__all__ = ["ParameterExtractionExperiment", "ToolDefinition"] - - diff --git a/experiments/param-opti/src/param_opti/__main__.py b/experiments/param-opti/src/param_opti/__main__.py deleted file mode 100644 index 893f0fe..0000000 --- a/experiments/param-opti/src/param_opti/__main__.py +++ /dev/null @@ -1,161 +0,0 @@ -""" -Command-line entry point for parameter extraction experiment. - -Usage: - python -m param_opti [--tool TOOL_NAME] [--no-clone] [--use-llm] -""" - -import argparse -import sys -from pathlib import Path - -from .experiment import ParameterExtractionExperiment - - -def get_project_root() -> Path: - """Get the param-opti project root directory.""" - return Path(__file__).parent.parent.parent - - -def main(): - parser = argparse.ArgumentParser( - description="Extract configuration parameters from data integration tools" - ) - parser.add_argument( - "--tool", "-t", - type=str, - nargs="*", - help="Specific tool(s) to process (default: all)" - ) - parser.add_argument( - "--no-clone", - action="store_true", - help="Skip cloning repositories" - ) - parser.add_argument( - "--use-llm", - action="store_true", - help="Use LLM-based extraction as fallback" - ) - parser.add_argument( - "--input-dir", - type=Path, - default=None, - help="Input directory with tool definitions" - ) - parser.add_argument( - "--output-dir", - type=Path, - default=None, - help="Output directory for results" - ) - parser.add_argument( - "--repos-dir", - type=Path, - default=None, - help="Directory for cloned repositories" - ) - parser.add_argument( - "--cluster", - action="store_true", - help="Cluster parameters across tools after extraction" - ) - parser.add_argument( - "--cluster-only", - action="store_true", - help="Skip extraction, only cluster from existing output" - ) - parser.add_argument( - "--distance-threshold", - type=float, - default=0.55, - help="Cosine distance threshold for clustering (default: 0.55, lower = tighter)" - ) - parser.add_argument( - "--visualize", - action="store_true", - help="Generate visualization plots from clustering results" - ) - - args = parser.parse_args() - - # Determine directories - project_root = get_project_root() - input_dir = args.input_dir or project_root / "input" - output_dir = args.output_dir or project_root / "output" - repos_dir = args.repos_dir or project_root / "repos" - - # Initialize LLM client if requested - llm_client = None - if args.use_llm: - try: - from kgpipe_llm.common.core import get_client_from_env - llm_client = get_client_from_env() - print("LLM client initialized") - except ImportError: - print("Warning: kgpipe_llm not available, proceeding without LLM") - - # Create and run experiment - experiment = ParameterExtractionExperiment( - input_dir=input_dir, - output_dir=output_dir, - repos_dir=repos_dir, - clone_repos=not args.no_clone, - use_llm=args.use_llm, - llm_client=llm_client, - ) - - if not args.cluster_only: - results = experiment.run(tool_names=args.tool) - - # Print extraction summary - print("\n" + "=" * 60) - print("Extraction Summary") - print("=" * 60) - for name, result in results.items(): - status = "✓" if not result.errors else "⚠" - print(f"{status} {name}: {len(result.parameters)} parameters from {len(result.sources)} sources") - if result.errors: - for err in result.errors[:3]: - print(f" Error: {err}") - - # Clustering (after extraction, or standalone with --cluster-only) - if args.cluster or args.cluster_only: - print("\n" + "=" * 60) - print("Clustering Parameters") - print("=" * 60) - cluster_result = experiment.cluster_parameters( - distance_threshold=args.distance_threshold, - ) - if cluster_result: - cross_tool = cluster_result.cross_tool_clusters() - print(f" Total parameters: {cluster_result.n_parameters}") - print(f" Clusters: {cluster_result.n_clusters}") - print(f" Cross-tool clusters: {len(cross_tool)}") - if cross_tool: - print("\n Cross-tool clusters:") - for c in cross_tool[:15]: - tools_str = ", ".join(c.tools) - print(f" [{c.cluster_id}] {c.label!r} ({c.size()} params) — tools: {tools_str}") - print(f"\n Results saved to: {output_dir / '_clusters.json'}") - print(f" Table saved to: {output_dir / '_parameter_table.csv'}") - - # Visualization - if args.visualize: - print("\n" + "=" * 60) - print("Generating Visualizations") - print("=" * 60) - viz_paths = experiment.visualize_clusters() - if viz_paths: - for p in viz_paths: - print(f" Saved: {p}") - else: - print(" No visualizations generated (run with --cluster first?)") - - return 0 - - -if __name__ == "__main__": - sys.exit(main()) - - diff --git a/experiments/param-opti/src/param_opti/experiment.py b/experiments/param-opti/src/param_opti/experiment.py deleted file mode 100644 index b3c18ea..0000000 --- a/experiments/param-opti/src/param_opti/experiment.py +++ /dev/null @@ -1,767 +0,0 @@ -""" -Main experiment runner for parameter extraction. -""" - -import json -import subprocess -import logging -from datetime import datetime -from pathlib import Path -from typing import List, Optional, Dict, Any -from dataclasses import dataclass, field, asdict - -from .tool import ToolDefinition - -# Configure logging -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(levelname)s - %(message)s" -) -logger = logging.getLogger(__name__) - - -@dataclass -class ExtractionSource: - """Represents a source that was used for extraction.""" - source_type: str # cli, python, docker, readme, etc. - file_path: Optional[str] = None - content_preview: Optional[str] = None - parameters_count: int = 0 - - -@dataclass -class ToolExtractionResult: - """Result of parameter extraction for a single tool.""" - tool_name: str - timestamp: str - sources: List[ExtractionSource] = field(default_factory=list) - parameters: List[Dict[str, Any]] = field(default_factory=list) - errors: List[str] = field(default_factory=list) - metadata: Dict[str, Any] = field(default_factory=dict) - - def to_dict(self) -> Dict[str, Any]: - """Convert to dictionary for JSON serialization.""" - return { - "tool_name": self.tool_name, - "timestamp": self.timestamp, - "sources": [asdict(s) for s in self.sources], - "parameters": self.parameters, - "errors": self.errors, - "metadata": self.metadata, - "summary": { - "total_parameters": len(self.parameters), - "total_sources": len(self.sources), - "total_errors": len(self.errors), - } - } - - -class ParameterExtractionExperiment: - """ - Main experiment class for extracting parameters from tools. - - This class orchestrates the parameter extraction process: - 1. Discovers tools from input folder - 2. Clones repositories if needed - 3. Applies extractors to various sources (CLI, Python, Docker, etc.) - 4. Aggregates and saves results - """ - - def __init__( - self, - input_dir: Path, - output_dir: Path, - repos_dir: Path, - clone_repos: bool = True, - use_llm: bool = False, - llm_client: Optional[Any] = None, - ): - """ - Initialize the experiment. - - Args: - input_dir: Directory containing tool definitions - output_dir: Directory for output results - repos_dir: Directory for cloned repositories - clone_repos: Whether to clone repositories - use_llm: Whether to use LLM-based extraction as fallback - llm_client: Optional LLM client instance - """ - self.input_dir = Path(input_dir) - self.output_dir = Path(output_dir) - self.repos_dir = Path(repos_dir) - self.clone_repos = clone_repos - self.use_llm = use_llm - self.llm_client = llm_client - - # Create directories - self.output_dir.mkdir(parents=True, exist_ok=True) - self.repos_dir.mkdir(parents=True, exist_ok=True) - - # Initialize miner lazily - self._miner = None - - @property - def miner(self): - """Lazy initialization of ParameterMiner.""" - if self._miner is None: - from kgpipe_parameters.extraction import ParameterMiner - self._miner = ParameterMiner(llm_client=self.llm_client) - return self._miner - - def discover_tools(self) -> List[ToolDefinition]: - """ - Discover all tool definitions in the input directory. - - Returns: - List of ToolDefinition instances - """ - tools = [] - for folder in sorted(self.input_dir.iterdir()): - if folder.is_dir() and not folder.name.startswith("."): - try: - tool = ToolDefinition.from_folder(folder) - tools.append(tool) - logger.info(f"Discovered tool: {tool}") - except Exception as e: - logger.warning(f"Failed to load tool from {folder}: {e}") - - logger.info(f"Discovered {len(tools)} tools") - return tools - - def clone_repository(self, tool: ToolDefinition) -> Optional[Path]: - """ - Clone a tool's repository if not already present. - - Args: - tool: Tool definition with repo URL - - Returns: - Path to the cloned repository, or None if failed - """ - if not tool.has_repo(): - logger.warning(f"No repository URL for {tool.name}") - return None - - repo_path = self.repos_dir / tool.name - - if repo_path.exists(): - logger.info(f"Repository already exists: {repo_path}") - return repo_path - - logger.info(f"Cloning {tool.repo_url} to {repo_path}") - try: - result = subprocess.run( - ["git", "clone", "--depth", "1", tool.repo_url, str(repo_path)], - capture_output=True, - text=True, - timeout=300, # 5 minute timeout - ) - if result.returncode == 0: - logger.info(f"Successfully cloned {tool.name}") - return repo_path - else: - logger.error(f"Git clone failed: {result.stderr}") - return None - except subprocess.TimeoutExpired: - logger.error(f"Git clone timed out for {tool.name}") - return None - except Exception as e: - logger.error(f"Failed to clone {tool.name}: {e}") - return None - - def extract_from_cli(self, tool: ToolDefinition) -> Optional[Dict[str, Any]]: - """ - Extract parameters from CLI help output. - - Args: - tool: Tool definition with CLI help - - Returns: - Extraction result dictionary, or None if no CLI help - """ - if not tool.has_cli_help(): - return None - - logger.info(f"Extracting from CLI help for {tool.name}") - from kgpipe_parameters.extraction import SourceType - - result = self.miner.extract_parameters( - source=tool.cli_help, - source_type=SourceType.CLI, - tool_name=tool.name, - ) - - return { - "source_type": "cli", - "source_file": str(tool.input_path / "cli.txt"), - "result": json.loads(result.model_dump_json()), - } - - def extract_from_readme(self, tool: ToolDefinition) -> Optional[Dict[str, Any]]: - """ - Extract parameters from a README bundled with the tool input definition. - - Args: - tool: Tool definition with readme_content - - Returns: - Extraction result dictionary, or None if no README content - """ - if not tool.readme_content: - return None - - logger.info(f"Extracting from bundled README for {tool.name}") - from kgpipe_parameters.extraction import SourceType - - result = self.miner.extract_parameters( - source=tool.readme_content, - source_type=SourceType.README, - tool_name=tool.name, - ) - - return { - "source_type": "readme", - "source_file": str(tool.input_path / "readme.md"), - "result": json.loads(result.model_dump_json()), - } - - def extract_from_repo(self, tool: ToolDefinition, repo_path: Path) -> List[Dict[str, Any]]: - """ - Extract parameters from repository files. - - Scans Python, Java, .properties, .xml, Docker, and README/doc files. - A keyword-based chunk filter is applied first so that only files - containing parameter-signal keywords are sent to the extractors, - preventing noise from irrelevant source files. - - Args: - tool: Tool definition - repo_path: Path to cloned repository - - Returns: - List of extraction result dictionaries - """ - from kgpipe_parameters.extraction import SourceType - from kgpipe_parameters.extraction.chunk_filter import has_parameter_signals, score_chunk - - results = [] - - # ------------------------------------------------------------------ - # Helper: prioritize files whose names suggest config / CLI / params - # ------------------------------------------------------------------ - priority_patterns = [ - "main", "cli", "config", "settings", "args", "params", "options", - "__main__", "run", "train", "evaluate", "application", "setup", - ] - - def priority_score(path: Path) -> int: - name = path.stem.lower() - for i, pattern in enumerate(priority_patterns): - if pattern in name: - return i - return len(priority_patterns) - - def _is_test_file(path: Path) -> bool: - """Return True for test / example files we want to skip.""" - low = str(path).lower() - return any(s in low for s in ["test", "/example", "/demo", "/sample"]) - - # ================================================================== - # 1. Python files - # ================================================================== - python_files = sorted(repo_path.rglob("*.py"), key=priority_score) - logger.info(f"Found {len(python_files)} Python files in {tool.name}") - - accepted_py = 0 - for py_file in python_files: - if accepted_py >= 20: - break - try: - content = py_file.read_text(errors="ignore") - if len(content) < 100 or _is_test_file(py_file): - continue - - # ── Keyword chunk filter ── - if not has_parameter_signals(content, file_path=str(py_file)): - logger.debug(f" Skipped (no param signals): {py_file.name}") - continue - - result = self.miner.extract_parameters( - source=content, - source_type=SourceType.PYTHON_LIB, - tool_name=f"{tool.name}/{py_file.name}", - ) - - if result.parameters: - results.append({ - "source_type": "python", - "source_file": str(py_file.relative_to(repo_path)), - "result": json.loads(result.model_dump_json()), - }) - logger.info(f" Extracted {len(result.parameters)} params from {py_file.name}") - accepted_py += 1 - except Exception as e: - logger.warning(f" Failed to process {py_file}: {e}") - - # ================================================================== - # 2. Java files - # ================================================================== - java_files = sorted(repo_path.rglob("*.java"), key=priority_score) - logger.info(f"Found {len(java_files)} Java files in {tool.name}") - - accepted_java = 0 - for java_file in java_files: - if accepted_java >= 20: - break - try: - content = java_file.read_text(errors="ignore") - if len(content) < 100 or _is_test_file(java_file): - continue - - # ── Keyword chunk filter ── - if not has_parameter_signals(content, file_path=str(java_file)): - logger.debug(f" Skipped (no param signals): {java_file.name}") - continue - - # Java config files are best handled by the README extractor - # (it picks up flag patterns, key-value pairs, etc.) - result = self.miner.extract_parameters( - source=content, - source_type=SourceType.README, - tool_name=f"{tool.name}/{java_file.name}", - ) - - if result.parameters: - results.append({ - "source_type": "java", - "source_file": str(java_file.relative_to(repo_path)), - "result": json.loads(result.model_dump_json()), - }) - logger.info(f" Extracted {len(result.parameters)} params from {java_file.name}") - accepted_java += 1 - except Exception as e: - logger.warning(f" Failed to process {java_file}: {e}") - - # ================================================================== - # 3. .properties files (Java native config format) - # ================================================================== - properties_files = list(repo_path.rglob("*.properties")) - logger.info(f"Found {len(properties_files)} .properties files in {tool.name}") - - for prop_file in properties_files[:15]: - try: - content = prop_file.read_text(errors="ignore") - if len(content) < 10 or _is_test_file(prop_file): - continue - - # .properties files are inherently config — always relevant - result = self.miner.extract_parameters( - source=content, - source_type=SourceType.README, # kv-pair patterns work well - tool_name=f"{tool.name}/{prop_file.name}", - ) - - if result.parameters: - results.append({ - "source_type": "properties", - "source_file": str(prop_file.relative_to(repo_path)), - "result": json.loads(result.model_dump_json()), - }) - logger.info(f" Extracted {len(result.parameters)} params from {prop_file.name}") - except Exception as e: - logger.warning(f" Failed to process {prop_file}: {e}") - - # ================================================================== - # 4. XML config files - # ================================================================== - xml_files = list(repo_path.rglob("*.xml")) - # Only keep files whose names suggest config, not build scripts - _xml_config_hints = [ - "config", "setting", "param", "property", "application", - "persistence", "context", "bean", - ] - xml_files = [ - f for f in xml_files - if any(h in f.stem.lower() for h in _xml_config_hints) - or has_parameter_signals( - f.read_text(errors="ignore")[:2000], - file_path=str(f), - ) - ] - logger.info(f"Found {len(xml_files)} XML config files in {tool.name}") - - for xml_file in xml_files[:10]: - try: - content = xml_file.read_text(errors="ignore") - if len(content) < 30 or _is_test_file(xml_file): - continue - - result = self.miner.extract_parameters( - source=content, - source_type=SourceType.README, - tool_name=f"{tool.name}/{xml_file.name}", - ) - - if result.parameters: - results.append({ - "source_type": "xml", - "source_file": str(xml_file.relative_to(repo_path)), - "result": json.loads(result.model_dump_json()), - }) - logger.info(f" Extracted {len(result.parameters)} params from {xml_file.name}") - except Exception as e: - logger.warning(f" Failed to process {xml_file}: {e}") - - # ================================================================== - # 5. Dockerfiles - # ================================================================== - for dockerfile in repo_path.rglob("Dockerfile*"): - try: - content = dockerfile.read_text(errors="ignore") - result = self.miner.extract_parameters( - source=content, - source_type=SourceType.DOCKER, - tool_name=f"{tool.name}/Dockerfile", - ) - - if result.parameters: - results.append({ - "source_type": "docker", - "source_file": str(dockerfile.relative_to(repo_path)), - "result": json.loads(result.model_dump_json()), - }) - logger.info(f" Extracted {len(result.parameters)} params from {dockerfile.name}") - except Exception as e: - logger.warning(f" Failed to process {dockerfile}: {e}") - - # ================================================================== - # 6. docker-compose files - # ================================================================== - for compose_file in repo_path.rglob("docker-compose*.y*ml"): - try: - content = compose_file.read_text(errors="ignore") - result = self.miner.extract_parameters( - source=content, - source_type=SourceType.DOCKER, - tool_name=f"{tool.name}/docker-compose", - ) - - if result.parameters: - results.append({ - "source_type": "docker", - "source_file": str(compose_file.relative_to(repo_path)), - "result": json.loads(result.model_dump_json()), - }) - logger.info(f" Extracted {len(result.parameters)} params from {compose_file.name}") - except Exception as e: - logger.warning(f" Failed to process {compose_file}: {e}") - - # ================================================================== - # 7. README and documentation files - # ================================================================== - readme_patterns = ["README*", "readme*", "INSTALL*", "USAGE*", "CONFIGURATION*"] - doc_dirs = ["doc", "docs", "documentation"] - - readme_files: List[Path] = [] - for pattern in readme_patterns: - readme_files.extend(repo_path.glob(pattern)) - # Also pick up .md files scattered in the repo root (e.g. RunPARIS.md) - readme_files.extend(repo_path.glob("*.md")) - for doc_dir_name in doc_dirs: - doc_dir = repo_path / doc_dir_name - if doc_dir.is_dir(): - readme_files.extend(doc_dir.rglob("*.md")) - readme_files.extend(doc_dir.rglob("*.txt")) - readme_files.extend(doc_dir.rglob("*.rst")) - - # Deduplicate while preserving order - seen_readme: set = set() - unique_readmes: List[Path] = [] - for f in readme_files: - if f.resolve() not in seen_readme and f.is_file(): - seen_readme.add(f.resolve()) - unique_readmes.append(f) - - logger.info(f"Found {len(unique_readmes)} README/doc files in {tool.name}") - - for readme_file in unique_readmes[:15]: - try: - content = readme_file.read_text(errors="ignore") - if len(content) < 50: - continue - - # ── Keyword chunk filter for docs ── - if not has_parameter_signals(content, file_path=str(readme_file), threshold=1): - logger.debug(f" Skipped (no param signals): {readme_file.name}") - continue - - result = self.miner.extract_parameters( - source=content, - source_type=SourceType.README, - tool_name=f"{tool.name}/{readme_file.name}", - ) - - if result.parameters: - results.append({ - "source_type": "readme", - "source_file": str(readme_file.relative_to(repo_path)), - "result": json.loads(result.model_dump_json()), - }) - logger.info(f" Extracted {len(result.parameters)} params from {readme_file.name}") - except Exception as e: - logger.warning(f" Failed to process {readme_file}: {e}") - - return results - - def process_tool(self, tool: ToolDefinition) -> ToolExtractionResult: - """ - Process a single tool and extract all parameters. - - Args: - tool: Tool definition to process - - Returns: - ToolExtractionResult with all extracted parameters - """ - logger.info(f"Processing tool: {tool.name}") - - result = ToolExtractionResult( - tool_name=tool.name, - timestamp=datetime.now().isoformat(), - metadata={ - "repo_url": tool.repo_url, - "has_cli_help": tool.has_cli_help(), - "config": tool.config, - } - ) - - # Extract from CLI help - if tool.has_cli_help(): - try: - cli_result = self.extract_from_cli(tool) - if cli_result: - params = cli_result["result"].get("parameters", []) - result.sources.append(ExtractionSource( - source_type="cli", - file_path=cli_result["source_file"], - content_preview=tool.cli_help[:200] if tool.cli_help else None, - parameters_count=len(params), - )) - for p in params: - p["_source"] = "cli" - result.parameters.append(p) - except Exception as e: - result.errors.append(f"CLI extraction failed: {str(e)}") - logger.error(f"CLI extraction failed for {tool.name}: {e}") - - # Extract from bundled README - if tool.readme_content: - try: - readme_result = self.extract_from_readme(tool) - if readme_result: - params = readme_result["result"].get("parameters", []) - result.sources.append(ExtractionSource( - source_type="readme", - file_path=readme_result["source_file"], - content_preview=tool.readme_content[:200] if tool.readme_content else None, - parameters_count=len(params), - )) - for p in params: - p["_source"] = "readme" - result.parameters.append(p) - except Exception as e: - result.errors.append(f"README extraction failed: {str(e)}") - logger.error(f"README extraction failed for {tool.name}: {e}") - - # Clone (if requested) and extract from repository - if tool.has_repo(): - repo_path = self.repos_dir / tool.name - if self.clone_repos: - repo_path = self.clone_repository(tool) - if repo_path and repo_path.exists(): - try: - repo_results = self.extract_from_repo(tool, repo_path) - for r in repo_results: - params = r["result"].get("parameters", []) - result.sources.append(ExtractionSource( - source_type=r["source_type"], - file_path=r["source_file"], - parameters_count=len(params), - )) - for p in params: - p["_source"] = f"{r['source_type']}:{r['source_file']}" - result.parameters.append(p) - except Exception as e: - result.errors.append(f"Repository extraction failed: {str(e)}") - logger.error(f"Repository extraction failed for {tool.name}: {e}") - - logger.info(f"Completed {tool.name}: {len(result.parameters)} parameters from {len(result.sources)} sources") - return result - - def save_result(self, result: ToolExtractionResult) -> Path: - """ - Save extraction result to output directory. - - Args: - result: Extraction result to save - - Returns: - Path to saved file - """ - output_file = self.output_dir / f"{result.tool_name}.json" - - with open(output_file, "w") as f: - json.dump(result.to_dict(), f, indent=2, default=str) - - logger.info(f"Saved result to {output_file}") - return output_file - - def run(self, tool_names: Optional[List[str]] = None) -> Dict[str, ToolExtractionResult]: - """ - Run the experiment for all or selected tools. - - Args: - tool_names: Optional list of tool names to process (all if None) - - Returns: - Dictionary mapping tool names to their extraction results - """ - logger.info("=" * 60) - logger.info("Starting Parameter Extraction Experiment") - logger.info("=" * 60) - - # Discover tools - tools = self.discover_tools() - - # Filter if specific tools requested - if tool_names: - tools = [t for t in tools if t.name in tool_names] - logger.info(f"Filtered to {len(tools)} tools: {[t.name for t in tools]}") - - # Process each tool - results = {} - for tool in tools: - try: - result = self.process_tool(tool) - self.save_result(result) - results[tool.name] = result - except Exception as e: - logger.error(f"Failed to process {tool.name}: {e}") - results[tool.name] = ToolExtractionResult( - tool_name=tool.name, - timestamp=datetime.now().isoformat(), - errors=[str(e)], - ) - - # Generate summary - self._generate_summary(results) - - logger.info("=" * 60) - logger.info("Experiment Complete") - logger.info("=" * 60) - - return results - - def cluster_parameters( - self, - model_name: str = "all-MiniLM-L6-v2", - distance_threshold: float = 0.55, - ) -> Optional[Any]: - """ - Cluster extracted parameters across all tools using sentence-transformer - embeddings and agglomerative clustering. - - This reads the per-tool JSON files already written to ``output_dir``, - embeds every parameter, and groups similar ones together. - - Args: - model_name: Sentence-transformer model identifier. - distance_threshold: Max cosine distance for merging (lower = tighter). - - Returns: - A ClusteringResult, or None if no parameters were found. - """ - from kgpipe_parameters.clustering import ParameterClusterer - - clusterer = ParameterClusterer( - model_name=model_name, - distance_threshold=distance_threshold, - ) - - result = clusterer.cluster_from_output_dir(self.output_dir) - - if result.n_clusters == 0: - logger.warning("Clustering produced 0 clusters") - return result - - # Persist results - clusterer.save_result(result, self.output_dir / "_clusters.json") - clusterer.save_table(result, self.output_dir / "_parameter_table.csv") - - # Log summary - cross_tool = result.cross_tool_clusters() - logger.info( - "Clustering: %d parameters → %d clusters (%d cross-tool)", - result.n_parameters, - result.n_clusters, - len(cross_tool), - ) - return result - - def visualize_clusters(self) -> list[Path]: - """ - Generate visualization plots from existing clustering output. - - Reads ``_clusters.json`` from the output directory and produces - PNG plots in the same directory. Returns the list of generated - file paths. - """ - clusters_json = self.output_dir / "_clusters.json" - if not clusters_json.exists(): - logger.warning( - "No _clusters.json found in %s — run clustering first", - self.output_dir, - ) - return [] - - from kgpipe_parameters.visualization import ParameterVisualizer - - viz = ParameterVisualizer.from_clusters_json(clusters_json, self.output_dir) - return viz.generate_all() - - def _generate_summary(self, results: Dict[str, ToolExtractionResult]) -> None: - """Generate and save experiment summary.""" - summary = { - "timestamp": datetime.now().isoformat(), - "total_tools": len(results), - "tools": {} - } - - total_params = 0 - total_sources = 0 - total_errors = 0 - - for name, result in results.items(): - summary["tools"][name] = { - "parameters": len(result.parameters), - "sources": len(result.sources), - "errors": len(result.errors), - } - total_params += len(result.parameters) - total_sources += len(result.sources) - total_errors += len(result.errors) - - summary["totals"] = { - "parameters": total_params, - "sources": total_sources, - "errors": total_errors, - } - - summary_file = self.output_dir / "_summary.json" - with open(summary_file, "w") as f: - json.dump(summary, f, indent=2) - - logger.info(f"Summary: {total_params} parameters from {total_sources} sources ({total_errors} errors)") - - diff --git a/experiments/param-opti/src/param_opti/pipeline_selection/test_configuration.py b/experiments/param-opti/src/param_opti/pipeline_selection/test_configuration.py deleted file mode 100644 index 926605f..0000000 --- a/experiments/param-opti/src/param_opti/pipeline_selection/test_configuration.py +++ /dev/null @@ -1,26 +0,0 @@ -from random import random, seed, sample -from typing import List - - - -def entity_matching_a() -> List[str]: - seed(42) - # select 5 positive values and 5 negative values - positive_values=["+A", "+B", "+C", "+D", "+E", "+F", "+G", "+H", "+I", "+J", "+K", "+L", "+M", "+N", "+O", "+P", "+Q", "+R", "+S", "+T", "+U", "+V", "+W", "+X", "+Y", "+Z"] - negative_values=["-A", "-B", "-C", "-D", "-E", "-F", "-G", "-H", "-I", "-J", "-K", "-L", "-M", "-N", "-O", "-P", "-Q", "-R", "-S", "-T", "-U", "-V", "-W", "-X", "-Y", "-Z"] - positive_values = sample(positive_values, 5) - negative_values = sample(negative_values, 5) - return positive_values + negative_values - -def schmea_matching_a(): pass - -def test_selecting_pipelines(): pass - - -def test_run(): - - values = entity_matching_a() - print(values) - values2 = entity_matching_a() - print(values2) - # print(values == values2) \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/pipeline_util.py b/experiments/param-opti/src/param_opti/pipeline_util.py deleted file mode 100644 index eb0acf3..0000000 --- a/experiments/param-opti/src/param_opti/pipeline_util.py +++ /dev/null @@ -1,5 +0,0 @@ - - - -# check current implementation state - diff --git a/experiments/param-opti/src/param_opti/search.py b/experiments/param-opti/src/param_opti/search.py deleted file mode 100644 index 82658f7..0000000 --- a/experiments/param-opti/src/param_opti/search.py +++ /dev/null @@ -1,17 +0,0 @@ - - -def sample_random_valid(task_impls: List[str]): - pass - -class SearchSpace: - def __init__(self, task_impls: List[str]): - self.task_impls = task_impls - -class NeighborhoodSearch: - def __init__(self, search_space: SearchSpace): - self.search_space = search_space - - def search(self, budget: int): - pass - - diff --git a/experiments/param-opti/src/param_opti/tool.py b/experiments/param-opti/src/param_opti/tool.py deleted file mode 100644 index eb16aab..0000000 --- a/experiments/param-opti/src/param_opti/tool.py +++ /dev/null @@ -1,92 +0,0 @@ -""" -Tool definition model for parameter extraction experiments. -""" - -from dataclasses import dataclass, field -from pathlib import Path -from typing import Optional, List, Dict, Any -import json - - -@dataclass -class ToolDefinition: - """ - Represents a tool to be analyzed for parameter extraction. - - A tool is defined by a folder containing: - - repo.url: Git repository URL - - cli.txt: (optional) CLI help output - - readme.md: (optional) README content - - config.json: (optional) Additional configuration - """ - name: str - input_path: Path - repo_url: Optional[str] = None - cli_help: Optional[str] = None - readme_content: Optional[str] = None - config: Dict[str, Any] = field(default_factory=dict) - - @classmethod - def from_folder(cls, folder_path: Path) -> "ToolDefinition": - """ - Load a tool definition from a folder. - - Args: - folder_path: Path to the tool definition folder - - Returns: - ToolDefinition instance - """ - name = folder_path.name - - # Load repo URL - repo_url = None - repo_url_file = folder_path / "repo.url" - if repo_url_file.exists(): - repo_url = repo_url_file.read_text().strip() - - # Load CLI help - cli_help = None - cli_file = folder_path / "cli.txt" - if cli_file.exists(): - cli_help = cli_file.read_text() - - # Load README - readme_content = None - for readme_name in ["readme.md", "README.md", "readme.txt", "README.txt"]: - readme_file = folder_path / readme_name - if readme_file.exists(): - readme_content = readme_file.read_text() - break - - # Load config - config = {} - config_file = folder_path / "config.json" - if config_file.exists(): - config = json.loads(config_file.read_text()) - - return cls( - name=name, - input_path=folder_path, - repo_url=repo_url, - cli_help=cli_help, - readme_content=readme_content, - config=config, - ) - - def has_repo(self) -> bool: - """Check if this tool has a repository URL.""" - return self.repo_url is not None and len(self.repo_url) > 0 - - def has_cli_help(self) -> bool: - """Check if this tool has CLI help output.""" - return self.cli_help is not None and len(self.cli_help) > 0 - - def get_language(self) -> Optional[str]: - """Get the primary language of the tool (from config or auto-detect).""" - return self.config.get("language") - - def __repr__(self) -> str: - return f"ToolDefinition(name={self.name!r}, repo={self.has_repo()}, cli={self.has_cli_help()})" - - diff --git a/experiments/param-opti/src/qap/fixtures/rdf_sampled_pipeline_configs.json b/experiments/param-opti/src/qap/fixtures/rdf_sampled_pipeline_configs.json deleted file mode 100644 index 5a745c1..0000000 --- a/experiments/param-opti/src/qap/fixtures/rdf_sampled_pipeline_configs.json +++ /dev/null @@ -1,147 +0,0 @@ -{ - "samples": [ - { - "profiles": { - "graph_alignment_label_alias_embedding_transformer": { - "bindings": [ - { - "parameter": "model_name", - "value": "intfloat/e5-base-v2" - }, - { - "parameter": "similarity_threshold", - "value": 0.8 - } - ], - "profile_name": "graph_alignment_label_alias_embedding_transformer_model_name=intfloat/e5-base-v2,similarity_threshold=0.8" - } - }, - "task_keys": [ - "graph_alignment_label_alias_embedding_transformer_task", - "fusion_first_value_task" - ] - }, - { - "profiles": { - "entity_matcher_label_alias_embedding_transformer": { - "bindings": [ - { - "parameter": "model_name", - "value": "intfloat/e5-base-v2" - }, - { - "parameter": "similarity_threshold", - "value": 0.8 - } - ], - "profile_name": "entity_matcher_label_alias_embedding_transformer_model_name=intfloat/e5-base-v2,similarity_threshold=0.8" - }, - "relation_matcher_label_alias_embedding_transformer": { - "bindings": [ - { - "parameter": "model_name", - "value": "intfloat/e5-base-v2" - }, - { - "parameter": "similarity_threshold", - "value": 0.5 - } - ], - "profile_name": "relation_matcher_label_alias_embedding_transformer_model_name=intfloat/e5-base-v2,similarity_threshold=0.5" - } - }, - "task_keys": [ - "relation_matcher_label_alias_embedding_transformer_task", - "entity_matcher_label_alias_embedding_transformer_task", - "aggregate_matching_results_task", - "fusion_first_value_task" - ] - }, - { - "profiles": { - "paris_entity_alignment": { - "bindings": [ - { - "parameter": "entity_matching_threshold", - "value": 0.9 - } - ], - "profile_name": "paris_entity_alignment_entity_matching_threshold=0.9" - }, - "relation_matcher_label_alias_embedding_transformer": { - "bindings": [ - { - "parameter": "model_name", - "value": "intfloat/e5-base-v2" - }, - { - "parameter": "similarity_threshold", - "value": 0.5 - } - ], - "profile_name": "relation_matcher_label_alias_embedding_transformer_model_name=intfloat/e5-base-v2,similarity_threshold=0.5" - } - }, - "task_keys": [ - "relation_matcher_label_alias_embedding_transformer_task", - "paris_entity_alignment_task", - "aggregate_matching_results_task", - "fusion_first_value_task" - ] - }, - { - "profiles": { - "entity_matcher_label_alias_embedding_transformer": { - "bindings": [ - { - "parameter": "model_name", - "value": "intfloat/e5-base-v2" - }, - { - "parameter": "similarity_threshold", - "value": 0.9 - } - ], - "profile_name": "entity_matcher_label_alias_embedding_transformer_model_name=intfloat/e5-base-v2,similarity_threshold=0.9" - }, - "paris_ontology_matching": { - "bindings": [ - { - "parameter": "ontology_matching_threshold", - "value": 0.5 - } - ], - "profile_name": "paris_ontology_matching_ontology_matching_threshold=0.5" - } - }, - "task_keys": [ - "paris_ontology_matching_task", - "entity_matcher_label_alias_embedding_transformer_task", - "aggregate_matching_results_task", - "fusion_first_value_task" - ] - }, - { - "profiles": { - "paris_graph_alignment": { - "bindings": [ - { - "parameter": "entity_matching_threshold", - "value": 0.9 - }, - { - "parameter": "relation_matching_threshold", - "value": 0.5 - } - ], - "profile_name": "paris_graph_alignment_entity_matching_threshold=0.9,relation_matching_threshold=0.5" - } - }, - "task_keys": [ - "paris_graph_alignment_task", - "fusion_first_value_task" - ] - } - ], - "version": 1 -} diff --git a/experiments/param-opti/src/qap/fixtures/text_sampled_pipeline_configs.json b/experiments/param-opti/src/qap/fixtures/text_sampled_pipeline_configs.json deleted file mode 100644 index e5336cc..0000000 --- a/experiments/param-opti/src/qap/fixtures/text_sampled_pipeline_configs.json +++ /dev/null @@ -1,153 +0,0 @@ -{ - "samples": [ - { - "profiles": { - "relation_linker_label_alias_embedding_transformer": { - "bindings": [ - { - "parameter": "model_name", - "value": "intfloat/e5-base-v2" - }, - { - "parameter": "similarity_threshold", - "value": 0.5 - } - ], - "profile_name": "relation_linker_label_alias_embedding_transformer_model_name=intfloat/e5-base-v2,similarity_threshold=0.5" - }, - "spotlight_entity_linking": { - "bindings": [ - { - "parameter": "similarity_threshold", - "value": 0.8 - } - ], - "profile_name": "spotlight_entity_linking_similarity_threshold=0.8" - } - }, - "task_keys": [ - "corenlp_text_extraction_task", - "spotlight_entity_linking_task", - "aggregate_entity_linking_task", - "relation_linker_label_alias_embedding_transformer_task", - "aggregate_relation_linking_task", - "generate_rdf_from_text_results_task", - "select_first_value_task" - ] - }, - { - "profiles": { - "entity_linker_label_alias_embedding_transformer": { - "bindings": [ - { - "parameter": "model_name", - "value": "intfloat/e5-base-v2" - }, - { - "parameter": "similarity_threshold", - "value": 0.9 - } - ], - "profile_name": "entity_linker_label_alias_embedding_transformer_model_name=intfloat/e5-base-v2,similarity_threshold=0.9" - }, - "relation_linker_label_alias_embedding_transformer": { - "bindings": [ - { - "parameter": "model_name", - "value": "intfloat/e5-base-v2" - }, - { - "parameter": "similarity_threshold", - "value": 0.5 - } - ], - "profile_name": "relation_linker_label_alias_embedding_transformer_model_name=intfloat/e5-base-v2,similarity_threshold=0.5" - } - }, - "task_keys": [ - "corenlp_text_extraction_task", - "entity_linker_label_alias_embedding_transformer_task", - "aggregate_entity_linking_task", - "relation_linker_label_alias_embedding_transformer_task", - "aggregate_relation_linking_task", - "generate_rdf_from_text_results_task", - "select_first_value_task" - ] - }, - { - "profiles": { - "relation_linker_label_alias_embedding_transformer": { - "bindings": [ - { - "parameter": "model_name", - "value": "intfloat/e5-base-v2" - }, - { - "parameter": "similarity_threshold", - "value": 0.5 - } - ], - "profile_name": "relation_linker_label_alias_embedding_transformer_model_name=intfloat/e5-base-v2,similarity_threshold=0.5" - }, - "spotlight_entity_linking": { - "bindings": [ - { - "parameter": "similarity_threshold", - "value": 0.8 - } - ], - "profile_name": "spotlight_entity_linking_similarity_threshold=0.8" - } - }, - "task_keys": [ - "genie_text_extraction_task", - "spotlight_entity_linking_task", - "aggregate_entity_linking_task", - "relation_linker_label_alias_embedding_transformer_task", - "aggregate_relation_linking_task", - "generate_rdf_from_text_results_task", - "select_first_value_task" - ] - }, - { - "profiles": { - "entity_linker_label_alias_embedding_transformer": { - "bindings": [ - { - "parameter": "model_name", - "value": "intfloat/e5-base-v2" - }, - { - "parameter": "similarity_threshold", - "value": 0.9 - } - ], - "profile_name": "entity_linker_label_alias_embedding_transformer_model_name=intfloat/e5-base-v2,similarity_threshold=0.9" - }, - "relation_linker_label_alias_embedding_transformer": { - "bindings": [ - { - "parameter": "model_name", - "value": "intfloat/e5-base-v2" - }, - { - "parameter": "similarity_threshold", - "value": 0.5 - } - ], - "profile_name": "relation_linker_label_alias_embedding_transformer_model_name=intfloat/e5-base-v2,similarity_threshold=0.5" - } - }, - "task_keys": [ - "genie_text_extraction_task", - "entity_linker_label_alias_embedding_transformer_task", - "aggregate_entity_linking_task", - "relation_linker_label_alias_embedding_transformer_task", - "aggregate_relation_linking_task", - "generate_rdf_from_text_results_task", - "select_first_value_task" - ] - } - ], - "version": 1 -} diff --git a/experiments/param-opti/src/qap/sge_metrics.py b/experiments/param-opti/src/qap/sge_metrics.py deleted file mode 100644 index b4e0e33..0000000 --- a/experiments/param-opti/src/qap/sge_metrics.py +++ /dev/null @@ -1,17 +0,0 @@ -from kg_sge.api.correctness import SourceGroundCorrectenss, SourceGroundCorrectnessConfig -from kg_sge.api.coverage import SourceGroundedCoverage, SourceGroundedCoverageConfig -from kgpipe_eval.utils.kg_utils import KgManager, KG, KgLike - -class SourceGroundedCorrectnessMetric: - def __init__(self): - self.correctness = SourceGroundCorrectenss() - - def compute(self, kg: KG, config: SourceGroundCorrectnessConfig): - pass - -class SourceGroundedCoverageMetric: - def __init__(self): - self.coverage = SourceGroundedCoverage() - - def compute(self, kg: KG, config: SourceGroundedCoverageConfig): - pass \ No newline at end of file diff --git a/experiments/param-opti/src/qap/test_eval_pipelines.py b/experiments/param-opti/src/qap/test_eval_pipelines.py deleted file mode 100644 index bcc5012..0000000 --- a/experiments/param-opti/src/qap/test_eval_pipelines.py +++ /dev/null @@ -1,122 +0,0 @@ -from pathlib import Path -import json -import pytest - -from kgpipe_eval.utils.kg_utils import KgManager -from kgpipe_eval.metrics.triple_alignment import TripleAlignmentMetric, TripleAlignmentConfig -from kgpipe_eval.metrics.entity_alignment import EntityAlignmentMetric, EntityAlignmentConfig -from kgpipe_eval.api import MetricResult -from kgpipe_eval.test.utils import render_metric_result - -rdf_base_dir = Path("data/tmp/rdf_pipelines/") -text_base_dir = Path("data/tmp/text_pipelines/") -result_dir = Path("data/output/reference_eval") - -def get_rdf_final_kgs(): - """ - get all files matching rdf_result_saved_sample_config_idx_*.nt in dir - """ - BASE_DIR = rdf_base_dir - return [f for f in BASE_DIR.glob("*eval.nt")] - -def get_text_final_kgs(): - """ - get all files matching text_result_saved_sample_config_idx_*.nt in dir - """ - BASE_DIR = text_base_dir - return [f for f in BASE_DIR.glob("*eval.nt")] - -def test_get_rdf_final_kgs(): - """ - test the get_final_kgs function - """ - final_kgs = get_rdf_final_kgs() - for final_kg in final_kgs: - print(final_kg) - -def test_get_text_final_kgs(): - """ - test the get_final_kgs function - """ - final_kgs = get_text_final_kgs() - for final_kg in final_kgs: - print(final_kg) - -def _write_to_file(string: str, path: Path): - with open(path, "w") as f: - f.write(string) - print(f"wrote to {path}") - -def _metric_result_to_jsonable(metric_result: MetricResult) -> dict: - metric = metric_result.metric - metric_key = getattr(metric, "key", metric.__class__.__name__) - return { - "metric": metric_key, - "summary": metric_result.summary, - "measurements": [ - {"name": m.name, "value": m.value, "unit": m.unit} - for m in metric_result.measurements - ], - } - -def _write_json(obj: object, path: Path): - with open(path, "w") as f: - json.dump(obj, f, indent=2, sort_keys=True, default=str) - f.write("\n") - print(f"wrote to {path}") - -# seed_kg = KgManager.load_kg(Path("data/input_final/target_kg/graph.nt")) - -def eval_pipeline(final_kg, reference_kg_path): - - print(f"evaluating {final_kg}") - - ref_kg_path = reference_kg_path - gen_kg_path = final_kg - - entity_alignment_config = EntityAlignmentConfig( - method="label_embedding", - reference_kg=ref_kg_path, - verified_entities_path=None, - verified_entities_delimiter="\t", - entity_sim_threshold=0.95 - ) - - - gen_kg = KgManager.load_kg(gen_kg_path) - # test_kg = KgManager.substract_kg(gen_kg, seed_kg) # TODO add back labels and types - test_kg = gen_kg - - metric_result : MetricResult = EntityAlignmentMetric().compute(test_kg, entity_alignment_config) - result_string = render_metric_result(metric_result) - _write_to_file(result_string, result_dir / (final_kg.name + ".entity_alignment.txt")) - _write_json(_metric_result_to_jsonable(metric_result), result_dir / (final_kg.name + ".entity_alignment.json")) - - - triple_alignment_config = TripleAlignmentConfig( - reference_kg=ref_kg_path, - entity_alignment_config=entity_alignment_config, - value_sim_threshold=0.5, - cache_literal_embeddings=True - ) - - metric_result : MetricResult = TripleAlignmentMetric().compute(test_kg, triple_alignment_config) - result_string = render_metric_result(metric_result) - _write_to_file(result_string, result_dir / (final_kg.name + ".triple_alignment.txt")) - _write_json(_metric_result_to_jsonable(metric_result), result_dir / (final_kg.name + ".triple_alignment.json")) - -@pytest.mark.parametrize("final_kg", get_rdf_final_kgs()) -def test_eval_rdf_pipeline_runs(final_kg): - """ - evaluate all runs of the rdf pipelines - """ - eval_pipeline(final_kg, Path("data/input_final/reference_kg/data_no_seed.nt")) - -@pytest.mark.parametrize("final_kg", get_text_final_kgs()) -def test_eval_text_pipeline_runs(final_kg): - """ - evaluate all runs of the text pipelines - """ - # data/input_final/txt_source/ref - - eval_pipeline(final_kg, Path("/data/datasets/params_experiments/latest/input_final/txt_source/tmp_reference/reference_kg_noseed.nt")) \ No newline at end of file diff --git a/experiments/param-opti/src/qap/test_exec_pipelines.py b/experiments/param-opti/src/qap/test_exec_pipelines.py deleted file mode 100644 index 1804e86..0000000 --- a/experiments/param-opti/src/qap/test_exec_pipelines.py +++ /dev/null @@ -1,202 +0,0 @@ -from kgpipe.common import KgPipe, Data, DataFormat -from kgpipe.common.model.configuration import ConfigurationProfile, ParameterBinding, ConfigurationDefinition -from param_opti.tasks.paris import paris_graph_alignment_task, paris_entity_alignment_task, paris_ontology_matching_task -from param_opti.tasks.fusion import fusion_first_value_task -from param_opti.tasks.base_linker import relation_linker_label_alias_embedding_transformer_task, entity_linker_label_alias_embedding_transformer_task -from param_opti.tasks.corenlp import corenlp_text_extraction_task -from param_opti.tasks.genie import genie_text_extraction_task -from param_opti.tasks.spotlight import spotlight_entity_linking_task -from param_opti.tasks.text_helpers import aggregate_text_tasks_task, generate_rdf_from_text_results_task -from param_opti.tasks.select_lib import select_first_value_task -from qap.test_conf_pipelines import ( - PipelineConfig, - _get_param, - load_rdf_sampled_pipeline_configs, - load_text_sampled_pipeline_configs, -) -from pathlib import Path -import pytest -import os - -from dotenv import load_dotenv -load_dotenv() - -tmp_base_dir = Path("data/tmp/text_pipelines") -if not tmp_base_dir.exists(): - tmp_base_dir.mkdir(parents=True, exist_ok=True) - - -ontology_path = "data/input_final/target_kg/ontology.ttl" -os.environ["ONTOLOGY_PATH"] = ontology_path - - -def get_default_rdf_pipeline_config() -> PipelineConfig: - return PipelineConfig( - tasks=[ - paris_graph_alignment_task, - fusion_first_value_task, - ], - config_catalog={ - # Key must match KgTask.name because KgPipe delegates by task.name - "paris_graph_alignment": ConfigurationProfile( - name="paris_graph_alignment", - definition=paris_graph_alignment_task.config_spec, - bindings=[ - ParameterBinding(parameter=_get_param(paris_graph_alignment_task.config_spec, "entity_matching_threshold"), value=0.5), - ParameterBinding(parameter=_get_param(paris_graph_alignment_task.config_spec, "relation_matching_threshold"), value=0.5), - ], - ) - }, - ) - -def test_rdf_pipeline_from_default_config(): - pipeline_config = get_default_rdf_pipeline_config() - - seed_path = tmp_base_dir / "seed.nt" - source_path = tmp_base_dir / "source.nt" - result_path = tmp_base_dir / "result.nt" - tasks_tmp_dir = tmp_base_dir / "tasks_tmp" - tasks_tmp_dir.mkdir(parents=True, exist_ok=True) - - # Ensure inputs exist for pipeline execution. - seed_path.write_text(" .\n") - source_path.write_text(" .\n") - - pipeline = KgPipe( - tasks=pipeline_config.tasks, - seed=Data(path=seed_path, format=DataFormat.RDF_NTRIPLES), - data_dir=tasks_tmp_dir, - name="test_pipeline") - - pipeline.build( - stable_files=True, - configCatalog=pipeline_config.config_catalog, - source=Data(path=source_path, format=DataFormat.RDF_NTRIPLES), - result=Data(path=result_path, format=DataFormat.RDF_NTRIPLES)) - - pipeline.run(configCatalog=pipeline_config.config_catalog, stable_files_override=True) - - -@pytest.mark.parametrize("config_idx", range(len(load_rdf_sampled_pipeline_configs()))) -def test_rdf_pipeline_from_saved_sampled_configs(config_idx): - """Runs KGpipe using PipelineConfigs materialized from the JSON fixture written by test_pipeline_config.""" - configs = load_rdf_sampled_pipeline_configs() - assert configs, "fixtures/rdf_sampled_pipeline_configs.json is missing or empty; run test_enumerate_all_valid_rdf_task_combinations_with_config_sampling" - - pipeline_config = configs[config_idx] - - seed_path = Path("data/input_final/target_kg/graph.nt") - source_path = Path("data/input_final/rdf_source/graph.nt") - result_path = tmp_base_dir / f"rdf_result_saved_sample_config_idx_{config_idx}.nt" - tasks_tmp_dir = tmp_base_dir / f"rdf_tasks_tmp_saved_sample_config_idx_{config_idx}" - tasks_tmp_dir.mkdir(parents=True, exist_ok=True) - - pipeline = KgPipe( - tasks=pipeline_config.tasks, - seed=Data(path=seed_path, format=DataFormat.RDF_NTRIPLES), - data_dir=tasks_tmp_dir, - name="test_pipeline_saved_sample", - ) - - pipeline.build( - stable_files=True, - configCatalog=pipeline_config.config_catalog, - source=Data(path=source_path, format=DataFormat.RDF_NTRIPLES), - result=Data(path=result_path, format=DataFormat.RDF_NTRIPLES), - ) - - pipeline.run(configCatalog=pipeline_config.config_catalog, stable_files_override=True) - - -def get_default_text_pipeline_config() -> PipelineConfig: - return PipelineConfig( - tasks=[ - corenlp_text_extraction_task, - entity_linker_label_alias_embedding_transformer_task, - relation_linker_label_alias_embedding_transformer_task, - aggregate_text_tasks_task, - generate_rdf_from_text_results_task, - select_first_value_task, - ], - config_catalog={ - "entity_linker_label_alias_embedding_transformer": ConfigurationProfile( - name="entity_linker_label_alias_embedding_transformer", - definition=entity_linker_label_alias_embedding_transformer_task.config_spec, - bindings=[ - ParameterBinding(parameter=_get_param(entity_linker_label_alias_embedding_transformer_task.config_spec, "model_name"), value="sentence-transformers/all-MiniLM-L6-v2"), - ParameterBinding(parameter=_get_param(entity_linker_label_alias_embedding_transformer_task.config_spec, "similarity_threshold"), value=0.5), - ], - ), - "relation_linker_label_alias_embedding_transformer": ConfigurationProfile( - name="relation_linker_label_alias_embedding_transformer", - definition=relation_linker_label_alias_embedding_transformer_task.config_spec, - bindings=[ - ParameterBinding(parameter=_get_param(relation_linker_label_alias_embedding_transformer_task.config_spec, "model_name"), value="sentence-transformers/all-MiniLM-L6-v2"), - ParameterBinding(parameter=_get_param(relation_linker_label_alias_embedding_transformer_task.config_spec, "similarity_threshold"), value=0.5), - ], - ), - }, - ) - -def test_text_pipeline_from_default_config(): - pipeline_config = get_default_text_pipeline_config() - - import os - os.environ["ONTOLOGY_PATH"] = "data/input_final/target_kg/ontology.ttl" - - seed_path = Path("data/input_final/target_kg/graph.nt") - source_path = Path("data/input_final/txt_source/docs") - result_path = Path("data/tmp/text_pipelines/result.nt") - tasks_tmp_dir = Path("data/tmp/text_pipelines/tasks_tmp") - tasks_tmp_dir.mkdir(parents=True, exist_ok=True) - - pipeline = KgPipe( - tasks=pipeline_config.tasks, - seed=Data(path=seed_path, format=DataFormat.RDF_NTRIPLES), - data_dir=tasks_tmp_dir, - name="test_text_pipeline") - - pipeline.build( - stable_files=True, - configCatalog=pipeline_config.config_catalog, - source=Data(path=source_path, format=DataFormat.TEXT), - result=Data(path=result_path, format=DataFormat.RDF_NTRIPLES)) - - pipeline.run(configCatalog=pipeline_config.config_catalog, stable_files_override=False) - - -@pytest.mark.parametrize("config_idx", range(len(load_text_sampled_pipeline_configs()))) -def test_text_pipeline_from_saved_sampled_configs(config_idx): - """Runs KGpipe using PipelineConfigs materialized from the JSON fixture written by test_pipeline_config.""" - configs = load_text_sampled_pipeline_configs() - assert configs, "fixtures/text_sampled_pipeline_configs.json is missing or empty; run test_enumerate_all_valid_text_task_combinations_with_config_sampling" - - pipeline_config = configs[config_idx] - - seed_path = Path("data/input_final/target_kg/graph.nt") - source_path = Path("data/input_final/txt_source/docs") - result_path = tmp_base_dir / f"text_result_saved_sample_config_idx_{config_idx}.nt" - tasks_tmp_dir = tmp_base_dir / f"text_tasks_tmp_saved_sample_config_idx_{config_idx}" - tasks_tmp_dir.mkdir(parents=True, exist_ok=True) - - pipeline = KgPipe( - tasks=pipeline_config.tasks, - seed=Data(path=seed_path, format=DataFormat.RDF_NTRIPLES), - data_dir=tasks_tmp_dir, - name="test_text_pipeline_saved_sample", - ) - - print(f"Building pipeline... {config_idx}") - print("#######################") - - pipeline.build( - stable_files=True, - configCatalog=pipeline_config.config_catalog, - source=Data(path=source_path, format=DataFormat.TEXT), - result=Data(path=result_path, format=DataFormat.RDF_NTRIPLES), - ) - - print(f"Running pipeline... {config_idx}") - print("#######################") - - pipeline.run(configCatalog=pipeline_config.config_catalog, stable_files_override=False) \ No newline at end of file diff --git a/experiments/param-opti/src/qap/test_ref_based.py b/experiments/param-opti/src/qap/test_ref_based.py deleted file mode 100644 index fca309f..0000000 --- a/experiments/param-opti/src/qap/test_ref_based.py +++ /dev/null @@ -1,205 +0,0 @@ -from kgpipe.common import KgPipe, Data, DataFormat -from kgpipe.common.model.configuration import ConfigurationProfile, ParameterBinding, ConfigurationDefinition -from param_opti.tasks.paris import paris_graph_alignment_task -from param_opti.tasks.fusion import fusion_first_value_task -from param_opti.tasks.openie import openie_pipeline_task -from param_opti.tasks.base_linker import relation_linker_label_alias_embedding_transformer_task, entity_linker_label_alias_embedding_transformer_task -from pathlib import Path -from typing import List -import pytest -# Using ground truth - -# 1. execute PARIS pipeline, with different thresholds -# 2. evaluate the quality of the pipeline, with different thresholds - - -# - [ ] impl paris wrapper with exchange and threshold filter - -ontology_path = "tmp/ontology.ttl" - -tmp_base_dir = Path("data/tmp/rdf_pipelines") -tmp_base_dir.mkdir(parents=True, exist_ok=True) - - -def _write_to_file(string: str, path: Path): - with open(path, "w") as f: - f.write(string) - -def _get_param(definition: ConfigurationDefinition, param_name: str): - params = getattr(definition, "parameters", None) - if params is None: - raise KeyError(f"Task config_spec has no parameters field (missing {param_name})") - - if hasattr(params, "get"): - p = params.get(param_name) - if p is None: - raise KeyError(f"Parameter {param_name} not found in config_spec.parameters") - return p - - for p in params: - if getattr(p, "name", None) == param_name: - return p - raise KeyError(f"Parameter {param_name} not found in config_spec.parameters") - - -def get_paris_pipeline(entity_matching_threshold: float, relation_matching_threshold: float): - name = ( - f"paris_graph_alignment(entity={entity_matching_threshold},rel={relation_matching_threshold})" - "_fusion_first_value" - ) - - seed_path = Path("data/inputs/target_kg/data.nt") - source_path = Path("data/inputs/rdf_source/data.nt") - result_path = Path(f"data/tmp/rdf_pipelines/result_{entity_matching_threshold}_{relation_matching_threshold}.nt") - tasks_tmp_dir = Path(f"data/tmp/rdf_pipelines/tasks_tmp_{entity_matching_threshold}_{relation_matching_threshold}") - tasks_tmp_dir.mkdir(parents=True, exist_ok=True) - - config_catalog = { - "paris_graph_alignment": ConfigurationProfile( - name=f"paris_graph_alignment_entity={entity_matching_threshold},relation={relation_matching_threshold}", - definition=paris_graph_alignment_task.config_spec, - bindings=[ - ParameterBinding( - parameter=_get_param(paris_graph_alignment_task.config_spec, "entity_matching_threshold"), - value=entity_matching_threshold, - ), - ParameterBinding( - parameter=_get_param(paris_graph_alignment_task.config_spec, "relation_matching_threshold"), - value=relation_matching_threshold, - ), - ], - ), - "fusion_first_value": ConfigurationProfile( - name="fusion_first_value", - definition=fusion_first_value_task.config_spec, - bindings=[ - ParameterBinding( - parameter=_get_param(fusion_first_value_task.config_spec, "ontology_path"), - value=ontology_path, - ), - ], - ) - } - - pipeline = KgPipe( - name=name, - tasks=[paris_graph_alignment_task, fusion_first_value_task], - seed=Data(path=seed_path, format=DataFormat.RDF_NTRIPLES), - data_dir=tasks_tmp_dir, - ) - - pipeline.build( - stable_files=True, - configCatalog=config_catalog, - source=Data(path=source_path, format=DataFormat.RDF_NTRIPLES), - result=Data(path=result_path, format=DataFormat.RDF_NTRIPLES), - ) - - return pipeline, config_catalog - -def get_openie_pipeline(entity_linking_threshold: float, relation_linking_threshold: float): - name = ( - f"openie_pipeline(entity={entity_linking_threshold},rel={relation_linking_threshold})" - ) - - seed_path = Path("data/inputs/target_kg/data.nt") - source_path = Path("data/inputs/text_source/docs") - result_path = Path(f"data/tmp/rdf_pipelines/result_{entity_linking_threshold}_{relation_linking_threshold}.nt") - tasks_tmp_dir = Path(f"data/tmp/rdf_pipelines/tasks_tmp_{entity_linking_threshold}_{relation_linking_threshold}") - tasks_tmp_dir.mkdir(parents=True, exist_ok=True) - - config_catalog = { - "openie_pipeline": ConfigurationProfile( - name=name, - definition=openie_pipeline_task.config_spec, - bindings=[ - ParameterBinding(parameter=_get_param(openie_pipeline_task.config_spec, "entity_linking_threshold"), value=entity_linking_threshold), - ParameterBinding(parameter=_get_param(openie_pipeline_task.config_spec, "relation_linking_threshold"), value=relation_linking_threshold), - ], - ), - } - - pipeline = KgPipe( - name=name, - tasks=[openie_pipeline_task, relation_linker_label_alias_embedding_transformer_task, entity_linker_label_alias_embedding_transformer_task], - seed=Data(path=seed_path, format=DataFormat.TEXT), - data_dir=tasks_tmp_dir, - ) - - pipeline.build( - stable_files=True, - configCatalog=config_catalog, - source=Data(path=source_path, format=DataFormat.TEXT), - result=Data(path=result_path, format=DataFormat.RDF_NTRIPLES), - ) - - return pipeline, config_catalog - -# parameterize the test with different thresholds for entity matching and relation matching -@pytest.mark.parametrize("entity_matching_threshold", [0.5, 0.6, 0.7, 0.8, 0.9]) -@pytest.mark.parametrize("relation_matching_threshold", [0.5, 0.6, 0.7, 0.8, 0.9]) -def test_paris_pipelines(entity_matching_threshold, relation_matching_threshold): - """ - test a paris pipeline with different thresholds for entity matching and relation matching - """ - pipeline, config_catalog = get_paris_pipeline( - entity_matching_threshold, relation_matching_threshold - ) - pipeline.run(configCatalog=config_catalog, stable_files_override=False) - print(f"Pipeline run with entity_matching_threshold={entity_matching_threshold} and relation_matching_threshold={relation_matching_threshold}") - - -@pytest.mark.parametrize("entity_matching_threshold", [0.5, 0.6, 0.7, 0.8, 0.9]) -@pytest.mark.parametrize("relation_matching_threshold", [0.5, 0.6, 0.7, 0.8, 0.9]) -def test_eval_paris_pipeline(entity_matching_threshold, relation_matching_threshold): - """ - evaluate a paris pipeline with different thresholds for entity matching and relation matching - current best "entity_alignment_0.9_0.7" with f1 score 0.971 - """ - - print(f"Evaluating triple alignment with entity_matching_threshold={entity_matching_threshold} and relation_matching_threshold={relation_matching_threshold}...") - from kgpipe_eval.utils.kg_utils import KgManager - from kgpipe_eval.metrics.triple_alignment import TripleAlignmentMetric, TripleAlignmentConfig - from kgpipe_eval.metrics.entity_alignment import EntityAlignmentMetric, EntityAlignmentConfig - from kgpipe_eval.api import MetricResult - from kgpipe_eval.test.utils import render_metric_result - - ref_kg_path = Path("data/inputs/reference_kg/data_agg.nt") - gen_kg_path = Path(f"data/tmp/rdf_pipelines/result_{entity_matching_threshold}_{relation_matching_threshold}.nt") - - entity_alignment_config = EntityAlignmentConfig( - method="label_embedding", - reference_kg=ref_kg_path, - verified_entities_path=None, - verified_entities_delimiter="\t", - entity_sim_threshold=0.95 - ) - - tg = KgManager.load_kg(gen_kg_path) - metric_result : MetricResult = EntityAlignmentMetric().compute(tg, entity_alignment_config) - result_string = render_metric_result(metric_result) - _write_to_file(result_string, Path(f"data/tmp/rdf_pipelines/entity_alignment_{entity_matching_threshold}_{relation_matching_threshold}.txt")) - - - triple_alignment_config = TripleAlignmentConfig( - reference_kg=ref_kg_path, - entity_alignment_config=entity_alignment_config, - value_sim_threshold=0.5, - cache_literal_embeddings=True - ) - - tg = KgManager.load_kg(gen_kg_path) - metric_result : MetricResult = TripleAlignmentMetric().compute(tg, triple_alignment_config) - result_string = render_metric_result(metric_result) - _write_to_file(result_string, Path(f"data/tmp/rdf_pipelines/triple_alignment_{entity_matching_threshold}_{relation_matching_threshold}.txt")) - - -@pytest.mark.parametrize("entity_linking_threshold", [0.5, 0.6, 0.7, 0.8, 0.9]) -@pytest.mark.parametrize("relation_linking_threshold", [0.5, 0.6, 0.7, 0.8, 0.9]) -def test_openie_pipeline(entity_linking_threshold, relation_linking_threshold): - """ - test the openie pipeline - """ - pipeline, config_catalog = get_openie_pipeline(entity_linking_threshold, relation_linking_threshold) - - print(pipeline.plan()) \ No newline at end of file diff --git a/experiments/param-opti/src/qap/test_sge_based.py b/experiments/param-opti/src/qap/test_sge_based.py deleted file mode 100644 index b6674bd..0000000 --- a/experiments/param-opti/src/qap/test_sge_based.py +++ /dev/null @@ -1,36 +0,0 @@ -def eval_paris_pipeline(entity_matching_threshold: float, relation_matching_threshold: float): - """ - evaluate a paris pipeline with different thresholds for entity matching and relation matching - """ - pass - - # ref_kg_path = Path("data/inputs/reference_kg/data_agg.nt") - # gen_kg_path = Path(f"data/tmp/rdf_pipelines/result_{entity_matching_threshold}_{relation_matching_threshold}.nt") - - # source_grounded_correctness_config = SourceGroundedCorrectnessConfig( - # kg_graph=ref_kg_path, - # source_corpus=gen_kg_path, - # index_dir=Path("data/tmp/source_grounded_correctness"), - # verbalize_method="natural", - # verifier="nli", - # nli_model="facebook/bart-large-mnli", - # nli_device="cpu", - # llm_model="gpt-4.1-mini", - # llm_device="cpu" - # ) - - # source_grounded_correctness_metric = SourceGroundedCorrectnessMetric() - # source_grounded_correctness_metric.compute(KgManager.load_kg(gen_kg_path), source_grounded_correctness_config) - -def eval_openie_pipeline(): - """ - evaluate an openie pipeline - """ - pass - - # ref_kg_path = Path("data/inputs/reference_kg/data_agg.nt") - # gen_kg_path = Path(f"data/tmp/rdf_pipelines/result_{entity_matching_threshold}_{relation_matching_threshold}.nt") - - # source_grounded_coverage_config = SourceGroundedCoverageConfig( - # kg_graph=ref_kg_path, - # source_corpus=gen_kg_path, \ No newline at end of file diff --git a/experiments/param-opti/src/qap_mock/__init__.py b/experiments/param-opti/src/qap_mock/__init__.py deleted file mode 100644 index ddd4d3e..0000000 --- a/experiments/param-opti/src/qap_mock/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -""" -Mock implementation of the experiments described in `Quality_Aware_Pipelines.pdf`. - -This package is intentionally self-contained and does not depend on KGpipe. -It simulates: -- A small configuration space (implementations + parameters) -- A "true" end-to-end quality objective -- A correlated approximate quality estimator -- Search strategies (default, random, quality-aware) -""" - -from .models import PipelineFamily, SearchMethod - -__all__ = ["PipelineFamily", "SearchMethod"] - diff --git a/experiments/param-opti/src/qap_mock/__main__.py b/experiments/param-opti/src/qap_mock/__main__.py deleted file mode 100644 index 405c19e..0000000 --- a/experiments/param-opti/src/qap_mock/__main__.py +++ /dev/null @@ -1,57 +0,0 @@ -from __future__ import annotations - -import argparse -import json -from pathlib import Path - -from .experiments import ( - experiment_1_search_effectiveness, - experiment_2_estimation_reliability, - experiment_3_dimension_impact, -) - - -def main(argv: list[str] | None = None) -> int: - p = argparse.ArgumentParser( - description="Mock experiments for Quality_Aware_Pipelines.pdf (quality-aware search)" - ) - p.add_argument( - "which", - choices=["exp1", "exp2", "exp3", "all"], - help="Which experiment(s) to run", - ) - p.add_argument( - "--outdir", - type=Path, - default=Path(__file__).parent.parent.parent / "output_qap_mock", - help="Output directory for JSON results", - ) - p.add_argument("--budget", type=int, default=20, help="Evaluation budget B (exp1/exp3)") - p.add_argument("--runs", type=int, default=5, help="Number of runs/seeds (exp1/exp3)") - p.add_argument("--samples", type=int, default=60, help="Number of sampled configs (exp2)") - - args = p.parse_args(argv) - - results: dict[str, object] = {} - - if args.which in ("exp1", "all"): - results["exp1"] = experiment_1_search_effectiveness( - outdir=args.outdir, budget=args.budget, runs=args.runs - ) - if args.which in ("exp2", "all"): - results["exp2"] = experiment_2_estimation_reliability( - outdir=args.outdir, n_samples=args.samples - ) - if args.which in ("exp3", "all"): - results["exp3"] = experiment_3_dimension_impact( - outdir=args.outdir, budget=args.budget, runs=args.runs - ) - - # Short stdout summary so it's easy to sanity-check runs. - print(json.dumps({"outdir": str(args.outdir), "ran": list(results.keys())}, indent=2)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) - diff --git a/experiments/param-opti/src/qap_mock/experiments.py b/experiments/param-opti/src/qap_mock/experiments.py deleted file mode 100644 index e0ec17c..0000000 --- a/experiments/param-opti/src/qap_mock/experiments.py +++ /dev/null @@ -1,204 +0,0 @@ -from __future__ import annotations - -import json -import random -from dataclasses import dataclass -from pathlib import Path -from typing import Dict, List, Optional - -from .models import PipelineFamily, SearchMethod, SearchSpaceMode -from .search import ( - best_so_far_curve, - evals_to_fraction_of_final_best, - run_search, -) -from .search_space import get_family_space, sample_config -from .stats import mae, mean, pearsonr, spearmanr, stdev, topk_agreement -from .objectives import evaluate_true_quality, estimate_quality_from_config - - -@dataclass -class Exp1Cell: - mean_best: float - std_best: float - mean_evals_to_95: Optional[float] - - def as_dict(self) -> dict: - return { - "best_score_mean": self.mean_best, - "best_score_std": self.std_best, - "evals_to_95_mean": self.mean_evals_to_95, - } - - -def _ensure_outdir(outdir: Path) -> None: - outdir.mkdir(parents=True, exist_ok=True) - - -def experiment_1_search_effectiveness( - *, - outdir: Path, - budget: int = 20, - runs: int = 5, - base_seed: int = 7, -) -> dict: - """ - Mirrors Section 6.3 / Table 2 narrative: - - Compare Default, Random Search, Quality-Aware Search - - Fixed budget B=20 - - Report best achieved score (mean ± std over 5 runs) - - Report mean evaluations to reach 95% of each run's final best - """ - _ensure_outdir(outdir) - - methods = [SearchMethod.DEFAULT] #, SearchMethod.RANDOM, SearchMethod.QUALITY_AWARE] - families = [PipelineFamily.RDF, PipelineFamily.TEXT] - - table: Dict[str, Dict[str, Exp1Cell]] = {} - raw: Dict[str, Dict[str, List[dict]]] = {} - - for fam in families: - fam_key = fam.value - table[fam_key] = {} - raw[fam_key] = {} - - for m in methods: - seeds = [base_seed + i for i in range(runs)] - bests: List[float] = [] - evals95: List[float] = [] - raw_runs: List[dict] = [] - - for i, s in enumerate(seeds): - recs = run_search( - seed=10_000 * (i + 1) + s, - family=fam, - method=m, - budget=budget, - mode=SearchSpaceMode.JOINT, - ) - curve = best_so_far_curve(recs) - bests.append(curve[-1]) - e95 = evals_to_fraction_of_final_best(curve, 0.95) - if e95 is not None: - evals95.append(float(e95)) - - raw_runs.append( - { - "seed": s, - "curve_best_so_far": curve, - } - ) - - cell = Exp1Cell( - mean_best=mean(bests), - std_best=stdev(bests) if m != SearchMethod.DEFAULT else float("nan"), - mean_evals_to_95=mean(evals95) if (m != SearchMethod.DEFAULT and evals95) else None, - ) - table[fam_key][m.value] = cell - raw[fam_key][m.value] = raw_runs - - result = { - "budget": budget, - "runs": runs, - "table": { - fam: {meth: cell.as_dict() for meth, cell in methods_.items()} - for fam, methods_ in table.items() - }, - "raw": raw, - } - - (outdir / "exp1_search_effectiveness.json").write_text(json.dumps(result, indent=2)) - return result - - -def experiment_2_estimation_reliability( - *, - outdir: Path, - n_samples: int = 60, - seed: int = 23, - topk: int = 10, -) -> dict: - """ - Mirrors Section 6.4 narrative: - - sample configurations - - compute estimated vs true scores - - compute correlation (Pearson/Spearman), MAE, top-k agreement - """ - _ensure_outdir(outdir) - - rng = random.Random(seed) - families = [PipelineFamily.RDF, PipelineFamily.TEXT] - - out: Dict[str, dict] = {"n_samples": n_samples, "topk": topk, "by_family": {}} - - for fam in families: - true_scores: List[float] = [] - est_scores: List[float] = [] - - for _ in range(n_samples): - cfg = sample_config(rng, fam, mode=SearchSpaceMode.JOINT) - true = evaluate_true_quality(rng, cfg).total - est = estimate_quality_from_config(rng, cfg) - true_scores.append(true) - est_scores.append(est) - - fam_key = fam.value - out["by_family"][fam_key] = { - "pearson": pearsonr(est_scores, true_scores), - "spearman": spearmanr(est_scores, true_scores), - "mae": mae(est_scores, true_scores), - "topk_agreement": topk_agreement(est_scores, true_scores, topk), - } - - (outdir / "exp2_estimation_reliability.json").write_text(json.dumps(out, indent=2)) - return out - - -def experiment_3_dimension_impact( - *, - outdir: Path, - budget: int = 20, - runs: int = 5, - base_seed: int = 101, -) -> dict: - """ - Mirrors Section 6.5 narrative: - Compare best scores for restricted spaces: - - implementation-only - - parameter-only - - joint - """ - _ensure_outdir(outdir) - - families = [PipelineFamily.RDF, PipelineFamily.TEXT] - modes = [ - SearchSpaceMode.IMPLEMENTATION_ONLY, - SearchSpaceMode.PARAMETER_ONLY, - SearchSpaceMode.JOINT, - ] - - out: Dict[str, dict] = {"budget": budget, "runs": runs, "by_family": {}} - - for fam in families: - fam_out: Dict[str, dict] = {} - for mode in modes: - bests: List[float] = [] - for i in range(runs): - seed = base_seed + i * 17 - recs = run_search( - seed=20_000 * (i + 1) + seed, - family=fam, - method=SearchMethod.QUALITY_AWARE, - budget=budget, - mode=mode, - ) - curve = best_so_far_curve(recs) - bests.append(curve[-1]) - - fam_out[mode.value] = {"best_mean": mean(bests), "best_std": stdev(bests)} - - out["by_family"][fam.value] = fam_out - - (outdir / "exp3_dimension_impact.json").write_text(json.dumps(out, indent=2)) - return out - diff --git a/experiments/param-opti/src/qap_mock/models.py b/experiments/param-opti/src/qap_mock/models.py deleted file mode 100644 index 5809be5..0000000 --- a/experiments/param-opti/src/qap_mock/models.py +++ /dev/null @@ -1,37 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from enum import Enum -from typing import Mapping - - -class PipelineFamily(str, Enum): - RDF = "rdf" - TEXT = "text" - - -class SearchMethod(str, Enum): - DEFAULT = "default" - RANDOM = "random" - QUALITY_AWARE = "quality_aware" - - -class SearchSpaceMode(str, Enum): - JOINT = "joint" - IMPLEMENTATION_ONLY = "implementation_only" - PARAMETER_ONLY = "parameter_only" - - -@dataclass(frozen=True) -class PipelineConfig: - family: PipelineFamily - implementations: Mapping[str, str] - params: Mapping[str, float] - - def as_dict(self) -> dict: - return { - "family": self.family.value, - "implementations": dict(self.implementations), - "params": dict(self.params), - } - diff --git a/experiments/param-opti/src/qap_mock/objectives.py b/experiments/param-opti/src/qap_mock/objectives.py deleted file mode 100644 index 8691ebe..0000000 --- a/experiments/param-opti/src/qap_mock/objectives.py +++ /dev/null @@ -1,226 +0,0 @@ -from __future__ import annotations - -import math -import random -from dataclasses import dataclass - -from .models import PipelineConfig, PipelineFamily -from .pipeline_util import ( - compute_rdf_metrics, - compute_te_metrics, - default_base_workdir, - run_pipeline_for_config, - TEST_DATA_ONTOLOGY_PATH, - # _test_data_path, -) - - -@dataclass(frozen=True) -class QualityBreakdown: - accuracy: float - coverage: float - consistency: float - total: float - - -def _sigmoid(x: float) -> float: - return 1.0 / (1.0 + math.exp(-x)) - - -def _base_quality_components(cfg: PipelineConfig) -> tuple[float, float, float]: - """ - Deterministic (noise-free) quality components for a configuration. - - This is used both for the simulated "true" evaluation (with added noise) - and for the approximate estimator (with different noise). - """ - if cfg.family == PipelineFamily.RDF: - impl_acc = 0.0 - impl_cov = 0.0 - impl_con = 0.0 - - om = cfg.implementations["ontology_matching"] - if om == "string_sim": - impl_con += 0.01 - elif om == "embedding_sim": - impl_acc += 0.05 - impl_cov += 0.02 - elif om == "hybrid": - impl_acc += 0.06 - impl_cov += 0.03 - impl_con += 0.01 - elif om == "llm_alignment": - impl_cov += 0.05 - impl_acc += 0.04 - impl_con -= 0.01 - - em = cfg.implementations["entity_matching"] - if em == "rule_based": - impl_con += 0.02 - elif em == "blocking_sim": - impl_acc += 0.04 - impl_cov += 0.02 - elif em == "embedding_er": - impl_acc += 0.06 - impl_cov += 0.03 - elif em == "llm_er": - impl_cov += 0.05 - impl_acc += 0.05 - impl_con -= 0.01 - - fu = cfg.implementations["fusion"] - if fu == "union": - impl_cov += 0.03 - elif fu == "majority_vote": - impl_con += 0.03 - impl_acc += 0.01 - elif fu == "quality_weighted": - impl_con += 0.06 - impl_acc += 0.02 - - s_thr = float(cfg.params["schema_sim_threshold"]) - e_thr = float(cfg.params["entity_sim_threshold"]) - f_thr = float(cfg.params["fusion_confidence_threshold"]) - bk = float(cfg.params.get("blocking_key_strength", 0.5)) - - acc = 0.55 + 0.18 * _sigmoid((s_thr - 0.65) * 8) + 0.18 * _sigmoid((e_thr - 0.65) * 8) - cov = 0.65 - 0.25 * _sigmoid((s_thr - 0.6) * 7) - 0.25 * _sigmoid((e_thr - 0.6) * 7) - con = 0.55 + 0.20 * _sigmoid((f_thr - 0.45) * 6) - - strict = (s_thr + e_thr) / 2.0 - con -= 0.05 * _sigmoid((strict - 0.85) * 10) - - cov += 0.03 * _sigmoid((bk - 0.3) * 6) - acc -= 0.02 * _sigmoid((bk - 0.8) * 10) - - acc += impl_acc - cov += impl_cov - con += impl_con - - return acc, cov, con - - if cfg.family == PipelineFamily.TEXT: - impl_acc = 0.0 - impl_cov = 0.0 - impl_con = 0.0 - - ie = cfg.implementations["information_extraction"] - if ie == "pattern_ie": - impl_con += 0.01 - elif ie == "openie": - impl_cov += 0.04 - impl_acc += 0.01 - elif ie == "hybrid_ie": - impl_cov += 0.06 - impl_acc += 0.02 - impl_con += 0.01 - elif ie == "llm_ie": - impl_cov += 0.08 - impl_acc += 0.03 - impl_con -= 0.01 - - el = cfg.implementations["entity_linking"] - if el == "dictionary_linking": - impl_cov += 0.02 - elif el == "embedding_linking": - impl_acc += 0.06 - elif el == "llm_linking": - impl_acc += 0.07 - impl_cov += 0.02 - impl_con -= 0.01 - - fu = cfg.implementations["fusion"] - if fu == "union": - impl_cov += 0.03 - elif fu == "majority_vote": - impl_con += 0.03 - impl_acc += 0.01 - elif fu == "quality_weighted": - impl_con += 0.07 - impl_acc += 0.02 - - ie_thr = float(cfg.params["ie_conf_threshold"]) - link_thr = float(cfg.params["link_sim_threshold"]) - f_thr = float(cfg.params["fusion_confidence_threshold"]) - cw = float(cfg.params.get("context_window", 256.0)) - - acc = 0.40 + 0.22 * _sigmoid((link_thr - 0.6) * 7) + 0.10 * _sigmoid((ie_thr - 0.55) * 6) - cov = 0.55 - 0.28 * _sigmoid((ie_thr - 0.55) * 7) - 0.18 * _sigmoid((link_thr - 0.6) * 6) - con = 0.45 + 0.22 * _sigmoid((f_thr - 0.45) * 6) - - noisy = (0.6 - ie_thr) + (0.6 - link_thr) - con -= 0.10 * _sigmoid(noisy * 6) - - cov += 0.03 * _sigmoid((cw - 160.0) / 60.0) - con -= 0.02 * _sigmoid((cw - 420.0) / 70.0) - - acc += impl_acc - cov += impl_cov - con += impl_con - - return acc, cov, con - - raise ValueError(f"Unknown family: {cfg.family}") - - -def evaluate_true_quality(rng: random.Random, cfg: PipelineConfig) -> QualityBreakdown: - """ - Real(ish) end-to-end objective: run a KGpipe pipeline for this config and - compute measurable proxy metrics from its outputs. - - Notes: - - This intentionally uses bundled `kgpipe_tasks/test/test_data` inputs so - the experiments are runnable out of the box. - - Metrics are proxy/reference-independent signals (no gold labels yet). - """ - base = default_base_workdir() - run = run_pipeline_for_config(cfg=cfg, base_workdir=base, stable_files=False) - - if cfg.family == PipelineFamily.RDF: - ontology = TEST_DATA_ONTOLOGY_PATH - m = compute_rdf_metrics(output_nt=run.final_output.path, ontology_ttl=ontology) - else: - m = compute_te_metrics(te_json_path=run.final_output.path) - - acc = min(1.0, max(0.0, float(m["accuracy"]))) - cov = min(1.0, max(0.0, float(m["coverage"]))) - con = min(1.0, max(0.0, float(m["consistency"]))) - - total = 0.45 * acc + 0.30 * cov + 0.25 * con - total = min(1.0, max(0.0, total)) - return QualityBreakdown(accuracy=acc, coverage=cov, consistency=con, total=total) - - -def estimate_quality_from_config(rng: random.Random, cfg: PipelineConfig) -> float: - """ - Approximate estimator Q-hat used by the quality-aware search to rank candidates - without executing the full pipeline. - - For now this remains a cheap heuristic over the config (so the search is not - dominated by expensive runs). The "true" objective is produced by actually - executing the pipeline in `evaluate_true_quality`. - """ - acc, cov, con = _base_quality_components(cfg) - # Estimator has its own noise and slight systematic distortion. - if cfg.family == PipelineFamily.RDF: - acc += rng.gauss(0.0, 0.015) - cov += rng.gauss(0.0, 0.015) - con += rng.gauss(0.0, 0.015) - else: - acc += rng.gauss(0.0, 0.020) - cov += rng.gauss(0.0, 0.020) - con += rng.gauss(0.0, 0.020) - - acc = min(1.0, max(0.0, acc)) - cov = min(1.0, max(0.0, cov)) - con = min(1.0, max(0.0, con)) - est = 0.45 * acc + 0.30 * cov + 0.25 * con - return min(1.0, max(0.0, est)) - - -def estimate_quality(rng: random.Random, true_total: float, family: PipelineFamily) -> float: - raise RuntimeError( - "estimate_quality(true_total, family) is deprecated; " - "use estimate_quality_from_config(rng, cfg) instead." - ) - diff --git a/experiments/param-opti/src/qap_mock/pipeline_util.py b/experiments/param-opti/src/qap_mock/pipeline_util.py deleted file mode 100644 index e5f0890..0000000 --- a/experiments/param-opti/src/qap_mock/pipeline_util.py +++ /dev/null @@ -1,414 +0,0 @@ -from __future__ import annotations - -import hashlib -import json -import os -import tempfile -from dataclasses import dataclass -from pathlib import Path -from typing import TYPE_CHECKING, Any, Iterable, Optional - -if TYPE_CHECKING: - from kgpipe.common import Data, DataFormat, KgPipe, KgTask # pragma: no cover - from kgpipe.common.model.task import KgTaskReport # pragma: no cover - -from .models import PipelineConfig, PipelineFamily - - -@dataclass(frozen=True) -class PipelineRunResult: - family: PipelineFamily - cfg: PipelineConfig - workdir: Path - final_output: Any # Data - task_reports: Any # list[KgTaskReport] - aux: dict - - -TEST_DATA_SEED_KG_PATH = Path("/home/marvin/project/data/final/film_1k/split_0/kg/seed/data.nt") -TEST_DATA_ONTOLOGY_PATH = Path("/home/marvin/project/data/final/film_1k/movie-ontology.ttl") -TEST_DATA_RDF_PATH = Path("/home/marvin/project/data/final/film_1k/split_1/sources/rdf/data.nt") -TEST_DATA_TEXT_PATH = Path("/home/marvin/project/data/final/film_1k/split_1/sources/text/data/") - -def _import_tasks_for_family(family: PipelineFamily) -> None: - """ - Import task modules so their @Registry.task decorators execute. - - This keeps the rest of qap_mock independent from kgpipe_tasks import side effects. - """ - # RDF: PARIS matcher + exchange + fusion tasks. - if family == PipelineFamily.RDF: - # Entity matching (docker) + exchange (python) - import kgpipe_tasks.entity_resolution.matcher.paris_rdf_matcher # noqa: F401 - import kgpipe_tasks.entity_resolution.entity_match # noqa: F401 - - # Fusion (python) - import kgpipe_tasks.entity_resolution.fusion.union # noqa: F401 - import kgpipe_tasks.entity_resolution.fusion.preference # noqa: F401 - - return - - if family == PipelineFamily.TEXT: - # CoreNLP OpenIE extraction (docker) + exchange (python) - import kgpipe_tasks.text_processing.text_extraction.corenlp_extraction # noqa: F401 - - return - - raise ValueError(f"Unknown family: {family}") - - -def _cfg_hash(cfg: PipelineConfig) -> str: - payload = json.dumps(cfg.as_dict(), sort_keys=True, separators=(",", ":")).encode("utf-8") - return hashlib.sha256(payload).hexdigest()[:16] - - -def _ensure_dir(p: Path) -> None: - p.mkdir(parents=True, exist_ok=True) - - -# def _test_data_path(relative_path: str) -> Path: -# """ -# Use kgpipe_tasks' bundled test data as default inputs so qap_mock is runnable. -# """ -# base = Path(__file__).resolve().parents[3] / "src" / "kgpipe_tasks" / "test" / "test_data" -# path = (base / relative_path).resolve() -# if not path.exists(): -# raise FileNotFoundError(f"Missing test data file: {path}") -# return path - - -def _set_env_from_params(params: dict[str, float]) -> dict[str, Optional[str]]: - """ - Apply a minimal mapping from qap_mock params to the env-var based configuration - convention used by many kgpipe tasks. - - Returns a dict of previous env values so callers can restore them. - """ - # Only set variables that are known to be read by the tasks we use. - mapping: dict[str, tuple[str, float]] = { - # RDF fusion/preference tasks - "ENTITY_MATCHING_THRESHOLD": ("entity_sim_threshold", 0.7), - "RELATION_MATCHING_THRESHOLD": ("schema_sim_threshold", 0.7), - # Text: no stable env knobs used by CoreNLP task today - } - - prev: dict[str, Optional[str]] = {} - for env_key, (p_key, default) in mapping.items(): - prev[env_key] = os.environ.get(env_key) - val = float(params.get(p_key, default)) - os.environ[env_key] = str(val) - return prev - - -def _restore_env(prev: dict[str, Optional[str]]) -> None: - for k, v in prev.items(): - if v is None: - os.environ.pop(k, None) - else: - os.environ[k] = v - - -def build_pipeline_for_config(*, cfg: PipelineConfig, workdir: Path) -> tuple[KgPipe, Data, Data]: - """ - Build a runnable KgPipe for the given configuration. - - We intentionally keep the mapping small and explicit: - - RDF: (optional) PARIS entity matching -> exchange -> fusion - - TEXT: CoreNLP OpenIE extraction (docker) -> exchange - - Returns (pipe, source, final_result_data). - """ - from kgpipe.common import Data, DataFormat, KgPipe, KgTask, Registry - - _import_tasks_for_family(cfg.family) - _ensure_dir(workdir) - - if cfg.family == PipelineFamily.RDF: - # Inputs: source + target (as seed) are bundled test fixtures. - source = Data(path=TEST_DATA_RDF_PATH, format=DataFormat.RDF_NTRIPLES) - target = Data(path=TEST_DATA_SEED_KG_PATH, format=DataFormat.RDF_NTRIPLES) - - # Ensure ontology env is set for fusion tasks that need it. - ontology_path = TEST_DATA_ONTOLOGY_PATH - os.environ.setdefault("ONTOLOGY_PATH", str(ontology_path)) - - # Decide whether to run entity matching. If we don't, we can still - # compute a meaningful output via simple union. - entity_impl = cfg.implementations.get("entity_matching", "rule_based") - fusion_impl = cfg.implementations.get("fusion", "union") - use_docker = os.environ.get("QAP_MOCK_USE_DOCKER", "0") == "1" - - tasks: list[KgTask] = [] - final_format = DataFormat.RDF_NTRIPLES - - def _empty_er(inputs: dict[str, Data], outputs: dict[str, Data]) -> None: - out_path = Path(outputs["output"].path) - out_path.parent.mkdir(parents=True, exist_ok=True) - out_path.write_text(json.dumps({"matches": [], "blocks": [], "clusters": []}, indent=2), encoding="utf-8") - - # dummy_entity_matching = KgTask( - # name="dummy_entity_matching", - # input_spec={"source": DataFormat.RDF_NTRIPLES, "target": DataFormat.RDF_NTRIPLES}, - # output_spec={"output": DataFormat.ER_JSON}, - # function=_empty_er, - # description="Dummy matcher emitting empty ER_JSON (no docker)", - # ) - - # if entity_impl != "rule_based": - # if use_docker: - tasks.extend( - [ - Registry.get_task("paris_entity_matching"), - Registry.get_task("paris_exchange"), - ] - ) - # When matches exist, prefer a fusion strategy that uses them. - if fusion_impl in ("quality_weighted", "majority_vote"): - tasks.append(Registry.get_task("fusion_first_value")) - else: - tasks.append(Registry.get_task("union_matched_rdf")) - # else: - # # Non-docker mode: skip PARIS and run a deterministic empty matcher. - # tasks.extend([dummy_entity_matching, Registry.get_task("union_matched_rdf")]) - # else: - # # No matching step: just union the two graphs. - # tasks.append(Registry.get_task("fusion_union_rdf")) - - # seed is the "kg"/target, which KgPipe.build will use when a task - # declares an input named "kg". - pipe = KgPipe(tasks=tasks, seed=target, data_dir=str(workdir), name=f"qap_mock_{cfg.family.value}") - - final = Data(path=workdir / "final.nt", format=final_format) - return pipe, source, final - - if cfg.family == PipelineFamily.TEXT: - text = Data(path=TEST_DATA_TEXT_PATH, format=DataFormat.TEXT) - - ie_impl = cfg.implementations.get("information_extraction", "pattern_ie") - - def _pattern_ie(inputs: dict[str, Data], outputs: dict[str, Data]) -> None: - import re - - in_path = Path(inputs["input"].path) - out_path = Path(outputs["output"].path) - out_path.mkdir(parents=True, exist_ok=True) - - txt = _read_text(in_path) - # Tiny, deterministic pattern extractor: "X is a Y" / "X is an Y". - triples = [] - for m in re.finditer(r"([A-Z][A-Za-z0-9_ ]{2,40}) is an? ([A-Za-z][A-Za-z0-9_ -]{2,40})", txt): - subj = m.group(1).strip() - obj = m.group(2).strip() - triples.append( - { - "subject": {"surface_form": subj}, - "predicate": {"surface_form": "is_a"}, - "object": {"surface_form": obj}, - } - ) - - doc = {"text": txt[:10_000], "triples": triples, "chains": [], "links": []} - (out_path / "pattern_ie.te.json").write_text(json.dumps(doc), encoding="utf-8") - - pattern_ie_task = KgTask( - name="pattern_ie_extraction", - input_spec={"input": DataFormat.TEXT}, - output_spec={"output": DataFormat.TE_JSON}, - function=_pattern_ie, - description="Lightweight pattern IE (no docker)", - ) - - if ie_impl == "pattern_ie": - tasks = [pattern_ie_task] - else: - use_docker = os.environ.get("QAP_MOCK_USE_DOCKER", "0") == "1" - if use_docker: - # Use CoreNLP OpenIE path for openie/hybrid/llm variants (docker-backed). - tasks = [ - Registry.get_task("corenlp_openie_extraction"), - Registry.get_task("corenlp_exchange"), - ] - else: - # Default to the lightweight extractor when docker isn't enabled. - tasks = [pattern_ie_task] - - pipe = KgPipe(tasks=tasks, seed=text, data_dir=str(workdir), name=f"qap_mock_{cfg.family.value}") - # Many TE_JSON-producing tasks treat the output as a directory of documents. - final = Data(path=workdir / "final_te", format=DataFormat.TE_JSON) - return pipe, text, final - - raise ValueError(f"Unknown family: {cfg.family}") - - -def run_pipeline_for_config( - *, cfg: PipelineConfig, base_workdir: Path, stable_files: bool = True -) -> PipelineRunResult: - """ - Execute a real KGpipe pipeline for this config and return its artifacts. - - Results are cached by (family, cfg-hash) under base_workdir to avoid repeating - expensive docker/service calls during search. - """ - run_id = f"{cfg.family.value}_{_cfg_hash(cfg)}" - workdir = base_workdir / run_id - _ensure_dir(workdir) - - try: - pipe, source, final = build_pipeline_for_config(cfg=cfg, workdir=workdir) - except ModuleNotFoundError as e: - raise RuntimeError( - "KGpipe dependencies are not installed in this environment. " - "To run the *real* (non-mock) execution path, install the project in editable mode:\n\n" - " python3 -m pip install -e .\n\n" - "This will also install the `kgcore` dependency declared in `pyproject.toml`.\n" - f"Original import error: {e}" - ) from e - - # Apply env-var config mapping used by tasks. - prev_env = _set_env_from_params(dict(cfg.params)) - try: - # If final exists and stable_files=True, KgTask.run will skip; still ok. - pipe.build(source=source, result=final, stable_files=stable_files) - reports = pipe.run(stable_files_override=stable_files) - finally: - _restore_env(prev_env) - - return PipelineRunResult( - family=cfg.family, - cfg=cfg, - workdir=workdir, - final_output=final, - task_reports=reports, - aux={"source": str(source.path), "seed": str(pipe.seed.path), "run_id": run_id}, - ) - - -def _read_text(path: Path, max_bytes: int = 4_000_000) -> str: - # Keep it simple and avoid huge reads in case a docker task goes wild. - data = path.read_bytes() - if len(data) > max_bytes: - data = data[:max_bytes] - return data.decode("utf-8", errors="replace") - - -def compute_rdf_metrics(*, output_nt: Path, ontology_ttl: Optional[Path] = None) -> dict[str, float]: - import importlib - - try: - rdflib = importlib.import_module("rdflib") - Graph = getattr(rdflib, "Graph") - URIRef = getattr(importlib.import_module("rdflib.term"), "URIRef") - g = Graph() - g.parse(output_nt, format="nt") - triples = len(g) - except Exception: - # Fallback without rdflib: approximate triples by counting lines. - txt = _read_text(output_nt) - triples = len([ln for ln in txt.splitlines() if ln.strip() and not ln.strip().startswith("#")]) - Graph = None # type: ignore[assignment] - URIRef = None # type: ignore[assignment] - g = None # type: ignore[assignment] - - # Consistency proxy: fraction of predicates that appear in ontology (or common RDF vocab). - allowed: set[str] = set() - if Graph is not None and URIRef is not None and ontology_ttl is not None and ontology_ttl.exists(): - try: - og = Graph() - og.parse(ontology_ttl) - # Allow all predicates defined as properties + rdfs:label/rdf:type. - for s, _, _ in og: - # cheap heuristic: treat all subjects that are URIRefs as "allowed" predicates - if isinstance(s, URIRef): - allowed.add(str(s)) - allowed.add("http://www.w3.org/2000/01/rdf-schema#label") - allowed.add("http://www.w3.org/1999/02/22-rdf-syntax-ns#type") - except Exception: - allowed = set() - - if allowed and g is not None and URIRef is not None: - ok = 0 - for _, p, _ in g: - if isinstance(p, URIRef) and str(p) in allowed: - ok += 1 - consistency = ok / max(1, triples) - else: - consistency = 0.5 - - # Coverage proxy: normalize by union of input graphs when using bundled test data. - try: - src = Graph().parse(TEST_DATA_RDF_PATH, format="nt") - tgt = Graph().parse(TEST_DATA_SEED_KG_PATH, format="nt") - union_triples = len(src) + len(tgt) - coverage = min(1.0, triples / max(1, union_triples)) - except Exception: - coverage = min(1.0, triples / 10_000.0) - - # Accuracy proxy: reward non-trivial graphs (very small outputs are likely bad). - accuracy = min(1.0, max(0.0, (triples / 2000.0))) - - return {"accuracy": float(accuracy), "coverage": float(coverage), "consistency": float(consistency)} - - -def compute_te_metrics(*, te_json_path: Path) -> dict[str, float]: - """ - Compute lightweight metrics from TE_JSON outputs. - - This intentionally avoids requiring a gold standard. It's a pragmatic proxy: - - coverage ~ extracted triples count - - consistency ~ fraction of triples that have all 3 spans populated - - accuracy ~ average link score if links exist, else a baseline - """ - # TE_JSON may be a directory (many files) or a single file. - triples = 0 - complete = 0 - link_scores: list[float] = [] - - paths: Iterable[Path] - if te_json_path.is_dir(): - paths = [p for p in te_json_path.iterdir() if p.is_file()] - else: - paths = [te_json_path] - - for p in paths: - try: - doc = json.loads(_read_text(p)) - except Exception: - continue - for t in doc.get("triples", []) or []: - triples += 1 - s = (t.get("subject") or {}).get("surface_form") - r = (t.get("predicate") or {}).get("surface_form") - o = (t.get("object") or {}).get("surface_form") - if s and r and o: - complete += 1 - for l in doc.get("links", []) or []: - try: - link_scores.append(float(l.get("score", 0.0))) - except Exception: - pass - - # Normalize coverage against a rough scale for the bundled Hobbit text. - coverage = min(1.0, triples / 5000.0) - consistency = complete / max(1, triples) if triples else 0.0 - accuracy = (sum(link_scores) / len(link_scores)) if link_scores else 0.35 - accuracy = min(1.0, max(0.0, accuracy)) - - return {"accuracy": float(accuracy), "coverage": float(coverage), "consistency": float(consistency)} - - -def default_base_workdir() -> Path: - # Keep outputs inside the experiment folder by default. - return Path(__file__).resolve().parents[2] / "output_qap_mock" / "_real_runs" - - -def make_temp_base_workdir() -> Path: - return Path(tempfile.mkdtemp(prefix="qap_mock_real_")) - - -# - pipeline auto algo -# - cleaning -# normalization task -# - pipeline task aggregation -# aggregate multiple task sub (DAGs) into a single task -# example: paris matching and fusion are two sub tasks, we can aggregate them into a single task - diff --git a/experiments/param-opti/src/qap_mock/search.py b/experiments/param-opti/src/qap_mock/search.py deleted file mode 100644 index 1d6c38d..0000000 --- a/experiments/param-opti/src/qap_mock/search.py +++ /dev/null @@ -1,138 +0,0 @@ -from __future__ import annotations - -import random -from dataclasses import dataclass -from typing import Dict, List, Optional, Tuple - -from .models import PipelineConfig, PipelineFamily, SearchMethod, SearchSpaceMode -from .objectives import QualityBreakdown, estimate_quality_from_config, evaluate_true_quality -from .search_space import get_family_space, mutate_config, sample_config - - -@dataclass -class EvaluationRecord: - cfg: PipelineConfig - true: QualityBreakdown - est_total: float - - def as_dict(self) -> dict: - return { - "config": self.cfg.as_dict(), - "true": { - "accuracy": self.true.accuracy, - "coverage": self.true.coverage, - "consistency": self.true.consistency, - "total": self.true.total, - }, - "estimated_total": self.est_total, - } - - -def _eval_once(rng: random.Random, cfg: PipelineConfig) -> EvaluationRecord: - true = evaluate_true_quality(rng, cfg) - est = estimate_quality_from_config(rng, cfg) - return EvaluationRecord(cfg=cfg, true=true, est_total=est) - - -def run_search( - *, - seed: int, - family: PipelineFamily, - method: SearchMethod, - budget: int, - mode: SearchSpaceMode = SearchSpaceMode.JOINT, -) -> List[EvaluationRecord]: - rng = random.Random(seed) - space = get_family_space(family) - default_cfg = PipelineConfig(family=family, implementations=space.default_impl, params=space.default_params) - - records: List[EvaluationRecord] = [] - - if method == SearchMethod.DEFAULT: - records.append(_eval_once(rng, default_cfg)) - return records - - if method == SearchMethod.RANDOM: - for _ in range(budget): - cfg = sample_config(rng, family, mode=mode, fixed_default=default_cfg) - records.append(_eval_once(rng, cfg)) - return records - - if method == SearchMethod.QUALITY_AWARE: - # Simple, explainable heuristic: - # - start from default - # - maintain incumbent based on estimated quality (Q-hat) - # - propose new configs by mutating incumbent (exploitation) - # - occasional random exploration - incumbent = default_cfg - incumbent_est: Optional[float] = None - - for t in range(budget): - # "Lookahead" using cheap quality estimates: generate a pool of - # candidates, pick the one with best estimated quality, then - # spend one "real" evaluation budget on it. - pool_size = 12 if t < 5 else 8 - candidates: List[PipelineConfig] = [] - for _ in range(pool_size): - explore = rng.random() < (0.35 if t < 3 else 0.20) - if explore: - candidates.append(sample_config(rng, family, mode=mode, fixed_default=default_cfg)) - else: - candidates.append( - mutate_config( - rng, - incumbent, - mode=mode, - p_change_impl=0.70, - p_change_param=0.85, - ) - ) - - best_est = None - best_cfg = None - for c in candidates: - est = estimate_quality_from_config(rng, c) - if best_est is None or est > best_est: - best_est = est - best_cfg = c - - assert best_cfg is not None - cfg = best_cfg - - rec = _eval_once(rng, cfg) - records.append(rec) - - if incumbent_est is None or rec.est_total > incumbent_est: - incumbent = cfg - incumbent_est = rec.est_total - - return records - - raise ValueError(f"Unknown method: {method}") - - -def best_so_far_curve(records: List[EvaluationRecord]) -> List[float]: - best = -1.0 - curve: List[float] = [] - for r in records: - best = max(best, r.true.total) - curve.append(best) - return curve - - -def evals_to_fraction_of_final_best(curve: List[float], fraction: float) -> Optional[int]: - if not curve: - return None - final_best = curve[-1] - target = fraction * final_best - for i, v in enumerate(curve, start=1): - if v >= target: - return i - return None - - -def summarize_best(records: List[EvaluationRecord]) -> Tuple[float, float]: - curve = best_so_far_curve(records) - best = curve[-1] if curve else float("nan") - return best, best - diff --git a/experiments/param-opti/src/qap_mock/search_space.py b/experiments/param-opti/src/qap_mock/search_space.py deleted file mode 100644 index c69f031..0000000 --- a/experiments/param-opti/src/qap_mock/search_space.py +++ /dev/null @@ -1,143 +0,0 @@ -from __future__ import annotations - -import random -from dataclasses import dataclass -from typing import Dict, List, Tuple - -from .models import PipelineConfig, PipelineFamily, SearchSpaceMode - - -@dataclass(frozen=True) -class FamilySpace: - tasks: List[str] - impl_choices: Dict[str, List[str]] - param_ranges: Dict[str, Tuple[float, float]] - default_impl: Dict[str, str] - default_params: Dict[str, float] - - -def get_family_space(family: PipelineFamily) -> FamilySpace: - # Compact but expressive, mirroring the paper text: - # - discrete implementation choices per task - # - continuous thresholds - if family == PipelineFamily.RDF: - tasks = ["ontology_matching", "entity_matching", "fusion"] - impl_choices = { - "ontology_matching": ["string_sim", "embedding_sim", "hybrid", "llm_alignment"], - "entity_matching": ["rule_based", "blocking_sim", "embedding_er", "llm_er"], - "fusion": ["union", "quality_weighted", "majority_vote"], - } - param_ranges = { - "schema_sim_threshold": (0.3, 0.95), - "entity_sim_threshold": (0.3, 0.95), - "fusion_confidence_threshold": (0.1, 0.9), - "blocking_key_strength": (0.0, 1.0), - } - default_impl = { - "ontology_matching": "string_sim", - "entity_matching": "rule_based", - "fusion": "union", - } - default_params = { - "schema_sim_threshold": 0.7, - "entity_sim_threshold": 0.7, - "fusion_confidence_threshold": 0.5, - "blocking_key_strength": 0.5, - } - return FamilySpace(tasks, impl_choices, param_ranges, default_impl, default_params) - - if family == PipelineFamily.TEXT: - tasks = ["information_extraction", "entity_linking", "fusion"] - impl_choices = { - "information_extraction": ["pattern_ie", "openie", "hybrid_ie", "llm_ie"], - "entity_linking": ["dictionary_linking", "embedding_linking", "llm_linking"], - "fusion": ["union", "quality_weighted", "majority_vote"], - } - param_ranges = { - "ie_conf_threshold": (0.2, 0.95), - "link_sim_threshold": (0.2, 0.95), - "fusion_confidence_threshold": (0.1, 0.9), - "context_window": (64.0, 512.0), - } - default_impl = { - "information_extraction": "pattern_ie", - "entity_linking": "dictionary_linking", - "fusion": "union", - } - default_params = { - "ie_conf_threshold": 0.6, - "link_sim_threshold": 0.6, - "fusion_confidence_threshold": 0.5, - "context_window": 256.0, - } - return FamilySpace(tasks, impl_choices, param_ranges, default_impl, default_params) - - raise ValueError(f"Unknown family: {family}") - - -def sample_config( - rng: random.Random, - family: PipelineFamily, - mode: SearchSpaceMode = SearchSpaceMode.JOINT, - fixed_default: PipelineConfig | None = None, -) -> PipelineConfig: - space = get_family_space(family) - - impl: Dict[str, str] = {} - params: Dict[str, float] = {} - - if fixed_default is None: - fixed_default = PipelineConfig(family=family, implementations=space.default_impl, params=space.default_params) - - if mode in (SearchSpaceMode.JOINT, SearchSpaceMode.IMPLEMENTATION_ONLY): - for t in space.tasks: - impl[t] = rng.choice(space.impl_choices[t]) - else: - impl = dict(fixed_default.implementations) - - if mode in (SearchSpaceMode.JOINT, SearchSpaceMode.PARAMETER_ONLY): - for p, (lo, hi) in space.param_ranges.items(): - params[p] = rng.uniform(lo, hi) - else: - params = dict(fixed_default.params) - - return PipelineConfig(family=family, implementations=impl, params=params) - - -def mutate_config( - rng: random.Random, - cfg: PipelineConfig, - mode: SearchSpaceMode = SearchSpaceMode.JOINT, - p_change_impl: float = 0.35, - p_change_param: float = 0.8, -) -> PipelineConfig: - space = get_family_space(cfg.family) - impl = dict(cfg.implementations) - params = dict(cfg.params) - - if mode in (SearchSpaceMode.JOINT, SearchSpaceMode.IMPLEMENTATION_ONLY) and rng.random() < p_change_impl: - t = rng.choice(space.tasks) - choices = [c for c in space.impl_choices[t] if c != impl[t]] - if choices: - impl[t] = rng.choice(choices) - # Occasionally flip a second task implementation to escape local optima. - if rng.random() < 0.25: - t2 = rng.choice([x for x in space.tasks if x != t]) - choices2 = [c for c in space.impl_choices[t2] if c != impl[t2]] - if choices2: - impl[t2] = rng.choice(choices2) - - if mode in (SearchSpaceMode.JOINT, SearchSpaceMode.PARAMETER_ONLY) and rng.random() < p_change_param: - p = rng.choice(list(space.param_ranges.keys())) - lo, hi = space.param_ranges[p] - # Gaussian step with clipping keeps changes local. - step = rng.gauss(0.0, (hi - lo) * 0.08) - params[p] = min(hi, max(lo, params[p] + step)) - if rng.random() < 0.25: - p2 = rng.choice([x for x in space.param_ranges.keys() if x != p]) - lo2, hi2 = space.param_ranges[p2] - step2 = rng.gauss(0.0, (hi2 - lo2) * 0.06) - params[p2] = min(hi2, max(lo2, params[p2] + step2)) - - return PipelineConfig(family=cfg.family, implementations=impl, params=params) - diff --git a/experiments/param-opti/src/qap_mock/stats.py b/experiments/param-opti/src/qap_mock/stats.py deleted file mode 100644 index 9232ff1..0000000 --- a/experiments/param-opti/src/qap_mock/stats.py +++ /dev/null @@ -1,58 +0,0 @@ -from __future__ import annotations - -import math -from typing import Iterable, List, Sequence, Tuple - - -def mean(xs: Sequence[float]) -> float: - return sum(xs) / len(xs) if xs else float("nan") - - -def stdev(xs: Sequence[float]) -> float: - if len(xs) < 2: - return float("nan") - m = mean(xs) - return math.sqrt(sum((x - m) ** 2 for x in xs) / (len(xs) - 1)) - - -def rankdata(xs: Sequence[float]) -> List[int]: - # Simple dense ranking (ties get same rank). - sorted_unique = sorted(set(xs)) - rank = {v: i + 1 for i, v in enumerate(sorted_unique)} - return [rank[v] for v in xs] - - -def pearsonr(x: Sequence[float], y: Sequence[float]) -> float: - if len(x) != len(y) or len(x) < 2: - return float("nan") - mx = mean(x) - my = mean(y) - num = sum((a - mx) * (b - my) for a, b in zip(x, y)) - denx = math.sqrt(sum((a - mx) ** 2 for a in x)) - deny = math.sqrt(sum((b - my) ** 2 for b in y)) - if denx == 0.0 or deny == 0.0: - return float("nan") - return num / (denx * deny) - - -def spearmanr(x: Sequence[float], y: Sequence[float]) -> float: - rx = rankdata(x) - ry = rankdata(y) - return pearsonr(rx, ry) - - -def mae(x: Sequence[float], y: Sequence[float]) -> float: - if len(x) != len(y) or not x: - return float("nan") - return sum(abs(a - b) for a, b in zip(x, y)) / len(x) - - -def topk_agreement(x: Sequence[float], y: Sequence[float], k: int) -> float: - if len(x) != len(y) or not x: - return float("nan") - n = len(x) - k = max(1, min(k, n)) - topx = set(sorted(range(n), key=lambda i: x[i], reverse=True)[:k]) - topy = set(sorted(range(n), key=lambda i: y[i], reverse=True)[:k]) - return len(topx & topy) / k - From 1bc2d18f62cd15877620ca002418642eb764c669 Mon Sep 17 00:00:00 2001 From: Marvin Hofer Date: Wed, 8 Jul 2026 17:30:01 +0200 Subject: [PATCH 78/96] exp(params): added experiment cli for flexible config testing --- experiments/param-opti/src/experiment.py | 64 +++++++++++++++--------- 1 file changed, 41 insertions(+), 23 deletions(-) diff --git a/experiments/param-opti/src/experiment.py b/experiments/param-opti/src/experiment.py index b3670b6..0daddd2 100644 --- a/experiments/param-opti/src/experiment.py +++ b/experiments/param-opti/src/experiment.py @@ -2,19 +2,21 @@ """ Run and evaluate pipeline configs from fixture files. -Example: +Example (quick test with the small sampled fixture, 6 RDF / 4 text configs): python experiment.py \\ --seed data/bench/.../seed/data.nt \\ --source data/bench/.../sources/rdf/data.nt \\ --reference data/bench/.../reference/data_agg.nt \\ - --ontology data/bench/.../ontology.ttl \\ - --pipeline-type rdf \\ - --configs exhaustive + --ontology data/bench/.../ontology.ttl + +Full exhaustive run (all task/parameter permutations): + python experiment.py ... --configs exhaustive """ from __future__ import annotations import argparse +import hashlib import json import os import sys @@ -30,15 +32,11 @@ load_rdf_sampled_pipeline_configs, load_text_exhaustive_pipeline_configs, load_text_sampled_pipeline_configs, - pipeline_config_snapshot_key, + pipeline_config_to_snapshot, print_pipeline_config_short, task_keys_from_pipeline_config, ) -from kgpipe_search.definitions import ( - PipelineConfig, - RDF_SEARCH_SPACE, - TEXT_SEARCH_SPACE, -) +from kgpipe_search.definitions import PipelineConfig from kgpipe_search.evaluation import evaluate_pipeline @@ -90,6 +88,18 @@ def _set_ontology_env(ontology_path: Optional[Path]) -> None: os.environ["ONTOLOGY_PATH"] = str(ontology_path.resolve()) +def _config_hash(snapshot: Dict[str, Any]) -> str: + canonical = json.dumps(snapshot, sort_keys=True) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def _write_config_snapshot(config_path: Path, snapshot: Dict[str, Any]) -> None: + config_path.write_text( + json.dumps(snapshot, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + def _validate_input_path(path: Path, label: str) -> Path: resolved = path.resolve() if not resolved.exists(): @@ -203,7 +213,6 @@ def run_all_configs( ) -> List[Dict[str, Any]]: _set_ontology_env(ontology_path) - search_space = RDF_SEARCH_SPACE if pipeline_type == "rdf" else TEXT_SEARCH_SPACE run_pipeline = run_rdf_pipeline if pipeline_type == "rdf" else run_text_pipeline pipeline_configs = _load_pipeline_configs( @@ -225,19 +234,24 @@ def run_all_configs( print(f"output_dir: {output_dir}") for offset, pipeline_config in enumerate(selected, start=start): - run_name = f"config_{offset:04d}" - result_path = output_dir / f"{run_name}.nt" - tasks_tmp_dir = output_dir / f"{run_name}_tasks_tmp" - config_key = pipeline_config_snapshot_key(pipeline_config, search_space) task_keys = task_keys_from_pipeline_config(pipeline_config) + snapshot = pipeline_config_to_snapshot(task_keys, pipeline_config) + config_hash = _config_hash(snapshot) - print(f"\n=== config {offset + 1}/{len(pipeline_configs)} ({run_name}) ===") + result_path = output_dir / f"{config_hash}.nt" + config_path = output_dir / f"{config_hash}.json" + tasks_tmp_dir = output_dir / f"{config_hash}_tasks_tmp" + run_name = config_hash + + print(f"\n=== config {offset + 1}/{len(pipeline_configs)} ({config_hash}) ===") print_pipeline_config_short(pipeline_config) + _write_config_snapshot(config_path, snapshot) + entry: Dict[str, Any] = { "config_idx": offset, - "task_keys": task_keys, - "config_key": config_key, + "config_hash": config_hash, + "config_path": str(config_path), "result_path": str(result_path), "status": "ok", } @@ -283,7 +297,7 @@ def run_all_configs( json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8", ) - print(f"\nWrote results to {results_path}") + print(f"\nWrote scores to {results_path}") succeeded = sum(1 for item in run_results if item["status"] == "ok") print(f"\nFinished: {succeeded}/{len(run_results)} succeeded") @@ -323,8 +337,12 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument( "--configs", choices=["sampled", "exhaustive"], - default="exhaustive", - help="Which fixture set to execute", + default="sampled", + help=( + "Which fixture set to execute: " + "'sampled' = small fixture for quick tests (default), " + "'exhaustive' = all task/parameter permutations" + ), ) parser.add_argument( "--configs-fixture", @@ -348,7 +366,7 @@ def build_parser() -> argparse.ArgumentParser: "--results", type=Path, default=None, - help="Optional path to write a JSON summary of all runs", + help="Path to write a single JSON summary of all run scores (default: /results.json)", ) return parser @@ -381,7 +399,7 @@ def main(argv: Optional[List[str]] = None) -> int: configs_fixture=args.configs_fixture, start=args.start, limit=args.limit, - results_path=args.results, + results_path=args.results or (args.output_dir / "results.json"), ) failed = sum(1 for item in run_results if item["status"] != "ok") From bd07125bbdd1c3f2f302c5a20fc99b821a6d8f42 Mon Sep 17 00:00:00 2001 From: Marvin Hofer Date: Wed, 8 Jul 2026 17:31:00 +0200 Subject: [PATCH 79/96] exp(param): modularized search strategies and implemented all 4 methods --- .../param-opti/src/kgpipe_search/search.py | 502 ++----- .../src/kgpipe_search/strategies/__init__.py | 4 + .../strategies/initialization.py | 253 ++++ .../kgpipe_search/strategies/strategies.py | 1187 +++++++++++++++++ .../test/test_search_strategies.py | 66 + 5 files changed, 1617 insertions(+), 395 deletions(-) create mode 100644 experiments/param-opti/src/kgpipe_search/strategies/__init__.py create mode 100644 experiments/param-opti/src/kgpipe_search/strategies/initialization.py create mode 100644 experiments/param-opti/src/kgpipe_search/strategies/strategies.py create mode 100644 experiments/param-opti/src/kgpipe_search/test/test_search_strategies.py diff --git a/experiments/param-opti/src/kgpipe_search/search.py b/experiments/param-opti/src/kgpipe_search/search.py index 9e1e618..4d0ee5c 100644 --- a/experiments/param-opti/src/kgpipe_search/search.py +++ b/experiments/param-opti/src/kgpipe_search/search.py @@ -1,413 +1,110 @@ +""" +Public API for configuration search. + +Implementation lives in `kgpipe_search/strategies/` to keep algorithms modular. +This module preserves the historical function names used by existing tests/scripts. +""" + from __future__ import annotations -import math import random -from dataclasses import dataclass -from typing import Any, Callable, Dict, List, Literal, Optional, Set, Tuple +from typing import Any, Dict -from kgpipe.common.model.configuration import ConfigurationProfile, ParameterBinding -from kgpipe_search.configuration import ( - build_pipeline_config_for_task_combo, - enumerate_valid_task_combinations, - pipeline_config_snapshot_key, - sample_valid_pipeline_config, - task_keys_from_pipeline_config, +from kgpipe_search.definitions import PipelineLayout +from kgpipe_search.strategies.initialization import ( + implementation_aware_initialization, + random_initialization, +) +from kgpipe_search.strategies.strategies import ( + EvaluateFn, + SearchRun, + run_bayesian, + run_hnr, + run_qgns, + run_random, ) -from kgpipe_search.definitions import PipelineConfig, PipelineLayout - -Observation = Tuple[float, PipelineConfig] -EvaluateFn = Callable[[PipelineConfig], float] -SearchStrategy = Literal["random", "neighborhood", "bayesian"] - - -@dataclass -class SearchRun: - strategy: SearchStrategy - history: List[Observation] - budget: int - decisions: List[str] - - -def _top_k(history: List[Observation], k: int) -> List[Observation]: - ranked = sorted(history, key=lambda item: item[0], reverse=True) - return ranked[: max(1, min(k, len(ranked)))] - - -def _parameter_neighbors( - anchor: PipelineConfig, - search_space: Dict[str, Dict[str, Any]], -) -> List[PipelineConfig]: - anchor_keys = task_keys_from_pipeline_config(anchor) - neighbors: List[PipelineConfig] = [] - - for task, task_key in zip(anchor.tasks, anchor_keys): - profile = anchor.config_catalog.get(task.name) - if profile is None: - continue - - for binding in profile.bindings: - param_name = binding.parameter.name - domain = search_space.get(task_key, {}).get(param_name) - if not isinstance(domain, list): - continue - - for value in domain: - if value == binding.value: - continue - - new_catalog = dict(anchor.config_catalog) - new_bindings: List[ParameterBinding] = [] - name_parts: List[str] = [] - for current in profile.bindings: - chosen = value if current.parameter.name == param_name else current.value - new_bindings.append( - ParameterBinding(parameter=current.parameter, value=chosen) - ) - name_parts.append(f"{current.parameter.name}={chosen}") - - new_catalog[task.name] = ConfigurationProfile( - name=f"{task.name}_" + ",".join(name_parts), - definition=profile.definition, - bindings=new_bindings, - ) - neighbors.append( - PipelineConfig(tasks=list(anchor.tasks), config_catalog=new_catalog) - ) - - return neighbors - - -def _implementation_neighbors( - anchor: PipelineConfig, - search_space: Dict[str, Dict[str, Any]], - pipeline_layout: PipelineLayout, - rng: random.Random, -) -> List[PipelineConfig]: - anchor_keys = task_keys_from_pipeline_config(anchor) - neighbors: List[PipelineConfig] = [] - - for combo in enumerate_valid_task_combinations(search_space, pipeline_layout): - if len(combo) != len(anchor_keys): - continue - if sum(left != right for left, right in zip(anchor_keys, combo)) != 1: - continue - neighbors.append( - build_pipeline_config_for_task_combo( - search_space, - combo, - rng=rng, - template=anchor, - ) - ) - - return neighbors - - -def neighbors_at_distance_one( - anchor: PipelineConfig, - search_space: Dict[str, Dict[str, Any]], - pipeline_layout: PipelineLayout, - rng: random.Random, -) -> List[PipelineConfig]: - seen: Set[str] = set() - neighbors: List[PipelineConfig] = [] - - for candidate in ( - _parameter_neighbors(anchor, search_space) - + _implementation_neighbors(anchor, search_space, pipeline_layout, rng) - ): - key = pipeline_config_snapshot_key(candidate, search_space) - if key in seen: - continue - seen.add(key) - neighbors.append(candidate) - return neighbors +__all__ = [ + "SearchRun", + "EvaluateFn", + "random_initialization", + "implementation_aware_initialization", + "random_search", + "neighborhood_optimization", + "qgns_search", + "hnr_search", + "bayesian_optimization", +] -def sample_unevaluated_config( - rng: random.Random, - search_space: Dict[str, Dict[str, Any]], - pipeline_layout: PipelineLayout, - evaluated_keys: Set[str], +def random_search( *, - max_attempts: int = 500, -) -> PipelineConfig: - for _ in range(max_attempts): - candidate = sample_valid_pipeline_config( - search_space, - pipeline_layout, - rng=rng, - ) - key = pipeline_config_snapshot_key(candidate, search_space) - if key not in evaluated_keys: - return candidate - - raise RuntimeError("Failed to sample an unevaluated configuration") - - -def select_next_random_config( - rng: random.Random, - history: List[Observation], - search_space: Dict[str, Dict[str, Any]], - pipeline_layout: PipelineLayout, - evaluated_keys: Set[str], -) -> PipelineConfig: - del history - return sample_unevaluated_config( - rng, - search_space, - pipeline_layout, - evaluated_keys, - ) - - -def select_next_neighborhood_config( - rng: random.Random, - history: List[Observation], + budget: int, + evaluate_fn: EvaluateFn, search_space: Dict[str, Dict[str, Any]], pipeline_layout: PipelineLayout, - evaluated_keys: Set[str], - *, - k: int = 3, - rho: float = 0.2, -) -> Tuple[PipelineConfig, str]: - if not history or rng.random() < rho: - return ( - sample_unevaluated_config( - rng, - search_space, - pipeline_layout, - evaluated_keys, - ), - "explore", - ) - - anchors = _top_k(history, k) - anchor_score, anchor_config = rng.choice(anchors) - neighborhood = neighbors_at_distance_one( - anchor_config, - search_space, - pipeline_layout, - rng, - ) - - unevaluated = [ - candidate - for candidate in neighborhood - if pipeline_config_snapshot_key(candidate, search_space) not in evaluated_keys - ] - if unevaluated: - return rng.choice(unevaluated), f"neighborhood(anchor_score={anchor_score:.4f})" - - return ( - sample_unevaluated_config( - rng, - search_space, - pipeline_layout, - evaluated_keys, - ), - "explore(fallback)", + rng: random.Random | None = None, +) -> SearchRun: + return run_random( + budget=budget, + evaluate_fn=evaluate_fn, + search_space=search_space, + pipeline_layout=pipeline_layout, + rng=rng, ) -def _config_distance( - left: PipelineConfig, - right: PipelineConfig, - search_space: Dict[str, Dict[str, Any]], -) -> float: - if pipeline_config_snapshot_key(left, search_space) == pipeline_config_snapshot_key( - right, search_space - ): - return 0.0 - - left_keys = task_keys_from_pipeline_config(left) - right_keys = task_keys_from_pipeline_config(right) - distance = float(sum(a != b for a, b in zip(left_keys, right_keys))) - if len(left_keys) != len(right_keys): - distance += abs(len(left_keys) - len(right_keys)) - - left_params = { - (task.name, binding.parameter.name): binding.value - for task in left.tasks - for binding in (left.config_catalog.get(task.name).bindings if left.config_catalog.get(task.name) else []) - } - right_params = { - (task.name, binding.parameter.name): binding.value - for task in right.tasks - for binding in (right.config_catalog.get(task.name).bindings if right.config_catalog.get(task.name) else []) - } - - all_param_keys = set(left_params) | set(right_params) - for key in all_param_keys: - if left_params.get(key) != right_params.get(key): - distance += 1.0 - - return distance - - -def _predict_with_uncertainty( - candidate: PipelineConfig, - history: List[Observation], - search_space: Dict[str, Dict[str, Any]], -) -> Tuple[float, float]: - weights: List[float] = [] - scores: List[float] = [] - - for score, observed in history: - distance = _config_distance(candidate, observed, search_space) - if distance == 0.0: - return score, 0.0 - weights.append(math.exp(-distance)) - scores.append(score) - - if not weights: - return 0.75, 1.0 - - total_weight = sum(weights) - mean = sum(score * weight for score, weight in zip(scores, weights)) / total_weight - uncertainty = 1.0 / (1.0 + total_weight) - return mean, uncertainty - - -def _acquisition(mean: float, uncertainty: float, *, beta: float = 0.5) -> float: - return mean + beta * uncertainty - - -def select_next_bayesian_config( - rng: random.Random, - history: List[Observation], - search_space: Dict[str, Dict[str, Any]], - pipeline_layout: PipelineLayout, - evaluated_keys: Set[str], +def qgns_search( *, - init_random: int = 3, - pool_size: int = 32, - beta: float = 0.5, -) -> Tuple[PipelineConfig, str]: - if len(history) < init_random: - return ( - sample_unevaluated_config( - rng, - search_space, - pipeline_layout, - evaluated_keys, - ), - "init_random", - ) - - candidates: List[PipelineConfig] = [] - for _ in range(pool_size): - candidates.append( - sample_unevaluated_config( - rng, - search_space, - pipeline_layout, - evaluated_keys, - ) - ) - - best_candidate = candidates[0] - best_acquisition = float("-inf") - best_prediction = 0.0 - best_uncertainty = 0.0 - - for candidate in candidates: - mean, uncertainty = _predict_with_uncertainty(candidate, history, search_space) - score = _acquisition(mean, uncertainty, beta=beta) - if score > best_acquisition: - best_acquisition = score - best_candidate = candidate - best_prediction = mean - best_uncertainty = uncertainty - - return ( - best_candidate, - f"acquisition(pred={best_prediction:.4f},unc={best_uncertainty:.4f},a={best_acquisition:.4f})", - ) - - -def run_search( - strategy: SearchStrategy, budget: int, evaluate_fn: EvaluateFn, search_space: Dict[str, Dict[str, Any]], pipeline_layout: PipelineLayout, - *, - rng: Optional[random.Random] = None, + init_budget: int = 0, + init_strategy: str = "random", + y: int = 1, k: int = 3, rho: float = 0.2, - init_random: int = 3, - pool_size: int = 32, - beta: float = 0.5, + rng: random.Random | None = None, ) -> SearchRun: - draw = rng or random.Random() - history: List[Observation] = [] - evaluated_keys: Set[str] = set() - decisions: List[str] = [] - - for _ in range(budget): - if strategy == "random": - candidate = select_next_random_config( - draw, - history, - search_space, - pipeline_layout, - evaluated_keys, - ) - decision = "sample" - elif strategy == "neighborhood": - candidate, decision = select_next_neighborhood_config( - draw, - history, - search_space, - pipeline_layout, - evaluated_keys, - k=k, - rho=rho, - ) - elif strategy == "bayesian": - candidate, decision = select_next_bayesian_config( - draw, - history, - search_space, - pipeline_layout, - evaluated_keys, - init_random=init_random, - pool_size=pool_size, - beta=beta, - ) - else: - raise ValueError(f"Unknown search strategy: {strategy!r}") - - key = pipeline_config_snapshot_key(candidate, search_space) - score = evaluate_fn(candidate) - history.append((score, candidate)) - evaluated_keys.add(key) - decisions.append(decision) - - return SearchRun( - strategy=strategy, - history=history, + return run_qgns( budget=budget, - decisions=decisions, + evaluate_fn=evaluate_fn, + search_space=search_space, + pipeline_layout=pipeline_layout, + init_budget=init_budget, + init_strategy="implementation_aware" + if init_strategy == "implementation_aware" + else "random", + y=y, + k=k, + rho=rho, + rng=rng, ) -def random_search( +def hnr_search( + *, budget: int, evaluate_fn: EvaluateFn, search_space: Dict[str, Dict[str, Any]], pipeline_layout: PipelineLayout, - **kwargs: Any, + init_budget: int, + init_strategy: str = "implementation_aware", + y: int = 1, + rho: float = 0.2, + rng: random.Random | None = None, ) -> SearchRun: - return run_search( - "random", - budget, - evaluate_fn, - search_space, - pipeline_layout, - **kwargs, + return run_hnr( + budget=budget, + evaluate_fn=evaluate_fn, + search_space=search_space, + pipeline_layout=pipeline_layout, + init_budget=init_budget, + init_strategy="random" if init_strategy == "random" else "implementation_aware", + y=y, + rho=rho, + rng=rng, ) @@ -419,17 +116,25 @@ def neighborhood_optimization( *, k: int = 3, rho: float = 0.2, + rng: random.Random | None = None, **kwargs: Any, ) -> SearchRun: - return run_search( - "neighborhood", - budget, - evaluate_fn, - search_space, - pipeline_layout, + """ + Backwards-compatible alias. + + Historically, this was called `neighborhood_optimization` and implemented QGNS-like behavior. + """ + del kwargs + return qgns_search( + budget=budget, + evaluate_fn=evaluate_fn, + search_space=search_space, + pipeline_layout=pipeline_layout, + init_budget=0, + init_strategy="random", k=k, rho=rho, - **kwargs, + rng=rng, ) @@ -440,18 +145,25 @@ def bayesian_optimization( pipeline_layout: PipelineLayout, *, init_random: int = 3, + init_strategy: str = "random", + y: int = 1, pool_size: int = 32, beta: float = 0.5, + rng: random.Random | None = None, **kwargs: Any, ) -> SearchRun: - return run_search( - "bayesian", - budget, - evaluate_fn, - search_space, - pipeline_layout, - init_random=init_random, + del kwargs + return run_bayesian( + budget=budget, + evaluate_fn=evaluate_fn, + search_space=search_space, + pipeline_layout=pipeline_layout, + init_budget=init_random, + init_strategy="implementation_aware" + if init_strategy == "implementation_aware" + else "random", + y=y, pool_size=pool_size, beta=beta, - **kwargs, - ) + rng=rng, + ) \ No newline at end of file diff --git a/experiments/param-opti/src/kgpipe_search/strategies/__init__.py b/experiments/param-opti/src/kgpipe_search/strategies/__init__.py new file mode 100644 index 0000000..e27115f --- /dev/null +++ b/experiments/param-opti/src/kgpipe_search/strategies/__init__.py @@ -0,0 +1,4 @@ +"""Search strategies and initialization routines for KGpipe configuration search.""" + +"""Search strategies and initialization routines for KGpipe configuration search.""" + diff --git a/experiments/param-opti/src/kgpipe_search/strategies/initialization.py b/experiments/param-opti/src/kgpipe_search/strategies/initialization.py new file mode 100644 index 0000000..49df3b7 --- /dev/null +++ b/experiments/param-opti/src/kgpipe_search/strategies/initialization.py @@ -0,0 +1,253 @@ +from __future__ import annotations + +import random +from typing import Any, Dict, List, Optional, Sequence, Set + +from kgpipe_search.configuration import ( + build_pipeline_config_for_task_combo, + enumerate_valid_task_combinations, + pipeline_config_snapshot_key, + sample_valid_pipeline_config, +) +from kgpipe_search.definitions import PipelineConfig, PipelineLayout + + +def random_initialization( + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, + *, + budget: int, + rng: Optional[random.Random] = None, +) -> List[PipelineConfig]: + """Sample `budget` unique valid pipeline configurations uniformly at random.""" + if budget <= 0: + return [] + + draw = rng or random.Random() + configs: List[PipelineConfig] = [] + seen: Set[str] = set() + attempts = 0 + max_attempts = max(1000, budget * 200) + + while len(configs) < budget and attempts < max_attempts: + attempts += 1 + candidate = sample_valid_pipeline_config(search_space, pipeline_layout, rng=draw) + key = pipeline_config_snapshot_key(candidate, search_space) + if key in seen: + continue + seen.add(key) + configs.append(candidate) + + if len(configs) < budget: + raise RuntimeError( + f"Failed to sample {budget} unique initial configs (got {len(configs)})." + ) + + return configs + + +def implementation_aware_initialization( + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, + *, + budget: int, + y: int = 1, + rng: Optional[random.Random] = None, +) -> List[PipelineConfig]: + """ + Implementation-aware initialization. + + Enumerate (or sample) valid implementation assignments (task combinations) and, + for each such assignment, generate `y` configurations by sampling parameters. + """ + if budget <= 0: + return [] + if y <= 0: + raise ValueError("y must be >= 1") + + draw = rng or random.Random() + all_combos = enumerate_valid_task_combinations(search_space, pipeline_layout) + if not all_combos: + raise ValueError("No valid implementation assignments found.") + + max_combos = max(1, budget // y) + combos: Sequence[List[str]] + if len(all_combos) <= max_combos: + combos = all_combos + else: + combos = draw.sample(all_combos, k=max_combos) + + configs: List[PipelineConfig] = [] + seen: Set[str] = set() + + for combo in combos: + for _ in range(y): + if len(configs) >= budget: + break + candidate = build_pipeline_config_for_task_combo( + search_space, + combo, + rng=draw, + template=None, + ) + key = pipeline_config_snapshot_key(candidate, search_space) + if key in seen: + continue + seen.add(key) + configs.append(candidate) + + if len(configs) >= budget: + break + + if len(configs) < budget: + remaining = budget - len(configs) + filler = random_initialization( + search_space, + pipeline_layout, + budget=remaining, + rng=draw, + ) + for candidate in filler: + key = pipeline_config_snapshot_key(candidate, search_space) + if key in seen: + continue + seen.add(key) + configs.append(candidate) + if len(configs) >= budget: + break + + if len(configs) < budget: + raise RuntimeError( + f"Failed to generate {budget} unique initial configs (got {len(configs)})." + ) + + return configs + +from __future__ import annotations + +import random +from typing import Any, Dict, List, Optional, Sequence, Set + +from kgpipe_search.configuration import ( + build_pipeline_config_for_task_combo, + enumerate_valid_task_combinations, + pipeline_config_snapshot_key, + sample_valid_pipeline_config, +) +from kgpipe_search.definitions import PipelineConfig, PipelineLayout + + +def random_initialization( + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, + *, + budget: int, + rng: Optional[random.Random] = None, +) -> List[PipelineConfig]: + """Sample `budget` unique valid pipeline configurations uniformly at random.""" + if budget <= 0: + return [] + + draw = rng or random.Random() + configs: List[PipelineConfig] = [] + seen: Set[str] = set() + attempts = 0 + max_attempts = max(1000, budget * 200) + + while len(configs) < budget and attempts < max_attempts: + attempts += 1 + candidate = sample_valid_pipeline_config(search_space, pipeline_layout, rng=draw) + key = pipeline_config_snapshot_key(candidate, search_space) + if key in seen: + continue + seen.add(key) + configs.append(candidate) + + if len(configs) < budget: + raise RuntimeError( + f"Failed to sample {budget} unique initial configs (got {len(configs)})." + ) + + return configs + + +def implementation_aware_initialization( + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, + *, + budget: int, + y: int = 1, + rng: Optional[random.Random] = None, +) -> List[PipelineConfig]: + """ + Implementation-aware initialization from the paper. + + Enumerate (or sample) valid implementation assignments (task combinations) and, + for each such assignment, generate `y` configurations by sampling parameters. + """ + if budget <= 0: + return [] + if y <= 0: + raise ValueError("y must be >= 1") + + draw = rng or random.Random() + all_combos = enumerate_valid_task_combinations(search_space, pipeline_layout) + if not all_combos: + raise ValueError("No valid implementation assignments found.") + + # Determine how many implementation assignments we can cover. + max_combos = max(1, budget // y) + combos: Sequence[List[str]] + if len(all_combos) <= max_combos: + combos = all_combos + else: + # Sample without replacement. + combos = draw.sample(all_combos, k=max_combos) + + configs: List[PipelineConfig] = [] + seen: Set[str] = set() + + for combo in combos: + for _ in range(y): + if len(configs) >= budget: + break + candidate = build_pipeline_config_for_task_combo( + search_space, + combo, + rng=draw, + template=None, + ) + key = pipeline_config_snapshot_key(candidate, search_space) + if key in seen: + continue + seen.add(key) + configs.append(candidate) + + if len(configs) >= budget: + break + + # If we still have budget left (due to duplicates), fill with random unique samples. + if len(configs) < budget: + remaining = budget - len(configs) + filler = random_initialization( + search_space, + pipeline_layout, + budget=remaining, + rng=draw, + ) + for candidate in filler: + key = pipeline_config_snapshot_key(candidate, search_space) + if key in seen: + continue + seen.add(key) + configs.append(candidate) + if len(configs) >= budget: + break + + if len(configs) < budget: + raise RuntimeError( + f"Failed to generate {budget} unique initial configs (got {len(configs)})." + ) + + return configs + diff --git a/experiments/param-opti/src/kgpipe_search/strategies/strategies.py b/experiments/param-opti/src/kgpipe_search/strategies/strategies.py new file mode 100644 index 0000000..4e2a6ff --- /dev/null +++ b/experiments/param-opti/src/kgpipe_search/strategies/strategies.py @@ -0,0 +1,1187 @@ +from __future__ import annotations + +import math +import random +from dataclasses import dataclass +from typing import Any, Callable, Dict, List, Literal, Optional, Set, Tuple + +from kgpipe.common.model.configuration import ConfigurationProfile, ParameterBinding +from kgpipe_search.configuration import ( + build_pipeline_config_for_task_combo, + enumerate_valid_task_combinations, + pipeline_config_snapshot_key, + sample_valid_pipeline_config, + task_keys_from_pipeline_config, +) +from kgpipe_search.definitions import PipelineConfig, PipelineLayout +from kgpipe_search.strategies.initialization import ( + implementation_aware_initialization, + random_initialization, +) + +Observation = Tuple[float, PipelineConfig] +EvaluateFn = Callable[[PipelineConfig], float] + +SearchStrategy = Literal["random", "qgns", "hnr", "bayesian"] + + +@dataclass +class SearchRun: + strategy: SearchStrategy + history: List[Observation] + budget: int + decisions: List[str] + + +def _top_k(history: List[Observation], k: int) -> List[Observation]: + ranked = sorted(history, key=lambda item: item[0], reverse=True) + return ranked[: max(1, min(k, len(ranked)))] + + +def _parameter_neighbors( + anchor: PipelineConfig, + search_space: Dict[str, Dict[str, Any]], +) -> List[PipelineConfig]: + anchor_keys = task_keys_from_pipeline_config(anchor) + neighbors: List[PipelineConfig] = [] + + for task, task_key in zip(anchor.tasks, anchor_keys): + profile = anchor.config_catalog.get(task.name) + if profile is None: + continue + + for binding in profile.bindings: + param_name = binding.parameter.name + domain = search_space.get(task_key, {}).get(param_name) + if not isinstance(domain, list): + continue + + for value in domain: + if value == binding.value: + continue + + new_catalog = dict(anchor.config_catalog) + new_bindings: List[ParameterBinding] = [] + name_parts: List[str] = [] + for current in profile.bindings: + chosen = value if current.parameter.name == param_name else current.value + new_bindings.append( + ParameterBinding(parameter=current.parameter, value=chosen) + ) + name_parts.append(f"{current.parameter.name}={chosen}") + + new_catalog[task.name] = ConfigurationProfile( + name=f"{task.name}_" + ",".join(name_parts), + definition=profile.definition, + bindings=new_bindings, + ) + neighbors.append( + PipelineConfig(tasks=list(anchor.tasks), config_catalog=new_catalog) + ) + + return neighbors + + +def _implementation_neighbors( + anchor: PipelineConfig, + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, + rng: random.Random, +) -> List[PipelineConfig]: + anchor_keys = task_keys_from_pipeline_config(anchor) + neighbors: List[PipelineConfig] = [] + + for combo in enumerate_valid_task_combinations(search_space, pipeline_layout): + if len(combo) != len(anchor_keys): + continue + if sum(left != right for left, right in zip(anchor_keys, combo)) != 1: + continue + neighbors.append( + build_pipeline_config_for_task_combo( + search_space, + combo, + rng=rng, + template=anchor, + ) + ) + + return neighbors + + +def neighbors_at_distance_one( + anchor: PipelineConfig, + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, + rng: random.Random, +) -> List[PipelineConfig]: + seen: Set[str] = set() + neighbors: List[PipelineConfig] = [] + + for candidate in ( + _parameter_neighbors(anchor, search_space) + + _implementation_neighbors(anchor, search_space, pipeline_layout, rng) + ): + key = pipeline_config_snapshot_key(candidate, search_space) + if key in seen: + continue + seen.add(key) + neighbors.append(candidate) + + return neighbors + + +def _restricted_implementation_neighbors_for_index( + anchor: PipelineConfig, + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, + rng: random.Random, + *, + index: int, +) -> List[PipelineConfig]: + anchor_keys = task_keys_from_pipeline_config(anchor) + if index < 0 or index >= len(anchor_keys): + return [] + + neighbors: List[PipelineConfig] = [] + for combo in enumerate_valid_task_combinations(search_space, pipeline_layout): + if len(combo) != len(anchor_keys): + continue + if any(i != index and combo[i] != anchor_keys[i] for i in range(len(anchor_keys))): + continue + if combo[index] == anchor_keys[index]: + continue + neighbors.append( + build_pipeline_config_for_task_combo( + search_space, + combo, + rng=rng, + template=anchor, + ) + ) + return neighbors + + +def _restricted_parameter_neighbors_for_index( + anchor: PipelineConfig, + search_space: Dict[str, Dict[str, Any]], + *, + index: int, +) -> List[PipelineConfig]: + anchor_keys = task_keys_from_pipeline_config(anchor) + if index < 0 or index >= len(anchor.tasks) or index >= len(anchor_keys): + return [] + + task = anchor.tasks[index] + task_key = anchor_keys[index] + profile = anchor.config_catalog.get(task.name) + if profile is None: + return [] + + neighbors: List[PipelineConfig] = [] + for binding in profile.bindings: + param_name = binding.parameter.name + domain = search_space.get(task_key, {}).get(param_name) + if not isinstance(domain, list): + continue + + for value in domain: + if value == binding.value: + continue + + new_catalog = dict(anchor.config_catalog) + new_bindings: List[ParameterBinding] = [] + name_parts: List[str] = [] + for current in profile.bindings: + chosen = value if current.parameter.name == param_name else current.value + new_bindings.append( + ParameterBinding(parameter=current.parameter, value=chosen) + ) + name_parts.append(f"{current.parameter.name}={chosen}") + + new_catalog[task.name] = ConfigurationProfile( + name=f"{task.name}_" + ",".join(name_parts), + definition=profile.definition, + bindings=new_bindings, + ) + neighbors.append( + PipelineConfig(tasks=list(anchor.tasks), config_catalog=new_catalog) + ) + + return neighbors + + +def sample_unevaluated_config( + rng: random.Random, + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, + evaluated_keys: Set[str], + *, + max_attempts: int = 500, +) -> PipelineConfig: + for _ in range(max_attempts): + candidate = sample_valid_pipeline_config( + search_space, + pipeline_layout, + rng=rng, + ) + key = pipeline_config_snapshot_key(candidate, search_space) + if key not in evaluated_keys: + return candidate + + raise RuntimeError("Failed to sample an unevaluated configuration") + + +def _config_distance( + left: PipelineConfig, + right: PipelineConfig, + search_space: Dict[str, Dict[str, Any]], +) -> float: + if pipeline_config_snapshot_key(left, search_space) == pipeline_config_snapshot_key( + right, search_space + ): + return 0.0 + + left_keys = task_keys_from_pipeline_config(left) + right_keys = task_keys_from_pipeline_config(right) + distance = float(sum(a != b for a, b in zip(left_keys, right_keys))) + if len(left_keys) != len(right_keys): + distance += abs(len(left_keys) - len(right_keys)) + + left_params = { + (task.name, binding.parameter.name): binding.value + for task in left.tasks + for binding in ( + left.config_catalog.get(task.name).bindings + if left.config_catalog.get(task.name) + else [] + ) + } + right_params = { + (task.name, binding.parameter.name): binding.value + for task in right.tasks + for binding in ( + right.config_catalog.get(task.name).bindings + if right.config_catalog.get(task.name) + else [] + ) + } + + all_param_keys = set(left_params) | set(right_params) + for key in all_param_keys: + if left_params.get(key) != right_params.get(key): + distance += 1.0 + + return distance + + +def _predict_with_uncertainty( + candidate: PipelineConfig, + history: List[Observation], + search_space: Dict[str, Dict[str, Any]], +) -> Tuple[float, float]: + weights: List[float] = [] + scores: List[float] = [] + + for score, observed in history: + distance = _config_distance(candidate, observed, search_space) + if distance == 0.0: + return score, 0.0 + weights.append(math.exp(-distance)) + scores.append(score) + + if not weights: + return 0.75, 1.0 + + total_weight = sum(weights) + mean = sum(score * weight for score, weight in zip(scores, weights)) / total_weight + uncertainty = 1.0 / (1.0 + total_weight) + return mean, uncertainty + + +def _acquisition(mean: float, uncertainty: float, *, beta: float = 0.5) -> float: + return mean + beta * uncertainty + + +def run_random( + *, + budget: int, + evaluate_fn: EvaluateFn, + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, + rng: Optional[random.Random] = None, +) -> SearchRun: + draw = rng or random.Random() + history: List[Observation] = [] + decisions: List[str] = [] + evaluated_keys: Set[str] = set() + + for _ in range(budget): + candidate = sample_unevaluated_config( + draw, search_space, pipeline_layout, evaluated_keys + ) + key = pipeline_config_snapshot_key(candidate, search_space) + score = evaluate_fn(candidate) + history.append((score, candidate)) + evaluated_keys.add(key) + decisions.append("sample") + + return SearchRun(strategy="random", history=history, budget=budget, decisions=decisions) + + +def run_qgns( + *, + budget: int, + evaluate_fn: EvaluateFn, + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, + init_budget: int = 0, + init_strategy: Literal["random", "implementation_aware"] = "random", + y: int = 1, + k: int = 3, + rho: float = 0.2, + rng: Optional[random.Random] = None, +) -> SearchRun: + if budget <= 0: + return SearchRun(strategy="qgns", history=[], budget=0, decisions=[]) + + draw = rng or random.Random() + history: List[Observation] = [] + decisions: List[str] = [] + evaluated_keys: Set[str] = set() + + if init_budget > 0: + if init_strategy == "implementation_aware": + init_set = implementation_aware_initialization( + search_space, + pipeline_layout, + budget=min(init_budget, budget), + y=y, + rng=draw, + ) + else: + init_set = random_initialization( + search_space, + pipeline_layout, + budget=min(init_budget, budget), + rng=draw, + ) + for cfg in init_set: + key = pipeline_config_snapshot_key(cfg, search_space) + if key in evaluated_keys: + continue + score = evaluate_fn(cfg) + history.append((score, cfg)) + evaluated_keys.add(key) + decisions.append(f"init({init_strategy})") + if len(history) >= budget: + return SearchRun(strategy="qgns", history=history, budget=budget, decisions=decisions) + + while len(history) < budget: + if not history or draw.random() < rho: + candidate = sample_unevaluated_config( + draw, search_space, pipeline_layout, evaluated_keys + ) + decision = "explore" + else: + anchors = _top_k(history, k) + candidate = None + decision = "explore(fallback)" + + shuffled = list(anchors) + draw.shuffle(shuffled) + for anchor_score, anchor_cfg in shuffled: + neighborhood = neighbors_at_distance_one( + anchor_cfg, search_space, pipeline_layout, draw + ) + unevaluated = [ + n + for n in neighborhood + if pipeline_config_snapshot_key(n, search_space) not in evaluated_keys + ] + if not unevaluated: + continue + candidate = draw.choice(unevaluated) + decision = f"neighborhood(anchor_score={anchor_score:.4f})" + break + + if candidate is None: + candidate = sample_unevaluated_config( + draw, search_space, pipeline_layout, evaluated_keys + ) + + key = pipeline_config_snapshot_key(candidate, search_space) + score = evaluate_fn(candidate) + history.append((score, candidate)) + evaluated_keys.add(key) + decisions.append(decision) + + return SearchRun(strategy="qgns", history=history, budget=budget, decisions=decisions) + + +def run_hnr( + *, + budget: int, + evaluate_fn: EvaluateFn, + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, + init_budget: int, + init_strategy: Literal["random", "implementation_aware"] = "implementation_aware", + y: int = 1, + rho: float = 0.2, + rng: Optional[random.Random] = None, +) -> SearchRun: + if budget <= 0: + return SearchRun(strategy="hnr", history=[], budget=0, decisions=[]) + if init_budget <= 0: + raise ValueError("HNR requires init_budget > 0") + + draw = rng or random.Random() + history: List[Observation] = [] + decisions: List[str] = [] + evaluated_keys: Set[str] = set() + + if init_strategy == "implementation_aware": + init_set = implementation_aware_initialization( + search_space, + pipeline_layout, + budget=min(init_budget, budget), + y=y, + rng=draw, + ) + else: + init_set = random_initialization( + search_space, + pipeline_layout, + budget=min(init_budget, budget), + rng=draw, + ) + + for cfg in init_set: + key = pipeline_config_snapshot_key(cfg, search_space) + if key in evaluated_keys: + continue + score = evaluate_fn(cfg) + history.append((score, cfg)) + evaluated_keys.add(key) + decisions.append(f"init({init_strategy})") + if len(history) >= budget: + return SearchRun(strategy="hnr", history=history, budget=budget, decisions=decisions) + + best_score, best_cfg = max(history, key=lambda item: item[0]) + + while len(history) < budget: + improved = False + + for idx in range(len(best_cfg.tasks)): + if len(history) >= budget: + break + + if draw.random() < rho: + candidate = sample_unevaluated_config( + draw, search_space, pipeline_layout, evaluated_keys + ) + decision = f"explore(task_idx={idx})" + else: + task_neighbors = _restricted_implementation_neighbors_for_index( + best_cfg, search_space, pipeline_layout, draw, index=idx + ) + task_candidates = [ + n + for n in task_neighbors + if pipeline_config_snapshot_key(n, search_space) not in evaluated_keys + ] + + if task_candidates: + candidate = draw.choice(task_candidates) + decision = f"task_neighbor(idx={idx})" + else: + param_neighbors = _restricted_parameter_neighbors_for_index( + best_cfg, search_space, index=idx + ) + param_candidates = [ + n + for n in param_neighbors + if pipeline_config_snapshot_key(n, search_space) not in evaluated_keys + ] + if param_candidates: + candidate = draw.choice(param_candidates) + decision = f"param_neighbor(idx={idx})" + else: + candidate = sample_unevaluated_config( + draw, search_space, pipeline_layout, evaluated_keys + ) + decision = f"explore(fallback,idx={idx})" + + key = pipeline_config_snapshot_key(candidate, search_space) + score = evaluate_fn(candidate) + history.append((score, candidate)) + evaluated_keys.add(key) + decisions.append(decision) + + if score > best_score: + best_score, best_cfg = score, candidate + improved = True + + if not improved and len(history) < budget and draw.random() < rho: + candidate = sample_unevaluated_config( + draw, search_space, pipeline_layout, evaluated_keys + ) + key = pipeline_config_snapshot_key(candidate, search_space) + score = evaluate_fn(candidate) + history.append((score, candidate)) + evaluated_keys.add(key) + decisions.append("explore(post_sweep)") + if score > best_score: + best_score, best_cfg = score, candidate + + return SearchRun(strategy="hnr", history=history, budget=budget, decisions=decisions) + + +def run_bayesian( + *, + budget: int, + evaluate_fn: EvaluateFn, + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, + init_budget: int = 3, + init_strategy: Literal["random", "implementation_aware"] = "random", + y: int = 1, + pool_size: int = 32, + beta: float = 0.5, + rng: Optional[random.Random] = None, +) -> SearchRun: + if budget <= 0: + return SearchRun(strategy="bayesian", history=[], budget=0, decisions=[]) + + draw = rng or random.Random() + history: List[Observation] = [] + decisions: List[str] = [] + evaluated_keys: Set[str] = set() + + if init_budget > 0: + if init_strategy == "implementation_aware": + init_set = implementation_aware_initialization( + search_space, + pipeline_layout, + budget=min(init_budget, budget), + y=y, + rng=draw, + ) + else: + init_set = random_initialization( + search_space, + pipeline_layout, + budget=min(init_budget, budget), + rng=draw, + ) + for cfg in init_set: + key = pipeline_config_snapshot_key(cfg, search_space) + if key in evaluated_keys: + continue + score = evaluate_fn(cfg) + history.append((score, cfg)) + evaluated_keys.add(key) + decisions.append(f"init({init_strategy})") + if len(history) >= budget: + return SearchRun(strategy="bayesian", history=history, budget=budget, decisions=decisions) + + while len(history) < budget: + candidates: List[PipelineConfig] = [] + for _ in range(pool_size): + candidates.append( + sample_unevaluated_config( + draw, search_space, pipeline_layout, evaluated_keys + ) + ) + + best_candidate = candidates[0] + best_acq = float("-inf") + best_pred = 0.0 + best_unc = 0.0 + + for candidate in candidates: + mean, unc = _predict_with_uncertainty(candidate, history, search_space) + acq = _acquisition(mean, unc, beta=beta) + if acq > best_acq: + best_acq = acq + best_candidate = candidate + best_pred = mean + best_unc = unc + + key = pipeline_config_snapshot_key(best_candidate, search_space) + score = evaluate_fn(best_candidate) + history.append((score, best_candidate)) + evaluated_keys.add(key) + decisions.append( + f"acquisition(pred={best_pred:.4f},unc={best_unc:.4f},a={best_acq:.4f})" + ) + + return SearchRun(strategy="bayesian", history=history, budget=budget, decisions=decisions) + +from __future__ import annotations + +import math +import random +from dataclasses import dataclass +from typing import Any, Callable, Dict, List, Literal, Optional, Set, Tuple + +from kgpipe.common.model.configuration import ConfigurationProfile, ParameterBinding +from kgpipe_search.configuration import ( + build_pipeline_config_for_task_combo, + enumerate_valid_task_combinations, + pipeline_config_snapshot_key, + sample_valid_pipeline_config, + task_keys_from_pipeline_config, +) +from kgpipe_search.definitions import PipelineConfig, PipelineLayout +from kgpipe_search.strategies.initialization import ( + implementation_aware_initialization, + random_initialization, +) + +Observation = Tuple[float, PipelineConfig] +EvaluateFn = Callable[[PipelineConfig], float] + +SearchStrategy = Literal["random", "qgns", "hnr", "bayesian"] + + +@dataclass +class SearchRun: + strategy: SearchStrategy + history: List[Observation] + budget: int + decisions: List[str] + + +def _top_k(history: List[Observation], k: int) -> List[Observation]: + ranked = sorted(history, key=lambda item: item[0], reverse=True) + return ranked[: max(1, min(k, len(ranked)))] + + +def _parameter_neighbors(anchor: PipelineConfig, search_space: Dict[str, Dict[str, Any]]) -> List[PipelineConfig]: + anchor_keys = task_keys_from_pipeline_config(anchor) + neighbors: List[PipelineConfig] = [] + + for task, task_key in zip(anchor.tasks, anchor_keys): + profile = anchor.config_catalog.get(task.name) + if profile is None: + continue + + for binding in profile.bindings: + param_name = binding.parameter.name + domain = search_space.get(task_key, {}).get(param_name) + if not isinstance(domain, list): + continue + + for value in domain: + if value == binding.value: + continue + + new_catalog = dict(anchor.config_catalog) + new_bindings: List[ParameterBinding] = [] + name_parts: List[str] = [] + for current in profile.bindings: + chosen = value if current.parameter.name == param_name else current.value + new_bindings.append(ParameterBinding(parameter=current.parameter, value=chosen)) + name_parts.append(f"{current.parameter.name}={chosen}") + + new_catalog[task.name] = ConfigurationProfile( + name=f"{task.name}_" + ",".join(name_parts), + definition=profile.definition, + bindings=new_bindings, + ) + neighbors.append(PipelineConfig(tasks=list(anchor.tasks), config_catalog=new_catalog)) + + return neighbors + + +def _implementation_neighbors( + anchor: PipelineConfig, + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, + rng: random.Random, +) -> List[PipelineConfig]: + anchor_keys = task_keys_from_pipeline_config(anchor) + neighbors: List[PipelineConfig] = [] + + for combo in enumerate_valid_task_combinations(search_space, pipeline_layout): + if len(combo) != len(anchor_keys): + continue + if sum(left != right for left, right in zip(anchor_keys, combo)) != 1: + continue + neighbors.append( + build_pipeline_config_for_task_combo( + search_space, + combo, + rng=rng, + template=anchor, + ) + ) + + return neighbors + + +def neighbors_at_distance_one( + anchor: PipelineConfig, + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, + rng: random.Random, +) -> List[PipelineConfig]: + seen: Set[str] = set() + neighbors: List[PipelineConfig] = [] + + for candidate in ( + _parameter_neighbors(anchor, search_space) + + _implementation_neighbors(anchor, search_space, pipeline_layout, rng) + ): + key = pipeline_config_snapshot_key(candidate, search_space) + if key in seen: + continue + seen.add(key) + neighbors.append(candidate) + + return neighbors + + +def _restricted_implementation_neighbors_for_index( + anchor: PipelineConfig, + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, + rng: random.Random, + *, + index: int, +) -> List[PipelineConfig]: + anchor_keys = task_keys_from_pipeline_config(anchor) + if index < 0 or index >= len(anchor_keys): + return [] + + neighbors: List[PipelineConfig] = [] + for combo in enumerate_valid_task_combinations(search_space, pipeline_layout): + if len(combo) != len(anchor_keys): + continue + if any(i != index and combo[i] != anchor_keys[i] for i in range(len(anchor_keys))): + continue + if combo[index] == anchor_keys[index]: + continue + neighbors.append( + build_pipeline_config_for_task_combo( + search_space, + combo, + rng=rng, + template=anchor, + ) + ) + return neighbors + + +def _restricted_parameter_neighbors_for_index( + anchor: PipelineConfig, + search_space: Dict[str, Dict[str, Any]], + *, + index: int, +) -> List[PipelineConfig]: + anchor_keys = task_keys_from_pipeline_config(anchor) + if index < 0 or index >= len(anchor.tasks) or index >= len(anchor_keys): + return [] + + task = anchor.tasks[index] + task_key = anchor_keys[index] + profile = anchor.config_catalog.get(task.name) + if profile is None: + return [] + + neighbors: List[PipelineConfig] = [] + for binding in profile.bindings: + param_name = binding.parameter.name + domain = search_space.get(task_key, {}).get(param_name) + if not isinstance(domain, list): + continue + + for value in domain: + if value == binding.value: + continue + + new_catalog = dict(anchor.config_catalog) + new_bindings: List[ParameterBinding] = [] + name_parts: List[str] = [] + for current in profile.bindings: + chosen = value if current.parameter.name == param_name else current.value + new_bindings.append(ParameterBinding(parameter=current.parameter, value=chosen)) + name_parts.append(f"{current.parameter.name}={chosen}") + + new_catalog[task.name] = ConfigurationProfile( + name=f"{task.name}_" + ",".join(name_parts), + definition=profile.definition, + bindings=new_bindings, + ) + neighbors.append(PipelineConfig(tasks=list(anchor.tasks), config_catalog=new_catalog)) + + return neighbors + + +def sample_unevaluated_config( + rng: random.Random, + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, + evaluated_keys: Set[str], + *, + max_attempts: int = 500, +) -> PipelineConfig: + for _ in range(max_attempts): + candidate = sample_valid_pipeline_config( + search_space, + pipeline_layout, + rng=rng, + ) + key = pipeline_config_snapshot_key(candidate, search_space) + if key not in evaluated_keys: + return candidate + + raise RuntimeError("Failed to sample an unevaluated configuration") + + +def _config_distance(left: PipelineConfig, right: PipelineConfig, search_space: Dict[str, Dict[str, Any]]) -> float: + if pipeline_config_snapshot_key(left, search_space) == pipeline_config_snapshot_key(right, search_space): + return 0.0 + + left_keys = task_keys_from_pipeline_config(left) + right_keys = task_keys_from_pipeline_config(right) + distance = float(sum(a != b for a, b in zip(left_keys, right_keys))) + if len(left_keys) != len(right_keys): + distance += abs(len(left_keys) - len(right_keys)) + + left_params = { + (task.name, binding.parameter.name): binding.value + for task in left.tasks + for binding in (left.config_catalog.get(task.name).bindings if left.config_catalog.get(task.name) else []) + } + right_params = { + (task.name, binding.parameter.name): binding.value + for task in right.tasks + for binding in (right.config_catalog.get(task.name).bindings if right.config_catalog.get(task.name) else []) + } + + all_param_keys = set(left_params) | set(right_params) + for key in all_param_keys: + if left_params.get(key) != right_params.get(key): + distance += 1.0 + + return distance + + +def _predict_with_uncertainty( + candidate: PipelineConfig, + history: List[Observation], + search_space: Dict[str, Dict[str, Any]], +) -> Tuple[float, float]: + weights: List[float] = [] + scores: List[float] = [] + + for score, observed in history: + distance = _config_distance(candidate, observed, search_space) + if distance == 0.0: + return score, 0.0 + weights.append(math.exp(-distance)) + scores.append(score) + + if not weights: + return 0.75, 1.0 + + total_weight = sum(weights) + mean = sum(score * weight for score, weight in zip(scores, weights)) / total_weight + uncertainty = 1.0 / (1.0 + total_weight) + return mean, uncertainty + + +def _acquisition(mean: float, uncertainty: float, *, beta: float = 0.5) -> float: + return mean + beta * uncertainty + + +def run_random( + *, + budget: int, + evaluate_fn: EvaluateFn, + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, + rng: Optional[random.Random] = None, +) -> SearchRun: + draw = rng or random.Random() + history: List[Observation] = [] + decisions: List[str] = [] + evaluated_keys: Set[str] = set() + + for _ in range(budget): + candidate = sample_unevaluated_config(draw, search_space, pipeline_layout, evaluated_keys) + key = pipeline_config_snapshot_key(candidate, search_space) + score = evaluate_fn(candidate) + history.append((score, candidate)) + evaluated_keys.add(key) + decisions.append("sample") + + return SearchRun(strategy="random", history=history, budget=budget, decisions=decisions) + + +def run_qgns( + *, + budget: int, + evaluate_fn: EvaluateFn, + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, + init_budget: int = 0, + init_strategy: Literal["random", "implementation_aware"] = "random", + y: int = 1, + k: int = 3, + rho: float = 0.2, + rng: Optional[random.Random] = None, +) -> SearchRun: + """ + Quality-Guided Neighborhood Search (QGNS) from the paper. + + Maintains top-k anchors; with probability rho explores globally, otherwise samples + one-step neighbors of an anchor. + """ + if budget <= 0: + return SearchRun(strategy="qgns", history=[], budget=0, decisions=[]) + + draw = rng or random.Random() + history: List[Observation] = [] + decisions: List[str] = [] + evaluated_keys: Set[str] = set() + + if init_budget > 0: + if init_strategy == "implementation_aware": + init_set = implementation_aware_initialization( + search_space, pipeline_layout, budget=min(init_budget, budget), y=y, rng=draw + ) + else: + init_set = random_initialization( + search_space, pipeline_layout, budget=min(init_budget, budget), rng=draw + ) + for cfg in init_set: + key = pipeline_config_snapshot_key(cfg, search_space) + if key in evaluated_keys: + continue + score = evaluate_fn(cfg) + history.append((score, cfg)) + evaluated_keys.add(key) + decisions.append(f"init({init_strategy})") + if len(history) >= budget: + return SearchRun(strategy="qgns", history=history, budget=budget, decisions=decisions) + + while len(history) < budget: + if not history or draw.random() < rho: + candidate = sample_unevaluated_config(draw, search_space, pipeline_layout, evaluated_keys) + decision = "explore" + else: + anchors = _top_k(history, k) + candidate = None + decision = "explore(fallback)" + + # Try anchors until we find an unevaluated neighbor; else fallback to explore. + shuffled = list(anchors) + draw.shuffle(shuffled) + for anchor_score, anchor_cfg in shuffled: + neighborhood = neighbors_at_distance_one(anchor_cfg, search_space, pipeline_layout, draw) + unevaluated = [ + n for n in neighborhood if pipeline_config_snapshot_key(n, search_space) not in evaluated_keys + ] + if not unevaluated: + continue + candidate = draw.choice(unevaluated) + decision = f"neighborhood(anchor_score={anchor_score:.4f})" + break + + if candidate is None: + candidate = sample_unevaluated_config(draw, search_space, pipeline_layout, evaluated_keys) + + key = pipeline_config_snapshot_key(candidate, search_space) + score = evaluate_fn(candidate) + history.append((score, candidate)) + evaluated_keys.add(key) + decisions.append(decision) + + return SearchRun(strategy="qgns", history=history, budget=budget, decisions=decisions) + + +def run_hnr( + *, + budget: int, + evaluate_fn: EvaluateFn, + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, + init_budget: int, + init_strategy: Literal["random", "implementation_aware"] = "implementation_aware", + y: int = 1, + rho: float = 0.2, + rng: Optional[random.Random] = None, +) -> SearchRun: + """ + Heuristic Neighborhood Refinement (HNR) from the paper. + + Starts from an initial evaluated set, then iterates task-wise. For each task index, + prefers trying implementation replacements, then parameter modifications, with occasional + global exploration. + """ + if budget <= 0: + return SearchRun(strategy="hnr", history=[], budget=0, decisions=[]) + if init_budget <= 0: + raise ValueError("HNR requires init_budget > 0") + + draw = rng or random.Random() + history: List[Observation] = [] + decisions: List[str] = [] + evaluated_keys: Set[str] = set() + + if init_strategy == "implementation_aware": + init_set = implementation_aware_initialization( + search_space, pipeline_layout, budget=min(init_budget, budget), y=y, rng=draw + ) + else: + init_set = random_initialization( + search_space, pipeline_layout, budget=min(init_budget, budget), rng=draw + ) + + for cfg in init_set: + key = pipeline_config_snapshot_key(cfg, search_space) + if key in evaluated_keys: + continue + score = evaluate_fn(cfg) + history.append((score, cfg)) + evaluated_keys.add(key) + decisions.append(f"init({init_strategy})") + if len(history) >= budget: + return SearchRun(strategy="hnr", history=history, budget=budget, decisions=decisions) + + # Current best anchor. + best_score, best_cfg = max(history, key=lambda item: item[0]) + + while len(history) < budget: + improved = False + + for idx in range(len(best_cfg.tasks)): + if len(history) >= budget: + break + + if draw.random() < rho: + candidate = sample_unevaluated_config(draw, search_space, pipeline_layout, evaluated_keys) + decision = f"explore(task_idx={idx})" + else: + task_neighbors = _restricted_implementation_neighbors_for_index( + best_cfg, search_space, pipeline_layout, draw, index=idx + ) + task_candidates = [ + n for n in task_neighbors if pipeline_config_snapshot_key(n, search_space) not in evaluated_keys + ] + + if task_candidates: + candidate = draw.choice(task_candidates) + decision = f"task_neighbor(idx={idx})" + else: + param_neighbors = _restricted_parameter_neighbors_for_index( + best_cfg, search_space, index=idx + ) + param_candidates = [ + n for n in param_neighbors if pipeline_config_snapshot_key(n, search_space) not in evaluated_keys + ] + if param_candidates: + candidate = draw.choice(param_candidates) + decision = f"param_neighbor(idx={idx})" + else: + candidate = sample_unevaluated_config(draw, search_space, pipeline_layout, evaluated_keys) + decision = f"explore(fallback,idx={idx})" + + key = pipeline_config_snapshot_key(candidate, search_space) + score = evaluate_fn(candidate) + history.append((score, candidate)) + evaluated_keys.add(key) + decisions.append(decision) + + if score > best_score: + best_score, best_cfg = score, candidate + improved = True + + # If we made no improvements during a full sweep, we still continue (paper keeps exploring). + if not improved and len(history) < budget and draw.random() < rho: + candidate = sample_unevaluated_config(draw, search_space, pipeline_layout, evaluated_keys) + key = pipeline_config_snapshot_key(candidate, search_space) + score = evaluate_fn(candidate) + history.append((score, candidate)) + evaluated_keys.add(key) + decisions.append("explore(post_sweep)") + if score > best_score: + best_score, best_cfg = score, candidate + + return SearchRun(strategy="hnr", history=history, budget=budget, decisions=decisions) + + +def run_bayesian( + *, + budget: int, + evaluate_fn: EvaluateFn, + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, + init_budget: int = 3, + init_strategy: Literal["random", "implementation_aware"] = "random", + y: int = 1, + pool_size: int = 32, + beta: float = 0.5, + rng: Optional[random.Random] = None, +) -> SearchRun: + """ + Simple Bayesian-optimization-like baseline using a distance-weighted surrogate, + adapted to support explicit initialization strategies. + """ + if budget <= 0: + return SearchRun(strategy="bayesian", history=[], budget=0, decisions=[]) + + draw = rng or random.Random() + history: List[Observation] = [] + decisions: List[str] = [] + evaluated_keys: Set[str] = set() + + if init_budget > 0: + if init_strategy == "implementation_aware": + init_set = implementation_aware_initialization( + search_space, pipeline_layout, budget=min(init_budget, budget), y=y, rng=draw + ) + else: + init_set = random_initialization( + search_space, pipeline_layout, budget=min(init_budget, budget), rng=draw + ) + for cfg in init_set: + key = pipeline_config_snapshot_key(cfg, search_space) + if key in evaluated_keys: + continue + score = evaluate_fn(cfg) + history.append((score, cfg)) + evaluated_keys.add(key) + decisions.append(f"init({init_strategy})") + if len(history) >= budget: + return SearchRun(strategy="bayesian", history=history, budget=budget, decisions=decisions) + + while len(history) < budget: + candidates: List[PipelineConfig] = [] + for _ in range(pool_size): + candidates.append(sample_unevaluated_config(draw, search_space, pipeline_layout, evaluated_keys)) + + best_candidate = candidates[0] + best_acq = float("-inf") + best_pred = 0.0 + best_unc = 0.0 + for candidate in candidates: + mean, unc = _predict_with_uncertainty(candidate, history, search_space) + acq = _acquisition(mean, unc, beta=beta) + if acq > best_acq: + best_acq = acq + best_candidate = candidate + best_pred = mean + best_unc = unc + + key = pipeline_config_snapshot_key(best_candidate, search_space) + score = evaluate_fn(best_candidate) + history.append((score, best_candidate)) + evaluated_keys.add(key) + decisions.append(f"acquisition(pred={best_pred:.4f},unc={best_unc:.4f},a={best_acq:.4f})") + + return SearchRun(strategy="bayesian", history=history, budget=budget, decisions=decisions) + diff --git a/experiments/param-opti/src/kgpipe_search/test/test_search_strategies.py b/experiments/param-opti/src/kgpipe_search/test/test_search_strategies.py new file mode 100644 index 0000000..6f790ee --- /dev/null +++ b/experiments/param-opti/src/kgpipe_search/test/test_search_strategies.py @@ -0,0 +1,66 @@ +import random + +from kgpipe_search.definitions import RDF_PIPELINE_LAYOUT, RDF_SEARCH_SPACE +from kgpipe_search.evaluation import dummy_evaluate_pipeline +from kgpipe_search.search import hnr_search, qgns_search + + +def _assert_valid(run) -> None: + assert len(run.history) == run.budget + assert len(run.decisions) == run.budget + + seen: set[str] = set() + for score, cfg in run.history: + assert 0.5 <= score <= 1.0 + assert cfg.tasks + # Snapshot key uniqueness is the true criterion; repr is good enough here. + key = repr( + [ + ( + task.name, + tuple( + (b.parameter.name, b.value) + for b in ( + cfg.config_catalog.get(task.name).bindings + if cfg.config_catalog.get(task.name) + else [] + ) + ), + ) + for task in cfg.tasks + ] + ) + assert key not in seen + seen.add(key) + + +def test_dummy_evaluate_pipeline_qgns_with_implementation_aware_init(): + run = qgns_search( + budget=10, + init_budget=3, + init_strategy="implementation_aware", + y=1, + evaluate_fn=dummy_evaluate_pipeline, + search_space=RDF_SEARCH_SPACE, + pipeline_layout=RDF_PIPELINE_LAYOUT, + k=3, + rho=0.2, + rng=random.Random(3), + ) + _assert_valid(run) + + +def test_dummy_evaluate_pipeline_hnr(): + run = hnr_search( + budget=10, + init_budget=4, + init_strategy="implementation_aware", + y=1, + evaluate_fn=dummy_evaluate_pipeline, + search_space=RDF_SEARCH_SPACE, + pipeline_layout=RDF_PIPELINE_LAYOUT, + rho=0.2, + rng=random.Random(4), + ) + _assert_valid(run) + From 02c6d81b4d1905ca032bff42f541372eedf47758 Mon Sep 17 00:00:00 2001 From: Marvin Hofer Date: Thu, 9 Jul 2026 14:48:35 +0200 Subject: [PATCH 80/96] exp(params): impl-aware configuration sampling tests --- .../src/kgpipe_search/configuration.py | 204 ++++++++++++++---- .../src/kgpipe_search/definitions.py | 6 + .../kgpipe_search/test/test_configuration.py | 80 ++++++- 3 files changed, 242 insertions(+), 48 deletions(-) diff --git a/experiments/param-opti/src/kgpipe_search/configuration.py b/experiments/param-opti/src/kgpipe_search/configuration.py index cff1cbb..0e2b39d 100644 --- a/experiments/param-opti/src/kgpipe_search/configuration.py +++ b/experiments/param-opti/src/kgpipe_search/configuration.py @@ -5,12 +5,16 @@ PipelineLayout, PipelineConfig, RDF_SAMPLED_PIPELINE_CONFIGS_FIXTURE, + RDF_UNIQUE_SAMPLED_PIPELINE_CONFIGS_FIXTURE, RDF_EXHAUSTIVE_PIPELINE_CONFIGS_FIXTURE, TEXT_SAMPLED_PIPELINE_CONFIGS_FIXTURE, + TEXT_UNIQUE_SAMPLED_PIPELINE_CONFIGS_FIXTURE, TEXT_EXHAUSTIVE_PIPELINE_CONFIGS_FIXTURE, _RDF_PIPELINE_CONFIG_SNAPSHOT_VERSION, + _RDF_UNIQUE_PIPELINE_CONFIG_SNAPSHOT_VERSION, _RDF_EXHAUSTIVE_PIPELINE_CONFIG_SNAPSHOT_VERSION, _TEXT_PIPELINE_CONFIG_SNAPSHOT_VERSION, + _TEXT_UNIQUE_PIPELINE_CONFIG_SNAPSHOT_VERSION, _TEXT_EXHAUSTIVE_PIPELINE_CONFIG_SNAPSHOT_VERSION, ) import json @@ -171,6 +175,28 @@ def load_text_sampled_pipeline_configs(path: Optional[Path] = None) -> List[Pipe return [pipeline_config_from_snapshot(item) for item in raw["samples"]] +def load_rdf_unique_sampled_pipeline_configs(path: Optional[Path] = None) -> List[PipelineConfig]: + fixture_path = path or RDF_UNIQUE_SAMPLED_PIPELINE_CONFIGS_FIXTURE + raw = json.loads(fixture_path.read_text(encoding="utf-8")) + if raw.get("version") != _RDF_UNIQUE_PIPELINE_CONFIG_SNAPSHOT_VERSION: + raise ValueError( + f"Unsupported rdf unique sampled configs snapshot version {raw.get('version')!r}; " + f"expected {_RDF_UNIQUE_PIPELINE_CONFIG_SNAPSHOT_VERSION}" + ) + return [pipeline_config_from_snapshot(item) for item in raw["samples"]] + + +def load_text_unique_sampled_pipeline_configs(path: Optional[Path] = None) -> List[PipelineConfig]: + fixture_path = path or TEXT_UNIQUE_SAMPLED_PIPELINE_CONFIGS_FIXTURE + raw = json.loads(fixture_path.read_text(encoding="utf-8")) + if raw.get("version") != _TEXT_UNIQUE_PIPELINE_CONFIG_SNAPSHOT_VERSION: + raise ValueError( + f"Unsupported text unique sampled configs snapshot version {raw.get('version')!r}; " + f"expected {_TEXT_UNIQUE_PIPELINE_CONFIG_SNAPSHOT_VERSION}" + ) + return [pipeline_config_from_snapshot(item) for item in raw["samples"]] + + def load_rdf_exhaustive_pipeline_configs(path: Optional[Path] = None) -> List[PipelineConfig]: fixture_path = path or RDF_EXHAUSTIVE_PIPELINE_CONFIGS_FIXTURE raw = json.loads(fixture_path.read_text(encoding="utf-8")) @@ -460,26 +486,144 @@ def sample_config_catalog_for_task_combo( return PipelineConfig(tasks=tasks, config_catalog=config_catalog) +def _task_param_assignments( + search_space: Dict[str, Dict[str, Any]], task_key: str +) -> List[Dict[str, Any]]: + space = search_space.get(task_key, {}) + param_space: Dict[str, List[Any]] = {k: v for k, v in space.items() if k != "category"} + if not param_space: + return [{}] + keys = list(param_space.keys()) + values_lists = [param_space[k] for k in keys] + return [dict(zip(keys, values)) for values in itertools.product(*values_lists)] + + +def _pipeline_config_for_combo_and_params( + search_space: Dict[str, Dict[str, Any]], + combo: List[str], + assignment_tuple: tuple[Dict[str, Any], ...], +) -> PipelineConfig: + tasks: List[KgTask] = [] + config_catalog: Dict[str, ConfigurationProfile] = {} + + for task_key, params in zip(combo, assignment_tuple): + task = task_dict[task_key] + tasks.append(task) + + if not params: + continue + if getattr(task, "config_spec", None) is None: + continue + + bindings: List[ParameterBinding] = [] + name_parts: List[str] = [] + + # Iterate in search_space order for stable snapshots. + for config_name, _config_values in search_space[task_key].items(): + if config_name == "category": + continue + if config_name not in params: + continue + config_value = params[config_name] + name_parts.append(f"{config_name}={config_value}") + bindings.append( + ParameterBinding( + parameter=_get_param(task.config_spec, config_name), + value=config_value, + ) + ) + + config_catalog[task.name] = ConfigurationProfile( + name=f"{task.name}_" + ",".join(name_parts), + definition=task.config_spec, + bindings=bindings, + ) + + return PipelineConfig(tasks=tasks, config_catalog=config_catalog) + + +def enumerate_snapshots_for_task_combo( + search_space: Dict[str, Dict[str, Any]], + combo: List[str], +) -> List[Dict[str, Any]]: + per_task_assignments = [ + _task_param_assignments(search_space, task_key) for task_key in combo + ] + snapshots: List[Dict[str, Any]] = [] + for assignment_tuple in itertools.product(*per_task_assignments): + pipeline_config = _pipeline_config_for_combo_and_params( + search_space, combo, assignment_tuple + ) + snapshots.append(pipeline_config_to_snapshot(combo, pipeline_config)) + return snapshots + + +def sample_unique_pipeline_config_snapshots_per_combo( + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, + *, + n: int, + rng: random.Random, +) -> tuple[List[Dict[str, Any]], Dict[str, Any]]: + """ + Sample up to n unique profile snapshots per valid task combo. + + When a combo has fewer than n distinct profile assignments, all available + profiles are returned for that combo. + """ + if n < 1: + raise ValueError(f"n must be >= 1, got {n}") + + combos = enumerate_valid_task_combinations(search_space, pipeline_layout) + snapshots: List[Dict[str, Any]] = [] + combo_stats: List[Dict[str, Any]] = [] + + for combo in combos: + available_snapshots = enumerate_snapshots_for_task_combo(search_space, combo) + serialized = [json.dumps(s, sort_keys=True) for s in available_snapshots] + if len(set(serialized)) != len(serialized): + raise ValueError(f"Duplicate profile snapshots for combo {combo!r}") + + sample_count = min(n, len(available_snapshots)) + picked = ( + rng.sample(available_snapshots, k=sample_count) + if sample_count > 0 + else [] + ) + snapshots.extend(picked) + combo_stats.append( + { + "task_keys": combo, + "available_profiles": len(available_snapshots), + "requested": n, + "sampled": sample_count, + "exhausted": sample_count < n, + } + ) + + stats: Dict[str, Any] = { + "requested_n": n, + "total_combos": len(combos), + "total_snapshots": len(snapshots), + "combos_exhausted": sum(1 for row in combo_stats if row["exhausted"]), + "combos": combo_stats, + } + return snapshots, stats + + def enumerate_exhaustive_pipeline_config_snapshots( search_space: Dict[str, Dict[str, Any]], pipeline_layout: PipelineLayout, ) -> List[Dict[str, Any]]: combos = enumerate_valid_task_combinations(search_space, pipeline_layout) - def _task_param_assignments(task_key: str) -> List[Dict[str, Any]]: - space = search_space.get(task_key, {}) - param_space: Dict[str, List[Any]] = {k: v for k, v in space.items() if k != "category"} - if not param_space: - return [{}] - keys = list(param_space.keys()) - values_lists = [param_space[k] for k in keys] - return [dict(zip(keys, values)) for values in itertools.product(*values_lists)] - all_snapshots: List[Dict[str, Any]] = [] total_expected = 0 for combo in combos: - per_task_assignments = [_task_param_assignments(task_key) for task_key in combo] + per_task_assignments = [ + _task_param_assignments(search_space, task_key) for task_key in combo + ] expected_for_combo = 1 for assignments in per_task_assignments: @@ -496,43 +640,9 @@ def _task_param_assignments(task_key: str) -> List[Dict[str, Any]]: if produced_for_combo % 100 == 1 or produced_for_combo == expected_for_combo: print(f"config {produced_for_combo}/{expected_for_combo}") - tasks: List[KgTask] = [] - config_catalog: Dict[str, ConfigurationProfile] = {} - - for task_key, params in zip(combo, assignment_tuple): - task = task_dict[task_key] - tasks.append(task) - - if not params: - continue - if getattr(task, "config_spec", None) is None: - continue - - bindings: List[ParameterBinding] = [] - name_parts: List[str] = [] - - # Iterate in search_space order for stable snapshots. - for config_name, _config_values in search_space[task_key].items(): - if config_name == "category": - continue - if config_name not in params: - continue - config_value = params[config_name] - name_parts.append(f"{config_name}={config_value}") - bindings.append( - ParameterBinding( - parameter=_get_param(task.config_spec, config_name), - value=config_value, - ) - ) - - config_catalog[task.name] = ConfigurationProfile( - name=f"{task.name}_" + ",".join(name_parts), - definition=task.config_spec, - bindings=bindings, - ) - - pipeline_config = PipelineConfig(tasks=tasks, config_catalog=config_catalog) + pipeline_config = _pipeline_config_for_combo_and_params( + search_space, combo, assignment_tuple + ) all_snapshots.append(pipeline_config_to_snapshot(combo, pipeline_config)) assert produced_for_combo == expected_for_combo diff --git a/experiments/param-opti/src/kgpipe_search/definitions.py b/experiments/param-opti/src/kgpipe_search/definitions.py index 348c04b..e891e81 100644 --- a/experiments/param-opti/src/kgpipe_search/definitions.py +++ b/experiments/param-opti/src/kgpipe_search/definitions.py @@ -21,12 +21,18 @@ class PipelineConfig(BaseModel): RDF_SAMPLED_PIPELINE_CONFIGS_FIXTURE = Path(__file__).resolve().parent.parent / "data" / "fixtures" / "rdf_sampled_pipeline_configs.json" _RDF_PIPELINE_CONFIG_SNAPSHOT_VERSION = 1 +RDF_UNIQUE_SAMPLED_PIPELINE_CONFIGS_FIXTURE = Path(__file__).resolve().parent.parent / "data" / "fixtures" / "rdf_unique_sampled_pipeline_configs.json" +_RDF_UNIQUE_PIPELINE_CONFIG_SNAPSHOT_VERSION = 1 + RDF_EXHAUSTIVE_PIPELINE_CONFIGS_FIXTURE = Path(__file__).resolve().parent.parent / "data" / "fixtures" / "rdf_exhaustive_pipeline_configs.json" _RDF_EXHAUSTIVE_PIPELINE_CONFIG_SNAPSHOT_VERSION = 1 TEXT_SAMPLED_PIPELINE_CONFIGS_FIXTURE = Path(__file__).resolve().parent.parent / "data" / "fixtures" / "text_sampled_pipeline_configs.json" _TEXT_PIPELINE_CONFIG_SNAPSHOT_VERSION = 1 +TEXT_UNIQUE_SAMPLED_PIPELINE_CONFIGS_FIXTURE = Path(__file__).resolve().parent.parent / "data" / "fixtures" / "text_unique_sampled_pipeline_configs.json" +_TEXT_UNIQUE_PIPELINE_CONFIG_SNAPSHOT_VERSION = 1 + TEXT_EXHAUSTIVE_PIPELINE_CONFIGS_FIXTURE = Path(__file__).resolve().parent.parent / "data" / "fixtures" / "text_exhaustive_pipeline_configs.json" _TEXT_EXHAUSTIVE_PIPELINE_CONFIG_SNAPSHOT_VERSION = 1 diff --git a/experiments/param-opti/src/kgpipe_search/test/test_configuration.py b/experiments/param-opti/src/kgpipe_search/test/test_configuration.py index 58a0655..38b0932 100644 --- a/experiments/param-opti/src/kgpipe_search/test/test_configuration.py +++ b/experiments/param-opti/src/kgpipe_search/test/test_configuration.py @@ -2,11 +2,14 @@ from kgpipe_search.configuration import ( sample_valid_pipeline_config, enumerate_valid_task_combinations, sample_config_catalog_for_task_combo, enumerate_exhaustive_pipeline_config_snapshots, pipeline_config_to_snapshot, - print_pipeline_config_short + print_pipeline_config_short, + sample_unique_pipeline_config_snapshots_per_combo, ) from kgpipe_search.definitions import RDF_SAMPLED_PIPELINE_CONFIGS_FIXTURE, _RDF_PIPELINE_CONFIG_SNAPSHOT_VERSION +from kgpipe_search.definitions import RDF_UNIQUE_SAMPLED_PIPELINE_CONFIGS_FIXTURE, _RDF_UNIQUE_PIPELINE_CONFIG_SNAPSHOT_VERSION from kgpipe_search.definitions import RDF_EXHAUSTIVE_PIPELINE_CONFIGS_FIXTURE, _RDF_EXHAUSTIVE_PIPELINE_CONFIG_SNAPSHOT_VERSION from kgpipe_search.definitions import TEXT_SAMPLED_PIPELINE_CONFIGS_FIXTURE, _TEXT_PIPELINE_CONFIG_SNAPSHOT_VERSION +from kgpipe_search.definitions import TEXT_UNIQUE_SAMPLED_PIPELINE_CONFIGS_FIXTURE, _TEXT_UNIQUE_PIPELINE_CONFIG_SNAPSHOT_VERSION from kgpipe_search.definitions import TEXT_EXHAUSTIVE_PIPELINE_CONFIGS_FIXTURE, _TEXT_EXHAUSTIVE_PIPELINE_CONFIG_SNAPSHOT_VERSION import json @@ -40,6 +43,22 @@ def test_enumerate_all_valid_rdf_task_combinations_no_config_sampling(): import random from typing import List, Dict, Any + +def _print_unique_sampling_stats(stats: Dict[str, Any]) -> None: + print() + print("unique sampling statistics") + print(f"requested n per combo: {stats['requested_n']}") + print(f"total combos: {stats['total_combos']}") + print(f"total snapshots: {stats['total_snapshots']}") + print(f"combos exhausted before n: {stats['combos_exhausted']}") + for row in stats["combos"]: + status = "EXHAUSTED" if row["exhausted"] else "ok" + print( + f" {row['task_keys']}: sampled {row['sampled']}/{row['requested']} " + f"(available {row['available_profiles']}) [{status}]" + ) + + def test_enumerate_all_valid_rdf_task_combinations_with_config_sampling(): print("enumerate_all_valid_rdf_task_combinations_with_config_sampling") n = 1 @@ -75,6 +94,35 @@ def test_enumerate_all_valid_rdf_task_combinations_with_config_sampling(): ) +def test_enumerate_all_valid_rdf_task_combinations_with_unique_config_sampling(): + print("enumerate_all_valid_rdf_task_combinations_with_unique_config_sampling") + n = 10 + rng = random.Random(0) + + snapshots, stats = sample_unique_pipeline_config_snapshots_per_combo( + RDF_SEARCH_SPACE, RDF_PIPELINE_LAYOUT, n=n, rng=rng + ) + _print_unique_sampling_stats(stats) + + for combo_row in stats["combos"]: + combo_task_keys = combo_row["task_keys"] + combo_snapshots = [s for s in snapshots if s["task_keys"] == combo_task_keys] + serialized = [json.dumps(s, sort_keys=True) for s in combo_snapshots] + assert len(set(serialized)) == len(serialized) + assert len(serialized) == combo_row["sampled"] + + RDF_UNIQUE_SAMPLED_PIPELINE_CONFIGS_FIXTURE.parent.mkdir(parents=True, exist_ok=True) + RDF_UNIQUE_SAMPLED_PIPELINE_CONFIGS_FIXTURE.write_text( + json.dumps( + {"version": _RDF_UNIQUE_PIPELINE_CONFIG_SNAPSHOT_VERSION, "samples": snapshots}, + indent=2, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + + def test_sample_valid_text_pipeline_config(): pipeline_config = sample_valid_pipeline_config(TEXT_SEARCH_SPACE, TEXT_PIPELINE_LAYOUT) print_pipeline_config_short(pipeline_config) @@ -119,6 +167,36 @@ def test_enumerate_all_valid_text_task_combinations_with_config_sampling(): encoding="utf-8", ) + +def test_enumerate_all_valid_text_task_combinations_with_unique_config_sampling(): + print("enumerate_all_valid_text_task_combinations_with_unique_config_sampling") + n = 3 + rng = random.Random(0) + + snapshots, stats = sample_unique_pipeline_config_snapshots_per_combo( + TEXT_SEARCH_SPACE, TEXT_PIPELINE_LAYOUT, n=n, rng=rng + ) + _print_unique_sampling_stats(stats) + + for combo_row in stats["combos"]: + combo_task_keys = combo_row["task_keys"] + combo_snapshots = [s for s in snapshots if s["task_keys"] == combo_task_keys] + serialized = [json.dumps(s, sort_keys=True) for s in combo_snapshots] + assert len(set(serialized)) == len(serialized) + assert len(serialized) == combo_row["sampled"] + + TEXT_UNIQUE_SAMPLED_PIPELINE_CONFIGS_FIXTURE.parent.mkdir(parents=True, exist_ok=True) + TEXT_UNIQUE_SAMPLED_PIPELINE_CONFIGS_FIXTURE.write_text( + json.dumps( + {"version": _TEXT_UNIQUE_PIPELINE_CONFIG_SNAPSHOT_VERSION, "samples": snapshots}, + indent=2, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + + def test_enumerate_all_valid_text_task_combinations_with_config_sampling_exhaustive(): print("enumerate_all_valid_text_task_combinations_with_config_sampling_exhaustive") all_snapshots = enumerate_exhaustive_pipeline_config_snapshots( From 066b8d61900d10d28ed8429a8cf278f883e02b52 Mon Sep 17 00:00:00 2001 From: Marvin Hofer Date: Thu, 9 Jul 2026 14:49:02 +0200 Subject: [PATCH 81/96] exp(params): added tasks-tmp-scope (config,pipeline,shared) to experiments.py cli --- experiments/param-opti/src/experiment.py | 59 +++++++++++++++++++++--- 1 file changed, 52 insertions(+), 7 deletions(-) diff --git a/experiments/param-opti/src/experiment.py b/experiments/param-opti/src/experiment.py index 0daddd2..33f4709 100644 --- a/experiments/param-opti/src/experiment.py +++ b/experiments/param-opti/src/experiment.py @@ -3,18 +3,16 @@ Run and evaluate pipeline configs from fixture files. Example (quick test with the small sampled fixture, 6 RDF / 4 text configs): - python experiment.py \\ - --seed data/bench/.../seed/data.nt \\ - --source data/bench/.../sources/rdf/data.nt \\ - --reference data/bench/.../reference/data_agg.nt \\ + python experiment.py \ + --seed data/bench/.../seed/data.nt \ + --source data/bench/.../sources/rdf/data.nt \ + --reference data/bench/.../reference/data_agg.nt \ --ontology data/bench/.../ontology.ttl Full exhaustive run (all task/parameter permutations): python experiment.py ... --configs exhaustive """ -from __future__ import annotations - import argparse import hashlib import json @@ -93,6 +91,32 @@ def _config_hash(snapshot: Dict[str, Any]) -> str: return hashlib.sha256(canonical.encode("utf-8")).hexdigest() +def _tasks_tmp_dir( + *, + output_dir: Path, + config_hash: str, + task_keys: List[str], + scope: str, +) -> Path: + """ + Decide where per-task temporary files live. + + - config: one tmp dir per config hash (default, current behavior) + - pipeline: reuse tmp dir for configs with identical task list (enables cache reuse across params) + - shared: reuse a single tmp dir for all configs + """ + + if scope == "config": + return output_dir / f"{config_hash}_tasks_tmp" + if scope == "shared": + return output_dir / "shared_tasks_tmp" + if scope == "pipeline": + canonical = json.dumps(task_keys, sort_keys=False) + pipeline_hash = hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:12] + return output_dir / f"pipeline_{pipeline_hash}_tasks_tmp" + raise ValueError(f"Unsupported tasks tmp dir scope {scope!r}") + + def _write_config_snapshot(config_path: Path, snapshot: Dict[str, Any]) -> None: config_path.write_text( json.dumps(snapshot, indent=2, sort_keys=True) + "\n", @@ -210,6 +234,7 @@ def run_all_configs( start: int, limit: Optional[int], results_path: Optional[Path], + tasks_tmp_scope: str, ) -> List[Dict[str, Any]]: _set_ontology_env(ontology_path) @@ -232,6 +257,7 @@ def run_all_configs( print(f"source: {source_path}") print(f"reference: {reference_path}") print(f"output_dir: {output_dir}") + print(f"tasks_tmp_scope: {tasks_tmp_scope}") for offset, pipeline_config in enumerate(selected, start=start): task_keys = task_keys_from_pipeline_config(pipeline_config) @@ -240,7 +266,12 @@ def run_all_configs( result_path = output_dir / f"{config_hash}.nt" config_path = output_dir / f"{config_hash}.json" - tasks_tmp_dir = output_dir / f"{config_hash}_tasks_tmp" + tasks_tmp_dir = _tasks_tmp_dir( + output_dir=output_dir, + config_hash=config_hash, + task_keys=task_keys, + scope=tasks_tmp_scope, + ) run_name = config_hash print(f"\n=== config {offset + 1}/{len(pipeline_configs)} ({config_hash}) ===") @@ -253,6 +284,7 @@ def run_all_configs( "config_hash": config_hash, "config_path": str(config_path), "result_path": str(result_path), + "tasks_tmp_dir": str(tasks_tmp_dir), "status": "ok", } @@ -291,6 +323,7 @@ def run_all_configs( "output_dir": str(output_dir), "start": start, "limit": limit, + "tasks_tmp_scope": tasks_tmp_scope, "results": run_results, } results_path.write_text( @@ -368,6 +401,17 @@ def build_parser() -> argparse.ArgumentParser: default=None, help="Path to write a single JSON summary of all run scores (default: /results.json)", ) + parser.add_argument( + "--tasks-tmp-scope", + choices=["config", "pipeline", "shared"], + default="config", + help=( + "How to name/reuse the per-run tasks tmp dir: " + "'config' = one tmp dir per config hash (default), " + "'pipeline' = reuse tmp dir for configs with identical task list, " + "'shared' = reuse one tmp dir for all configs" + ), + ) return parser @@ -400,6 +444,7 @@ def main(argv: Optional[List[str]] = None) -> int: start=args.start, limit=args.limit, results_path=args.results or (args.output_dir / "results.json"), + tasks_tmp_scope=args.tasks_tmp_scope, ) failed = sum(1 for item in run_results if item["status"] != "ok") From e405eb518eff3afbee5c760335fe34ff21d9d4ba Mon Sep 17 00:00:00 2001 From: Marvin Hofer Date: Thu, 9 Jul 2026 17:34:31 +0200 Subject: [PATCH 82/96] exp(params): added analysis of precomputed pipeline results (config + eval score) --- experiments/param-opti/src/analyse.py | 583 ++++++++++++++++++ .../param-opti/src/kgpipe_search/search.py | 12 +- .../strategies/initialization.py | 129 ---- .../kgpipe_search/strategies/strategies.py | 569 ----------------- 4 files changed, 588 insertions(+), 705 deletions(-) create mode 100644 experiments/param-opti/src/analyse.py diff --git a/experiments/param-opti/src/analyse.py b/experiments/param-opti/src/analyse.py new file mode 100644 index 0000000..e60bfd1 --- /dev/null +++ b/experiments/param-opti/src/analyse.py @@ -0,0 +1,583 @@ +#!/usr/bin/env python3 +""" +Offline analysis of search strategies on already computed pipeline eval results. + +This script treats a `results.json` (as written by `experiment.py`) as a cache: +- some configs have an evaluation score (status == "ok") +- some configs are missing or errored (partial results) + +We can then "simulate" different search strategies without re-running any pipeline by +letting the strategy propose configs and looking them up in the cache. +""" + +import argparse +import json +import math +import random +from pathlib import Path +from typing import Any, Dict, List, NamedTuple, Optional, Sequence, Tuple + +# Optional integration with the existing kgpipe_search strategies. +try: + from kgpipe_search.configuration import pipeline_config_snapshot_key + from kgpipe_search.definitions import RDF_PIPELINE_LAYOUT, RDF_SEARCH_SPACE, PipelineConfig + from kgpipe_search.search import bayesian_optimization, hnr_search, qgns_search, random_search + + _HAS_KGPIPE_SEARCH = True + _KGPIPE_SEARCH_IMPORT_ERROR: Exception | None = None +except Exception as exc: + _HAS_KGPIPE_SEARCH = False + _KGPIPE_SEARCH_IMPORT_ERROR = exc + + +class Candidate(NamedTuple): + config_hash: str + task_key: Tuple[str, ...] + snapshot_path: Optional[Path] + + +class StepLog(NamedTuple): + step: int + proposed_hash: str + hit: bool + score: Optional[float] + best_score: Optional[float] + misses_so_far: int + + +def _read_json(path: Path) -> Any: + return json.loads(path.read_text(encoding="utf-8")) + + +def _snapshot_key_from_snapshot_dict(snapshot: Dict[str, Any]) -> str: + # Must match kgpipe_search.configuration.pipeline_config_snapshot_key()'s serialization: + # json.dumps(snapshot, sort_keys=True) + return json.dumps(snapshot, sort_keys=True) + + +def _load_score_by_snapshot_key( + *, + results_path: Path, + entry_by_hash: Dict[str, Dict[str, Any]], + score_by_hash: Dict[str, float], +) -> Dict[str, float]: + """ + Map config snapshots (serialized canonical JSON) -> score. + This lets us evaluate PipelineConfig objects sampled by kgpipe_search strategies. + """ + score_by_key: Dict[str, float] = {} + for h, entry in entry_by_hash.items(): + score = score_by_hash.get(h) + if score is None: + continue + snapshot_path = _resolve_snapshot_path(results_path, entry.get("config_path")) + if snapshot_path is None: + continue + try: + snap = _read_json(snapshot_path) + if isinstance(snap, dict): + key = _snapshot_key_from_snapshot_dict(snap) + score_by_key[key] = float(score) + except Exception: + continue + return score_by_key + + +class OfflineCacheOracle: + def __init__(self, score_by_snapshot_key: Dict[str, float], *, miss_score: float = 0.5) -> None: + self._score_by_key = score_by_snapshot_key + self.miss_score = float(miss_score) + self.hits = 0 + self.misses = 0 + + def evaluate(self, cfg: "PipelineConfig") -> float: + key = pipeline_config_snapshot_key(cfg, RDF_SEARCH_SPACE) + score = self._score_by_key.get(key) + if score is None: + self.misses += 1 + return self.miss_score + self.hits += 1 + return float(score) + +def _load_cache(results_path: Path) -> Tuple[Dict[str, float], Dict[str, Dict[str, Any]]]: + """ + Returns: + score_by_hash: config_hash -> final_score (only status == ok) + entry_by_hash: config_hash -> raw results entry (all statuses) + """ + payload = _read_json(results_path) + results = payload.get("results") + if not isinstance(results, list): + raise ValueError(f"Expected 'results' list in {results_path}") + + entry_by_hash: Dict[str, Dict[str, Any]] = {} + score_by_hash: Dict[str, float] = {} + + for item in results: + if not isinstance(item, dict): + continue + h = item.get("config_hash") + if not isinstance(h, str): + continue + entry_by_hash[h] = item + if item.get("status") == "ok": + evaluation = item.get("evaluation") or {} + if isinstance(evaluation, dict) and isinstance(evaluation.get("final_score"), (int, float)): + score_by_hash[h] = float(evaluation["final_score"]) + + return score_by_hash, entry_by_hash + + +def _resolve_snapshot_path(results_path: Path, raw_path: Optional[str]) -> Optional[Path]: + if not raw_path: + return None + p = Path(raw_path) + if p.is_absolute(): + return p if p.exists() else None + candidate = results_path.parent / p + return candidate if candidate.exists() else None + + +def _load_candidates( + *, + results_path: Path, + entry_by_hash: Dict[str, Dict[str, Any]], + include_missing_snapshots: bool, +) -> List[Candidate]: + candidates: List[Candidate] = [] + for h, entry in entry_by_hash.items(): + snapshot_path = _resolve_snapshot_path(results_path, entry.get("config_path")) + if snapshot_path is None and not include_missing_snapshots: + continue + + task_key: Tuple[str, ...] = () + if snapshot_path is not None: + try: + snapshot = _read_json(snapshot_path) + task_keys = snapshot.get("task_keys") + if isinstance(task_keys, list) and all(isinstance(x, str) for x in task_keys): + task_key = tuple(task_keys) + except Exception: + task_key = () + + candidates.append(Candidate(config_hash=h, task_key=task_key, snapshot_path=snapshot_path)) + + return candidates + + +class Strategy: + name: str + + def propose(self) -> str: # returns config_hash + raise NotImplementedError + + def observe(self, config_hash: str, score: Optional[float]) -> None: + # score is None for cache miss or non-ok result + return + + +class RandomStrategy(Strategy): + name = "random" + + def __init__(self, rng: random.Random, universe: Sequence[Candidate]) -> None: + self._rng = rng + self._universe = universe + + def propose(self) -> str: + return self._rng.choice(self._universe).config_hash + + +class GreedyKnownStrategy(Strategy): + """ + Upper bound / sanity check: picks the best already-known score. + Useful to verify the harness and to see what "best possible" would be in the cache. + """ + + name = "greedy-known" + + def __init__(self, rng: random.Random, universe: Sequence[Candidate], score_by_hash: Dict[str, float]) -> None: + self._rng = rng + self._universe = universe + self._score_by_hash = score_by_hash + self._ordered: List[str] = [ + c.config_hash for c in sorted(universe, key=lambda c: score_by_hash.get(c.config_hash, float("-inf")), reverse=True) + ] + self._i = 0 + + def propose(self) -> str: + if self._i >= len(self._ordered): + return self._rng.choice(self._universe).config_hash + h = self._ordered[self._i] + self._i += 1 + return h + + +class UCBByTaskKeyStrategy(Strategy): + """ + Lightweight bandit baseline: + - treat each distinct task pipeline (task_keys tuple) as an arm + - within an arm, sample configs uniformly + - update arm rewards based on observed scores + + This is robust to partial caches: misses just don't update the arm. + """ + + name = "ucb-taskkey" + + def __init__(self, rng: random.Random, universe: Sequence[Candidate], exploration: float = 2.0) -> None: + self._rng = rng + self._exploration = exploration + + arms: Dict[Tuple[str, ...], List[str]] = {} + for c in universe: + arms.setdefault(c.task_key, []).append(c.config_hash) + self._arms = arms + self._arm_keys = list(arms.keys()) + + self._n_total = 0 + self._n: Dict[Tuple[str, ...], int] = {k: 0 for k in self._arm_keys} + self._mean: Dict[Tuple[str, ...], float] = {k: 0.0 for k in self._arm_keys} + + def propose(self) -> str: + # Ensure each arm is tried at least once + for k in self._arm_keys: + if self._n[k] == 0: + return self._rng.choice(self._arms[k]) + + # Standard UCB1 over arms + self._n_total = max(1, self._n_total) + best_k = None + best_ucb = float("-inf") + for k in self._arm_keys: + bonus = math.sqrt((self._exploration * math.log(self._n_total)) / self._n[k]) + ucb = self._mean[k] + bonus + if ucb > best_ucb: + best_ucb = ucb + best_k = k + assert best_k is not None + return self._rng.choice(self._arms[best_k]) + + def observe(self, config_hash: str, score: Optional[float]) -> None: + self._n_total += 1 + if score is None: + return + # find arm by scanning (cheap at this scale); if needed we can add hash->arm map later + for k, hashes in self._arms.items(): + if config_hash in hashes: + n = self._n[k] + 1 + prev = self._mean[k] + self._mean[k] = prev + (score - prev) / n + self._n[k] = n + return + + +def _build_strategy( + *, + name: str, + rng: random.Random, + universe: Sequence[Candidate], + score_by_hash: Dict[str, float], + exploration: float, +) -> Strategy: + if name == "random": + return RandomStrategy(rng, universe) + if name == "greedy-known": + return GreedyKnownStrategy(rng, universe, score_by_hash) + if name == "ucb-taskkey": + return UCBByTaskKeyStrategy(rng, universe, exploration=exploration) + raise ValueError(f"Unknown strategy {name!r}") + + +def _simulate( + *, + strategy: Strategy, + score_by_hash: Dict[str, float], + budget: int, + miss_policy: str, + max_resample: int, +) -> List[StepLog]: + """ + miss_policy: + - "count": a miss consumes budget and is recorded as hit=False + - "resample": keep resampling (up to max_resample) within the same step until hit, else count miss + """ + logs: List[StepLog] = [] + best: Optional[float] = None + misses = 0 + + for step in range(1, budget + 1): + proposed = strategy.propose() + + score = score_by_hash.get(proposed) + hit = score is not None + + if (not hit) and miss_policy == "resample": + tries = 0 + while tries < max_resample and not hit: + tries += 1 + proposed = strategy.propose() + score = score_by_hash.get(proposed) + hit = score is not None + + if not hit: + misses += 1 + strategy.observe(proposed, None) + else: + strategy.observe(proposed, score) + best = score if best is None else max(best, score) + + logs.append( + StepLog( + step=step, + proposed_hash=proposed, + hit=hit, + score=score, + best_score=best, + misses_so_far=misses, + ) + ) + + return logs + + +def _summarize(logs: Sequence[StepLog]) -> Dict[str, Any]: + hits = sum(1 for x in logs if x.hit) + misses = len(logs) - hits + best = next((x.best_score for x in reversed(logs) if x.best_score is not None), None) + return { + "budget": len(logs), + "hits": hits, + "misses": misses, + "hit_rate": hits / len(logs) if logs else 0.0, + "best_score": best, + } + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(description="Offline search strategy analysis on cached eval results.") + p.add_argument( + "--results", + type=Path, + default=Path(__file__).resolve().parent.parent / "results.json", + help="Path to results.json written by experiment.py (default: experiments/param-opti/results.json)", + ) + p.add_argument( + "--strategy", + choices=["random", "ucb-taskkey", "greedy-known", "kgpipe_random", "kgpipe_qgns", "kgpipe_hnr", "kgpipe_bayes"], + default="random", + help="Search strategy to simulate (greedy-known is an upper-bound baseline).", + ) + p.add_argument( + "--miss-score", + type=float, + default=0.5, + help="When using kgpipe_* strategies, score to return for cache misses.", + ) + p.add_argument( + "--init-budget", + type=int, + default=3, + help="Initialization budget for kgpipe_qgns/kgpipe_hnr/kgpipe_bayes.", + ) + p.add_argument( + "--init-strategy", + choices=["random", "implementation_aware"], + default="implementation_aware", + help="Initialization strategy for kgpipe_* strategies.", + ) + p.add_argument("--k", type=int, default=3, help="Top-k anchors for kgpipe_qgns.") + p.add_argument("--rho", type=float, default=0.2, help="Exploration probability for kgpipe_qgns/kgpipe_hnr.") + p.add_argument("--pool-size", type=int, default=32, help="Candidate pool size for kgpipe_bayes.") + p.add_argument("--beta", type=float, default=0.5, help="Acquisition beta for kgpipe_bayes.") + p.add_argument("--budget", type=int, default=50, help="Number of proposals to simulate.") + p.add_argument("--seed", type=int, default=0, help="RNG seed for reproducibility.") + p.add_argument( + "--miss-policy", + choices=["count", "resample"], + default="count", + help="How to handle proposing configs without cached score.", + ) + p.add_argument( + "--max-resample", + type=int, + default=50, + help="When miss-policy=resample, max resamples per step.", + ) + p.add_argument( + "--include-missing-snapshots", + action="store_true", + help="Include entries even if config_path snapshot file is missing.", + ) + p.add_argument( + "--exploration", + type=float, + default=2.0, + help="Exploration coefficient for ucb-taskkey.", + ) + p.add_argument( + "--out", + type=Path, + default=None, + help="Optional path to write a JSON report with step logs.", + ) + return p + + +def main(argv: Optional[List[str]] = None) -> int: + args = build_parser().parse_args(argv) + results_path: Path = args.results + if not results_path.exists(): + raise SystemExit(f"results.json not found: {results_path}") + + score_by_hash, entry_by_hash = _load_cache(results_path) + + rng = random.Random(args.seed) + + if str(args.strategy).startswith("kgpipe_"): + if not _HAS_KGPIPE_SEARCH: + raise SystemExit( + "kgpipe_search imports failed in this environment. " + "Run within the project environment where kgpipe_search is importable. " + f"Root cause: {type(_KGPIPE_SEARCH_IMPORT_ERROR).__name__}: {_KGPIPE_SEARCH_IMPORT_ERROR}" + ) + score_by_key = _load_score_by_snapshot_key( + results_path=results_path, + entry_by_hash=entry_by_hash, + score_by_hash=score_by_hash, + ) + if not score_by_key: + raise SystemExit( + "No cached snapshots could be loaded to score PipelineConfig objects. " + "This usually means the `config_path` files referenced by results.json are missing. " + "Either re-run experiment.py with an output-dir you keep, or point --results at a file " + "whose config_path entries exist." + ) + oracle = OfflineCacheOracle(score_by_key, miss_score=float(args.miss_score)) + + if args.strategy == "kgpipe_random": + run = random_search( + budget=int(args.budget), + evaluate_fn=oracle.evaluate, + search_space=RDF_SEARCH_SPACE, + pipeline_layout=RDF_PIPELINE_LAYOUT, + rng=rng, + ) + elif args.strategy == "kgpipe_qgns": + run = qgns_search( + budget=int(args.budget), + init_budget=int(args.init_budget), + init_strategy=str(args.init_strategy), + y=1, + evaluate_fn=oracle.evaluate, + search_space=RDF_SEARCH_SPACE, + pipeline_layout=RDF_PIPELINE_LAYOUT, + k=int(args.k), + rho=float(args.rho), + rng=rng, + ) + elif args.strategy == "kgpipe_hnr": + run = hnr_search( + budget=int(args.budget), + init_budget=int(args.init_budget), + init_strategy=str(args.init_strategy), + y=1, + evaluate_fn=oracle.evaluate, + search_space=RDF_SEARCH_SPACE, + pipeline_layout=RDF_PIPELINE_LAYOUT, + rho=float(args.rho), + rng=rng, + ) + elif args.strategy == "kgpipe_bayes": + run = bayesian_optimization( + budget=int(args.budget), + init_random=int(args.init_budget), + init_strategy=str(args.init_strategy), + y=1, + evaluate_fn=oracle.evaluate, + search_space=RDF_SEARCH_SPACE, + pipeline_layout=RDF_PIPELINE_LAYOUT, + pool_size=int(args.pool_size), + beta=float(args.beta), + rng=rng, + ) + else: + raise SystemExit(f"Unknown kgpipe strategy {args.strategy!r}") + + best = max((s for s, _cfg in run.history), default=None) + print(f"results: {results_path}") + print(f"strategy: {args.strategy}") + print( + f"budget: {run.budget} cache_hit_rate: {oracle.hits / max(1, oracle.hits + oracle.misses):.3f} best_score: {best}" + ) + + if args.out is not None: + report = { + "results_path": str(results_path), + "strategy": args.strategy, + "seed": args.seed, + "budget": args.budget, + "miss_score": args.miss_score, + "cache": {"hits": oracle.hits, "misses": oracle.misses}, + "best_score": best, + "decisions": run.decisions, + "history": [ + {"score": float(score), "snapshot_key": pipeline_config_snapshot_key(cfg, RDF_SEARCH_SPACE)} + for score, cfg in run.history + ], + } + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(f"wrote: {args.out}") + else: + candidates = _load_candidates( + results_path=results_path, + entry_by_hash=entry_by_hash, + include_missing_snapshots=bool(args.include_missing_snapshots), + ) + if not candidates: + raise SystemExit("No candidates found (check results.json and config_path files).") + strategy = _build_strategy( + name=args.strategy, + rng=rng, + universe=candidates, + score_by_hash=score_by_hash, + exploration=float(args.exploration), + ) + + logs = _simulate( + strategy=strategy, + score_by_hash=score_by_hash, + budget=int(args.budget), + miss_policy=str(args.miss_policy), + max_resample=int(args.max_resample), + ) + summary = _summarize(logs) + + print(f"results: {results_path}") + print(f"strategy: {args.strategy}") + print( + f"budget: {summary['budget']} hit_rate: {summary['hit_rate']:.3f} best_score: {summary['best_score']}" + ) + + if args.out is not None: + # Note: kgpipe_* branch handles writing its own report earlier. + if not str(args.strategy).startswith("kgpipe_"): + report = { + "results_path": str(results_path), + "strategy": args.strategy, + "seed": args.seed, + "budget": args.budget, + "miss_policy": args.miss_policy, + "max_resample": args.max_resample, + "summary": summary, + "steps": [x._asdict() for x in logs], + } + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(f"wrote: {args.out}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/experiments/param-opti/src/kgpipe_search/search.py b/experiments/param-opti/src/kgpipe_search/search.py index 4d0ee5c..58f6004 100644 --- a/experiments/param-opti/src/kgpipe_search/search.py +++ b/experiments/param-opti/src/kgpipe_search/search.py @@ -5,8 +5,6 @@ This module preserves the historical function names used by existing tests/scripts. """ -from __future__ import annotations - import random from typing import Any, Dict @@ -43,7 +41,7 @@ def random_search( evaluate_fn: EvaluateFn, search_space: Dict[str, Dict[str, Any]], pipeline_layout: PipelineLayout, - rng: random.Random | None = None, + rng: Any = None, ) -> SearchRun: return run_random( budget=budget, @@ -65,7 +63,7 @@ def qgns_search( y: int = 1, k: int = 3, rho: float = 0.2, - rng: random.Random | None = None, + rng: Any = None, ) -> SearchRun: return run_qgns( budget=budget, @@ -93,7 +91,7 @@ def hnr_search( init_strategy: str = "implementation_aware", y: int = 1, rho: float = 0.2, - rng: random.Random | None = None, + rng: Any = None, ) -> SearchRun: return run_hnr( budget=budget, @@ -116,7 +114,7 @@ def neighborhood_optimization( *, k: int = 3, rho: float = 0.2, - rng: random.Random | None = None, + rng: Any = None, **kwargs: Any, ) -> SearchRun: """ @@ -149,7 +147,7 @@ def bayesian_optimization( y: int = 1, pool_size: int = 32, beta: float = 0.5, - rng: random.Random | None = None, + rng: Any = None, **kwargs: Any, ) -> SearchRun: del kwargs diff --git a/experiments/param-opti/src/kgpipe_search/strategies/initialization.py b/experiments/param-opti/src/kgpipe_search/strategies/initialization.py index 49df3b7..e2b0a35 100644 --- a/experiments/param-opti/src/kgpipe_search/strategies/initialization.py +++ b/experiments/param-opti/src/kgpipe_search/strategies/initialization.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import random from typing import Any, Dict, List, Optional, Sequence, Set @@ -123,131 +121,4 @@ def implementation_aware_initialization( return configs -from __future__ import annotations - -import random -from typing import Any, Dict, List, Optional, Sequence, Set - -from kgpipe_search.configuration import ( - build_pipeline_config_for_task_combo, - enumerate_valid_task_combinations, - pipeline_config_snapshot_key, - sample_valid_pipeline_config, -) -from kgpipe_search.definitions import PipelineConfig, PipelineLayout - - -def random_initialization( - search_space: Dict[str, Dict[str, Any]], - pipeline_layout: PipelineLayout, - *, - budget: int, - rng: Optional[random.Random] = None, -) -> List[PipelineConfig]: - """Sample `budget` unique valid pipeline configurations uniformly at random.""" - if budget <= 0: - return [] - - draw = rng or random.Random() - configs: List[PipelineConfig] = [] - seen: Set[str] = set() - attempts = 0 - max_attempts = max(1000, budget * 200) - - while len(configs) < budget and attempts < max_attempts: - attempts += 1 - candidate = sample_valid_pipeline_config(search_space, pipeline_layout, rng=draw) - key = pipeline_config_snapshot_key(candidate, search_space) - if key in seen: - continue - seen.add(key) - configs.append(candidate) - - if len(configs) < budget: - raise RuntimeError( - f"Failed to sample {budget} unique initial configs (got {len(configs)})." - ) - - return configs - - -def implementation_aware_initialization( - search_space: Dict[str, Dict[str, Any]], - pipeline_layout: PipelineLayout, - *, - budget: int, - y: int = 1, - rng: Optional[random.Random] = None, -) -> List[PipelineConfig]: - """ - Implementation-aware initialization from the paper. - - Enumerate (or sample) valid implementation assignments (task combinations) and, - for each such assignment, generate `y` configurations by sampling parameters. - """ - if budget <= 0: - return [] - if y <= 0: - raise ValueError("y must be >= 1") - - draw = rng or random.Random() - all_combos = enumerate_valid_task_combinations(search_space, pipeline_layout) - if not all_combos: - raise ValueError("No valid implementation assignments found.") - - # Determine how many implementation assignments we can cover. - max_combos = max(1, budget // y) - combos: Sequence[List[str]] - if len(all_combos) <= max_combos: - combos = all_combos - else: - # Sample without replacement. - combos = draw.sample(all_combos, k=max_combos) - - configs: List[PipelineConfig] = [] - seen: Set[str] = set() - - for combo in combos: - for _ in range(y): - if len(configs) >= budget: - break - candidate = build_pipeline_config_for_task_combo( - search_space, - combo, - rng=draw, - template=None, - ) - key = pipeline_config_snapshot_key(candidate, search_space) - if key in seen: - continue - seen.add(key) - configs.append(candidate) - - if len(configs) >= budget: - break - - # If we still have budget left (due to duplicates), fill with random unique samples. - if len(configs) < budget: - remaining = budget - len(configs) - filler = random_initialization( - search_space, - pipeline_layout, - budget=remaining, - rng=draw, - ) - for candidate in filler: - key = pipeline_config_snapshot_key(candidate, search_space) - if key in seen: - continue - seen.add(key) - configs.append(candidate) - if len(configs) >= budget: - break - - if len(configs) < budget: - raise RuntimeError( - f"Failed to generate {budget} unique initial configs (got {len(configs)})." - ) - - return configs diff --git a/experiments/param-opti/src/kgpipe_search/strategies/strategies.py b/experiments/param-opti/src/kgpipe_search/strategies/strategies.py index 4e2a6ff..dfd3404 100644 --- a/experiments/param-opti/src/kgpipe_search/strategies/strategies.py +++ b/experiments/param-opti/src/kgpipe_search/strategies/strategies.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import math import random from dataclasses import dataclass @@ -618,570 +616,3 @@ def run_bayesian( return SearchRun(strategy="bayesian", history=history, budget=budget, decisions=decisions) -from __future__ import annotations - -import math -import random -from dataclasses import dataclass -from typing import Any, Callable, Dict, List, Literal, Optional, Set, Tuple - -from kgpipe.common.model.configuration import ConfigurationProfile, ParameterBinding -from kgpipe_search.configuration import ( - build_pipeline_config_for_task_combo, - enumerate_valid_task_combinations, - pipeline_config_snapshot_key, - sample_valid_pipeline_config, - task_keys_from_pipeline_config, -) -from kgpipe_search.definitions import PipelineConfig, PipelineLayout -from kgpipe_search.strategies.initialization import ( - implementation_aware_initialization, - random_initialization, -) - -Observation = Tuple[float, PipelineConfig] -EvaluateFn = Callable[[PipelineConfig], float] - -SearchStrategy = Literal["random", "qgns", "hnr", "bayesian"] - - -@dataclass -class SearchRun: - strategy: SearchStrategy - history: List[Observation] - budget: int - decisions: List[str] - - -def _top_k(history: List[Observation], k: int) -> List[Observation]: - ranked = sorted(history, key=lambda item: item[0], reverse=True) - return ranked[: max(1, min(k, len(ranked)))] - - -def _parameter_neighbors(anchor: PipelineConfig, search_space: Dict[str, Dict[str, Any]]) -> List[PipelineConfig]: - anchor_keys = task_keys_from_pipeline_config(anchor) - neighbors: List[PipelineConfig] = [] - - for task, task_key in zip(anchor.tasks, anchor_keys): - profile = anchor.config_catalog.get(task.name) - if profile is None: - continue - - for binding in profile.bindings: - param_name = binding.parameter.name - domain = search_space.get(task_key, {}).get(param_name) - if not isinstance(domain, list): - continue - - for value in domain: - if value == binding.value: - continue - - new_catalog = dict(anchor.config_catalog) - new_bindings: List[ParameterBinding] = [] - name_parts: List[str] = [] - for current in profile.bindings: - chosen = value if current.parameter.name == param_name else current.value - new_bindings.append(ParameterBinding(parameter=current.parameter, value=chosen)) - name_parts.append(f"{current.parameter.name}={chosen}") - - new_catalog[task.name] = ConfigurationProfile( - name=f"{task.name}_" + ",".join(name_parts), - definition=profile.definition, - bindings=new_bindings, - ) - neighbors.append(PipelineConfig(tasks=list(anchor.tasks), config_catalog=new_catalog)) - - return neighbors - - -def _implementation_neighbors( - anchor: PipelineConfig, - search_space: Dict[str, Dict[str, Any]], - pipeline_layout: PipelineLayout, - rng: random.Random, -) -> List[PipelineConfig]: - anchor_keys = task_keys_from_pipeline_config(anchor) - neighbors: List[PipelineConfig] = [] - - for combo in enumerate_valid_task_combinations(search_space, pipeline_layout): - if len(combo) != len(anchor_keys): - continue - if sum(left != right for left, right in zip(anchor_keys, combo)) != 1: - continue - neighbors.append( - build_pipeline_config_for_task_combo( - search_space, - combo, - rng=rng, - template=anchor, - ) - ) - - return neighbors - - -def neighbors_at_distance_one( - anchor: PipelineConfig, - search_space: Dict[str, Dict[str, Any]], - pipeline_layout: PipelineLayout, - rng: random.Random, -) -> List[PipelineConfig]: - seen: Set[str] = set() - neighbors: List[PipelineConfig] = [] - - for candidate in ( - _parameter_neighbors(anchor, search_space) - + _implementation_neighbors(anchor, search_space, pipeline_layout, rng) - ): - key = pipeline_config_snapshot_key(candidate, search_space) - if key in seen: - continue - seen.add(key) - neighbors.append(candidate) - - return neighbors - - -def _restricted_implementation_neighbors_for_index( - anchor: PipelineConfig, - search_space: Dict[str, Dict[str, Any]], - pipeline_layout: PipelineLayout, - rng: random.Random, - *, - index: int, -) -> List[PipelineConfig]: - anchor_keys = task_keys_from_pipeline_config(anchor) - if index < 0 or index >= len(anchor_keys): - return [] - - neighbors: List[PipelineConfig] = [] - for combo in enumerate_valid_task_combinations(search_space, pipeline_layout): - if len(combo) != len(anchor_keys): - continue - if any(i != index and combo[i] != anchor_keys[i] for i in range(len(anchor_keys))): - continue - if combo[index] == anchor_keys[index]: - continue - neighbors.append( - build_pipeline_config_for_task_combo( - search_space, - combo, - rng=rng, - template=anchor, - ) - ) - return neighbors - - -def _restricted_parameter_neighbors_for_index( - anchor: PipelineConfig, - search_space: Dict[str, Dict[str, Any]], - *, - index: int, -) -> List[PipelineConfig]: - anchor_keys = task_keys_from_pipeline_config(anchor) - if index < 0 or index >= len(anchor.tasks) or index >= len(anchor_keys): - return [] - - task = anchor.tasks[index] - task_key = anchor_keys[index] - profile = anchor.config_catalog.get(task.name) - if profile is None: - return [] - - neighbors: List[PipelineConfig] = [] - for binding in profile.bindings: - param_name = binding.parameter.name - domain = search_space.get(task_key, {}).get(param_name) - if not isinstance(domain, list): - continue - - for value in domain: - if value == binding.value: - continue - - new_catalog = dict(anchor.config_catalog) - new_bindings: List[ParameterBinding] = [] - name_parts: List[str] = [] - for current in profile.bindings: - chosen = value if current.parameter.name == param_name else current.value - new_bindings.append(ParameterBinding(parameter=current.parameter, value=chosen)) - name_parts.append(f"{current.parameter.name}={chosen}") - - new_catalog[task.name] = ConfigurationProfile( - name=f"{task.name}_" + ",".join(name_parts), - definition=profile.definition, - bindings=new_bindings, - ) - neighbors.append(PipelineConfig(tasks=list(anchor.tasks), config_catalog=new_catalog)) - - return neighbors - - -def sample_unevaluated_config( - rng: random.Random, - search_space: Dict[str, Dict[str, Any]], - pipeline_layout: PipelineLayout, - evaluated_keys: Set[str], - *, - max_attempts: int = 500, -) -> PipelineConfig: - for _ in range(max_attempts): - candidate = sample_valid_pipeline_config( - search_space, - pipeline_layout, - rng=rng, - ) - key = pipeline_config_snapshot_key(candidate, search_space) - if key not in evaluated_keys: - return candidate - - raise RuntimeError("Failed to sample an unevaluated configuration") - - -def _config_distance(left: PipelineConfig, right: PipelineConfig, search_space: Dict[str, Dict[str, Any]]) -> float: - if pipeline_config_snapshot_key(left, search_space) == pipeline_config_snapshot_key(right, search_space): - return 0.0 - - left_keys = task_keys_from_pipeline_config(left) - right_keys = task_keys_from_pipeline_config(right) - distance = float(sum(a != b for a, b in zip(left_keys, right_keys))) - if len(left_keys) != len(right_keys): - distance += abs(len(left_keys) - len(right_keys)) - - left_params = { - (task.name, binding.parameter.name): binding.value - for task in left.tasks - for binding in (left.config_catalog.get(task.name).bindings if left.config_catalog.get(task.name) else []) - } - right_params = { - (task.name, binding.parameter.name): binding.value - for task in right.tasks - for binding in (right.config_catalog.get(task.name).bindings if right.config_catalog.get(task.name) else []) - } - - all_param_keys = set(left_params) | set(right_params) - for key in all_param_keys: - if left_params.get(key) != right_params.get(key): - distance += 1.0 - - return distance - - -def _predict_with_uncertainty( - candidate: PipelineConfig, - history: List[Observation], - search_space: Dict[str, Dict[str, Any]], -) -> Tuple[float, float]: - weights: List[float] = [] - scores: List[float] = [] - - for score, observed in history: - distance = _config_distance(candidate, observed, search_space) - if distance == 0.0: - return score, 0.0 - weights.append(math.exp(-distance)) - scores.append(score) - - if not weights: - return 0.75, 1.0 - - total_weight = sum(weights) - mean = sum(score * weight for score, weight in zip(scores, weights)) / total_weight - uncertainty = 1.0 / (1.0 + total_weight) - return mean, uncertainty - - -def _acquisition(mean: float, uncertainty: float, *, beta: float = 0.5) -> float: - return mean + beta * uncertainty - - -def run_random( - *, - budget: int, - evaluate_fn: EvaluateFn, - search_space: Dict[str, Dict[str, Any]], - pipeline_layout: PipelineLayout, - rng: Optional[random.Random] = None, -) -> SearchRun: - draw = rng or random.Random() - history: List[Observation] = [] - decisions: List[str] = [] - evaluated_keys: Set[str] = set() - - for _ in range(budget): - candidate = sample_unevaluated_config(draw, search_space, pipeline_layout, evaluated_keys) - key = pipeline_config_snapshot_key(candidate, search_space) - score = evaluate_fn(candidate) - history.append((score, candidate)) - evaluated_keys.add(key) - decisions.append("sample") - - return SearchRun(strategy="random", history=history, budget=budget, decisions=decisions) - - -def run_qgns( - *, - budget: int, - evaluate_fn: EvaluateFn, - search_space: Dict[str, Dict[str, Any]], - pipeline_layout: PipelineLayout, - init_budget: int = 0, - init_strategy: Literal["random", "implementation_aware"] = "random", - y: int = 1, - k: int = 3, - rho: float = 0.2, - rng: Optional[random.Random] = None, -) -> SearchRun: - """ - Quality-Guided Neighborhood Search (QGNS) from the paper. - - Maintains top-k anchors; with probability rho explores globally, otherwise samples - one-step neighbors of an anchor. - """ - if budget <= 0: - return SearchRun(strategy="qgns", history=[], budget=0, decisions=[]) - - draw = rng or random.Random() - history: List[Observation] = [] - decisions: List[str] = [] - evaluated_keys: Set[str] = set() - - if init_budget > 0: - if init_strategy == "implementation_aware": - init_set = implementation_aware_initialization( - search_space, pipeline_layout, budget=min(init_budget, budget), y=y, rng=draw - ) - else: - init_set = random_initialization( - search_space, pipeline_layout, budget=min(init_budget, budget), rng=draw - ) - for cfg in init_set: - key = pipeline_config_snapshot_key(cfg, search_space) - if key in evaluated_keys: - continue - score = evaluate_fn(cfg) - history.append((score, cfg)) - evaluated_keys.add(key) - decisions.append(f"init({init_strategy})") - if len(history) >= budget: - return SearchRun(strategy="qgns", history=history, budget=budget, decisions=decisions) - - while len(history) < budget: - if not history or draw.random() < rho: - candidate = sample_unevaluated_config(draw, search_space, pipeline_layout, evaluated_keys) - decision = "explore" - else: - anchors = _top_k(history, k) - candidate = None - decision = "explore(fallback)" - - # Try anchors until we find an unevaluated neighbor; else fallback to explore. - shuffled = list(anchors) - draw.shuffle(shuffled) - for anchor_score, anchor_cfg in shuffled: - neighborhood = neighbors_at_distance_one(anchor_cfg, search_space, pipeline_layout, draw) - unevaluated = [ - n for n in neighborhood if pipeline_config_snapshot_key(n, search_space) not in evaluated_keys - ] - if not unevaluated: - continue - candidate = draw.choice(unevaluated) - decision = f"neighborhood(anchor_score={anchor_score:.4f})" - break - - if candidate is None: - candidate = sample_unevaluated_config(draw, search_space, pipeline_layout, evaluated_keys) - - key = pipeline_config_snapshot_key(candidate, search_space) - score = evaluate_fn(candidate) - history.append((score, candidate)) - evaluated_keys.add(key) - decisions.append(decision) - - return SearchRun(strategy="qgns", history=history, budget=budget, decisions=decisions) - - -def run_hnr( - *, - budget: int, - evaluate_fn: EvaluateFn, - search_space: Dict[str, Dict[str, Any]], - pipeline_layout: PipelineLayout, - init_budget: int, - init_strategy: Literal["random", "implementation_aware"] = "implementation_aware", - y: int = 1, - rho: float = 0.2, - rng: Optional[random.Random] = None, -) -> SearchRun: - """ - Heuristic Neighborhood Refinement (HNR) from the paper. - - Starts from an initial evaluated set, then iterates task-wise. For each task index, - prefers trying implementation replacements, then parameter modifications, with occasional - global exploration. - """ - if budget <= 0: - return SearchRun(strategy="hnr", history=[], budget=0, decisions=[]) - if init_budget <= 0: - raise ValueError("HNR requires init_budget > 0") - - draw = rng or random.Random() - history: List[Observation] = [] - decisions: List[str] = [] - evaluated_keys: Set[str] = set() - - if init_strategy == "implementation_aware": - init_set = implementation_aware_initialization( - search_space, pipeline_layout, budget=min(init_budget, budget), y=y, rng=draw - ) - else: - init_set = random_initialization( - search_space, pipeline_layout, budget=min(init_budget, budget), rng=draw - ) - - for cfg in init_set: - key = pipeline_config_snapshot_key(cfg, search_space) - if key in evaluated_keys: - continue - score = evaluate_fn(cfg) - history.append((score, cfg)) - evaluated_keys.add(key) - decisions.append(f"init({init_strategy})") - if len(history) >= budget: - return SearchRun(strategy="hnr", history=history, budget=budget, decisions=decisions) - - # Current best anchor. - best_score, best_cfg = max(history, key=lambda item: item[0]) - - while len(history) < budget: - improved = False - - for idx in range(len(best_cfg.tasks)): - if len(history) >= budget: - break - - if draw.random() < rho: - candidate = sample_unevaluated_config(draw, search_space, pipeline_layout, evaluated_keys) - decision = f"explore(task_idx={idx})" - else: - task_neighbors = _restricted_implementation_neighbors_for_index( - best_cfg, search_space, pipeline_layout, draw, index=idx - ) - task_candidates = [ - n for n in task_neighbors if pipeline_config_snapshot_key(n, search_space) not in evaluated_keys - ] - - if task_candidates: - candidate = draw.choice(task_candidates) - decision = f"task_neighbor(idx={idx})" - else: - param_neighbors = _restricted_parameter_neighbors_for_index( - best_cfg, search_space, index=idx - ) - param_candidates = [ - n for n in param_neighbors if pipeline_config_snapshot_key(n, search_space) not in evaluated_keys - ] - if param_candidates: - candidate = draw.choice(param_candidates) - decision = f"param_neighbor(idx={idx})" - else: - candidate = sample_unevaluated_config(draw, search_space, pipeline_layout, evaluated_keys) - decision = f"explore(fallback,idx={idx})" - - key = pipeline_config_snapshot_key(candidate, search_space) - score = evaluate_fn(candidate) - history.append((score, candidate)) - evaluated_keys.add(key) - decisions.append(decision) - - if score > best_score: - best_score, best_cfg = score, candidate - improved = True - - # If we made no improvements during a full sweep, we still continue (paper keeps exploring). - if not improved and len(history) < budget and draw.random() < rho: - candidate = sample_unevaluated_config(draw, search_space, pipeline_layout, evaluated_keys) - key = pipeline_config_snapshot_key(candidate, search_space) - score = evaluate_fn(candidate) - history.append((score, candidate)) - evaluated_keys.add(key) - decisions.append("explore(post_sweep)") - if score > best_score: - best_score, best_cfg = score, candidate - - return SearchRun(strategy="hnr", history=history, budget=budget, decisions=decisions) - - -def run_bayesian( - *, - budget: int, - evaluate_fn: EvaluateFn, - search_space: Dict[str, Dict[str, Any]], - pipeline_layout: PipelineLayout, - init_budget: int = 3, - init_strategy: Literal["random", "implementation_aware"] = "random", - y: int = 1, - pool_size: int = 32, - beta: float = 0.5, - rng: Optional[random.Random] = None, -) -> SearchRun: - """ - Simple Bayesian-optimization-like baseline using a distance-weighted surrogate, - adapted to support explicit initialization strategies. - """ - if budget <= 0: - return SearchRun(strategy="bayesian", history=[], budget=0, decisions=[]) - - draw = rng or random.Random() - history: List[Observation] = [] - decisions: List[str] = [] - evaluated_keys: Set[str] = set() - - if init_budget > 0: - if init_strategy == "implementation_aware": - init_set = implementation_aware_initialization( - search_space, pipeline_layout, budget=min(init_budget, budget), y=y, rng=draw - ) - else: - init_set = random_initialization( - search_space, pipeline_layout, budget=min(init_budget, budget), rng=draw - ) - for cfg in init_set: - key = pipeline_config_snapshot_key(cfg, search_space) - if key in evaluated_keys: - continue - score = evaluate_fn(cfg) - history.append((score, cfg)) - evaluated_keys.add(key) - decisions.append(f"init({init_strategy})") - if len(history) >= budget: - return SearchRun(strategy="bayesian", history=history, budget=budget, decisions=decisions) - - while len(history) < budget: - candidates: List[PipelineConfig] = [] - for _ in range(pool_size): - candidates.append(sample_unevaluated_config(draw, search_space, pipeline_layout, evaluated_keys)) - - best_candidate = candidates[0] - best_acq = float("-inf") - best_pred = 0.0 - best_unc = 0.0 - for candidate in candidates: - mean, unc = _predict_with_uncertainty(candidate, history, search_space) - acq = _acquisition(mean, unc, beta=beta) - if acq > best_acq: - best_acq = acq - best_candidate = candidate - best_pred = mean - best_unc = unc - - key = pipeline_config_snapshot_key(best_candidate, search_space) - score = evaluate_fn(best_candidate) - history.append((score, best_candidate)) - evaluated_keys.add(key) - decisions.append(f"acquisition(pred={best_pred:.4f},unc={best_unc:.4f},a={best_acq:.4f})") - - return SearchRun(strategy="bayesian", history=history, budget=budget, decisions=decisions) - From 960d5967acb1fc983950b0104885d6475a3b0137 Mon Sep 17 00:00:00 2001 From: Marvin Hofer Date: Thu, 9 Jul 2026 17:35:27 +0200 Subject: [PATCH 83/96] exp(params): helper script to subtract config sample json files (removing sampled configs of one file from another) --- .../src/subtract_pipeline_configs.py | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 experiments/param-opti/src/subtract_pipeline_configs.py diff --git a/experiments/param-opti/src/subtract_pipeline_configs.py b/experiments/param-opti/src/subtract_pipeline_configs.py new file mode 100644 index 0000000..5dbae4a --- /dev/null +++ b/experiments/param-opti/src/subtract_pipeline_configs.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 + +import argparse +import hashlib +import json +from pathlib import Path +from typing import Any, Dict, List, Tuple + + +def _normalize_bindings(bindings: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + # Ensure stable ordering and stable object shape. + norm = [] # type: List[Dict[str, Any]] + for b in bindings: + norm.append({"parameter": b.get("parameter"), "value": b.get("value")}) + norm.sort(key=lambda x: (str(x.get("parameter")), json.dumps(x.get("value"), sort_keys=True))) + return norm + + +def canonical_sample(sample: Dict[str, Any]) -> Dict[str, Any]: + task_keys = sample.get("task_keys") or [] + profiles = sample.get("profiles") or {} + + canon_profiles = {} # type: Dict[str, Any] + for profile_key, profile in profiles.items(): + bindings = _normalize_bindings(profile.get("bindings") or []) + # Prefer the explicit profile_name if present, but don't rely on it exclusively. + canon_profiles[str(profile_key)] = { + "profile_name": profile.get("profile_name"), + "bindings": bindings, + } + + return { + "task_keys": sorted(map(str, task_keys)), + "profiles": {k: canon_profiles[k] for k in sorted(canon_profiles.keys())}, + } + + +def sample_key(sample: Dict[str, Any]) -> str: + canon = canonical_sample(sample) + blob = json.dumps(canon, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + return hashlib.sha256(blob).hexdigest() + + +def load_fixture(path: Path) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]: + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict) or "samples" not in data: + raise ValueError(f"Expected dict with 'samples' key in {path}") + samples = data.get("samples") + if not isinstance(samples, list): + raise ValueError(f"Expected 'samples' to be a list in {path}") + return data, samples + + +def subtract( + keep: List[Dict[str, Any]], remove: List[Dict[str, Any]] +) -> Tuple[List[Dict[str, Any]], int, int]: + remove_keys = {sample_key(s) for s in remove} + out = [] # type: List[Dict[str, Any]] + kept = 0 + dropped = 0 + for s in keep: + if sample_key(s) in remove_keys: + dropped += 1 + continue + out.append(s) + kept += 1 + return out, kept, dropped + + +def main() -> int: + p = argparse.ArgumentParser( + description="Subtract pipeline config samples between two fixture JSON files." + ) + p.add_argument("--keep", required=True, type=Path, help="Base fixture (A)") + p.add_argument("--remove", required=True, type=Path, help="Fixture to subtract (B)") + p.add_argument("--out", required=True, type=Path, help="Output fixture path (A - B)") + p.add_argument( + "--preserve-version", + action="store_true", + help="Preserve top-level 'version' from --keep (default: keep entire top-level object and only replace samples).", + ) + args = p.parse_args() + + keep_data, keep_samples = load_fixture(args.keep) + _, remove_samples = load_fixture(args.remove) + + out_samples, kept, dropped = subtract(keep_samples, remove_samples) + + # Default behavior: keep the top-level shape of --keep (e.g. version, metadata) and swap samples. + out_data = {} # type: Dict[str, Any] + if args.preserve_version: + out_data = {"version": keep_data.get("version"), "samples": out_samples} + else: + out_data = dict(keep_data) + out_data["samples"] = out_samples + + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(json.dumps(out_data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + + print( + json.dumps( + { + "keep_file": str(args.keep), + "remove_file": str(args.remove), + "out_file": str(args.out), + "keep_samples": len(keep_samples), + "remove_samples": len(remove_samples), + "out_samples": len(out_samples), + "dropped_from_keep": dropped, + }, + indent=2, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) + From 0c9ed78223bb7308a6965bc1751b022ef88fabd3 Mon Sep 17 00:00:00 2001 From: Marvin Hofer Date: Mon, 20 Jul 2026 16:19:48 +0200 Subject: [PATCH 84/96] exp(parameters): updated search strategies --- .gitignore | 1 - experiments/param-opti/src/analyse.py | 60 +- experiments/param-opti/src/execute.py | 554 +++++++++++++++ experiments/param-opti/src/experiment.py | 654 ++++++++++-------- .../src/kgpipe_search/definitions.py | 1 + .../src/kgpipe_search/evaluation.py | 34 +- .../param-opti/src/kgpipe_search/search.py | 21 + .../strategies/initialization.py | 74 +- .../kgpipe_search/strategies/strategies.py | 67 +- .../test/test_search_strategies.py | 31 +- .../param-opti/src/plot_search_evolution.py | 401 +++++++++++ .../param-opti/src/split_pipeline_configs.py | 220 ++++++ src/kgpipe/execution/base.py | 13 + src/kgpipe/execution/local.py | 0 src/kgpipe/execution/swarm.py | 0 src/kgpipe_eval/utils/kg_utils.py | 2 + 16 files changed, 1794 insertions(+), 339 deletions(-) create mode 100644 experiments/param-opti/src/execute.py create mode 100644 experiments/param-opti/src/plot_search_evolution.py create mode 100644 experiments/param-opti/src/split_pipeline_configs.py create mode 100644 src/kgpipe/execution/base.py create mode 100644 src/kgpipe/execution/local.py create mode 100644 src/kgpipe/execution/swarm.py diff --git a/.gitignore b/.gitignore index c985141..4d70829 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,6 @@ out/ *.mmd *.db -uv.lock .metals/ .vscode/ data/ diff --git a/experiments/param-opti/src/analyse.py b/experiments/param-opti/src/analyse.py index e60bfd1..0912951 100644 --- a/experiments/param-opti/src/analyse.py +++ b/experiments/param-opti/src/analyse.py @@ -15,7 +15,7 @@ import math import random from pathlib import Path -from typing import Any, Dict, List, NamedTuple, Optional, Sequence, Tuple +from typing import Any, Dict, List, NamedTuple, Optional, Sequence, Set, Tuple # Optional integration with the existing kgpipe_search strategies. try: @@ -60,6 +60,7 @@ def _load_score_by_snapshot_key( results_path: Path, entry_by_hash: Dict[str, Dict[str, Any]], score_by_hash: Dict[str, float], + output_dir: Optional[str] = None, ) -> Dict[str, float]: """ Map config snapshots (serialized canonical JSON) -> score. @@ -70,7 +71,9 @@ def _load_score_by_snapshot_key( score = score_by_hash.get(h) if score is None: continue - snapshot_path = _resolve_snapshot_path(results_path, entry.get("config_path")) + snapshot_path = _resolve_snapshot_path( + results_path, entry.get("config_path"), output_dir=output_dir + ) if snapshot_path is None: continue try: @@ -99,11 +102,14 @@ def evaluate(self, cfg: "PipelineConfig") -> float: self.hits += 1 return float(score) -def _load_cache(results_path: Path) -> Tuple[Dict[str, float], Dict[str, Dict[str, Any]]]: +def _load_cache( + results_path: Path, +) -> Tuple[Dict[str, float], Dict[str, Dict[str, Any]], Optional[str]]: """ Returns: score_by_hash: config_hash -> final_score (only status == ok) entry_by_hash: config_hash -> raw results entry (all statuses) + output_dir: experiment output directory from results payload, if present """ payload = _read_json(results_path) results = payload.get("results") @@ -125,17 +131,46 @@ def _load_cache(results_path: Path) -> Tuple[Dict[str, float], Dict[str, Dict[st if isinstance(evaluation, dict) and isinstance(evaluation.get("final_score"), (int, float)): score_by_hash[h] = float(evaluation["final_score"]) - return score_by_hash, entry_by_hash + output_dir = payload.get("output_dir") + if not isinstance(output_dir, str): + output_dir = None + + return score_by_hash, entry_by_hash, output_dir -def _resolve_snapshot_path(results_path: Path, raw_path: Optional[str]) -> Optional[Path]: +def _resolve_snapshot_path( + results_path: Path, + raw_path: Optional[str], + *, + output_dir: Optional[str] = None, +) -> Optional[Path]: if not raw_path: return None + p = Path(raw_path) + candidates: List[Path] = [] + if p.is_absolute(): - return p if p.exists() else None - candidate = results_path.parent / p - return candidate if candidate.exists() else None + candidates.append(p) + else: + # Paths in results.json are relative to the cwd used when running experiment.py. + candidates.append(Path.cwd() / p) + candidates.append(results_path.parent / p) + candidates.append(results_path.parent / p.name) + if output_dir: + candidates.append(Path.cwd() / output_dir / p.name) + if results_path.parent.name == Path(output_dir).name: + candidates.append(results_path.parent / p.name) + + seen: Set[str] = set() + for candidate in candidates: + key = str(candidate) + if key in seen: + continue + seen.add(key) + if candidate.exists(): + return candidate + return None def _load_candidates( @@ -143,10 +178,13 @@ def _load_candidates( results_path: Path, entry_by_hash: Dict[str, Dict[str, Any]], include_missing_snapshots: bool, + output_dir: Optional[str] = None, ) -> List[Candidate]: candidates: List[Candidate] = [] for h, entry in entry_by_hash.items(): - snapshot_path = _resolve_snapshot_path(results_path, entry.get("config_path")) + snapshot_path = _resolve_snapshot_path( + results_path, entry.get("config_path"), output_dir=output_dir + ) if snapshot_path is None and not include_missing_snapshots: continue @@ -429,7 +467,7 @@ def main(argv: Optional[List[str]] = None) -> int: if not results_path.exists(): raise SystemExit(f"results.json not found: {results_path}") - score_by_hash, entry_by_hash = _load_cache(results_path) + score_by_hash, entry_by_hash, output_dir = _load_cache(results_path) rng = random.Random(args.seed) @@ -444,6 +482,7 @@ def main(argv: Optional[List[str]] = None) -> int: results_path=results_path, entry_by_hash=entry_by_hash, score_by_hash=score_by_hash, + output_dir=output_dir, ) if not score_by_key: raise SystemExit( @@ -533,6 +572,7 @@ def main(argv: Optional[List[str]] = None) -> int: results_path=results_path, entry_by_hash=entry_by_hash, include_missing_snapshots=bool(args.include_missing_snapshots), + output_dir=output_dir, ) if not candidates: raise SystemExit("No candidates found (check results.json and config_path files).") diff --git a/experiments/param-opti/src/execute.py b/experiments/param-opti/src/execute.py new file mode 100644 index 0000000..b97e036 --- /dev/null +++ b/experiments/param-opti/src/execute.py @@ -0,0 +1,554 @@ +#!/usr/bin/env python3 +""" +Run and evaluate pipeline configs from fixture files. + +Example (quick test with the small sampled fixture, 6 RDF / 4 text configs): + python execute.py \ + --seed data/bench/.../seed/data.nt \ + --source data/bench/.../sources/rdf/data.nt \ + --reference data/bench/.../reference/data_agg.nt \ + --ontology data/bench/.../ontology.ttl + +Full exhaustive run (all task/parameter permutations): + python execute.py ... --configs exhaustive +""" + +import argparse +import hashlib +import json +import os +import sys +import types +from dataclasses import asdict, is_dataclass +from importlib import import_module +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional + +from kgpipe.common import Data, DataFormat, KgPipe +from kgpipe.common.models import KgPipePlan +from kgpipe_search.configuration import ( + load_rdf_exhaustive_pipeline_configs, + load_rdf_sampled_pipeline_configs, + load_text_exhaustive_pipeline_configs, + load_text_sampled_pipeline_configs, + pipeline_config_to_snapshot, + print_pipeline_config_short, + task_keys_from_pipeline_config, +) +from kgpipe_search.definitions import PipelineConfig +from kgpipe_search.evaluation import evaluate_pipeline + + +def _install_param_opti_shim() -> None: + if "param_opti" in sys.modules: + return + + param_opti = types.ModuleType("param_opti") + tasks = types.ModuleType("param_opti.tasks") + + for lib in ( + "base_linker_lib", + "base_matcher_lib", + "paris_lib", + "fusion_lib", + "spotlight_lib", + "corenlp_lip", + "genie_lib", + ): + module = import_module(f"kgpipe_search.dev.tasks.{lib}") + setattr(tasks, lib, module) + sys.modules[f"param_opti.tasks.{lib}"] = module + + param_opti.tasks = tasks + sys.modules["param_opti"] = param_opti + sys.modules["param_opti.tasks"] = tasks + + +_install_param_opti_shim() + + +def _to_jsonable(value: Any) -> Any: + if is_dataclass(value): + return {k: _to_jsonable(v) for k, v in asdict(value).items()} + if isinstance(value, dict): + return {k: _to_jsonable(v) for k, v in value.items()} + if isinstance(value, list): + return [_to_jsonable(v) for v in value] + if isinstance(value, Path): + return str(value) + return value + + +def _set_ontology_env(ontology_path: Optional[Path]) -> None: + if ontology_path is None: + return + if not ontology_path.exists(): + raise FileNotFoundError(f"Ontology file not found: {ontology_path}") + os.environ["ONTOLOGY_PATH"] = str(ontology_path.resolve()) + + +def _config_hash(snapshot: Dict[str, Any]) -> str: + canonical = json.dumps(snapshot, sort_keys=True) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def _tasks_tmp_dir( + *, + output_dir: Path, + config_hash: str, + task_keys: List[str], + scope: str, +) -> Path: + """ + Decide where per-task temporary files live. + + - config: one tmp dir per config hash (default, current behavior) + - pipeline: reuse tmp dir for configs with identical task list (enables cache reuse across params) + - shared: reuse a single tmp dir for all configs + """ + + if scope == "config": + return output_dir / f"{config_hash}_tasks_tmp" + if scope == "shared": + return output_dir / "shared_tasks_tmp" + if scope == "pipeline": + canonical = json.dumps(task_keys, sort_keys=False) + pipeline_hash = hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:12] + return output_dir / f"pipeline_{pipeline_hash}_tasks_tmp" + raise ValueError(f"Unsupported tasks tmp dir scope {scope!r}") + + +def _write_config_snapshot(config_path: Path, snapshot: Dict[str, Any]) -> None: + config_path.write_text( + json.dumps(snapshot, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +def _write_plan_snapshot(plan_path: Path, plan: KgPipePlan) -> None: + plan_path.write_text( + json.dumps(plan.model_dump(mode="json"), indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +def _write_eval_snapshot(eval_path: Path, evaluation: Dict[str, Any]) -> None: + eval_path.write_text( + json.dumps(evaluation, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +def _load_cached_eval(eval_path: Path) -> Optional[Dict[str, Any]]: + if not eval_path.exists(): + return None + try: + payload = json.loads(eval_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + if not isinstance(payload, dict): + return None + if payload.get("status") == "error": + return { + "status": "error", + "error": payload.get("error", "cached error"), + "evaluation": None, + "score": 0.0, + } + final_score = payload.get("final_score") + if isinstance(final_score, (int, float)): + return { + "status": "ok", + "evaluation": payload, + "score": float(final_score), + } + return None + + +def _validate_input_path(path: Path, label: str) -> Path: + resolved = path.resolve() + if not resolved.exists(): + raise FileNotFoundError(f"{label} not found: {resolved}") + return resolved + + +def _load_pipeline_configs( + *, + seed_path: Path, + pipeline_type: str, + configs: str, + configs_fixture: Optional[Path], +) -> List[PipelineConfig]: + loaders: Dict[str, Dict[str, Callable[[], List[PipelineConfig]]]] = { + "rdf": { + "sampled": load_rdf_sampled_pipeline_configs, + "exhaustive": load_rdf_exhaustive_pipeline_configs, + }, + "text": { + "sampled": load_text_sampled_pipeline_configs, + "exhaustive": load_text_exhaustive_pipeline_configs, + }, + } + + if pipeline_type not in loaders: + raise ValueError(f"Unsupported pipeline type {pipeline_type!r}") + if configs not in loaders[pipeline_type]: + raise ValueError(f"Unsupported configs mode {configs!r}") + + loader = loaders[pipeline_type][configs] + loaded = loader(configs_fixture) if configs_fixture is not None else loader() + if not loaded: + raise ValueError( + f"No pipeline configs loaded for pipeline_type={pipeline_type!r}, configs={configs!r}. " + "Generate fixtures with the configuration tests first." + ) + for config in loaded: + config.seed_path = seed_path + return loaded + + +def run_rdf_pipeline( + pipeline_config: PipelineConfig, + *, + seed_path: Path, + source_path: Path, + result_path: Path, + plan_path: Path, + tasks_tmp_dir: Path, + run_name: str, +) -> Path: + tasks_tmp_dir.mkdir(parents=True, exist_ok=True) + result_path.parent.mkdir(parents=True, exist_ok=True) + + pipeline = KgPipe( + tasks=pipeline_config.tasks, + seed=Data(path=seed_path, format=DataFormat.RDF_NTRIPLES), + data_dir=tasks_tmp_dir, + name=run_name, + ) + + plan = pipeline.build( + stable_files=True, + configCatalog=pipeline_config.config_catalog, + source=Data(path=source_path, format=DataFormat.RDF_NTRIPLES), + result=Data(path=result_path, format=DataFormat.RDF_NTRIPLES), + ) + _write_plan_snapshot(plan_path, plan) + pipeline.run(configCatalog=pipeline_config.config_catalog, stable_files_override=False) + return result_path + + +def run_text_pipeline( + pipeline_config: PipelineConfig, + *, + seed_path: Path, + source_path: Path, + result_path: Path, + plan_path: Path, + tasks_tmp_dir: Path, + run_name: str, +) -> Path: + tasks_tmp_dir.mkdir(parents=True, exist_ok=True) + result_path.parent.mkdir(parents=True, exist_ok=True) + + pipeline = KgPipe( + tasks=pipeline_config.tasks, + seed=Data(path=seed_path, format=DataFormat.RDF_NTRIPLES), + data_dir=tasks_tmp_dir, + name=run_name, + ) + + plan = pipeline.build( + stable_files=True, + configCatalog=pipeline_config.config_catalog, + source=Data(path=source_path, format=DataFormat.TEXT), + result=Data(path=result_path, format=DataFormat.RDF_NTRIPLES), + ) + _write_plan_snapshot(plan_path, plan) + pipeline.run(configCatalog=pipeline_config.config_catalog, stable_files_override=False) + return result_path + + +def run_all_configs( + *, + seed_path: Path, + source_path: Path, + reference_path: Path, + ontology_path: Optional[Path], + output_dir: Path, + pipeline_type: str, + configs: str, + configs_fixture: Optional[Path], + start: int, + limit: Optional[int], + results_path: Optional[Path], + tasks_tmp_scope: str, + reuse_existing: bool = True, +) -> List[Dict[str, Any]]: + _set_ontology_env(ontology_path) + + run_pipeline = run_rdf_pipeline if pipeline_type == "rdf" else run_text_pipeline + + pipeline_configs = _load_pipeline_configs( + seed_path=seed_path, + pipeline_type=pipeline_type, + configs=configs, + configs_fixture=configs_fixture, + ) + + end = len(pipeline_configs) if limit is None else min(len(pipeline_configs), start + limit) + selected = pipeline_configs[start:end] + + output_dir.mkdir(parents=True, exist_ok=True) + run_results: List[Dict[str, Any]] = [] + cache_hits = 0 + + print(f"Running {len(selected)} pipeline config(s) [{start}:{end})") + print(f"seed: {seed_path}") + print(f"source: {source_path}") + print(f"reference: {reference_path}") + print(f"output_dir: {output_dir}") + print(f"tasks_tmp_scope: {tasks_tmp_scope}") + print(f"reuse_existing: {reuse_existing}") + + for offset, pipeline_config in enumerate(selected, start=start): + task_keys = task_keys_from_pipeline_config(pipeline_config) + snapshot = pipeline_config_to_snapshot(task_keys, pipeline_config) + config_hash = _config_hash(snapshot) + + result_path = output_dir / f"{config_hash}.nt" + config_path = output_dir / f"{config_hash}.json" + eval_path = output_dir / f"{config_hash}.eval.json" + plan_path = output_dir / f"{config_hash}.plan.json" + tasks_tmp_dir = _tasks_tmp_dir( + output_dir=output_dir, + config_hash=config_hash, + task_keys=task_keys, + scope=tasks_tmp_scope, + ) + run_name = config_hash + + print(f"\n=== config {offset + 1}/{len(pipeline_configs)} ({config_hash}) ===") + print_pipeline_config_short(pipeline_config) + + _write_config_snapshot(config_path, snapshot) + + entry: Dict[str, Any] = { + "config_idx": offset, + "config_hash": config_hash, + "config_path": str(config_path), + "eval_path": str(eval_path), + "plan_path": str(plan_path), + "result_path": str(result_path), + "tasks_tmp_dir": str(tasks_tmp_dir), + "status": "ok", + "cached": False, + } + + try: + cached = _load_cached_eval(eval_path) if reuse_existing else None + if cached is not None: + cache_hits += 1 + entry["cached"] = True + entry["status"] = cached["status"] + if cached["status"] == "error": + entry["error"] = cached["error"] + print(f"cached error: {entry['error']}") + else: + entry["evaluation"] = cached["evaluation"] + print(f"cached score: {cached['score']:.6f}") + elif reuse_existing and result_path.exists(): + aggregate_score = evaluate_pipeline( + pipeline_config, + result_path, + reference_path, + ) + evaluation = _to_jsonable(aggregate_score) + entry["evaluation"] = evaluation + entry["cached"] = "result_only" + _write_eval_snapshot(eval_path, evaluation) + print(f"reused result, score: {aggregate_score.final_score:.6f}") + else: + run_pipeline( + pipeline_config, + seed_path=seed_path, + source_path=source_path, + result_path=result_path, + plan_path=plan_path, + tasks_tmp_dir=tasks_tmp_dir, + run_name=run_name, + ) + aggregate_score = evaluate_pipeline( + pipeline_config, + result_path, + reference_path, + ) + evaluation = _to_jsonable(aggregate_score) + entry["evaluation"] = evaluation + _write_eval_snapshot(eval_path, evaluation) + print(f"score: {aggregate_score.final_score:.6f}") + except Exception as exc: + entry["status"] = "error" + entry["error"] = f"{type(exc).__name__}: {exc}" + _write_eval_snapshot( + eval_path, + {"status": "error", "error": entry["error"]}, + ) + print(f"failed: {entry['error']}") + + run_results.append(entry) + + if results_path is not None: + results_path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "pipeline_type": pipeline_type, + "configs": configs, + "seed": str(seed_path), + "source": str(source_path), + "reference": str(reference_path), + "ontology": str(ontology_path) if ontology_path is not None else None, + "output_dir": str(output_dir), + "start": start, + "limit": limit, + "tasks_tmp_scope": tasks_tmp_scope, + "cache_hits": cache_hits, + "reuse_existing": reuse_existing, + "results": run_results, + } + results_path.write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(f"\nWrote scores to {results_path}") + + succeeded = sum(1 for item in run_results if item["status"] == "ok") + print(f"\nFinished: {succeeded}/{len(run_results)} succeeded, cache_hits={cache_hits}") + return run_results + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Execute and evaluate pipeline configs from fixture files.", + ) + parser.add_argument("--seed", type=Path, required=True, help="Path to seed knowledge graph") + parser.add_argument("--source", type=Path, required=True, help="Path to source input graph/text") + parser.add_argument( + "--reference", + type=Path, + required=True, + help="Path to reference knowledge graph used for evaluation", + ) + parser.add_argument( + "--ontology", + type=Path, + default=None, + help="Optional ontology path (sets ONTOLOGY_PATH for matchers)", + ) + parser.add_argument( + "--output-dir", + type=Path, + default=Path("data/tmp/pipeline_runs"), + help="Directory for pipeline outputs and task temp files", + ) + parser.add_argument( + "--pipeline-type", + choices=["rdf", "text"], + default="rdf", + help="Pipeline family to run", + ) + parser.add_argument( + "--configs", + choices=["sampled", "exhaustive"], + default="sampled", + help=( + "Which fixture set to execute: " + "'sampled' = small fixture for quick tests (default), " + "'exhaustive' = all task/parameter permutations" + ), + ) + parser.add_argument( + "--configs-fixture", + type=Path, + default=None, + help="Optional path to a custom configs fixture JSON file", + ) + parser.add_argument( + "--start", + type=int, + default=0, + help="Start index into the loaded config list", + ) + parser.add_argument( + "--limit", + type=int, + default=None, + help="Maximum number of configs to run (default: all from --start)", + ) + parser.add_argument( + "--results", + type=Path, + default=None, + help="Path to write a single JSON summary of all run scores (default: /results.json)", + ) + parser.add_argument( + "--tasks-tmp-scope", + choices=["config", "pipeline", "shared"], + default="config", + help=( + "How to name/reuse the per-run tasks tmp dir: " + "'config' = one tmp dir per config hash (default), " + "'pipeline' = reuse tmp dir for configs with identical task list, " + "'shared' = reuse one tmp dir for all configs" + ), + ) + parser.add_argument( + "--force-rerun", + action="store_true", + help="Re-run pipelines and evaluation even when cached result/eval files exist", + ) + return parser + + +def main(argv: Optional[List[str]] = None) -> int: + args = build_parser().parse_args(argv) + + if args.start < 0: + raise SystemExit("--start must be >= 0") + if args.limit is not None and args.limit <= 0: + raise SystemExit("--limit must be > 0") + + seed_path = _validate_input_path(args.seed, "Seed graph") + source_path = _validate_input_path(args.source, "Source input") + reference_path = _validate_input_path(args.reference, "Reference graph") + ontology_path = ( + _validate_input_path(args.ontology, "Ontology") + if args.ontology is not None + else None + ) + + run_results = run_all_configs( + seed_path=seed_path, + source_path=source_path, + reference_path=reference_path, + ontology_path=ontology_path, + output_dir=args.output_dir, + pipeline_type=args.pipeline_type, + configs=args.configs, + configs_fixture=args.configs_fixture, + start=args.start, + limit=args.limit, + results_path=args.results or (args.output_dir / "results.json"), + tasks_tmp_scope=args.tasks_tmp_scope, + reuse_existing=not args.force_rerun, + ) + + failed = sum(1 for item in run_results if item["status"] != "ok") + return 1 if failed else 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except FileNotFoundError as exc: + print(f"error: {exc}", file=sys.stderr) + raise SystemExit(2) from exc diff --git a/experiments/param-opti/src/experiment.py b/experiments/param-opti/src/experiment.py index 33f4709..7ebf689 100644 --- a/experiments/param-opti/src/experiment.py +++ b/experiments/param-opti/src/experiment.py @@ -1,345 +1,363 @@ #!/usr/bin/env python3 """ -Run and evaluate pipeline configs from fixture files. - -Example (quick test with the small sampled fixture, 6 RDF / 4 text configs): - python experiment.py \ - --seed data/bench/.../seed/data.nt \ - --source data/bench/.../sources/rdf/data.nt \ - --reference data/bench/.../reference/data_agg.nt \ - --ontology data/bench/.../ontology.ttl - -Full exhaustive run (all task/parameter permutations): - python experiment.py ... --configs exhaustive +Run a full configuration search experiment. + +Given a search space (RDF or text), a search strategy proposes pipeline configs; +each candidate is executed against seed/source data, evaluated against a reference +KG, and written to the output directory. A combined results file is produced for +offline analysis via analyse.py. + +Example: + PYTHONPATH=src python src/experiment.py \\ + --seed data/bench/.../seed/data.nt \\ + --source data/bench/.../sources/rdf/data.nt \\ + --reference data/bench/.../reference/data_agg.nt \\ + --ontology data/bench/.../ontology.ttl \\ + --pipeline-type rdf \\ + --strategy qgns \\ + --budget 20 \\ + --init-budget 3 \\ + --output-dir fix_runs """ +from __future__ import annotations + import argparse -import hashlib import json -import os +import random import sys -import types -from dataclasses import asdict, is_dataclass -from importlib import import_module from pathlib import Path -from typing import Any, Callable, Dict, List, Optional +from typing import Any, Callable, Dict, List, Literal, Optional -from kgpipe.common import Data, DataFormat, KgPipe from kgpipe_search.configuration import ( - load_rdf_exhaustive_pipeline_configs, - load_rdf_sampled_pipeline_configs, - load_text_exhaustive_pipeline_configs, - load_text_sampled_pipeline_configs, pipeline_config_to_snapshot, print_pipeline_config_short, task_keys_from_pipeline_config, ) -from kgpipe_search.definitions import PipelineConfig +from kgpipe_search.definitions import ( + RDF_PIPELINE_LAYOUT, + RDF_SEARCH_SPACE, + TEXT_PIPELINE_LAYOUT, + TEXT_SEARCH_SPACE, + PipelineConfig, +) from kgpipe_search.evaluation import evaluate_pipeline +from kgpipe_search.search import ( + bayesian_optimization, + hnr_search, + implementation_aware_search, + qgns_search, + random_search, +) +from kgpipe_search.strategies.strategies import SearchRun +import execute as pipeline_execute -def _install_param_opti_shim() -> None: - if "param_opti" in sys.modules: - return - - param_opti = types.ModuleType("param_opti") - tasks = types.ModuleType("param_opti.tasks") - - for lib in ( - "base_linker_lib", - "base_matcher_lib", - "paris_lib", - "fusion_lib", - "spotlight_lib", - "corenlp_lip", - "genie_lib", - ): - module = import_module(f"kgpipe_search.dev.tasks.{lib}") - setattr(tasks, lib, module) - sys.modules[f"param_opti.tasks.{lib}"] = module - - param_opti.tasks = tasks - sys.modules["param_opti"] = param_opti - sys.modules["param_opti.tasks"] = tasks - - -_install_param_opti_shim() - - -def _to_jsonable(value: Any) -> Any: - if is_dataclass(value): - return {k: _to_jsonable(v) for k, v in asdict(value).items()} - if isinstance(value, dict): - return {k: _to_jsonable(v) for k, v in value.items()} - if isinstance(value, list): - return [_to_jsonable(v) for v in value] - if isinstance(value, Path): - return str(value) - return value - - -def _set_ontology_env(ontology_path: Optional[Path]) -> None: - if ontology_path is None: - return - if not ontology_path.exists(): - raise FileNotFoundError(f"Ontology file not found: {ontology_path}") - os.environ["ONTOLOGY_PATH"] = str(ontology_path.resolve()) - - -def _config_hash(snapshot: Dict[str, Any]) -> str: - canonical = json.dumps(snapshot, sort_keys=True) - return hashlib.sha256(canonical.encode("utf-8")).hexdigest() - - -def _tasks_tmp_dir( - *, - output_dir: Path, - config_hash: str, - task_keys: List[str], - scope: str, -) -> Path: - """ - Decide where per-task temporary files live. - - - config: one tmp dir per config hash (default, current behavior) - - pipeline: reuse tmp dir for configs with identical task list (enables cache reuse across params) - - shared: reuse a single tmp dir for all configs - """ - - if scope == "config": - return output_dir / f"{config_hash}_tasks_tmp" - if scope == "shared": - return output_dir / "shared_tasks_tmp" - if scope == "pipeline": - canonical = json.dumps(task_keys, sort_keys=False) - pipeline_hash = hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:12] - return output_dir / f"pipeline_{pipeline_hash}_tasks_tmp" - raise ValueError(f"Unsupported tasks tmp dir scope {scope!r}") - - -def _write_config_snapshot(config_path: Path, snapshot: Dict[str, Any]) -> None: - config_path.write_text( - json.dumps(snapshot, indent=2, sort_keys=True) + "\n", - encoding="utf-8", - ) +PipelineType = Literal["rdf", "text"] +SearchStrategyName = Literal["random", "implementation_aware", "qgns", "hnr", "bayesian"] +TasksTmpScope = Literal["config", "pipeline", "shared"] +InitStrategy = Literal["random", "implementation_aware"] -def _validate_input_path(path: Path, label: str) -> Path: - resolved = path.resolve() - if not resolved.exists(): - raise FileNotFoundError(f"{label} not found: {resolved}") - return resolved +def _pipeline_context(pipeline_type: PipelineType) -> tuple[Dict[str, Any], Any, Callable[..., Path]]: + if pipeline_type == "rdf": + return RDF_SEARCH_SPACE, RDF_PIPELINE_LAYOUT, pipeline_execute.run_rdf_pipeline + if pipeline_type == "text": + return TEXT_SEARCH_SPACE, TEXT_PIPELINE_LAYOUT, pipeline_execute.run_text_pipeline + raise ValueError(f"Unsupported pipeline type {pipeline_type!r}") -def _load_pipeline_configs( +def _run_search( *, - pipeline_type: str, - configs: str, - configs_fixture: Optional[Path], -) -> List[PipelineConfig]: - loaders: Dict[str, Dict[str, Callable[[], List[PipelineConfig]]]] = { - "rdf": { - "sampled": load_rdf_sampled_pipeline_configs, - "exhaustive": load_rdf_exhaustive_pipeline_configs, - }, - "text": { - "sampled": load_text_sampled_pipeline_configs, - "exhaustive": load_text_exhaustive_pipeline_configs, - }, + strategy: SearchStrategyName, + budget: int, + evaluate_fn: Callable[[PipelineConfig], float], + search_space: Dict[str, Any], + pipeline_layout: Any, + init_budget: int, + init_strategy: InitStrategy, + y: int, + k: int, + rho: float, + pool_size: int, + beta: float, + rng: random.Random, +) -> SearchRun: + common = { + "budget": budget, + "evaluate_fn": evaluate_fn, + "search_space": search_space, + "pipeline_layout": pipeline_layout, + "rng": rng, } - if pipeline_type not in loaders: - raise ValueError(f"Unsupported pipeline type {pipeline_type!r}") - if configs not in loaders[pipeline_type]: - raise ValueError(f"Unsupported configs mode {configs!r}") - - loader = loaders[pipeline_type][configs] - loaded = loader(configs_fixture) if configs_fixture is not None else loader() - if not loaded: - raise ValueError( - f"No pipeline configs loaded for pipeline_type={pipeline_type!r}, configs={configs!r}. " - "Generate fixtures with the configuration tests first." - ) - return loaded + if strategy == "random": + return random_search(**common) + if strategy == "implementation_aware": + return implementation_aware_search(**common, y=y) -def run_rdf_pipeline( - pipeline_config: PipelineConfig, - *, - seed_path: Path, - source_path: Path, - result_path: Path, - tasks_tmp_dir: Path, - run_name: str, -) -> Path: - tasks_tmp_dir.mkdir(parents=True, exist_ok=True) - result_path.parent.mkdir(parents=True, exist_ok=True) - - pipeline = KgPipe( - tasks=pipeline_config.tasks, - seed=Data(path=seed_path, format=DataFormat.RDF_NTRIPLES), - data_dir=tasks_tmp_dir, - name=run_name, - ) - - pipeline.build( - stable_files=True, - configCatalog=pipeline_config.config_catalog, - source=Data(path=source_path, format=DataFormat.RDF_NTRIPLES), - result=Data(path=result_path, format=DataFormat.RDF_NTRIPLES), - ) - pipeline.run(configCatalog=pipeline_config.config_catalog, stable_files_override=False) - return result_path + if strategy == "qgns": + return qgns_search( + **common, + init_budget=init_budget, + init_strategy=init_strategy, + y=y, + k=k, + rho=rho, + ) + if strategy == "hnr": + if init_budget <= 0: + raise ValueError("HNR requires --init-budget > 0") + return hnr_search( + **common, + init_budget=init_budget, + init_strategy=init_strategy, + y=y, + rho=rho, + ) -def run_text_pipeline( - pipeline_config: PipelineConfig, - *, - seed_path: Path, - source_path: Path, - result_path: Path, - tasks_tmp_dir: Path, - run_name: str, -) -> Path: - tasks_tmp_dir.mkdir(parents=True, exist_ok=True) - result_path.parent.mkdir(parents=True, exist_ok=True) - - pipeline = KgPipe( - tasks=pipeline_config.tasks, - seed=Data(path=seed_path, format=DataFormat.RDF_NTRIPLES), - data_dir=tasks_tmp_dir, - name=run_name, - ) + if strategy == "bayesian": + return bayesian_optimization( + **common, + init_random=init_budget, + init_strategy=init_strategy, + y=y, + pool_size=pool_size, + beta=beta, + ) - pipeline.build( - stable_files=True, - configCatalog=pipeline_config.config_catalog, - source=Data(path=source_path, format=DataFormat.TEXT), - result=Data(path=result_path, format=DataFormat.RDF_NTRIPLES), - ) - pipeline.run(configCatalog=pipeline_config.config_catalog, stable_files_override=False) - return result_path + raise ValueError(f"Unknown search strategy {strategy!r}") -def run_all_configs( +def run_search_experiment( *, seed_path: Path, source_path: Path, reference_path: Path, ontology_path: Optional[Path], output_dir: Path, - pipeline_type: str, - configs: str, - configs_fixture: Optional[Path], - start: int, - limit: Optional[int], + pipeline_type: PipelineType, + strategy: SearchStrategyName, + budget: int, + init_budget: int, + init_strategy: InitStrategy, + y: int, + k: int, + rho: float, + pool_size: int, + beta: float, + rng_seed: int, + tasks_tmp_scope: TasksTmpScope, results_path: Optional[Path], - tasks_tmp_scope: str, -) -> List[Dict[str, Any]]: - _set_ontology_env(ontology_path) - - run_pipeline = run_rdf_pipeline if pipeline_type == "rdf" else run_text_pipeline - - pipeline_configs = _load_pipeline_configs( - pipeline_type=pipeline_type, - configs=configs, - configs_fixture=configs_fixture, - ) - - end = len(pipeline_configs) if limit is None else min(len(pipeline_configs), start + limit) - selected = pipeline_configs[start:end] + reuse_existing: bool = True, +) -> Dict[str, Any]: + pipeline_execute._set_ontology_env(ontology_path) + search_space, pipeline_layout, run_pipeline = _pipeline_context(pipeline_type) output_dir.mkdir(parents=True, exist_ok=True) + + rng = random.Random(rng_seed) run_results: List[Dict[str, Any]] = [] + search_history: List[Dict[str, Any]] = [] + best_score: Optional[float] = None + cache_hits = 0 - print(f"Running {len(selected)} pipeline config(s) [{start}:{end})") - print(f"seed: {seed_path}") - print(f"source: {source_path}") - print(f"reference: {reference_path}") - print(f"output_dir: {output_dir}") - print(f"tasks_tmp_scope: {tasks_tmp_scope}") + def evaluate_fn(pipeline_config: PipelineConfig) -> float: + nonlocal best_score, cache_hits - for offset, pipeline_config in enumerate(selected, start=start): task_keys = task_keys_from_pipeline_config(pipeline_config) snapshot = pipeline_config_to_snapshot(task_keys, pipeline_config) - config_hash = _config_hash(snapshot) + config_hash = pipeline_execute._config_hash(snapshot) - result_path = output_dir / f"{config_hash}.nt" config_path = output_dir / f"{config_hash}.json" - tasks_tmp_dir = _tasks_tmp_dir( + result_path = output_dir / f"{config_hash}.nt" + eval_path = output_dir / f"{config_hash}.eval.json" + plan_path = output_dir / f"{config_hash}.plan.json" + tasks_tmp_dir = pipeline_execute._tasks_tmp_dir( output_dir=output_dir, config_hash=config_hash, task_keys=task_keys, scope=tasks_tmp_scope, ) - run_name = config_hash - print(f"\n=== config {offset + 1}/{len(pipeline_configs)} ({config_hash}) ===") + step = len(run_results) + 1 + print(f"\n=== trial {step}/{budget} ({config_hash}) ===") print_pipeline_config_short(pipeline_config) - _write_config_snapshot(config_path, snapshot) + pipeline_execute._write_config_snapshot(config_path, snapshot) entry: Dict[str, Any] = { - "config_idx": offset, + "trial": step, "config_hash": config_hash, "config_path": str(config_path), "result_path": str(result_path), + "eval_path": str(eval_path), + "plan_path": str(plan_path), "tasks_tmp_dir": str(tasks_tmp_dir), "status": "ok", + "cached": False, } try: - run_pipeline( - pipeline_config, - seed_path=seed_path, - source_path=source_path, - result_path=result_path, - tasks_tmp_dir=tasks_tmp_dir, - run_name=run_name, - ) - aggregate_score = evaluate_pipeline( - pipeline_config, - result_path, - reference_path, - ) - entry["evaluation"] = _to_jsonable(aggregate_score) - print(f"score: {aggregate_score.final_score:.6f}") + cached = pipeline_execute._load_cached_eval(eval_path) if reuse_existing else None + if cached is not None: + cache_hits += 1 + entry["cached"] = True + entry["status"] = cached["status"] + if cached["status"] == "error": + entry["error"] = cached["error"] + score = float(cached["score"]) + print(f"cached error: {entry['error']}") + else: + entry["evaluation"] = cached["evaluation"] + score = float(cached["score"]) + print(f"cached score: {score:.6f}") + elif reuse_existing and result_path.exists(): + aggregate_score = evaluate_pipeline( + pipeline_config, + result_path, + reference_path, + ) + evaluation = pipeline_execute._to_jsonable(aggregate_score) + entry["evaluation"] = evaluation + entry["cached"] = "result_only" + pipeline_execute._write_eval_snapshot(eval_path, evaluation) + score = float(aggregate_score.final_score) + print(f"reused result, score: {score:.6f}") + else: + run_pipeline( + pipeline_config, + seed_path=seed_path, + source_path=source_path, + result_path=result_path, + plan_path=plan_path, + tasks_tmp_dir=tasks_tmp_dir, + run_name=config_hash, + ) + aggregate_score = evaluate_pipeline( + pipeline_config, + result_path, + reference_path, + ) + evaluation = pipeline_execute._to_jsonable(aggregate_score) + entry["evaluation"] = evaluation + pipeline_execute._write_eval_snapshot(eval_path, evaluation) + score = float(aggregate_score.final_score) + print(f"score: {score:.6f}") except Exception as exc: entry["status"] = "error" entry["error"] = f"{type(exc).__name__}: {exc}" + score = 0.0 + pipeline_execute._write_eval_snapshot( + eval_path, + {"status": "error", "error": entry["error"]}, + ) print(f"failed: {entry['error']}") run_results.append(entry) + best_score = score if best_score is None else max(best_score, score) + return score - if results_path is not None: - results_path.parent.mkdir(parents=True, exist_ok=True) - payload = { - "pipeline_type": pipeline_type, - "configs": configs, - "seed": str(seed_path), - "source": str(source_path), - "reference": str(reference_path), - "ontology": str(ontology_path) if ontology_path is not None else None, - "output_dir": str(output_dir), - "start": start, - "limit": limit, - "tasks_tmp_scope": tasks_tmp_scope, - "results": run_results, - } - results_path.write_text( - json.dumps(payload, indent=2, sort_keys=True) + "\n", - encoding="utf-8", + print(f"Running search strategy={strategy!r} budget={budget}") + print(f"seed: {seed_path}") + print(f"source: {source_path}") + print(f"reference: {reference_path}") + print(f"output_dir: {output_dir}") + print(f"tasks_tmp_scope: {tasks_tmp_scope}") + + search_run = _run_search( + strategy=strategy, + budget=budget, + evaluate_fn=evaluate_fn, + search_space=search_space, + pipeline_layout=pipeline_layout, + init_budget=init_budget, + init_strategy=init_strategy, + y=y, + k=k, + rho=rho, + pool_size=pool_size, + beta=beta, + rng=rng, + ) + + result_by_hash = {item["config_hash"]: item for item in run_results} + running_best: Optional[float] = None + + for step, ((score, _cfg), decision) in enumerate( + zip(search_run.history, search_run.decisions), + start=1, + ): + task_keys = task_keys_from_pipeline_config(_cfg) + snapshot = pipeline_config_to_snapshot(task_keys, _cfg) + config_hash = pipeline_execute._config_hash(snapshot) + running_best = score if running_best is None else max(running_best, score) + + entry = result_by_hash.get(config_hash, {}) + search_history.append( + { + "step": step, + "decision": decision, + "config_hash": config_hash, + "score": score, + "best_score": running_best, + "status": entry.get("status", "unknown"), + "config_path": entry.get("config_path"), + "result_path": entry.get("result_path"), + "eval_path": entry.get("eval_path"), + "plan_path": entry.get("plan_path"), + } ) - print(f"\nWrote scores to {results_path}") + + payload: Dict[str, Any] = { + "pipeline_type": pipeline_type, + "search": { + "strategy": strategy, + "budget": budget, + "init_budget": init_budget, + "init_strategy": init_strategy, + "y": y, + "k": k, + "rho": rho, + "pool_size": pool_size, + "beta": beta, + "rng_seed": rng_seed, + "decisions": search_run.decisions, + }, + "seed": str(seed_path), + "source": str(source_path), + "reference": str(reference_path), + "ontology": str(ontology_path) if ontology_path is not None else None, + "output_dir": str(output_dir), + "tasks_tmp_scope": tasks_tmp_scope, + "results": run_results, + "search_history": search_history, + "best_score": running_best, + "cache_hits": cache_hits, + "reuse_existing": reuse_existing, + } + + resolved_results_path = results_path or (output_dir / "results.json") + resolved_results_path.parent.mkdir(parents=True, exist_ok=True) + resolved_results_path.write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(f"\nWrote combined results to {resolved_results_path}") succeeded = sum(1 for item in run_results if item["status"] == "ok") - print(f"\nFinished: {succeeded}/{len(run_results)} succeeded") - return run_results + print( + f"Finished: {succeeded}/{len(run_results)} succeeded, " + f"cache_hits={cache_hits}, best_score={running_best}" + ) + return payload def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( - description="Execute and evaluate pipeline configs from fixture files.", + description="Run a full pipeline configuration search experiment.", ) parser.add_argument("--seed", type=Path, required=True, help="Path to seed knowledge graph") parser.add_argument("--source", type=Path, required=True, help="Path to source input graph/text") @@ -358,48 +376,74 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument( "--output-dir", type=Path, - default=Path("data/tmp/pipeline_runs"), - help="Directory for pipeline outputs and task temp files", + default=Path("data/tmp/search_runs"), + help="Directory for pipeline outputs, eval files, and task temp files", ) parser.add_argument( "--pipeline-type", choices=["rdf", "text"], default="rdf", - help="Pipeline family to run", + help="Pipeline family to search over", ) parser.add_argument( - "--configs", - choices=["sampled", "exhaustive"], - default="sampled", + "--strategy", + choices=["random", "implementation_aware", "qgns", "hnr", "bayesian"], + default="random", help=( - "Which fixture set to execute: " - "'sampled' = small fixture for quick tests (default), " - "'exhaustive' = all task/parameter permutations" + "Search strategy to use. " + "'random' = uniform random configs; " + "'implementation_aware' = systematic task-combo coverage with random params" ), ) + parser.add_argument("--budget", type=int, default=10, help="Total number of configs to evaluate") parser.add_argument( - "--configs-fixture", - type=Path, - default=None, - help="Optional path to a custom configs fixture JSON file", + "--init-budget", + type=int, + default=3, + help="Initialization budget for qgns/hnr/bayesian (ignored by random)", ) parser.add_argument( - "--start", + "--init-strategy", + choices=["random", "implementation_aware"], + default="implementation_aware", + help="Initialization sampling strategy", + ) + parser.add_argument( + "--y", type=int, - default=0, - help="Start index into the loaded config list", + default=1, + help="Number of parameter samples per task combo during implementation-aware init", ) + parser.add_argument("--k", type=int, default=3, help="Top-k anchors for QGNS") parser.add_argument( - "--limit", + "--rho", + type=float, + default=0.2, + help="Exploration probability for QGNS/HNR", + ) + parser.add_argument( + "--pool-size", type=int, - default=None, - help="Maximum number of configs to run (default: all from --start)", + default=32, + help="Candidate pool size for Bayesian optimization", + ) + parser.add_argument( + "--beta", + type=float, + default=0.5, + help="Acquisition beta for Bayesian optimization", + ) + parser.add_argument( + "--rng-seed", + type=int, + default=0, + help="RNG seed for reproducible search", ) parser.add_argument( "--results", type=Path, default=None, - help="Path to write a single JSON summary of all run scores (default: /results.json)", + help="Path to write combined results JSON (default: /results.json)", ) parser.add_argument( "--tasks-tmp-scope", @@ -412,42 +456,56 @@ def build_parser() -> argparse.ArgumentParser: "'shared' = reuse one tmp dir for all configs" ), ) + parser.add_argument( + "--force-rerun", + action="store_true", + help="Re-run pipelines and evaluation even when cached result/eval files exist", + ) return parser def main(argv: Optional[List[str]] = None) -> int: args = build_parser().parse_args(argv) - if args.start < 0: - raise SystemExit("--start must be >= 0") - if args.limit is not None and args.limit <= 0: - raise SystemExit("--limit must be > 0") + if args.budget <= 0: + raise SystemExit("--budget must be > 0") + if args.init_budget < 0: + raise SystemExit("--init-budget must be >= 0") + if args.strategy == "hnr" and args.init_budget <= 0: + raise SystemExit("HNR requires --init-budget > 0") - seed_path = _validate_input_path(args.seed, "Seed graph") - source_path = _validate_input_path(args.source, "Source input") - reference_path = _validate_input_path(args.reference, "Reference graph") + seed_path = pipeline_execute._validate_input_path(args.seed, "Seed graph") + source_path = pipeline_execute._validate_input_path(args.source, "Source input") + reference_path = pipeline_execute._validate_input_path(args.reference, "Reference graph") ontology_path = ( - _validate_input_path(args.ontology, "Ontology") + pipeline_execute._validate_input_path(args.ontology, "Ontology") if args.ontology is not None else None ) - run_results = run_all_configs( + payload = run_search_experiment( seed_path=seed_path, source_path=source_path, reference_path=reference_path, ontology_path=ontology_path, output_dir=args.output_dir, pipeline_type=args.pipeline_type, - configs=args.configs, - configs_fixture=args.configs_fixture, - start=args.start, - limit=args.limit, - results_path=args.results or (args.output_dir / "results.json"), + strategy=args.strategy, + budget=args.budget, + init_budget=args.init_budget, + init_strategy=args.init_strategy, + y=args.y, + k=args.k, + rho=args.rho, + pool_size=args.pool_size, + beta=args.beta, + rng_seed=args.rng_seed, tasks_tmp_scope=args.tasks_tmp_scope, + results_path=args.results, + reuse_existing=not args.force_rerun, ) - failed = sum(1 for item in run_results if item["status"] != "ok") + failed = sum(1 for item in payload["results"] if item["status"] != "ok") return 1 if failed else 0 diff --git a/experiments/param-opti/src/kgpipe_search/definitions.py b/experiments/param-opti/src/kgpipe_search/definitions.py index e891e81..df8ac24 100644 --- a/experiments/param-opti/src/kgpipe_search/definitions.py +++ b/experiments/param-opti/src/kgpipe_search/definitions.py @@ -15,6 +15,7 @@ class PipelineConfig(BaseModel): tasks: List[KgTask] config_catalog: Dict[str, ConfigurationProfile] result_path: Optional[Path] = None + seed_path: Optional[Path] = None diff --git a/experiments/param-opti/src/kgpipe_search/evaluation.py b/experiments/param-opti/src/kgpipe_search/evaluation.py index 61e3d41..0d3de5a 100644 --- a/experiments/param-opti/src/kgpipe_search/evaluation.py +++ b/experiments/param-opti/src/kgpipe_search/evaluation.py @@ -2,6 +2,7 @@ from kgpipe_eval.utils.kg_utils import KgLike, KgManager from kgpipe_eval.utils.score_utils import aggregate_scores_from_json, aggregate_scores_from_results from kgpipe_search.definitions import PipelineConfig +import os aggregation_config = { "subgroups": { @@ -35,9 +36,10 @@ "final": { "aggregation": "weighted_mean", "weights": { - "coverage": 0.5, - "correctness": 0.5, - # "cleanliness": 0.2 + "coverage": 0.3333, + "correctness": 0.3333, + "consistency": 0.3333 + # "cleanliness": 0.3333 } } } @@ -51,8 +53,22 @@ def test_aggregate_results(): print(f' {m.metric}.{m.measurement} = {m.value:.6f}') def evaluate_pipeline(pipeline_config: PipelineConfig, result_kg: KgLike, reference_kg: KgLike): + from kgpipe_eval.metrics.statistics import CountMetric from kgpipe_eval.metrics.triple_alignment import TripleAlignmentMetric, TripleAlignmentConfig from kgpipe_eval.metrics.entity_alignment import EntityAlignmentMetric, EntityAlignmentConfig + from kgpipe_eval.metrics.consistency_violations import ConsistencyViolationsConfig,DisjointDomainMetric, DomainMetric, RangeMetric, DatatypeFormatMetric, DatatypeMetric, RelationDirectionMetric + + from kgpipe_eval.utils.kg_utils import KgManager + + source_seed_path: KgLike = os.getenv("SOURCE_SEED_PATH") + source_seed_graph = KgManager.load_kg(source_seed_path) + result_graph = KgManager.load_kg(result_kg) + result_no_seed_graph = KgManager.substract_kg(result_graph, source_seed_graph) + + consistency_violations_config = ConsistencyViolationsConfig( + reference_kg=None, + ontology_path=os.getenv("ONTOLOGY_PATH") + ) entity_alignment_config = EntityAlignmentConfig( method="label_embedding", @@ -69,14 +85,20 @@ def evaluate_pipeline(pipeline_config: PipelineConfig, result_kg: KgLike, refere cache_literal_embeddings=True ) - result_graph = KgManager.load_kg(result_kg) try: - results = Evaluator().run(result_graph, [TripleAlignmentMetric(), EntityAlignmentMetric()], { + results = Evaluator().run(result_no_seed_graph, [TripleAlignmentMetric(), EntityAlignmentMetric(), CountMetric(), DisjointDomainMetric(), DomainMetric(), RangeMetric(), DatatypeFormatMetric(), DatatypeMetric(), RelationDirectionMetric()], { "TripleAlignmentMetric": triple_alignment_config, - "EntityAlignmentMetric": entity_alignment_config + "EntityAlignmentMetric": entity_alignment_config, + "DisjointDomainMetric": consistency_violations_config, + "DomainMetric": consistency_violations_config, + "RangeMetric": consistency_violations_config, + "DatatypeFormatMetric": consistency_violations_config, + "DatatypeMetric": consistency_violations_config, + "RelationDirectionMetric": consistency_violations_config }) finally: KgManager.unload_kg(result_graph) + KgManager.unload_kg(result_no_seed_graph) return aggregate_scores_from_results(results, aggregation_config) diff --git a/experiments/param-opti/src/kgpipe_search/search.py b/experiments/param-opti/src/kgpipe_search/search.py index 58f6004..2f67b22 100644 --- a/experiments/param-opti/src/kgpipe_search/search.py +++ b/experiments/param-opti/src/kgpipe_search/search.py @@ -18,6 +18,7 @@ SearchRun, run_bayesian, run_hnr, + run_implementation_aware, run_qgns, run_random, ) @@ -28,6 +29,7 @@ "random_initialization", "implementation_aware_initialization", "random_search", + "implementation_aware_search", "neighborhood_optimization", "qgns_search", "hnr_search", @@ -52,6 +54,25 @@ def random_search( ) +def implementation_aware_search( + *, + budget: int, + evaluate_fn: EvaluateFn, + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, + y: int = 1, + rng: Any = None, +) -> SearchRun: + return run_implementation_aware( + budget=budget, + evaluate_fn=evaluate_fn, + search_space=search_space, + pipeline_layout=pipeline_layout, + y=y, + rng=rng, + ) + + def qgns_search( *, budget: int, diff --git a/experiments/param-opti/src/kgpipe_search/strategies/initialization.py b/experiments/param-opti/src/kgpipe_search/strategies/initialization.py index e2b0a35..fa5318a 100644 --- a/experiments/param-opti/src/kgpipe_search/strategies/initialization.py +++ b/experiments/param-opti/src/kgpipe_search/strategies/initialization.py @@ -10,6 +10,38 @@ from kgpipe_search.definitions import PipelineConfig, PipelineLayout +def _try_add_unique_config( + configs: List[PipelineConfig], + seen: Set[str], + candidate: PipelineConfig, + search_space: Dict[str, Dict[str, Any]], +) -> bool: + key = pipeline_config_snapshot_key(candidate, search_space) + if key in seen: + return False + seen.add(key) + configs.append(candidate) + return True + + +def _fill_unique_configs( + *, + configs: List[PipelineConfig], + seen: Set[str], + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, + budget: int, + rng: random.Random, + max_attempts_factor: int = 200, +) -> None: + attempts = 0 + max_attempts = max(1000, max(1, budget - len(configs)) * max_attempts_factor) + while len(configs) < budget and attempts < max_attempts: + attempts += 1 + candidate = sample_valid_pipeline_config(search_space, pipeline_layout, rng=rng) + _try_add_unique_config(configs, seen, candidate, search_space) + + def random_initialization( search_space: Dict[str, Dict[str, Any]], pipeline_layout: PipelineLayout, @@ -79,44 +111,42 @@ def implementation_aware_initialization( seen: Set[str] = set() for combo in combos: - for _ in range(y): - if len(configs) >= budget: - break + added_for_combo = 0 + attempts = 0 + max_attempts = max(100, y * 50) + while ( + added_for_combo < y + and len(configs) < budget + and attempts < max_attempts + ): + attempts += 1 candidate = build_pipeline_config_for_task_combo( search_space, combo, rng=draw, template=None, ) - key = pipeline_config_snapshot_key(candidate, search_space) - if key in seen: - continue - seen.add(key) - configs.append(candidate) + if _try_add_unique_config(configs, seen, candidate, search_space): + added_for_combo += 1 if len(configs) >= budget: break if len(configs) < budget: - remaining = budget - len(configs) - filler = random_initialization( - search_space, - pipeline_layout, - budget=remaining, + _fill_unique_configs( + configs=configs, + seen=seen, + search_space=search_space, + pipeline_layout=pipeline_layout, + budget=budget, rng=draw, ) - for candidate in filler: - key = pipeline_config_snapshot_key(candidate, search_space) - if key in seen: - continue - seen.add(key) - configs.append(candidate) - if len(configs) >= budget: - break if len(configs) < budget: raise RuntimeError( - f"Failed to generate {budget} unique initial configs (got {len(configs)})." + f"Failed to generate {budget} unique initial configs (got {len(configs)}). " + f"The search space has {len(all_combos)} implementation assignment(s); " + "try lowering init_budget." ) return configs diff --git a/experiments/param-opti/src/kgpipe_search/strategies/strategies.py b/experiments/param-opti/src/kgpipe_search/strategies/strategies.py index dfd3404..e338175 100644 --- a/experiments/param-opti/src/kgpipe_search/strategies/strategies.py +++ b/experiments/param-opti/src/kgpipe_search/strategies/strategies.py @@ -20,7 +20,7 @@ Observation = Tuple[float, PipelineConfig] EvaluateFn = Callable[[PipelineConfig], float] -SearchStrategy = Literal["random", "qgns", "hnr", "bayesian"] +SearchStrategy = Literal["random", "implementation_aware", "qgns", "hnr", "bayesian"] @dataclass @@ -326,6 +326,71 @@ def run_random( return SearchRun(strategy="random", history=history, budget=budget, decisions=decisions) +def run_implementation_aware( + *, + budget: int, + evaluate_fn: EvaluateFn, + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, + y: int = 1, + rng: Optional[random.Random] = None, +) -> SearchRun: + """ + Evaluate `budget` configs from implementation-aware initialization. + + Task combinations are covered systematically (`y` random parameter samples per combo). + Any remaining budget is filled with uniform random valid configs. + """ + if budget <= 0: + return SearchRun( + strategy="implementation_aware", + history=[], + budget=0, + decisions=[], + ) + + draw = rng or random.Random() + history: List[Observation] = [] + decisions: List[str] = [] + evaluated_keys: Set[str] = set() + + init_set = implementation_aware_initialization( + search_space, + pipeline_layout, + budget=budget, + y=y, + rng=draw, + ) + + for cfg in init_set: + if len(history) >= budget: + break + key = pipeline_config_snapshot_key(cfg, search_space) + if key in evaluated_keys: + continue + score = evaluate_fn(cfg) + history.append((score, cfg)) + evaluated_keys.add(key) + decisions.append("init(implementation_aware)") + + while len(history) < budget: + candidate = sample_unevaluated_config( + draw, search_space, pipeline_layout, evaluated_keys + ) + key = pipeline_config_snapshot_key(candidate, search_space) + score = evaluate_fn(candidate) + history.append((score, candidate)) + evaluated_keys.add(key) + decisions.append("sample") + + return SearchRun( + strategy="implementation_aware", + history=history, + budget=budget, + decisions=decisions, + ) + + def run_qgns( *, budget: int, diff --git a/experiments/param-opti/src/kgpipe_search/test/test_search_strategies.py b/experiments/param-opti/src/kgpipe_search/test/test_search_strategies.py index 6f790ee..8931274 100644 --- a/experiments/param-opti/src/kgpipe_search/test/test_search_strategies.py +++ b/experiments/param-opti/src/kgpipe_search/test/test_search_strategies.py @@ -1,8 +1,10 @@ import random +from kgpipe_search.configuration import enumerate_valid_task_combinations from kgpipe_search.definitions import RDF_PIPELINE_LAYOUT, RDF_SEARCH_SPACE from kgpipe_search.evaluation import dummy_evaluate_pipeline -from kgpipe_search.search import hnr_search, qgns_search +from kgpipe_search.search import hnr_search, implementation_aware_search, qgns_search +from kgpipe_search.strategies.initialization import implementation_aware_initialization def _assert_valid(run) -> None: @@ -64,3 +66,30 @@ def test_dummy_evaluate_pipeline_hnr(): ) _assert_valid(run) + +def test_dummy_evaluate_pipeline_implementation_aware_search(): + run = implementation_aware_search( + budget=10, + y=1, + evaluate_fn=dummy_evaluate_pipeline, + search_space=RDF_SEARCH_SPACE, + pipeline_layout=RDF_PIPELINE_LAYOUT, + rng=random.Random(5), + ) + _assert_valid(run) + assert any(str(d).startswith("init(implementation_aware)") for d in run.decisions) + + +def test_implementation_aware_init_can_exceed_task_combo_count(): + combos = enumerate_valid_task_combinations(RDF_SEARCH_SPACE, RDF_PIPELINE_LAYOUT) + init_budget = len(combos) + 1 + + configs = implementation_aware_initialization( + RDF_SEARCH_SPACE, + RDF_PIPELINE_LAYOUT, + budget=init_budget, + y=1, + rng=random.Random(0), + ) + assert len(configs) == init_budget + diff --git a/experiments/param-opti/src/plot_search_evolution.py b/experiments/param-opti/src/plot_search_evolution.py new file mode 100644 index 0000000..de2bfe9 --- /dev/null +++ b/experiments/param-opti/src/plot_search_evolution.py @@ -0,0 +1,401 @@ +#!/usr/bin/env python3 +"""Plot search evolution (iteration vs quality score) from search result reports.""" + +from __future__ import annotations + +import argparse +import csv +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterable, List, Optional, Sequence, Tuple + +import matplotlib.pyplot as plt + + +DEFAULT_RESULTS_DIR = Path(__file__).resolve().parent.parent / "search-results" +DEFAULT_OUTPUT = Path(__file__).resolve().parent.parent / "search-results" / "search-evolution.png" +DEFAULT_TABLE_CSV = Path(__file__).resolve().parent.parent / "search-results" / "search-evolution-table.csv" +DEFAULT_TABLE_MD = Path(__file__).resolve().parent.parent / "search-results" / "search-evolution-table.md" + +STRATEGY_LABELS = { + "bayes-offline.json": "Bayesian optimization", + "bayesian-results.json": "Bayesian optimization", + "hnr-offline.json": "HNR", + "hnr-results.json": "HNR", + "qgns-offline.json": "QGNS", + "qgns-results.json": "QGNS", + "random-implementation-aware-offline.json": "Random (implementation-aware)", + "implementation-aware-results.json": "Implementation-aware", + "random-random-offline.json": "Random", + "random-results.json": "Random", +} + +STRATEGY_NAME_LABELS = { + "bayesian": "Bayesian optimization", + "kgpipe_bayes": "Bayesian optimization", + "hnr": "HNR", + "kgpipe_hnr": "HNR", + "qgns": "QGNS", + "kgpipe_qgns": "QGNS", + "implementation_aware": "Implementation-aware", + "random": "Random", + "kgpipe_random": "Random", +} + + +def _read_report(path: Path) -> dict[str, Any]: + return _normalize_report(json.loads(path.read_text(encoding="utf-8"))) + + +def _normalize_report(raw: dict[str, Any]) -> dict[str, Any]: + """Adapt experiment.py results.json to the offline analyse report shape.""" + if "search_history" not in raw: + return raw + + search = raw.get("search") + search_dict = search if isinstance(search, dict) else {} + history = [ + {"score": float(item["score"])} + for item in raw["search_history"] + if isinstance(item, dict) and "score" in item + ] + return { + **raw, + "history": history, + "decisions": search_dict.get("decisions", []), + "strategy": search_dict.get("strategy"), + "init_budget": search_dict.get("init_budget"), + } + + +def _init_budget(report: dict[str, Any]) -> int: + explicit = report.get("init_budget") + if explicit is not None: + return int(explicit) + + search = report.get("search") + if isinstance(search, dict) and search.get("init_budget") is not None: + return int(search["init_budget"]) + + decisions = report.get("decisions") or [] + if isinstance(decisions, list): + return sum(1 for d in decisions if str(d).startswith("init(")) + return 0 + + +def _discover_report_paths(results_dir: Path) -> List[Path]: + offline = sorted(results_dir.glob("*-offline.json")) + if offline: + return offline + return sorted(results_dir.glob("*-results.json")) + + +def _running_best(scores: Sequence[float]) -> List[float]: + best: float | None = None + out: List[float] = [] + for score in scores: + best = score if best is None else max(best, score) + out.append(best) + return out + + +def _evolution_curve( + scores: Sequence[float], + *, + init_budget: int, + reorder_init: bool, + running_best: bool = True, +) -> Tuple[List[int], List[float]]: + if init_budget <= 0 or init_budget >= len(scores): + xs = list(range(1, len(scores) + 1)) + ys = _running_best(scores) if running_best else list(scores) + return xs, ys + + init_scores = list(scores[:init_budget]) + search_scores = list(scores[init_budget:]) + + if reorder_init: + init_scores = sorted(init_scores) + + ordered_scores = init_scores + search_scores + xs = list(range(1, len(ordered_scores) + 1)) + ys = _running_best(ordered_scores) if running_best else ordered_scores + return xs, ys + + +@dataclass(frozen=True) +class StrategyMetrics: + strategy: str + q_best: float + evals_to_95pct: Optional[int] + aoc: float + + +def _area_under_curve(xs: Sequence[int], ys: Sequence[float]) -> float: + if len(xs) < 2: + return 0.0 + area = 0.0 + for i in range(len(xs) - 1): + dx = float(xs[i + 1] - xs[i]) + area += dx * (ys[i] + ys[i + 1]) / 2.0 + return area + + +def _evals_to_fraction(xs: Sequence[int], ys: Sequence[float], *, fraction: float) -> Optional[int]: + if not ys: + return None + q_best = max(ys) + threshold = fraction * q_best + for x, y in zip(xs, ys): + if y >= threshold: + return int(x) + return None + + +def _metrics_for_report( + path: Path, + report: dict[str, Any], + *, + reorder_init: bool, + target_fraction: float, +) -> Optional[StrategyMetrics]: + history = report.get("history") + if not isinstance(history, list) or not history: + return None + + scores = [float(item["score"]) for item in history if isinstance(item, dict) and "score" in item] + if not scores: + return None + + init_budget = _init_budget(report) + xs, ys = _evolution_curve( + scores, + init_budget=init_budget, + reorder_init=reorder_init, + running_best=True, + ) + + return StrategyMetrics( + strategy=_label_for(path, report), + q_best=max(ys), + evals_to_95pct=_evals_to_fraction(xs, ys, fraction=target_fraction), + aoc=_area_under_curve(xs, ys), + ) + + +def _format_metrics_table(rows: Sequence[StrategyMetrics]) -> List[List[str]]: + header = ["Strategy", "Q best", "Evals to 95%", "AOC"] + body = [ + [ + row.strategy, + f"{row.q_best:.4f}", + str(row.evals_to_95pct) if row.evals_to_95pct is not None else "—", + f"{row.aoc:.2f}", + ] + for row in rows + ] + return [header, *body] + + +def _print_metrics_table(rows: Sequence[StrategyMetrics]) -> None: + table = _format_metrics_table(rows) + widths = [max(len(row[i]) for row in table) for i in range(len(table[0]))] + for row_idx, row in enumerate(table): + line = " ".join(cell.ljust(widths[i]) for i, cell in enumerate(row)) + print(line) + if row_idx == 0: + print(" ".join("-" * widths[i] for i in range(len(widths)))) + + +def _write_metrics_csv(path: Path, rows: Sequence[StrategyMetrics]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8", newline="") as f: + writer = csv.writer(f) + writer.writerow(["strategy", "q_best", "evals_to_95pct", "aoc"]) + for row in rows: + writer.writerow([row.strategy, f"{row.q_best:.6f}", row.evals_to_95pct, f"{row.aoc:.4f}"]) + + +def _write_metrics_markdown(path: Path, rows: Sequence[StrategyMetrics]) -> None: + table = _format_metrics_table(rows) + path.parent.mkdir(parents=True, exist_ok=True) + lines = [ + "| " + " | ".join(table[0]) + " |", + "| " + " | ".join("---" for _ in table[0]) + " |", + ] + for row in table[1:]: + lines.append("| " + " | ".join(row) + " |") + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def _chronological_out_path(out: Path) -> Path: + return out.with_name(f"{out.stem}-chronological{out.suffix}") + + +def _label_for(path: Path, report: dict[str, Any]) -> str: + if path.name in STRATEGY_LABELS: + return STRATEGY_LABELS[path.name] + strategy = report.get("strategy") + if isinstance(strategy, str) and strategy in STRATEGY_NAME_LABELS: + return STRATEGY_NAME_LABELS[strategy] + if isinstance(strategy, str): + return strategy + return path.stem + + +def plot_reports( + reports: Iterable[Tuple[Path, dict[str, Any]]], + *, + reorder_init: bool, + running_best: bool, + out: Path, + title: str, +) -> None: + fig, ax = plt.subplots(figsize=(9, 5.5)) + + for path, report in reports: + history = report.get("history") + if not isinstance(history, list) or not history: + continue + + scores = [float(item["score"]) for item in history if isinstance(item, dict) and "score" in item] + if not scores: + continue + + init_budget = _init_budget(report) + + xs, ys = _evolution_curve( + scores, + init_budget=init_budget, + reorder_init=reorder_init, + running_best=running_best, + ) + label = _label_for(path, report) + ax.plot(xs, ys, marker="o", markersize=3, linewidth=1.8, label=label) + + if init_budget > 0: + ax.axvline(init_budget + 0.5, color="0.75", linestyle=":", linewidth=0.8) + + ax.set_xlabel("Iteration") + ax.set_ylabel("Best quality score so far" if running_best else "Quality score") + ax.set_title(title) + ax.grid(True, alpha=0.3) + ax.legend(loc="lower right", fontsize=9) + fig.tight_layout() + + out.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(out, dpi=160) + plt.close(fig) + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(description="Plot search evolution from JSON result reports.") + p.add_argument( + "--results-dir", + type=Path, + default=DEFAULT_RESULTS_DIR, + help="Directory containing *-offline.json or *-results.json reports.", + ) + p.add_argument( + "--out", + type=Path, + default=DEFAULT_OUTPUT, + help="Output image path for init-sorted plot.", + ) + p.add_argument( + "--out-chronological", + type=Path, + default=None, + help="Output image path for chronological-init plot (default: -chronological).", + ) + p.add_argument( + "--skip-chronological-plot", + action="store_true", + help="Skip writing the chronological-init plot.", + ) + p.add_argument( + "--title", + default="Search evolution", + help="Plot title.", + ) + p.add_argument( + "--table-csv", + type=Path, + default=DEFAULT_TABLE_CSV, + help="CSV path for strategy summary metrics.", + ) + p.add_argument( + "--table-md", + type=Path, + default=DEFAULT_TABLE_MD, + help="Markdown path for strategy summary metrics.", + ) + p.add_argument( + "--target-fraction", + type=float, + default=0.95, + help="Fraction of Q best used for the evals-to-target column (default: 0.95).", + ) + return p + + +def main(argv: Sequence[str] | None = None) -> int: + args = build_parser().parse_args(argv) + results_dir: Path = args.results_dir + if not results_dir.is_dir(): + raise SystemExit(f"Results directory not found: {results_dir}") + + report_paths = _discover_report_paths(results_dir) + if not report_paths: + raise SystemExit( + f"No *-offline.json or *-results.json files found in {results_dir}" + ) + + reports = [(path, _read_report(path)) for path in report_paths] + + metrics: List[StrategyMetrics] = [] + for path, report in reports: + row = _metrics_for_report( + path, + report, + reorder_init=True, + target_fraction=float(args.target_fraction), + ) + if row is not None: + metrics.append(row) + + plot_reports( + reports, + reorder_init=True, + running_best=True, + out=args.out, + title=str(args.title), + ) + print(f"wrote: {args.out}") + + if not args.skip_chronological_plot: + chrono_out = args.out_chronological or _chronological_out_path(args.out) + chrono_title = f"{args.title} (chronological scores)" + plot_reports( + reports, + reorder_init=False, + running_best=False, + out=chrono_out, + title=chrono_title, + ) + print(f"wrote: {chrono_out}") + + if metrics: + _write_metrics_csv(args.table_csv, metrics) + _write_metrics_markdown(args.table_md, metrics) + print(f"wrote: {args.table_csv}") + print(f"wrote: {args.table_md}") + print() + _print_metrics_table(metrics) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/experiments/param-opti/src/split_pipeline_configs.py b/experiments/param-opti/src/split_pipeline_configs.py new file mode 100644 index 0000000..bb44d89 --- /dev/null +++ b/experiments/param-opti/src/split_pipeline_configs.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 + +import argparse +import json +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + + +def load_fixture(path: Path) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]: + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict) or "samples" not in data: + raise ValueError(f"Expected dict with 'samples' key in {path}") + samples = data.get("samples") + if not isinstance(samples, list): + raise ValueError(f"Expected 'samples' to be a list in {path}") + return data, samples + + +def task_layout_key(sample: Dict[str, Any]) -> Tuple[str, ...]: + return tuple(str(key) for key in (sample.get("task_keys") or [])) + + +def chunk_list(items: List[Dict[str, Any]], chunk_size: int) -> List[List[Dict[str, Any]]]: + if chunk_size <= 0: + raise ValueError(f"chunk_size must be positive, got {chunk_size}") + return [items[i : i + chunk_size] for i in range(0, len(items), chunk_size)] + + +def split_into_num_parts( + items: List[Dict[str, Any]], num_parts: int +) -> List[List[Dict[str, Any]]]: + if num_parts <= 0: + raise ValueError(f"num_parts must be positive, got {num_parts}") + if num_parts > len(items): + raise ValueError( + f"num_parts ({num_parts}) cannot exceed number of samples ({len(items)})" + ) + + base_size, remainder = divmod(len(items), num_parts) + parts: List[List[Dict[str, Any]]] = [] + start = 0 + for index in range(num_parts): + size = base_size + (1 if index < remainder else 0) + parts.append(items[start : start + size]) + start += size + return parts + + +def split_sequential( + samples: List[Dict[str, Any]], + *, + num_parts: Optional[int], + max_per_file: Optional[int], +) -> List[List[Dict[str, Any]]]: + if num_parts is not None and max_per_file is not None: + raise ValueError("Use only one of --num-parts or --max-per-file for sequential splitting") + if num_parts is not None: + return split_into_num_parts(samples, num_parts) + if max_per_file is not None: + return chunk_list(samples, max_per_file) + raise ValueError("Sequential splitting requires --num-parts or --max-per-file") + + +def split_by_layout( + samples: List[Dict[str, Any]], + *, + max_per_file: Optional[int], +) -> List[List[Dict[str, Any]]]: + grouped: Dict[Tuple[str, ...], List[Dict[str, Any]]] = {} + layout_order: List[Tuple[str, ...]] = [] + for sample in samples: + layout = task_layout_key(sample) + if layout not in grouped: + grouped[layout] = [] + layout_order.append(layout) + grouped[layout].append(sample) + + parts: List[List[Dict[str, Any]]] = [] + for layout in layout_order: + layout_samples = grouped[layout] + if max_per_file is None: + parts.append(layout_samples) + else: + parts.extend(chunk_list(layout_samples, max_per_file)) + return parts + + +def output_path(out_dir: Path, stem: str, index: int, total_parts: int) -> Path: + width = max(2, len(str(total_parts))) + return out_dir / f"{stem}_{index:0{width}d}.json" + + +def write_parts( + *, + top_level: Dict[str, Any], + parts: List[List[Dict[str, Any]]], + out_dir: Path, + stem: str, + dry_run: bool, +) -> List[Dict[str, Any]]: + if not parts: + raise ValueError("No output parts produced") + + total_parts = len(parts) + written: List[Dict[str, Any]] = [] + for index, part_samples in enumerate(parts, start=1): + out_path = output_path(out_dir, stem, index, total_parts) + out_data = dict(top_level) + out_data["samples"] = part_samples + + record = { + "part": index, + "path": str(out_path), + "samples": len(part_samples), + } + written.append(record) + + if dry_run: + continue + + out_dir.mkdir(parents=True, exist_ok=True) + out_path.write_text( + json.dumps(out_data, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + return written + + +def main() -> int: + parser = argparse.ArgumentParser( + description=( + "Split a pipeline configs fixture into numbered sub-files " + "(e.g. rdf_exhaustive_pipeline_configs_01.json)." + ) + ) + parser.add_argument("--input", required=True, type=Path, help="Input fixture JSON file") + parser.add_argument( + "--out-dir", + required=True, + type=Path, + help="Directory for numbered output files", + ) + parser.add_argument( + "--stem", + type=str, + default=None, + help="Output filename stem (default: input filename without extension)", + ) + parser.add_argument( + "--num-parts", + type=int, + default=None, + help="Split sequentially into N roughly equal parts", + ) + parser.add_argument( + "--max-per-file", + type=int, + default=None, + help="Maximum configs per output file", + ) + parser.add_argument( + "--by-layout", + action="store_true", + help=( + "Group configs by task layout (task_keys) before splitting. " + "Without --max-per-file, writes one file per layout." + ), + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print the split plan without writing files", + ) + args = parser.parse_args() + + if args.num_parts is None and args.max_per_file is None and not args.by_layout: + parser.error("Specify --num-parts, --max-per-file, or --by-layout") + + top_level, samples = load_fixture(args.input) + if not samples: + raise ValueError(f"No samples found in {args.input}") + + if args.by_layout: + parts = split_by_layout(samples, max_per_file=args.max_per_file) + else: + parts = split_sequential( + samples, + num_parts=args.num_parts, + max_per_file=args.max_per_file, + ) + + stem = args.stem or args.input.stem + written = write_parts( + top_level=top_level, + parts=parts, + out_dir=args.out_dir, + stem=stem, + dry_run=args.dry_run, + ) + + print( + json.dumps( + { + "input_file": str(args.input), + "out_dir": str(args.out_dir), + "stem": stem, + "mode": "layout" if args.by_layout else "sequential", + "input_samples": len(samples), + "num_parts": len(parts), + "dry_run": args.dry_run, + "parts": written, + }, + indent=2, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/kgpipe/execution/base.py b/src/kgpipe/execution/base.py new file mode 100644 index 0000000..829672c --- /dev/null +++ b/src/kgpipe/execution/base.py @@ -0,0 +1,13 @@ +from abc import ABC, abstractmethod +from kgpipe.common.model.pipeline import KgPipe + +class KGpipeExecution(ABC): + """Base class for KGpipe execution.""" + + def __init__(self, pipeline: KGpipePipeline): + self.pipeline = pipeline + + @abstractmethod + def execute(self): + """Execute the pipeline.""" + pass \ No newline at end of file diff --git a/src/kgpipe/execution/local.py b/src/kgpipe/execution/local.py new file mode 100644 index 0000000..e69de29 diff --git a/src/kgpipe/execution/swarm.py b/src/kgpipe/execution/swarm.py new file mode 100644 index 0000000..e69de29 diff --git a/src/kgpipe_eval/utils/kg_utils.py b/src/kgpipe_eval/utils/kg_utils.py index 8d0317d..9c304a0 100644 --- a/src/kgpipe_eval/utils/kg_utils.py +++ b/src/kgpipe_eval/utils/kg_utils.py @@ -110,6 +110,8 @@ def _graph(self) -> Graph: return self.kg.get_graph() elif isinstance(self.kg, Path): return Graph().parse(str(self.kg)) + elif isinstance(self.kg, str): + return Graph().parse(self.kg) else: raise ValueError(f"Unsupported KG type: {type(self.kg)}") From aff6d0b0722b62269c50237a7cca0b5993c419c0 Mon Sep 17 00:00:00 2001 From: Marvin Hofer Date: Mon, 20 Jul 2026 16:20:33 +0200 Subject: [PATCH 85/96] chore: added uv.lock and .python-version --- .python-version | 1 + uv.lock | 3742 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 3743 insertions(+) create mode 100644 .python-version create mode 100644 uv.lock diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..d5f7acc --- /dev/null +++ b/uv.lock @@ -0,0 +1,3742 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda'", + "python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda'", + "python_full_version < '3.14' and sys_platform == 'win32' and extra != 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda'", + "python_full_version < '3.14' and sys_platform == 'emscripten' and extra != 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda'", + "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda'", + "python_full_version >= '3.14' and sys_platform == 'win32' and extra == 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda'", + "python_full_version >= '3.14' and sys_platform == 'emscripten' and extra == 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda'", + "python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra == 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda'", + "python_full_version >= '3.14' and sys_platform == 'darwin' and extra == 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda'", + "python_full_version < '3.14' and sys_platform == 'win32' and extra == 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda'", + "python_full_version < '3.14' and sys_platform == 'emscripten' and extra == 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda'", + "python_full_version < '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra == 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda'", + "python_full_version < '3.14' and sys_platform == 'darwin' and extra == 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda'", + "python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda'", + "python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda'", + "python_full_version < '3.14' and sys_platform == 'win32' and extra != 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda'", + "python_full_version < '3.14' and sys_platform == 'emscripten' and extra != 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda'", + "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda'", +] +conflicts = [[ + { package = "kgpipe", extra = "cpu" }, + { package = "kgpipe", extra = "cuda" }, +]] + +[[package]] +name = "altair" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "narwhals" }, + { name = "packaging" }, + { name = "typing-extensions", marker = "python_full_version < '3.15' or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/1e/365a9144db3254f86f1b974660b9ede1e9a38c9dc0730e4a9b1192eec5d6/altair-6.1.0.tar.gz", hash = "sha256:dda699216cf85b040d968ae5a569ad45957616811e38760a85e5118269daca67", size = 765519, upload-time = "2026-04-21T13:08:46.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/63/5dacc8d8306c715088b897a479e551bc0779fd2f0f26c97fec5e36542b4e/altair-6.1.0-py3-none-any.whl", hash = "sha256:fdf5fd939512e5b2fc4441c82dfd2635e706defbd037db0ac429ef5ddce66c3b", size = 796996, upload-time = "2026-04-21T13:08:48.549Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "appnope" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170, upload-time = "2024-02-06T09:43:11.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" }, +] + +[[package]] +name = "asttokens" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "backrefs" +version = "7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/a7dd63622beef68cc0d3c3c36d472e143dd95443d5ebf14cd1a5b4dfbf11/backrefs-7.0.tar.gz", hash = "sha256:4989bb9e1e99eb23647c7160ed51fb21d0b41b5d200f2d3017da41e023097e82", size = 7012453, upload-time = "2026-04-28T16:28:04.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/39/39a31d7eae729ea14ed10c3ccef79371197177b9355a86cb3525709e8502/backrefs-7.0-py310-none-any.whl", hash = "sha256:b57cd227ea556b0aed3dc9b8da4628db4eabc0402c6d7fcfc69283a93955f7e9", size = 380824, upload-time = "2026-04-28T16:27:55.647Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b5/9302644225ba7dfa934a2ff2b9c7bb85701313a90dddb3dfaf693fa5bae2/backrefs-7.0-py311-none-any.whl", hash = "sha256:a0fa7360c63509e9e077e174ef4e6d3c21c8db94189b9d957289ae6d794b9475", size = 392626, upload-time = "2026-04-28T16:27:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/36/da/87912ddec6e06feffbaa3d7aa18fc6352bee2e8f1fee185d7d1690f8f4e8/backrefs-7.0-py312-none-any.whl", hash = "sha256:ca42ce6a49ace3d75684dfa9937f3373902a63284ecb385ce36d15e5dcb41c12", size = 398537, upload-time = "2026-04-28T16:27:58.913Z" }, + { url = "https://files.pythonhosted.org/packages/00/bb/90ba423612b6aa0adccc6b1874bcd4a9b44b660c0c16f346611e00f64ac3/backrefs-7.0-py313-none-any.whl", hash = "sha256:f2c52955d631b9e1ac4cd56209f0a3a946d592b98e7790e77699339ae01c102a", size = 400491, upload-time = "2026-04-28T16:28:00.928Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5c/fb93d3092640a24dfb7bd7727a24016d7c01774ca013e60efd3f683c8002/backrefs-7.0-py314-none-any.whl", hash = "sha256:a6448b28180e3ca01134c9cf09dcebafad8531072e09903c5451748a05f24bc9", size = 412349, upload-time = "2026-04-28T16:28:02.412Z" }, +] + +[[package]] +name = "black" +version = "26.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pytokens" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/f8/da5eae4fc75e78e6dceb60624e1b9662ab00d6b452996046dfa9b8a6025b/black-26.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e6f89631eb88a7302d416594a32faeee9fb8fb848290da9d0a5f2903519fc1", size = 1895920, upload-time = "2026-03-12T03:40:13.921Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9f/04e6f26534da2e1629b2b48255c264cabf5eedc5141d04516d9d68a24111/black-26.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cd2012d35b47d589cb8a16faf8a32ef7a336f56356babd9fcf70939ad1897f", size = 1718499, upload-time = "2026-03-12T03:40:15.239Z" }, + { url = "https://files.pythonhosted.org/packages/04/91/a5935b2a63e31b331060c4a9fdb5a6c725840858c599032a6f3aac94055f/black-26.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f76ff19ec5297dd8e66eb64deda23631e642c9393ab592826fd4bdc97a4bce7", size = 1794994, upload-time = "2026-03-12T03:40:17.124Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0a/86e462cdd311a3c2a8ece708d22aba17d0b2a0d5348ca34b40cdcbea512e/black-26.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:ddb113db38838eb9f043623ba274cfaf7d51d5b0c22ecb30afe58b1bb8322983", size = 1420867, upload-time = "2026-03-12T03:40:18.83Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e5/22515a19cb7eaee3440325a6b0d95d2c0e88dd180cb011b12ae488e031d1/black-26.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:dfdd51fc3e64ea4f35873d1b3fb25326773d55d2329ff8449139ebaad7357efb", size = 1230124, upload-time = "2026-03-12T03:40:20.425Z" }, + { url = "https://files.pythonhosted.org/packages/f5/77/5728052a3c0450c53d9bb3945c4c46b91baa62b2cafab6801411b6271e45/black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54", size = 1895034, upload-time = "2026-03-12T03:40:21.813Z" }, + { url = "https://files.pythonhosted.org/packages/52/73/7cae55fdfdfbe9d19e9a8d25d145018965fe2079fa908101c3733b0c55a0/black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f", size = 1718503, upload-time = "2026-03-12T03:40:23.666Z" }, + { url = "https://files.pythonhosted.org/packages/e1/87/af89ad449e8254fdbc74654e6467e3c9381b61472cc532ee350d28cfdafb/black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56", size = 1793557, upload-time = "2026-03-12T03:40:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/43/10/d6c06a791d8124b843bf325ab4ac7d2f5b98731dff84d6064eafd687ded1/black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839", size = 1422766, upload-time = "2026-03-12T03:40:27.14Z" }, + { url = "https://files.pythonhosted.org/packages/59/4f/40a582c015f2d841ac24fed6390bd68f0fc896069ff3a886317959c9daf8/black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2", size = 1232140, upload-time = "2026-03-12T03:40:28.882Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/e36e27c9cebc1311b7579210df6f1c86e50f2d7143ae4fcf8a5017dc8809/black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78", size = 1889234, upload-time = "2026-03-12T03:40:30.964Z" }, + { url = "https://files.pythonhosted.org/packages/0e/7b/9871acf393f64a5fa33668c19350ca87177b181f44bb3d0c33b2d534f22c/black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568", size = 1720522, upload-time = "2026-03-12T03:40:32.346Z" }, + { url = "https://files.pythonhosted.org/packages/03/87/e766c7f2e90c07fb7586cc787c9ae6462b1eedab390191f2b7fc7f6170a9/black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f", size = 1787824, upload-time = "2026-03-12T03:40:33.636Z" }, + { url = "https://files.pythonhosted.org/packages/ac/94/2424338fb2d1875e9e83eed4c8e9c67f6905ec25afd826a911aea2b02535/black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c", size = 1445855, upload-time = "2026-03-12T03:40:35.442Z" }, + { url = "https://files.pythonhosted.org/packages/86/43/0c3338bd928afb8ee7471f1a4eec3bdbe2245ccb4a646092a222e8669840/black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1", size = 1258109, upload-time = "2026-03-12T03:40:36.832Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "cachetools" +version = "7.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/e2/85f227594656000ff4d8adadae91a21f536d4a84c6c716a86bd6685874be/cachetools-7.1.1.tar.gz", hash = "sha256:27bdf856d68fd3c71c26c01b5edc312124ed427524d1ddb31aa2b7746fe20d4b", size = 40202, upload-time = "2026-05-03T20:00:29.391Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/0f/f897abe4ea0a8c408ae65c8c83bffab4936ad65d6032d4fb4cd35bbdc3ee/cachetools-7.1.1-py3-none-any.whl", hash = "sha256:0335cd7a0952d2b22327441fb0628139e234c565559eeb91a8a4ac7551c5353d", size = 16775, upload-time = "2026-05-03T20:00:27.857Z" }, +] + +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy' or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, +] + +[[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 = "comm" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, + { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, + { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, + { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, + { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, + { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, + { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, + { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, + { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, + { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, + { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, + { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, + { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, +] + +[[package]] +name = "coverage" +version = "7.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/1e/2f996b2c8415cbb6f54b0f5ec1ee850c96d7911961afb4fc05f4a89d8c58/coverage-7.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5", size = 219967, upload-time = "2026-05-10T18:00:13.756Z" }, + { url = "https://files.pythonhosted.org/packages/34/23/35c7aea1274aef7525bdd2dc92f710bdde6d11652239d71d1ec450067939/coverage-7.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662", size = 220329, upload-time = "2026-05-10T18:00:15.264Z" }, + { url = "https://files.pythonhosted.org/packages/75/cf/a8f4b43a16e194b0261257ad28ded5853ec052570afef4a84e1d81189f3b/coverage-7.14.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f", size = 251839, upload-time = "2026-05-10T18:00:17.16Z" }, + { url = "https://files.pythonhosted.org/packages/69/ff/6699e7b71e60d3049eb2bdcbc95ee3f35707b2b0e48f32e9e63d3ce30c08/coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67", size = 254576, upload-time = "2026-05-10T18:00:18.829Z" }, + { url = "https://files.pythonhosted.org/packages/22/ec/c936d495fcd67f48f03a9c4ad3297ff80d1f222a5df3980f15b34c186c21/coverage-7.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9", size = 255690, upload-time = "2026-05-10T18:00:20.648Z" }, + { url = "https://files.pythonhosted.org/packages/5c/42/5af63f636cc62a4a2b1b3ba9146f6ee6f53a35a50d5cefc54d5670f60999/coverage-7.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb", size = 257949, upload-time = "2026-05-10T18:00:22.28Z" }, + { url = "https://files.pythonhosted.org/packages/26/d3/a225317bd2012132a27e1176d51660b826f99bb975876463c44ea0d7ee5a/coverage-7.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e", size = 252242, upload-time = "2026-05-10T18:00:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/f1/7f/9e65495298c3ea414742998539c37d048b5e81cc818fb1828cc6b51d10bf/coverage-7.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3", size = 253608, upload-time = "2026-05-10T18:00:25.588Z" }, + { url = "https://files.pythonhosted.org/packages/94/46/1522b524a35bdad22b2b8c4f9d32d0a104b524726ec380b2db68db1746f5/coverage-7.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4", size = 251753, upload-time = "2026-05-10T18:00:27.104Z" }, + { url = "https://files.pythonhosted.org/packages/f3/e9/cdf00d38817742c541ade405e115a3f7bf36e6f2a8b99d4f209861b85a2d/coverage-7.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1", size = 255823, upload-time = "2026-05-10T18:00:29.038Z" }, + { url = "https://files.pythonhosted.org/packages/38/fc/5e7877cf5f902d08a17ff1c532511476d87e1bea355bd5028cb97f902e79/coverage-7.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5", size = 251323, upload-time = "2026-05-10T18:00:30.647Z" }, + { url = "https://files.pythonhosted.org/packages/18/9d/50f05a72dff8487464fdd4178dda5daed642a060e60afb644e3d45123559/coverage-7.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595", size = 253197, upload-time = "2026-05-10T18:00:32.211Z" }, + { url = "https://files.pythonhosted.org/packages/00/3f/6f61ffe6439df266c3cf60f5c99cfaa21103d0210d706a42fc6c30683ff8/coverage-7.14.0-cp312-cp312-win32.whl", hash = "sha256:5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27", size = 222515, upload-time = "2026-05-10T18:00:33.717Z" }, + { url = "https://files.pythonhosted.org/packages/85/19/93853133df2cb371083285ef6a93982a0173e7a233b0f61373ba9fd30eb2/coverage-7.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2", size = 223324, upload-time = "2026-05-10T18:00:35.172Z" }, + { url = "https://files.pythonhosted.org/packages/74/18/9f7fe62f659f24b7a82a0be56bf94c1bd0a89e0ae7ab4c668f6e82404294/coverage-7.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d", size = 221944, upload-time = "2026-05-10T18:00:37.014Z" }, + { url = "https://files.pythonhosted.org/packages/6b/76/b7c66ee3c66e1b0f9d894c8125983aa0c03fb2336f2fd16559f9c966157f/coverage-7.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f2bbb8254370eb4c628ff3d6fa8a7f74ddc40565394d4f7ab791d1fe568e37ef", size = 219990, upload-time = "2026-05-10T18:00:38.887Z" }, + { url = "https://files.pythonhosted.org/packages/b3/af/e567cbad5ba69c013a50146dfa886dc7193361fda77521f51274ff620e1b/coverage-7.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23b81107f46d3f21d0cbce30664fcec0f5d9f585638a67081750f99738f6bf66", size = 220365, upload-time = "2026-05-10T18:00:40.864Z" }, + { url = "https://files.pythonhosted.org/packages/44/6f/9ad575d505b4d805b254febc8a5b338a2efe278f8786e56ff1cb8413f9c3/coverage-7.14.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:22a7e06a5f11a757cdfe79018e9095f9f69ae283c5cd8123774c788deec8717b", size = 251363, upload-time = "2026-05-10T18:00:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5f/b5370068b2f57787454592ed7dcd1002f0f1703b7db1fa30f6a325a4ca6e/coverage-7.14.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9d1aa57a1dc8e05bdc42e81c5d671d849577aeedf279f4c449d6d286f9ed88ca", size = 253961, upload-time = "2026-05-10T18:00:44.079Z" }, + { url = "https://files.pythonhosted.org/packages/29/1e/51adf17738976e8f2b85ddef7b7aa12a0838b056c92f175941d8862767c1/coverage-7.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c1a51bcfddf645b3bb7ec333d9e94393a8e94f55642380fa8a9a5a9e636cb7", size = 255193, upload-time = "2026-05-10T18:00:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/9e/7b/5bfd7ac1df3b881c2ac7a5cbc99c7609e6296c402f5ef587cd81c6f355b3/coverage-7.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a841fae2fadcae4f438d43b6ccc4aac2ad609f47cdb6cfdce60cbb3fe5ca7bc2", size = 257326, upload-time = "2026-05-10T18:00:47.173Z" }, + { url = "https://files.pythonhosted.org/packages/7d/38/1d37d316b174fad3843a1d76dbdfe4398771c9ecd0515935dd9ece9cd627/coverage-7.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c79d2319cabef1fe8e86df73371126931550804738f78ad7d31e3aad85a67367", size = 251582, upload-time = "2026-05-10T18:00:49.152Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/746704f95980ba220214e1a41e18cec5aea80a898eaa53c51bf2d645ff36/coverage-7.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b23b0c6f0b1db6ad769b7050c8b641c0bf215ded26c1816955b17b7f26edfa9", size = 253325, upload-time = "2026-05-10T18:00:51.252Z" }, + { url = "https://files.pythonhosted.org/packages/e1/b9/bbe87206d9687b192352f893797825b5f5b15ecd3aa9c68fbff0c074d77b/coverage-7.14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:55d3089079ce181a4566b1065ab28d2575eb76d8ac8f81f4fcda2bf037fee087", size = 251291, upload-time = "2026-05-10T18:00:52.816Z" }, + { url = "https://files.pythonhosted.org/packages/46/57/b8cdb12ac0d73ef0243218bd5e22c9df8f92edab8018213a86aec67c5324/coverage-7.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:49c005cba1e2f9677fb2845dcdf9a2e72a52a17d63e8231aaaae35d9f50215ef", size = 255448, upload-time = "2026-05-10T18:00:54.548Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d4/5002019538b2036ce3c84340f54d2fd5100d55b0a6b0894eee56128d03c7/coverage-7.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9117377b823daa28aa8635fbb08cda1cd6be3d7143257345459559aeef852d52", size = 251110, upload-time = "2026-05-10T18:00:56.122Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/20c5009477660f084e6ed60bc02a91894b8e234e617e86ecfd9aaf78e27b/coverage-7.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b79d646cf46d5cf9a9f40281d4441df5849e445726e369006d2b117710b33fe", size = 252885, upload-time = "2026-05-10T18:00:57.967Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ab/3cf6427ac9c1f1db747dbb1ce71dde47984876d4c2cfd018a3fef0a78d4d/coverage-7.14.0-cp313-cp313-win32.whl", hash = "sha256:fb609b3658479e33f9516d46f1a89dbb9b6c261366e3a11844a96ec487533dae", size = 222539, upload-time = "2026-05-10T18:00:59.581Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b8/9228523e80321c2cb4880d1f589bc0171f2f71432c35118ad04dc01decce/coverage-7.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0773d8329cf32b6fd222e4b52622c61fe8d503eb966cfc8d3c3c10c96266d50e", size = 223344, upload-time = "2026-05-10T18:01:01.531Z" }, + { url = "https://files.pythonhosted.org/packages/a3/99/118daa192f95e3a6cb2740100fbf8797cda1734b4134ef0b5d501a7fa8f3/coverage-7.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:b4e26a0f1b696faf283bffe5b8569e44e336c582439df5d53281ab89ee0cba96", size = 221966, upload-time = "2026-05-10T18:01:03.16Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f1/a46cc0c013be170216253184a32366d7cbdb9252feaec866b05c2d12a894/coverage-7.14.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:953f521ca9445300397e65fda3dca58b2dbd68fee983777420b57ac3c77e9f90", size = 220679, upload-time = "2026-05-10T18:01:05.058Z" }, + { url = "https://files.pythonhosted.org/packages/64/8c/9c30a3d311a34177fa432995be7fbfc64477d8bac5630bd38055b1c9b424/coverage-7.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:98af83fd65ae24b1fdd03aaead967a9f523bcd2f1aab2d4f3ffda65bb568a6f1", size = 221033, upload-time = "2026-05-10T18:01:07.002Z" }, + { url = "https://files.pythonhosted.org/packages/9a/cd/3fb5e06c3badefd0c1b47e2044fdca67f8220a4ec2e7fcfb476aa0a67c6c/coverage-7.14.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:668b92e6958c4db7cf92e81caac328dfbbdbb215db2850ad28f0cbe1eea0bfbd", size = 262333, upload-time = "2026-05-10T18:01:08.903Z" }, + { url = "https://files.pythonhosted.org/packages/a8/e6/fbc322325c7294d3e22c1ad6b79e45d0806b25228c8e5842aed6d8169aa7/coverage-7.14.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9fbd898551762dea00d3fef2b1c4f99afd2c6a3ff952ea07d60a9bd5ed4f34bc", size = 264410, upload-time = "2026-05-10T18:01:10.531Z" }, + { url = "https://files.pythonhosted.org/packages/08/92/c497b264bec1673c47cc77e26f760fcda4654cabf1f39546d1a23a3b8c35/coverage-7.14.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68af363c07ecd8d4b7d4043d85cb376d7d227eceb54e5323ee45da73dbd3e426", size = 266836, upload-time = "2026-05-10T18:01:12.19Z" }, + { url = "https://files.pythonhosted.org/packages/78/fc/045da320987f401af5d2815d351e8aa799aec859f60e29f445e3089eeedb/coverage-7.14.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e57054a583da8ac55edf24117ea4c9133032cfc4cf72aa2d48c1e5d4b52f899", size = 267974, upload-time = "2026-05-10T18:01:13.926Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ae/227b1e379497fb7a4fc3286e620f80c8a1e7cec66d45695a01639eb1af65/coverage-7.14.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3499459bbcdd51a65b64c35ab7ed2764eaf3cba826e0df3f1d7fe2e102b70b", size = 261578, upload-time = "2026-05-10T18:01:15.564Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f5/3570342900f2acea31d33ff1590c5d8bac1a8e1a2e1c6d34a5d5e61de681/coverage-7.14.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:45899ec2138a4346ed34d601dedf5076fb74edf2d1dd9dc76a78e82397edee90", size = 264394, upload-time = "2026-05-10T18:01:17.607Z" }, + { url = "https://files.pythonhosted.org/packages/16/29/de1bbc01c935b28f89b1dc3db85b011c055e843a8e5e3b83141c3f80af7f/coverage-7.14.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8767486808c436f05b23ab98eb963fb29185e32a9357a166971685cb3459900f", size = 262022, upload-time = "2026-05-10T18:01:19.304Z" }, + { url = "https://files.pythonhosted.org/packages/35/95/f53890b0bf2fc10ab168e05d38869215e73ca24c4cb521c3bb0eb62fe16b/coverage-7.14.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a3b5ddfd6aa7ddad53ee3edb231e88a2151507a43229b7d71b953916deca127d", size = 265732, upload-time = "2026-05-10T18:01:21.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ea/c919e259081dd2bdf0e43b87209709ba7ec2e4117c2a7f5185379c43463c/coverage-7.14.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:63df0fe568e698e1045792399f8ab6da3a6c2dce3182813fb92afa2641087b47", size = 260921, upload-time = "2026-05-10T18:01:23.533Z" }, + { url = "https://files.pythonhosted.org/packages/1a/2c/c2831889705a81dc5d1c6ca12e4d8e9b95dfc146d153488a6c0ea685d28e/coverage-7.14.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:827d6397dbd95144939b18f89edf31f63e1f99633e8d5f32f22ba8bdda567477", size = 263109, upload-time = "2026-05-10T18:01:25.165Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a9/2fcae5003cac3d63fe344d2166243c2756935f48420863c5272b240d550b/coverage-7.14.0-cp313-cp313t-win32.whl", hash = "sha256:7bf43e000d24012599b879791cff41589af90674722421ef11b11a5431920bab", size = 223212, upload-time = "2026-05-10T18:01:27.157Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bb/18e94d7b14b9b398164197114a587a04ab7c9fdbe1d237eef57311c5e883/coverage-7.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3f5549365af25d770e06b1f8f5682d9a5637d06eb494db91c6fa75d3950cc917", size = 224272, upload-time = "2026-05-10T18:01:29.107Z" }, + { url = "https://files.pythonhosted.org/packages/db/56/4f14fad782b035c81c4ffd09159e7103d42bb1d93ac8496d04b90a11b7da/coverage-7.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6d160217ec6fe890f16ad3a9531761589443749e448f91986c972714fad361c8", size = 222530, upload-time = "2026-05-10T18:01:31.151Z" }, + { url = "https://files.pythonhosted.org/packages/1c/18/b9a6586d73992807c26f9a5f274131be3d76b56b18a82b9392e2a25d2e45/coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d", size = 220036, upload-time = "2026-05-10T18:01:33.057Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63", size = 220368, upload-time = "2026-05-10T18:01:34.705Z" }, + { url = "https://files.pythonhosted.org/packages/69/aa/c12e52a5ba148d9995229d557e3be6e554fe469addc0e9241b2f0956d8ea/coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212", size = 251417, upload-time = "2026-05-10T18:01:36.949Z" }, + { url = "https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3", size = 253924, upload-time = "2026-05-10T18:01:38.985Z" }, + { url = "https://files.pythonhosted.org/packages/33/c4/59c3de0bd1b538824173fd518fed51c1ce740ca5ed68e74545983f4053a9/coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97", size = 255269, upload-time = "2026-05-10T18:01:40.957Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a9/36dfa153a62040296f6e7febfdb20a5720622f6ef5a81a41e8237b9a5344/coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8", size = 257583, upload-time = "2026-05-10T18:01:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/26/7b/cc2c048d4114d9ab1c2409e9ee365e5ae10736df6dffcfc9444effa6c708/coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb", size = 251434, upload-time = "2026-05-10T18:01:44.537Z" }, + { url = "https://files.pythonhosted.org/packages/ee/df/6770eaa576e604575e9a78055313250faef5faa84bd6f71a39fece519c43/coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe", size = 253280, upload-time = "2026-05-10T18:01:46.175Z" }, + { url = "https://files.pythonhosted.org/packages/ad/9e/1c0264514a3f98259a6d64765a397b2c8373e3ba59ee722a4802d3ec0c61/coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa", size = 251241, upload-time = "2026-05-10T18:01:48.732Z" }, + { url = "https://files.pythonhosted.org/packages/64/16/4efdf3e3c4079cdbf0ece56a2fea872df9e8a3e15a13a0af4400e1075944/coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5", size = 255516, upload-time = "2026-05-10T18:01:50.819Z" }, + { url = "https://files.pythonhosted.org/packages/93/69/b1de96346603881b3d1bc8d6447c83200e1c9700ffbaff926ba01ff5724c/coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c", size = 251059, upload-time = "2026-05-10T18:01:52.773Z" }, + { url = "https://files.pythonhosted.org/packages/a4/66/2881853e0363a5e0a724d1103e53650795367471b6afb234f8b49e713bc6/coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca", size = 252716, upload-time = "2026-05-10T18:01:54.506Z" }, + { url = "https://files.pythonhosted.org/packages/55/5c/0d3305d002c41dcde873dbe456491e663dc55152ca526b630b5c47efd62f/coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828", size = 222788, upload-time = "2026-05-10T18:01:56.487Z" }, + { url = "https://files.pythonhosted.org/packages/f9/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d", size = 223600, upload-time = "2026-05-10T18:01:58.497Z" }, + { url = "https://files.pythonhosted.org/packages/00/70/a18c408e674bc26281cadaedc7351f929bd2094e191e4b15271c30b084cc/coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9", size = 222168, upload-time = "2026-05-10T18:02:00.411Z" }, + { url = "https://files.pythonhosted.org/packages/3d/89/2681f071d238b62aff8dfc2ab44fc24cfdb38d1c01f391a80522ff5d3a16/coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1", size = 220766, upload-time = "2026-05-10T18:02:02.313Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c7/c987babafd9207ffa1995e1ef1f9b26762cf4963aa768a66b6f0501e4616/coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c", size = 221035, upload-time = "2026-05-10T18:02:04.017Z" }, + { url = "https://files.pythonhosted.org/packages/5a/e9/d6a5ac3b333088143d6fc877d398a9a674dc03124a2f776e131f03864823/coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84", size = 262405, upload-time = "2026-05-10T18:02:05.915Z" }, + { url = "https://files.pythonhosted.org/packages/38/b1/e70838d29a7c08e22d44398a46db90815bbcbf28de06992bd9210d1a8d8e/coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436", size = 264530, upload-time = "2026-05-10T18:02:07.582Z" }, + { url = "https://files.pythonhosted.org/packages/6b/73/5c31ef97763288d03d9995152b96d5475b527c63d91c84b01caea894b83a/coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a", size = 266932, upload-time = "2026-05-10T18:02:09.401Z" }, + { url = "https://files.pythonhosted.org/packages/e1/76/dd56d80f29c5f05b4d76f7e7c6d47cafacae017189c75c5759d24f9ff0cc/coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f", size = 268062, upload-time = "2026-05-10T18:02:11.399Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c7/27ba85cd5b95614f159ff93ebff1901584a8d192e2e5e24c4943a7453f59/coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb", size = 261504, upload-time = "2026-05-10T18:02:13.257Z" }, + { url = "https://files.pythonhosted.org/packages/13/2e/e8149f60ab5d5684c6eee881bdf34b127115cddbb958b196768dd9d63473/coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490", size = 264398, upload-time = "2026-05-10T18:02:15.063Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7f/1261b025285323225f4b4abffa5a643649dfd67e25ddca7ebcbdea3b7cb3/coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9", size = 262000, upload-time = "2026-05-10T18:02:16.756Z" }, + { url = "https://files.pythonhosted.org/packages/d3/dc/829c54f60b9d08389439c00f813c752781c496fc5788c78d8006db4b4f2b/coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020", size = 265732, upload-time = "2026-05-10T18:02:18.817Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b0/70bd1419941652fa062689cba9c3eeafb8f5e6fbb890bce41c3bdda5dbd6/coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6", size = 260847, upload-time = "2026-05-10T18:02:20.528Z" }, + { url = "https://files.pythonhosted.org/packages/f2/73/be40b2390656c654d35ea0015ea7ba3d945769cf80790ad5e0bb2d56d2ba/coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db", size = 263166, upload-time = "2026-05-10T18:02:22.337Z" }, + { url = "https://files.pythonhosted.org/packages/29/55/4a643f712fcf7cf2881f8ec1e0ccb7b164aff3108f69b51801246c8799f2/coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2", size = 223573, upload-time = "2026-05-10T18:02:24.11Z" }, + { url = "https://files.pythonhosted.org/packages/27/96/3acae5da0953be042c0b4dea6d6789d2f080701c77b88e44d5bd41b9219b/coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644", size = 224680, upload-time = "2026-05-10T18:02:25.896Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/6ab5d2dd8325d838737c6f8d83d62eb6230e0d70b87b51b57bbfd08fa767/coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b", size = 222703, upload-time = "2026-05-10T18:02:27.822Z" }, + { url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" }, +] + +[[package]] +name = "cuda-bindings" +version = "13.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cuda-pathfinder", marker = "(sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-6-kgpipe-cpu') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/c8/b2589d68acf7e3d63e2be330b84bc25712e97ed799affbca7edd7eae25d6/cuda_bindings-13.2.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e865447abfb83d6a98ad5130ed3c70b1fc295ae3eeee39fd07b4ddb0671b6788", size = 5722404, upload-time = "2026-03-11T00:12:44.041Z" }, + { url = "https://files.pythonhosted.org/packages/1f/92/f899f7bbb5617bb65ec52a6eac1e9a1447a86b916c4194f8a5001b8cde0c/cuda_bindings-13.2.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46d8776a55d6d5da9dd6e9858fba2efcda2abe6743871dee47dd06eb8cb6d955", size = 6320619, upload-time = "2026-03-11T00:12:45.939Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a5/d7f01a415e134546248cef612adad8153c9f1eb10ec79505a7cd8294370b/cuda_bindings-13.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:45815daeb595bf3b405c52671a2542b1f8e9329f3b029494acbfcc74aeaa1f2d", size = 5840830, upload-time = "2026-03-11T00:12:48.43Z" }, + { url = "https://files.pythonhosted.org/packages/df/93/eef988860a3ca985f82c4f3174fc0cdd94e07331ba9a92e8e064c260337f/cuda_bindings-13.2.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6629ca2df6f795b784752409bcaedbd22a7a651b74b56a165ebc0c9dcbd504d0", size = 5614610, upload-time = "2026-03-11T00:12:50.337Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/6db3aba46864aee357ab2415135b3fe3da7e9f1fa0221fa2a86a5968099c/cuda_bindings-13.2.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7dca0da053d3b4cc4869eff49c61c03f3c5dbaa0bcd712317a358d5b8f3f385d", size = 6149914, upload-time = "2026-03-11T00:12:52.374Z" }, + { url = "https://files.pythonhosted.org/packages/c4/84/d3b6220b51cbc02ca14db7387e97445126b4ff5125aaa6c5dd7dcb75e679/cuda_bindings-13.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:8cebe3ce4aeeca5af9c490e175f76c4b569bbf4a35a62294b777bc77bf7ac4d8", size = 5796512, upload-time = "2026-03-11T00:12:54.483Z" }, + { url = "https://files.pythonhosted.org/packages/c0/87/87a014f045b77c6de5c8527b0757fe644417b184e5367db977236a141602/cuda_bindings-13.2.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6464b30f46692d6c7f65d4a0e0450d81dd29de3afc1bb515653973d01c2cd6e", size = 5685673, upload-time = "2026-03-11T00:12:56.371Z" }, + { url = "https://files.pythonhosted.org/packages/ee/5e/c0fe77a73aaefd3fff25ffaccaac69c5a63eafdf8b9a4c476626ef0ac703/cuda_bindings-13.2.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4af9f3e1be603fa12d5ad6cfca7844c9d230befa9792b5abdf7dd79979c3626", size = 6191386, upload-time = "2026-03-11T00:12:58.965Z" }, + { url = "https://files.pythonhosted.org/packages/e3/73/98bcb069778fe420226db75aff54b5dd6c3ecfd0912edabab723326e80b7/cuda_bindings-13.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:bd658bb5c0e55b7b3e5dd0ed509c6addb298c665db26a9bfba35e1e626000ba2", size = 5938605, upload-time = "2026-03-11T00:13:01.639Z" }, + { url = "https://files.pythonhosted.org/packages/5f/58/ed2c3b39c8dd5f96aa7a4abef0d47a73932c7a988e30f5fa428f00ed0da1/cuda_bindings-13.2.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df850a1ff8ce1b3385257b08e47b70e959932f5f432d0a4e46a355962b4e4771", size = 5507469, upload-time = "2026-03-11T00:13:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/0c941b112ceeb21439b05895eace78ca1aa2eaaf695c8521a068fd9b4c00/cuda_bindings-13.2.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8a16384c6494e5485f39314b0b4afb04bee48d49edb16d5d8593fd35bbd231b", size = 6059693, upload-time = "2026-03-11T00:13:06.003Z" }, + { url = "https://files.pythonhosted.org/packages/52/49/4e01cc06447d39476e138d1b1adec8d35c0d04eccd2c8d69befc08cd66e8/cuda_bindings-13.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6ccf14e0c1def3b7200100aafff3a9f7e210ecb6e409329e92dcf6cd2c00d5c7", size = 6662637, upload-time = "2026-03-11T00:13:07.881Z" }, +] + +[[package]] +name = "cuda-pathfinder" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/d0/c177e29701cf1d3008d7d2b16b5fc626592ce13bd535f8795c5f57187e0e/cuda_pathfinder-1.5.4-py3-none-any.whl", hash = "sha256:9563d3175ce1828531acf4b94e1c1c7d67208c347ca002493e2654878b26f4b7", size = 51657, upload-time = "2026-04-27T22:42:07.712Z" }, +] + +[[package]] +name = "cuda-toolkit" +version = "13.0.2" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b2/453099f5f3b698d7d0eab38916aac44c7f76229f451709e2eb9db6615dcd/cuda_toolkit-13.0.2-py2.py3-none-any.whl", hash = "sha256:b198824cf2f54003f50d64ada3a0f184b42ca0846c1c94192fa269ecd97a66eb", size = 2364, upload-time = "2025-12-19T23:24:07.328Z" }, +] + +[package.optional-dependencies] +cudart = [ + { name = "nvidia-cuda-runtime", marker = "(sys_platform == 'linux' and extra != 'extra-6-kgpipe-cpu') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, +] +cufft = [ + { name = "nvidia-cufft", marker = "(sys_platform == 'linux' and extra != 'extra-6-kgpipe-cpu') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, +] +cufile = [ + { name = "nvidia-cufile", marker = "(sys_platform == 'linux' and extra != 'extra-6-kgpipe-cpu') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, +] +cupti = [ + { name = "nvidia-cuda-cupti", marker = "(sys_platform == 'linux' and extra != 'extra-6-kgpipe-cpu') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, +] +curand = [ + { name = "nvidia-curand", marker = "(sys_platform == 'linux' and extra != 'extra-6-kgpipe-cpu') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, +] +cusolver = [ + { name = "nvidia-cusolver", marker = "(sys_platform == 'linux' and extra != 'extra-6-kgpipe-cpu') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, +] +cusparse = [ + { name = "nvidia-cusparse", marker = "(sys_platform == 'linux' and extra != 'extra-6-kgpipe-cpu') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, +] +nvjitlink = [ + { name = "nvidia-nvjitlink", marker = "(sys_platform == 'linux' and extra != 'extra-6-kgpipe-cpu') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, +] +nvrtc = [ + { name = "nvidia-cuda-nvrtc", marker = "(sys_platform == 'linux' and extra != 'extra-6-kgpipe-cpu') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, +] +nvtx = [ + { name = "nvidia-nvtx", marker = "(sys_platform == 'linux' and extra != 'extra-6-kgpipe-cpu') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + +[[package]] +name = "debugpy" +version = "1.8.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/aa/12037145b7a56eaa5b29b41872f7a21b538e807e13f32c4d3c46e59be084/debugpy-1.8.21.tar.gz", hash = "sha256:a3c53278e84c94e11bd87c53970ec391d1a67396c8b22609fcac576520e611a6", size = 1697577, upload-time = "2026-06-01T19:30:35.156Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/df/bf625547431a9cadc9f4cbfeda38866e2b17f6aed147b625377e87834449/debugpy-1.8.21-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:9f96713896f39c3dff0ee841f47320c3f2983d33c341e009361bb0ebc79adc4e", size = 2483609, upload-time = "2026-06-01T19:30:50.794Z" }, + { url = "https://files.pythonhosted.org/packages/bf/09/59324b903599031ff9faaec1758292409f6561a0ec2492fe4b703327705a/debugpy-1.8.21-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:c193d474f0a211191f2b4449d2d06157c689013035bd952f3b617e0ef422b176", size = 3968900, upload-time = "2026-06-01T19:30:52.341Z" }, + { url = "https://files.pythonhosted.org/packages/14/cd/27f65b805d7fe005c44e1a36b9183ecdfbcdbf9d3e721a5115d461ecc7ee/debugpy-1.8.21-cp312-cp312-win32.whl", hash = "sha256:4743373c1cac7f9e74a1b9915bf1dbe0e900eca657ffb170ae07ac8363205ae9", size = 5336340, upload-time = "2026-06-01T19:30:54.047Z" }, + { url = "https://files.pythonhosted.org/packages/77/1d/c84e30c0c674184948b66f076ab271c01d940618a2824c23cd035a27bc20/debugpy-1.8.21-cp312-cp312-win_amd64.whl", hash = "sha256:bd7ba9dd3daa7c2f942c6ca8d4695a16bf9ac16b63615261c7982bc74f7ed20c", size = 5374751, upload-time = "2026-06-01T19:30:55.891Z" }, + { url = "https://files.pythonhosted.org/packages/77/6b/d817e1f8cc77aa055d37fba092e0febfdff40fe652d8d53d4cd7a86ad98d/debugpy-1.8.21-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:13678151fc401e2d68c9880b91e28714f797d40422994572b24560ef80910a88", size = 2477398, upload-time = "2026-06-01T19:30:57.644Z" }, + { url = "https://files.pythonhosted.org/packages/48/57/412421516afc3055fa577516f00beec3d663f9b0ab330639547ae6c57720/debugpy-1.8.21-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:ecbd158386c31ffe71d46f72d44d56e66331ab9b16cad649156d514368f23ab2", size = 3962096, upload-time = "2026-06-01T19:30:59.235Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2c616337cf6ba7b07ebbc97f02c6c945a8e2f76b365e33ee809c32ee36d1/debugpy-1.8.21-cp313-cp313-win32.whl", hash = "sha256:2c2ae706dec41d99a9ca1f7ebc987a83e65578363be6f6b3ac9067504917fae1", size = 5336288, upload-time = "2026-06-01T19:31:00.79Z" }, + { url = "https://files.pythonhosted.org/packages/f8/99/9175103392f84c4b1bf7622888cdc68da07f0ff7d9e581266428f6776033/debugpy-1.8.21-cp313-cp313-win_amd64.whl", hash = "sha256:aa648733047443eb1d07682c4ef287d36a54507b643ffdf38b09a3ef002c72a0", size = 5376567, upload-time = "2026-06-01T19:31:02.56Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3d/f4bbb323a548bfab2af3d6b4ffd9bf22636e55956a1285d317a1de643aad/debugpy-1.8.21-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9bb2a685287a2ac9b181cde89edcec64845cb51de7faaa75badb9a698bc24782", size = 2477209, upload-time = "2026-06-01T19:31:04.157Z" }, + { url = "https://files.pythonhosted.org/packages/8c/2d/6e7ec524984a1702777868de49a4c53202bddac2a432a76a093469587750/debugpy-1.8.21-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:3d6922439bf33fd38a3e2c447869ebc7b97da5cd3d329ff1ef9bc06c4903437e", size = 3927115, upload-time = "2026-06-01T19:31:05.863Z" }, + { url = "https://files.pythonhosted.org/packages/97/47/d1aa6d64005a98a9144647d99306b419396f9ad7bf1d73c119e17a81fb4d/debugpy-1.8.21-cp314-cp314-win32.whl", hash = "sha256:15d4963bd5ffa48f0da0947fd06757fa7621945048a14ad7705431566d3c0e7c", size = 5336724, upload-time = "2026-06-01T19:31:07.711Z" }, + { url = "https://files.pythonhosted.org/packages/5f/67/b905b90d163af11878c1af8abafa4a25206335e112e284e413454543a6da/debugpy-1.8.21-cp314-cp314-win_amd64.whl", hash = "sha256:fe0744a12353406de0ae8ccff0d0a4a666f00801a3db8fd04e7a5f761cd520e8", size = 5373803, upload-time = "2026-06-01T19:31:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/95/51/67e7cf11a53e40694f720457d5b3a1cdaaa3d5a9a633e482f225456b93ff/debugpy-1.8.21-py2.py3-none-any.whl", hash = "sha256:b1e37d333663c8851516a47364ef473da127f9caebe4417e6df6f5825a7e9a92", size = 5352888, upload-time = "2026-06-01T19:31:25.186Z" }, +] + +[[package]] +name = "decorator" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, +] + +[[package]] +name = "docker" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32' or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, +] + +[[package]] +name = "dotenv" +version = "0.9.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dotenv" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892, upload-time = "2025-02-19T22:15:01.647Z" }, +] + +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + +[[package]] +name = "fastapi" +version = "0.136.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, +] + +[[package]] +name = "filelock" +version = "3.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, +] + +[[package]] +name = "fonttools" +version = "4.62.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/08/7012b00a9a5874311b639c3920270c36ee0c445b69d9989a85e5c92ebcb0/fonttools-4.62.1.tar.gz", hash = "sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d", size = 3580737, upload-time = "2026-03-13T13:54:25.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/d4/dbacced3953544b9a93088cc10ef2b596d348c983d5c67a404fa41ec51ba/fonttools-4.62.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:90365821debbd7db678809c7491ca4acd1e0779b9624cdc6ddaf1f31992bf974", size = 2870219, upload-time = "2026-03-13T13:52:53.664Z" }, + { url = "https://files.pythonhosted.org/packages/66/9e/a769c8e99b81e5a87ab7e5e7236684de4e96246aae17274e5347d11ebd78/fonttools-4.62.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12859ff0b47dd20f110804c3e0d0970f7b832f561630cd879969011541a464a9", size = 2414891, upload-time = "2026-03-13T13:52:56.493Z" }, + { url = "https://files.pythonhosted.org/packages/69/64/f19a9e3911968c37e1e620e14dfc5778299e1474f72f4e57c5ec771d9489/fonttools-4.62.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c125ffa00c3d9003cdaaf7f2c79e6e535628093e14b5de1dccb08859b680936", size = 5033197, upload-time = "2026-03-13T13:52:59.179Z" }, + { url = "https://files.pythonhosted.org/packages/9b/8a/99c8b3c3888c5c474c08dbfd7c8899786de9604b727fcefb055b42c84bba/fonttools-4.62.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:149f7d84afca659d1a97e39a4778794a2f83bf344c5ee5134e09995086cc2392", size = 4988768, upload-time = "2026-03-13T13:53:02.761Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c6/0f904540d3e6ab463c1243a0d803504826a11604c72dd58c2949796a1762/fonttools-4.62.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0aa72c43a601cfa9273bb1ae0518f1acadc01ee181a6fc60cd758d7fdadffc04", size = 4971512, upload-time = "2026-03-13T13:53:05.678Z" }, + { url = "https://files.pythonhosted.org/packages/29/0b/5cbef6588dc9bd6b5c9ad6a4d5a8ca384d0cea089da31711bbeb4f9654a6/fonttools-4.62.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:19177c8d96c7c36359266e571c5173bcee9157b59cfc8cb0153c5673dc5a3a7d", size = 5122723, upload-time = "2026-03-13T13:53:08.662Z" }, + { url = "https://files.pythonhosted.org/packages/4a/47/b3a5342d381595ef439adec67848bed561ab7fdb1019fa522e82101b7d9c/fonttools-4.62.1-cp312-cp312-win32.whl", hash = "sha256:a24decd24d60744ee8b4679d38e88b8303d86772053afc29b19d23bb8207803c", size = 2281278, upload-time = "2026-03-13T13:53:10.998Z" }, + { url = "https://files.pythonhosted.org/packages/28/b1/0c2ab56a16f409c6c8a68816e6af707827ad5d629634691ff60a52879792/fonttools-4.62.1-cp312-cp312-win_amd64.whl", hash = "sha256:9e7863e10b3de72376280b515d35b14f5eeed639d1aa7824f4cf06779ec65e42", size = 2331414, upload-time = "2026-03-13T13:53:13.992Z" }, + { url = "https://files.pythonhosted.org/packages/3b/56/6f389de21c49555553d6a5aeed5ac9767631497ac836c4f076273d15bd72/fonttools-4.62.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c22b1014017111c401469e3acc5433e6acf6ebcc6aa9efb538a533c800971c79", size = 2865155, upload-time = "2026-03-13T13:53:16.132Z" }, + { url = "https://files.pythonhosted.org/packages/03/c5/0e3966edd5ec668d41dfe418787726752bc07e2f5fd8c8f208615e61fa89/fonttools-4.62.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68959f5fc58ed4599b44aad161c2837477d7f35f5f79402d97439974faebfebe", size = 2412802, upload-time = "2026-03-13T13:53:18.878Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/e6ac4b44026de7786fe46e3bfa0c87e51d5d70a841054065d49cd62bb909/fonttools-4.62.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef46db46c9447103b8f3ff91e8ba009d5fe181b1920a83757a5762551e32bb68", size = 5013926, upload-time = "2026-03-13T13:53:21.379Z" }, + { url = "https://files.pythonhosted.org/packages/e2/98/8b1e801939839d405f1f122e7d175cebe9aeb4e114f95bfc45e3152af9a7/fonttools-4.62.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6706d1cb1d5e6251a97ad3c1b9347505c5615c112e66047abbef0f8545fa30d1", size = 4964575, upload-time = "2026-03-13T13:53:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/46/76/7d051671e938b1881670528fec69cc4044315edd71a229c7fd712eaa5119/fonttools-4.62.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e7abd2b1e11736f58c1de27819e1955a53267c21732e78243fa2fa2e5c1e069", size = 4953693, upload-time = "2026-03-13T13:53:26.569Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ae/b41f8628ec0be3c1b934fc12b84f4576a5c646119db4d3bdd76a217c90b5/fonttools-4.62.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:403d28ce06ebfc547fbcb0cb8b7f7cc2f7a2d3e1a67ba9a34b14632df9e080f9", size = 5094920, upload-time = "2026-03-13T13:53:29.329Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/53a1e9469331a23dcc400970a27a4caa3d9f6edbf5baab0260285238b884/fonttools-4.62.1-cp313-cp313-win32.whl", hash = "sha256:93c316e0f5301b2adbe6a5f658634307c096fd5aae60a5b3412e4f3e1728ab24", size = 2279928, upload-time = "2026-03-13T13:53:32.352Z" }, + { url = "https://files.pythonhosted.org/packages/38/60/35186529de1db3c01f5ad625bde07c1f576305eab6d86bbda4c58445f721/fonttools-4.62.1-cp313-cp313-win_amd64.whl", hash = "sha256:7aa21ff53e28a9c2157acbc44e5b401149d3c9178107130e82d74ceb500e5056", size = 2330514, upload-time = "2026-03-13T13:53:34.991Z" }, + { url = "https://files.pythonhosted.org/packages/36/f0/2888cdac391807d68d90dcb16ef858ddc1b5309bfc6966195a459dd326e2/fonttools-4.62.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca", size = 2864442, upload-time = "2026-03-13T13:53:37.509Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b2/e521803081f8dc35990816b82da6360fa668a21b44da4b53fc9e77efcd62/fonttools-4.62.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aa69d10ed420d8121118e628ad47d86e4caa79ba37f968597b958f6cceab7eca", size = 2410901, upload-time = "2026-03-13T13:53:40.55Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/8c3511ff06e53110039358dbbdc1a65d72157a054638387aa2ada300a8b8/fonttools-4.62.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd13b7999d59c5eb1c2b442eb2d0c427cb517a0b7a1f5798fc5c9e003f5ff782", size = 4999608, upload-time = "2026-03-13T13:53:42.798Z" }, + { url = "https://files.pythonhosted.org/packages/28/63/cd0c3b26afe60995a5295f37c246a93d454023726c3261cfbb3559969bb9/fonttools-4.62.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d337fdd49a79b0d51c4da87bc38169d21c3abbf0c1aa9367eff5c6656fb6dae", size = 4912726, upload-time = "2026-03-13T13:53:45.405Z" }, + { url = "https://files.pythonhosted.org/packages/70/b9/ac677cb07c24c685cf34f64e140617d58789d67a3dd524164b63648c6114/fonttools-4.62.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d241cdc4a67b5431c6d7f115fdf63335222414995e3a1df1a41e1182acd4bcc7", size = 4951422, upload-time = "2026-03-13T13:53:48.326Z" }, + { url = "https://files.pythonhosted.org/packages/e6/10/11c08419a14b85b7ca9a9faca321accccc8842dd9e0b1c8a72908de05945/fonttools-4.62.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c05557a78f8fa514da0f869556eeda40887a8abc77c76ee3f74cf241778afd5a", size = 5060979, upload-time = "2026-03-13T13:53:51.366Z" }, + { url = "https://files.pythonhosted.org/packages/4e/3c/12eea4a4cf054e7ab058ed5ceada43b46809fce2bf319017c4d63ae55bb4/fonttools-4.62.1-cp314-cp314-win32.whl", hash = "sha256:49a445d2f544ce4a69338694cad575ba97b9a75fff02720da0882d1a73f12800", size = 2283733, upload-time = "2026-03-13T13:53:53.606Z" }, + { url = "https://files.pythonhosted.org/packages/6b/67/74b070029043186b5dd13462c958cb7c7f811be0d2e634309d9a1ffb1505/fonttools-4.62.1-cp314-cp314-win_amd64.whl", hash = "sha256:1eecc128c86c552fb963fe846ca4e011b1be053728f798185a1687502f6d398e", size = 2335663, upload-time = "2026-03-13T13:53:56.23Z" }, + { url = "https://files.pythonhosted.org/packages/42/c5/4d2ed3ca6e33617fc5624467da353337f06e7f637707478903c785bd8e20/fonttools-4.62.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1596aeaddf7f78e21e68293c011316a25267b3effdaccaf4d59bc9159d681b82", size = 2947288, upload-time = "2026-03-13T13:53:59.397Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e9/7ab11ddfda48ed0f89b13380e5595ba572619c27077be0b2c447a63ff351/fonttools-4.62.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8f8fca95d3bb3208f59626a4b0ea6e526ee51f5a8ad5d91821c165903e8d9260", size = 2449023, upload-time = "2026-03-13T13:54:01.642Z" }, + { url = "https://files.pythonhosted.org/packages/b2/10/a800fa090b5e8819942e54e19b55fc7c21fe14a08757c3aa3ca8db358939/fonttools-4.62.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee91628c08e76f77b533d65feb3fbe6d9dad699f95be51cf0d022db94089cdc4", size = 5137599, upload-time = "2026-03-13T13:54:04.495Z" }, + { url = "https://files.pythonhosted.org/packages/37/dc/8ccd45033fffd74deb6912fa1ca524643f584b94c87a16036855b498a1ed/fonttools-4.62.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f37df1cac61d906e7b836abe356bc2f34c99d4477467755c216b72aa3dc748b", size = 4920933, upload-time = "2026-03-13T13:54:07.557Z" }, + { url = "https://files.pythonhosted.org/packages/99/eb/e618adefb839598d25ac8136cd577925d6c513dc0d931d93b8af956210f0/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92bb00a947e666169c99b43753c4305fc95a890a60ef3aeb2a6963e07902cc87", size = 5016232, upload-time = "2026-03-13T13:54:10.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5f/9b5c9bfaa8ec82def8d8168c4f13615990d6ce5996fe52bd49bfb5e05134/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bdfe592802ef939a0e33106ea4a318eeb17822c7ee168c290273cbd5fabd746c", size = 5042987, upload-time = "2026-03-13T13:54:13.569Z" }, + { url = "https://files.pythonhosted.org/packages/90/aa/dfbbe24c6a6afc5c203d90cc0343e24bcbb09e76d67c4d6eef8c2558d7ba/fonttools-4.62.1-cp314-cp314t-win32.whl", hash = "sha256:b820fcb92d4655513d8402d5b219f94481c4443d825b4372c75a2072aa4b357a", size = 2348021, upload-time = "2026-03-13T13:54:16.98Z" }, + { url = "https://files.pythonhosted.org/packages/13/6f/ae9c4e4dd417948407b680855c2c7790efb52add6009aaecff1e3bc50e8e/fonttools-4.62.1-cp314-cp314t-win_amd64.whl", hash = "sha256:59b372b4f0e113d3746b88985f1c796e7bf830dd54b28374cd85c2b8acd7583e", size = 2414147, upload-time = "2026-03-13T13:54:19.416Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ba/56147c165442cc5ba7e82ecf301c9a68353cede498185869e6e02b4c264f/fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd", size = 1152647, upload-time = "2026-03-13T13:54:22.735Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/8d/1c51c094345df128ca4a990d633fe1a0ff28726c9e6b3c41ba65087bba1d/fsspec-2026.4.0.tar.gz", hash = "sha256:301d8ac70ae90ef3ad05dcf94d6c3754a097f9b5fe4667d2787aa359ec7df7e4", size = 312760, upload-time = "2026-04-29T20:42:38.635Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/0c/043d5e551459da400957a1395e0febbf771446ff34291afcbe3d8be2a279/fsspec-2026.4.0-py3-none-any.whl", hash = "sha256:11ef7bb35dab8a394fde6e608221d5cf3e8499401c249bebaeaad760a1a8dec2", size = 203402, upload-time = "2026-04-29T20:42:36.842Z" }, +] + +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.50" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/f6/354ae6491228b5eb40e10d89c4d13c651fe1cf7556e35ebdded50cff57ce/gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc", size = 219798, upload-time = "2026-05-06T04:01:26.571Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" }, +] + +[[package]] +name = "griffelib" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/82/74f4a3310cdabfbb10da554c3a672847f1ed33c6f61dd472681ce7f1fe67/griffelib-2.0.2.tar.gz", hash = "sha256:3cf20b3bc470e83763ffbf236e0076b1211bac1bc67de13daf494640f2de707e", size = 166461, upload-time = "2026-03-27T11:34:51.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "hf-xet" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/d8/5c06fc76461418326a7decf8367480c35be11a41fd938633929c60a9ec6b/hf_xet-1.5.0.tar.gz", hash = "sha256:e0fb0a34d9f406eed88233e829a67ec016bec5af19e480eac65a233ea289a948", size = 837196, upload-time = "2026-05-06T06:18:15.583Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/9b/6912c99070915a4f28119e3c5b52a9abd1eec0ad5cb293b8c967a0c6f5a2/hf_xet-1.5.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:7d70fe2ce97b9db73b9c9b9c81fe3693640aec83416a966c446afea54acfae3c", size = 4023383, upload-time = "2026-05-06T06:17:53.947Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6d/9563cfde59b5d8128a9c7ec972a087f4c782e4f7bac5a85234edfd5d5e49/hf_xet-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:73a0dae8c71de3b0633a45c73f4a4a5ed09e94b43441d82981a781d4f12baa42", size = 3792751, upload-time = "2026-05-06T06:17:51.791Z" }, + { url = "https://files.pythonhosted.org/packages/07/a5/ed5a0cf35b49a0571af5a8f53416dad1877a718c021c9937c3a53cb45781/hf_xet-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a60290ec57e9b71767fba7c3645ddafdd0759974b540441510c629c6db6db24a", size = 4456058, upload-time = "2026-05-06T06:17:40.735Z" }, + { url = "https://files.pythonhosted.org/packages/60/fb/3ae8bf2a7a37a4197d0195d7247fd25b3952e15cb8a599e285dfaa6f52b3/hf_xet-1.5.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:e5de0f6deada0dada870bb376a11bcd1f08abf3a968a6d118f33e72d1b1eb480", size = 4250783, upload-time = "2026-05-06T06:17:38.412Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/8bae40d4d91525085137196e84eb0ed49cf65b5e96e5c3ecdadd8bd0fac2/hf_xet-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c799d49f1a5544a0ef7591c0ee75e0d6b93d6f56dc7a4979f59f7518d2872216", size = 4445594, upload-time = "2026-05-06T06:18:04.219Z" }, + { url = "https://files.pythonhosted.org/packages/13/59/c74efbbd4e8728172b2cc72a2bc014d2947a4b7bdced932fbd3f5da1a4e5/hf_xet-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2baea1b0b989e5c152fe81425f7745ddc8901280ba3d97c98d8cdece7b706c60", size = 4663995, upload-time = "2026-05-06T06:18:06.1Z" }, + { url = "https://files.pythonhosted.org/packages/73/32/8e1e0410af64cda9b139d1dcebdc993a8ff9c8c7c0e2696ae356d75ccc0d/hf_xet-1.5.0-cp313-cp313t-win_amd64.whl", hash = "sha256:526345b3ed45f374f6317349df489167606736c876241ba984105afe7fd4839d", size = 3966608, upload-time = "2026-05-06T06:18:19.74Z" }, + { url = "https://files.pythonhosted.org/packages/fc/34/a8febc8f4edbea8b3e21b02ebc8b628679b84ba7e45cde624a7736b51500/hf_xet-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:786d28e2eb8315d5035544b9d137b4a842d600c434bb91bf7d0d953cce906ad4", size = 3796946, upload-time = "2026-05-06T06:18:17.568Z" }, + { url = "https://files.pythonhosted.org/packages/2a/20/8fc8996afe5815fa1a6be8e9e5c02f24500f409d599e905800d498a4e14d/hf_xet-1.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:872d5601e6deea30d15865ede55d29eac6daf5a534ab417b99b6ef6b076dd96c", size = 4023495, upload-time = "2026-05-06T06:18:01.94Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/93d84463c00cecb561a7508aa6303e35ee2894294eac14245526924415fe/hf_xet-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9929561f5abf4581c8ea79587881dfef6b8abb2a0d8a51915936fc2a614f4e73", size = 3792731, upload-time = "2026-05-06T06:18:00.021Z" }, + { url = "https://files.pythonhosted.org/packages/9d/5a/8ec8e0c863b382d00b3c2e2af6ded6b06371be617144a625903a6d562f4b/hf_xet-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f7b7bbae318e583a86fb21e5a4a175d6721d628a2874f4bd022d0e660c32a682", size = 4456738, upload-time = "2026-05-06T06:17:49.574Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ca/f7effa1a67717da2bcc6b6c28f71c6ca648c77acaec4e2c32f40cbe16d85/hf_xet-1.5.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:cf7b2dc6f31a4ea754bb50f74cde482dcf5d366d184076d8530b9872787f3761", size = 4251622, upload-time = "2026-05-06T06:17:47.096Z" }, + { url = "https://files.pythonhosted.org/packages/65/f2/19247dba3e231cf77dec59ddfb878f00057635ff773d099c9b59d37812c3/hf_xet-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8dbcbab554c9ef158ef2c991545c3e970ddd8cc7acdcd0a78c5a41095dab4ded", size = 4445667, upload-time = "2026-05-06T06:18:11.983Z" }, + { url = "https://files.pythonhosted.org/packages/7f/64/6f116801a3bcfb6f59f5c251f48cadc47ea54026441c4a385079286a94fa/hf_xet-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5906bf7718d3636dc13402914736abe723492cb730f744834f5f5b67d3a12702", size = 4664619, upload-time = "2026-05-06T06:18:13.771Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e8/069542d37946ed08669b127e1496fa99e78196d71de8d41eda5e9f1b7a58/hf_xet-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5f3dc2248fc01cc0a00cd392ab497f1ca373fcbc7e3f2da1f452480b384e839e", size = 3966802, upload-time = "2026-05-06T06:18:28.162Z" }, + { url = "https://files.pythonhosted.org/packages/f9/91/fc6fdec27b14d04e88c386ac0a0129732b53fa23f7c4a78f4b83a039c567/hf_xet-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b285cea1b5bab46b758772716ba8d6854a1a0310fed1c249d678a8b38601e5a0", size = 3797168, upload-time = "2026-05-06T06:18:26.287Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fb/69ff198a82cae7eb1a69fb84d93b3a3e4816564d76817fe541ddc96874eb/hf_xet-1.5.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:dad0dc84e941b8ba3c860659fe1fdc35c049d47cce293f003287757e971a8f56", size = 4030814, upload-time = "2026-05-06T06:17:57.933Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ff/edcc2b40162bef3ff78e14ab637e5f3b89243d6aee72f5949d3bb6a5af83/hf_xet-1.5.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:fd6e5a9b0fdac4ed03ed45ef79254a655b1aaab514a02202617fbf643f5fdf7a", size = 3798444, upload-time = "2026-05-06T06:17:55.79Z" }, + { url = "https://files.pythonhosted.org/packages/49/4d/103f76b04310e5e57656696cc184690d20c466af0bca3ca88f8c8ea5d4f3/hf_xet-1.5.0-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3531b1823a0e6d77d80f9ed15ca0e00f0d115094f8ac033d5cae88f4564cc949", size = 4465986, upload-time = "2026-05-06T06:17:44.886Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a2/546f47f464737b3edbab6f8ddb57f2599b93d2cbb66f06abb475ccb48651/hf_xet-1.5.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9a0ee58cd18d5ea799f7ed11290bbccbe56bdd8b1d97ca74b9cc49a3945d7a3b", size = 4259865, upload-time = "2026-05-06T06:17:42.639Z" }, + { url = "https://files.pythonhosted.org/packages/95/7f/1be593c1f28613be2e196473481cd81bfc5910795e30a34e8f744f6cac4f/hf_xet-1.5.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e60df5a42e9bed8628b6416af2cba4cba57ae9f02de226a06b020d98e1aab18", size = 4459835, upload-time = "2026-05-06T06:18:08.026Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b2/703569fc881f3284487e68cda7b42179978480da3c438042a6bbbb4a671c/hf_xet-1.5.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4b35549ce62601b84da4ff9b24d970032ace3d4430f52d91bcbb26c901d6c690", size = 4672414, upload-time = "2026-05-06T06:18:09.864Z" }, + { url = "https://files.pythonhosted.org/packages/af/37/1b6def445c567286b50aa3b33828158e135b1be44938dde59f11382a500c/hf_xet-1.5.0-cp37-abi3-win_amd64.whl", hash = "sha256:2806c7c17b4d23f8d88f7c4814f838c3b6150773fe339c20af23e1cfaf2797e4", size = 3977238, upload-time = "2026-05-06T06:18:23.621Z" }, + { url = "https://files.pythonhosted.org/packages/62/94/3b66b148778ee100dcfd69c2ca22b57b41b44d3063ceec934f209e9184ce/hf_xet-1.5.0-cp37-abi3-win_arm64.whl", hash = "sha256:b6c9df403040248c76d808d3e047d64db2d923bae593eb244c41e425cf6cd7be", size = 3806916, upload-time = "2026-05-06T06:18:21.7Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, + { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, + { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, + { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "huggingface-hub" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64' or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typer" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/40/43109e943fd718b0ccd0cd61eb4f1c347df22bf81f5874c6f22adf44bcff/huggingface_hub-1.14.0.tar.gz", hash = "sha256:d6d2c9cd6be1d02ae9ec6672d5587d10a427f377db688e82528f426a041622c2", size = 782365, upload-time = "2026-05-06T14:14:34.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/a5/33b49ba7bea7c41bb37f74ec0f8beea0831e052330196633fe2c77516ea6/huggingface_hub-1.14.0-py3-none-any.whl", hash = "sha256:efe075535c62e130b30e836b138e13785f6f043d1f0539e0a39aa411a99e90b8", size = 661479, upload-time = "2026-05-06T14:14:32.029Z" }, +] + +[[package]] +name = "idna" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, +] + +[[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 = "ipykernel" +version = "7.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "appnope", marker = "sys_platform == 'darwin' or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "comm" }, + { name = "debugpy" }, + { name = "ipython" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "matplotlib-inline" }, + { name = "nest-asyncio2" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/c4/e4a38f579de4225a561305666f7541cdabb30075def2aa1ac17bd73c1fb5/ipykernel-7.3.0.tar.gz", hash = "sha256:9acaaaf97d16355166e4085afe9d225bfbdf2b7ef520f9df3be8f2b248275e09", size = 184899, upload-time = "2026-06-10T08:41:25.481Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/02/77b271f5dc58bfbc0b577c877b2365d1ffea2afe66a80c13f2312820348c/ipykernel-7.3.0-py3-none-any.whl", hash = "sha256:897eb64da762549ef610698fca5e9675195ec6ac8ec7f19d81ce1ca20c876057", size = 120583, upload-time = "2026-06-10T08:41:23.648Z" }, +] + +[[package]] +name = "ipython" +version = "9.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "decorator" }, + { name = "ipython-pygments-lexers" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "(sys_platform != 'emscripten' and sys_platform != 'win32') or (sys_platform == 'emscripten' and extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda') or (sys_platform == 'win32' and extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "prompt-toolkit" }, + { name = "psutil", marker = "(sys_platform != 'cygwin' and sys_platform != 'emscripten') or (sys_platform == 'cygwin' and extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda') or (sys_platform == 'emscripten' and extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/59/165d3b4d75cc34add3122c4417ecb229085140ac573103c223cd01dde96f/ipython-9.15.0.tar.gz", hash = "sha256:da2819ce2aa83135257df830660b1176d986c3d2876db24df01974fa955b2756", size = 4442580, upload-time = "2026-06-26T11:03:35.913Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/3a/948263ca3b9d65bb2b1b0c521b3a49fad5d59ada58724bd87d2bd5ff3f36/ipython-9.15.0-py3-none-any.whl", hash = "sha256:515ad9c3cdf0c932a5a9f6245419e8aba706b7bd03c3e1d3a1c83d9351d6aa6e", size = 630895, upload-time = "2026-06-26T11:03:33.809Z" }, +] + +[[package]] +name = "ipython-pygments-lexers" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jedi" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + +[[package]] +name = "jsonpath-ng" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/32/58/250751940d75c8019659e15482d548a4aa3b6ce122c515102a4bfdac50e3/jsonpath_ng-1.8.0.tar.gz", hash = "sha256:54252968134b5e549ea5b872f1df1168bd7defe1a52fed5a358c194e1943ddc3", size = 74513, upload-time = "2026-02-24T14:42:06.182Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/99/33c7d78a3fb70d545fd5411ac67a651c81602cc09c9cf0df383733f068c5/jsonpath_ng-1.8.0-py3-none-any.whl", hash = "sha256:b8dde192f8af58d646fc031fac9c99fe4d00326afc4148f1f043c601a8cfe138", size = 67844, upload-time = "2026-02-28T00:53:19.637Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "jupyter-client" +version = "8.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-core" }, + { name = "python-dateutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/dc/5512503b088997c2250b8bf18258fba9d9ce5ead641183700960d3c9d342/jupyter_client-8.9.1.tar.gz", hash = "sha256:a58f730dd9e728ba16ba1d62ebccf7ffe1ebbdbce4e95cfae941b7321ae1f4fa", size = 359256, upload-time = "2026-06-09T13:15:01.033Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/6f/56d39bf385c5c27988aebaf0c18a2a17e960575740100973511018bd904e/jupyter_client-8.9.1-py3-none-any.whl", hash = "sha256:0b7a295bc46e8751e9adae84781f726c851c1d911bd793edc4a3bde942e3da81", size = 109828, upload-time = "2026-06-09T13:14:58.835Z" }, +] + +[[package]] +name = "jupyter-core" +version = "5.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, +] + +[[package]] +name = "kgcore" +version = "0.1.0" +source = { git = "https://github.com/Vehnem/kgcore.git#62aad8b1937fe613f4b28372d142961fbfe78547" } +dependencies = [ + { name = "click" }, + { name = "pydantic" }, + { name = "pytest" }, + { name = "python-dotenv" }, + { name = "rdflib" }, +] + +[[package]] +name = "kgpipe" +version = "0.7.0" +source = { editable = "." } +dependencies = [ + { name = "click" }, + { name = "docker" }, + { name = "dotenv" }, + { name = "fastapi" }, + { name = "ipykernel" }, + { name = "jsonpath-ng" }, + { name = "kgcore" }, + { name = "matplotlib" }, + { name = "networkx" }, + { name = "pandas" }, + { name = "pulp" }, + { name = "pydantic" }, + { name = "pytest" }, + { name = "pyyaml" }, + { name = "rdflib" }, + { name = "redis" }, + { name = "rich" }, + { name = "scipy" }, + { name = "seaborn" }, + { name = "sparqlwrapper" }, + { name = "streamlit-elements" }, + { name = "tiktoken" }, + { name = "tqdm" }, + { name = "uvicorn" }, +] + +[package.optional-dependencies] +cpu = [ + { name = "torch", version = "2.12.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(sys_platform == 'darwin' and extra == 'extra-6-kgpipe-cpu') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "torch", version = "2.12.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(sys_platform != 'darwin' and extra == 'extra-6-kgpipe-cpu') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "torchaudio", version = "2.11.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(sys_platform == 'darwin' and extra == 'extra-6-kgpipe-cpu') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "torchaudio", version = "2.11.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(sys_platform != 'darwin' and extra == 'extra-6-kgpipe-cpu') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "torchvision", version = "0.27.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(sys_platform == 'darwin' and extra == 'extra-6-kgpipe-cpu') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "torchvision", version = "0.27.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(sys_platform != 'darwin' and extra == 'extra-6-kgpipe-cpu') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, +] +cuda = [ + { name = "torch", version = "2.12.0+cu130", source = { registry = "https://download.pytorch.org/whl/cu130" } }, + { name = "torchaudio", version = "2.11.0+cu130", source = { registry = "https://download.pytorch.org/whl/cu130" } }, + { name = "torchvision", version = "0.27.0+cu130", source = { registry = "https://download.pytorch.org/whl/cu130" } }, +] +dev = [ + { name = "black" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, + { name = "ruff" }, +] +docs = [ + { name = "mkdocs-material" }, + { name = "mkdocstrings", extra = ["python"] }, +] +ml = [ + { name = "sentence-transformers" }, + { name = "transformers" }, +] + +[package.metadata] +requires-dist = [ + { name = "black", marker = "extra == 'dev'" }, + { name = "click", specifier = ">=8.0" }, + { name = "docker", specifier = ">=7.0.0" }, + { name = "dotenv", specifier = ">=0.9.9" }, + { name = "fastapi", specifier = ">=0.135.1" }, + { name = "ipykernel", specifier = ">=7.3.0" }, + { name = "jsonpath-ng", specifier = ">=1.7.0" }, + { name = "kgcore", git = "https://github.com/Vehnem/kgcore.git" }, + { name = "matplotlib", specifier = ">=3.5.0" }, + { name = "mkdocs-material", marker = "extra == 'docs'" }, + { name = "mkdocstrings", extras = ["python"], marker = "extra == 'docs'" }, + { name = "networkx", specifier = ">=2.8.0" }, + { name = "pandas", specifier = ">=1.5.0" }, + { name = "pulp", specifier = ">=3.3.0" }, + { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pytest", specifier = ">=8.4.2" }, + { name = "pytest", marker = "extra == 'dev'" }, + { name = "pytest-cov", marker = "extra == 'dev'" }, + { name = "pytest-mock", marker = "extra == 'dev'" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "rdflib", specifier = ">=6.0.0" }, + { name = "redis", specifier = ">=7.0.0" }, + { name = "rich", specifier = ">=13.0.0" }, + { name = "ruff", marker = "extra == 'dev'" }, + { name = "scipy", specifier = ">=1.16.2" }, + { name = "seaborn", specifier = ">=0.13.2" }, + { name = "sentence-transformers", marker = "extra == 'ml'", specifier = ">=4.1.0" }, + { name = "sparqlwrapper", specifier = ">=2.0.0" }, + { name = "streamlit-elements", specifier = ">=0.1.0" }, + { name = "tiktoken", specifier = ">=0.11.0" }, + { name = "torch", marker = "extra == 'cpu'", index = "https://download.pytorch.org/whl/cpu", conflict = { package = "kgpipe", extra = "cpu" } }, + { name = "torch", marker = "extra == 'cuda'", index = "https://download.pytorch.org/whl/cu130", conflict = { package = "kgpipe", extra = "cuda" } }, + { name = "torchaudio", marker = "extra == 'cpu'", index = "https://download.pytorch.org/whl/cpu", conflict = { package = "kgpipe", extra = "cpu" } }, + { name = "torchaudio", marker = "extra == 'cuda'", index = "https://download.pytorch.org/whl/cu130", conflict = { package = "kgpipe", extra = "cuda" } }, + { name = "torchvision", marker = "extra == 'cpu'", index = "https://download.pytorch.org/whl/cpu", conflict = { package = "kgpipe", extra = "cpu" } }, + { name = "torchvision", marker = "extra == 'cuda'", index = "https://download.pytorch.org/whl/cu130", conflict = { package = "kgpipe", extra = "cuda" } }, + { name = "tqdm", specifier = ">=4.67.1" }, + { name = "transformers", marker = "extra == 'ml'", specifier = ">=4.50.0" }, + { name = "uvicorn", specifier = ">=0.41.0" }, +] +provides-extras = ["dev", "docs", "cpu", "cuda", "ml"] + +[[package]] +name = "kiwisolver" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, + { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, + { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, + { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, + { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, + { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, + { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" }, + { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" }, + { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" }, + { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" }, + { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" }, + { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" }, + { url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" }, + { url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" }, + { url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" }, + { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" }, + { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" }, + { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" }, + { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" }, + { url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" }, + { url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" }, + { url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" }, + { url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" }, + { url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" }, + { url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" }, + { url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" }, + { url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" }, + { url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" }, + { url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, + { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, +] + +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.10.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233, upload-time = "2026-04-24T00:14:13.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/c6/5581e26c72233ebb2a2a6fed2d24fb7c66b4700120b813f51b0555acf0b6/matplotlib-3.10.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0c3c28d9fbcc1fe7a03be236d73430cf6409c41fb2383a7ac52fe932b072cb1", size = 8319908, upload-time = "2026-04-24T00:12:21.323Z" }, + { url = "https://files.pythonhosted.org/packages/b7/18/4880dd762e40cd360c1bf06e890c5a97b997e91cb324602b1a19950ad5ce/matplotlib-3.10.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cb28c2bd769aa3e98322c6ab09854cbcc52ab69d2759d681bba3e327b2b320", size = 8216016, upload-time = "2026-04-24T00:12:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/32/91/d024616abdba99e83120e07a20658976f6a343646710760c4a51df126029/matplotlib-3.10.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ae20801130378b82d647ff5047c07316295b68dc054ca6b3c13519d0ea624285", size = 8789336, upload-time = "2026-04-24T00:12:26.096Z" }, + { url = "https://files.pythonhosted.org/packages/5c/04/030a2f61ef2158f5e4c259487a92ac877732499fb33d871585d89e03c42d/matplotlib-3.10.9-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c63ebcd8b4b169eb2f5c200552ae6b8be8999a005b6b507ed76fb8d7d674fe2", size = 9604602, upload-time = "2026-04-24T00:12:29.052Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c2/541e4d09d87bb6b5830fc28b4c887a9a8cf4e1c6cee698a8c05552ae2003/matplotlib-3.10.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d75d11c949914165976c621b2324f9ef162af7ebf4b057ddf95dd1dba7e5edcf", size = 9670966, upload-time = "2026-04-24T00:12:32.131Z" }, + { url = "https://files.pythonhosted.org/packages/04/a1/4571fc46e7702de8d0c2dc54ad1b2f8e29328dea3ee90831181f7353d93c/matplotlib-3.10.9-cp312-cp312-win_amd64.whl", hash = "sha256:d091f9d758b34aaaaa6331d13574bf01891d903b3dec59bfff458ef7551de5d6", size = 8217462, upload-time = "2026-04-24T00:12:35.226Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d0/2269edb12aa30c13c8bcc9382892e39943ce1d28aab4ec296e0381798e81/matplotlib-3.10.9-cp312-cp312-win_arm64.whl", hash = "sha256:10cc5ce06d10231c36f40e875f3c7e8050362a4ee8f0ee5d29a6b3277d57bb42", size = 8136688, upload-time = "2026-04-24T00:12:37.442Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d3/8d4f6afbecb49fc04e060a57c0fce39ea51cc163a6bd87303ccd698e4fa6/matplotlib-3.10.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b580440f1ff81a0e34122051a3dfabb7e4b7f9e380629929bde0eff9af72165f", size = 8320331, upload-time = "2026-04-24T00:12:39.688Z" }, + { url = "https://files.pythonhosted.org/packages/63/d9/9e14bc7564bf92d5ffa801ae5fac819ce74b925dfb55e3ebde61a3bbad3e/matplotlib-3.10.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b1b745c489cd1a77a0dc1120a05dc87af9798faebc913601feb8c73d89bf2d1e", size = 8216461, upload-time = "2026-04-24T00:12:42.494Z" }, + { url = "https://files.pythonhosted.org/packages/8a/17/4402d0d14ccf1dfc70932600b68097fbbf9c898a4871d2cbbe79c7801a32/matplotlib-3.10.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8f3bcac1ca5ed000a6f4337d47ba67dfddf37ed6a46c15fd7f014997f7bf865f", size = 8790091, upload-time = "2026-04-24T00:12:44.789Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0b/322aeec06dd9b91411f92028b37d447342770a24392aa4813e317064dad5/matplotlib-3.10.9-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a8d66a55def891c33147ba3ba9bfcabf0b526a43764c818acbb4525e5ed0838", size = 9605027, upload-time = "2026-04-24T00:12:47.583Z" }, + { url = "https://files.pythonhosted.org/packages/74/88/5f13482f55e7b00bcfc09838b093c2456e1379978d2a146844aae05350ad/matplotlib-3.10.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d843374407c4017a6403b59c6c81606773d136f3259d5b6da3131bc814542cc2", size = 9671269, upload-time = "2026-04-24T00:12:50.878Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/0840fd2f93da988ec660b8ad1984abe9f25d2aed22a5e394ff1c68c88307/matplotlib-3.10.9-cp313-cp313-win_amd64.whl", hash = "sha256:f4399f64b3e94cd500195490972ae1ee81170df1636fa15364d157d5bdd7b921", size = 8217588, upload-time = "2026-04-24T00:12:53.784Z" }, + { url = "https://files.pythonhosted.org/packages/47/b9/d706d06dd605c49b9f83a2aed8c13e3e5db70697d7a80b7e3d7915de6b17/matplotlib-3.10.9-cp313-cp313-win_arm64.whl", hash = "sha256:ba7b3b8ef09eab7df0e86e9ae086faa433efbfbdb46afcb3aa16aabf779469a8", size = 8136913, upload-time = "2026-04-24T00:12:56.501Z" }, + { url = "https://files.pythonhosted.org/packages/9b/45/6e32d96978264c8ca8c4b1010adb955a1a49cfaf314e212bbc8908f04a61/matplotlib-3.10.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:09218df8a93712bd6ea133e83a153c755448cf7868316c531cffcc43f69d1cc9", size = 8368019, upload-time = "2026-04-24T00:12:58.896Z" }, + { url = "https://files.pythonhosted.org/packages/86/0a/c8e3d3bba245f0f7fc424937f8ff7ef77291a36af3edb97ccd78aa93d84f/matplotlib-3.10.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:82368699727bfb7b0182e1aa13082e3c08e092fa1a25d3e1fd92405bff96f6d4", size = 8264645, upload-time = "2026-04-24T00:13:01.406Z" }, + { url = "https://files.pythonhosted.org/packages/3d/aa/5bf5a14fe4fed73a4209a155606f8096ff797aad89c6c35179026571133e/matplotlib-3.10.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3225f4e1edcb8c86c884ddf79ebe20ecd0a67d30188f279897554ccd8fded4dc", size = 8802194, upload-time = "2026-04-24T00:13:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/dd/5e/b4be852d6bba6fd15893fadf91ff26ae49cb91aac789e95dde9d342e664f/matplotlib-3.10.9-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de2445a0c6690d21b7eb6ce071cebad6d40a2e9bdf10d039074a96ba19797b99", size = 9622684, upload-time = "2026-04-24T00:13:06.647Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/ed428c971139112ef730f62770654d609467346d09d4b62617e1afd68a5a/matplotlib-3.10.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b2b9516251cb89ff618d757daec0e2ed1bf21248013844a853d87ef85ab3081d", size = 9680790, upload-time = "2026-04-24T00:13:10.009Z" }, + { url = "https://files.pythonhosted.org/packages/e7/09/052e884aaf2b985c63cb79f715f1d5b6a3eaa7de78f6a52b9dbc077d5b53/matplotlib-3.10.9-cp313-cp313t-win_amd64.whl", hash = "sha256:e9fae004b941b23ff2edcf1567a857ed77bafc8086ffa258190462328434faf8", size = 8287571, upload-time = "2026-04-24T00:13:13.087Z" }, + { url = "https://files.pythonhosted.org/packages/f4/38/ae27288e788c35a4250491422f3db7750366fc8c97d6f36fbdecfc1f5518/matplotlib-3.10.9-cp313-cp313t-win_arm64.whl", hash = "sha256:6b63d9c7c769b88ab81e10dc86e4e0607cf56817b9f9e6cf24b2a5f1693b8e38", size = 8188292, upload-time = "2026-04-24T00:13:15.546Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e6/3bd8afd04949f02eabc1c17115ea5255e19cacd4d06fc5abdde4eeb0052c/matplotlib-3.10.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:172db52c9e683f5d12eaf57f0f54834190e12581fe1cc2a19595a8f5acb4e77d", size = 8321276, upload-time = "2026-04-24T00:13:18.318Z" }, + { url = "https://files.pythonhosted.org/packages/41/86/86231232fff41c9f8e4a1a7d7a597d349a02527109c3af7d618366122139/matplotlib-3.10.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97e35e8d39ccc85859095e01a53847432ba9a53ddf7986f7a54a11b73d0e143f", size = 8218218, upload-time = "2026-04-24T00:13:20.974Z" }, + { url = "https://files.pythonhosted.org/packages/85/8f/becc9722cafc64f5d2eb0b7c1bf5f585271c618a45dbd8fabeb021f898b6/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aba1615dabe83188e19d4f75a253c6a08423e04c1425e64039f800050a69de6b", size = 9608145, upload-time = "2026-04-24T00:13:23.228Z" }, + { url = "https://files.pythonhosted.org/packages/32/5d/f7e914f7d9325abff4057cee62c0fa70263683189f774473cbfb534cd13b/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34cf8167e023ad956c15f36302911d5406bd99a9862c1a8499ea6f7c0e015dc2", size = 9885085, upload-time = "2026-04-24T00:13:25.849Z" }, + { url = "https://files.pythonhosted.org/packages/a5/fd/fa69f2221534e80cc5772ac2b7d222011a2acafc2ec7216d5dd174c864ae/matplotlib-3.10.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59476c6d29d612b8e9bb6ce8c5b631be6ba8f9e3a2421f22a02b192c7dd28716", size = 9672358, upload-time = "2026-04-24T00:13:28.906Z" }, + { url = "https://files.pythonhosted.org/packages/ab/1a/5a4f747a8b271cbb024946d2dd3c913ab5032ba430626f8c3528ada96b4b/matplotlib-3.10.9-cp314-cp314-win_amd64.whl", hash = "sha256:336b9acc64d309063126edcdaca00db9373af3c476bb94388fe9c5a53ad13e6f", size = 8349970, upload-time = "2026-04-24T00:13:31.904Z" }, + { url = "https://files.pythonhosted.org/packages/64/dc/95d60ecaefe30680a154b52ea96ab4b0dab547f1fd6aa12f5fb655e89cae/matplotlib-3.10.9-cp314-cp314-win_arm64.whl", hash = "sha256:2dc9477819ffd78ad12a20df1d9d6a6bd4fec6aaa9072681465fddca052f1456", size = 8272785, upload-time = "2026-04-24T00:13:34.511Z" }, + { url = "https://files.pythonhosted.org/packages/70/a0/005d68bc8b8418300ce6591f18586910a8526806e2ab663933d9f20a41e9/matplotlib-3.10.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:da4e09638420548f31c354032a6250e473c68e5a4e96899b4844cf39ddea23fe", size = 8367999, upload-time = "2026-04-24T00:13:36.962Z" }, + { url = "https://files.pythonhosted.org/packages/22/05/1236cc9290be70b2498af20ca348add76e3fffe7f67b477db5133a84f3ea/matplotlib-3.10.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:345f6f68ecc8da0ca56fad2ea08fde1a115eda530079eca185d50a7bc3e146c6", size = 8264543, upload-time = "2026-04-24T00:13:39.851Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c2/071f5a5ff6c5bd63aaaf2f45c811d9bf2ced94bde188d9e1a519e21d0cba/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4edcfbd8565339aa62f1cd4012f7180926fdbe71850f7b0d3c379c175cd6b66c", size = 9622800, upload-time = "2026-04-24T00:13:42.296Z" }, + { url = "https://files.pythonhosted.org/packages/95/57/da7d1f10a85624b9e7db68e069dd94e58dc41dbf9463c5921632ecbe3661/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6be157fe17fc37cb95ac1d7374cf717ce9259616edec911a78d9d26dae8522d4", size = 9888561, upload-time = "2026-04-24T00:13:45.026Z" }, + { url = "https://files.pythonhosted.org/packages/67/b2/ef8d6bb59b0edb6c16c968b70f548aa13b54348972def5aa6ac85df67145/matplotlib-3.10.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4e42042d54db34fda4e95a7bd3e5789c2a995d2dad3eb8850232ee534092fbbf", size = 9680884, upload-time = "2026-04-24T00:13:48.066Z" }, + { url = "https://files.pythonhosted.org/packages/61/1c/d21bfeb9931881ebe96bcfcff27c7ae4b160ae0ec291a714c42641a56d75/matplotlib-3.10.9-cp314-cp314t-win_amd64.whl", hash = "sha256:c27df8b3848f32a83d1767566595e43cfaa4460380974da06f4279a7ec143c39", size = 8432333, upload-time = "2026-04-24T00:13:51.008Z" }, + { url = "https://files.pythonhosted.org/packages/78/23/92493c3e6e1b635ccfff146f7b99e674808787915420373ac399283764c2/matplotlib-3.10.9-cp314-cp314t-win_arm64.whl", hash = "sha256:a49f1eadc84ca85fd72fa4e89e70e61bf86452df6f971af04b12c60761a0772c", size = 8324785, upload-time = "2026-04-24T00:13:53.633Z" }, +] + +[[package]] +name = "matplotlib-inline" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-autorefs" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588, upload-time = "2026-02-10T15:23:55.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530, upload-time = "2026-02-10T15:23:53.817Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" }, +] + +[[package]] +name = "mkdocs-material" +version = "9.7.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/29/6d2bcf41ae40802c4beda2432396fff97b8456fb496371d1bc7aad6512ec/mkdocs_material-9.7.6.tar.gz", hash = "sha256:00bdde50574f776d328b1862fe65daeaf581ec309bd150f7bff345a098c64a69", size = 4097959, upload-time = "2026-03-19T15:41:58.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl", hash = "sha256:71b84353921b8ea1ba84fe11c50912cc512da8fe0881038fcc9a0761c0e635ba", size = 9305470, upload-time = "2026-03-19T15:41:55.217Z" }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, +] + +[[package]] +name = "mkdocstrings" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "pymdown-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/5d/f888d4d3eb31359b327bc9b17a212d6ef03fe0b0682fbb3fc2cb849fb12b/mkdocstrings-1.0.4.tar.gz", hash = "sha256:3969a6515b77db65fd097b53c1b7aa4ae840bd71a2ee62a6a3e89503446d7172", size = 100088, upload-time = "2026-04-15T09:16:53.376Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/94/be70f8ee9c45f2f62b39a1f0e9303bc20e138a8f3b8e50ffd89498e177e1/mkdocstrings-1.0.4-py3-none-any.whl", hash = "sha256:63464b4b29053514f32a1dbbf604e52876d5e638111b0c295ab7ed3cac73ca9b", size = 35560, upload-time = "2026-04-15T09:16:51.436Z" }, +] + +[package.optional-dependencies] +python = [ + { name = "mkdocstrings-python" }, +] + +[[package]] +name = "mkdocstrings-python" +version = "2.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffelib" }, + { name = "mkdocs-autorefs" }, + { name = "mkdocstrings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/b4/5fed370d8ebd96e4e399460a7146ae989263f16588b05a6facd6dbd51e60/mkdocstrings_python-2.0.4.tar.gz", hash = "sha256:58c73c5d358e64e9b1673447663f4a2f8a8941e392e225fc0a0c893758cc452f", size = 199219, upload-time = "2026-06-05T08:13:01.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/e3/00ec594aef5f55522e6d373bc2ac53e53a8f5e9ae32f2d6854b0de4270f3/mkdocstrings_python-2.0.4-py3-none-any.whl", hash = "sha256:fd87c173e1e719a85997b6d4f852cdc55f36710e0ed08da3a7bd9abe79c9db00", size = 104790, upload-time = "2026-06-05T08:13:00.393Z" }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "narwhals" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/0e/3ad61eb87088cc4932e0d851531fa82f845a6230b68b091a0e298cc7e537/narwhals-2.21.0.tar.gz", hash = "sha256:7c6e7f50528e62b7a967dd864d7e117d2955d38d4f730653ce46a9861358e2dc", size = 633083, upload-time = "2026-05-08T12:29:02.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/e1/68c2256b69a314eba133673377ba9118c356f6342a0c02b61de449cf2bf2/narwhals-2.21.0-py3-none-any.whl", hash = "sha256:1e6617d0fca68ae1fda29e5397c4eaacd3ffc9fffe6bcd6ded0c690475e853be", size = 451943, upload-time = "2026-05-08T12:29:01.058Z" }, +] + +[[package]] +name = "nest-asyncio2" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/73/731debf26e27e0a0323d7bda270dc2f634b398e38f040a09da1f4351d0aa/nest_asyncio2-1.7.2.tar.gz", hash = "sha256:1921d70b92cc4612c374928d081552efb59b83d91b2b789d935c665fa01729a8", size = 14743, upload-time = "2026-02-13T00:34:04.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/3c/3179b85b0e1c3659f0369940200cd6d0fa900e6cefcc7ea0bc6dd0e29ffb/nest_asyncio2-1.7.2-py3-none-any.whl", hash = "sha256:f5dfa702f3f81f6a03857e9a19e2ba578c0946a4ad417b4c50a24d7ba641fe01", size = 7843, upload-time = "2026-02-13T00:34:02.691Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" }, + { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" }, + { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" }, + { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" }, + { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, + { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, + { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, + { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, + { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, + { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, + { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, + { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, + { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, + { url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" }, + { url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" }, + { url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" }, + { url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" }, + { url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" }, + { url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" }, + { url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" }, + { url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" }, + { url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" }, + { url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" }, + { url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" }, + { url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" }, + { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" }, +] + +[[package]] +name = "nvidia-cublas" +version = "13.1.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cuda-nvrtc", marker = "(sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-6-kgpipe-cpu') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, + { url = "https://files.pythonhosted.org/packages/3b/cd/154ca20c38269e05eff77c1464e6c1da89f50a6390b565e9d82e06bc11e1/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:37936a16db8fe4ac1f065c2139360608a543a09275cb1a1af612e08cfa065436", size = 423138758, upload-time = "2026-04-08T18:46:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/45/9e/2f562daf80eb8f7a685fb7bea4fda71f6048e4f359d6fdd1b6e70206cb2f/nvidia_cublas-13.1.1.3-py3-none-win_amd64.whl", hash = "sha256:b6cdce694e47ff6aadf0a69df1cab6628d696f5ff56e8d16af50309d855fa20f", size = 404358158, upload-time = "2026-04-08T18:47:26.987Z" }, +] + +[[package]] +name = "nvidia-cuda-cupti" +version = "13.0.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/2a/80353b103fc20ce05ef51e928daed4b6015db4aaa9162ed0997090fe2250/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_aarch64.whl", hash = "sha256:796bd679890ee55fb14a94629b698b6db54bcfd833d391d5e94017dd9d7d3151", size = 10310827, upload-time = "2025-09-04T08:26:42.012Z" }, + { url = "https://files.pythonhosted.org/packages/33/6d/737d164b4837a9bbd202f5ae3078975f0525a55730fe871d8ed4e3b952b0/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_x86_64.whl", hash = "sha256:4eb01c08e859bf924d222250d2e8f8b8ff6d3db4721288cf35d14252a4d933c8", size = 10715597, upload-time = "2025-09-04T08:26:51.312Z" }, + { url = "https://files.pythonhosted.org/packages/ad/df/b74b10025c1205695c5676373f2edd3e87a7202cc62ead0dfbc373b0f6ea/nvidia_cuda_cupti-13.0.85-py3-none-win_amd64.whl", hash = "sha256:683f58d301548deeefcb8f6fac1b8d907691b9d8b18eccab417f51e362102f00", size = 7736776, upload-time = "2025-09-04T08:38:08.38Z" }, +] + +[[package]] +name = "nvidia-cuda-nvrtc" +version = "13.0.88" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/68/483a78f5e8f31b08fb1bb671559968c0ca3a065ac7acabfc7cee55214fd6/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:ad9b6d2ead2435f11cbb6868809d2adeeee302e9bb94bcf0539c7a40d80e8575", size = 90215200, upload-time = "2025-09-04T08:28:44.204Z" }, + { url = "https://files.pythonhosted.org/packages/b7/dc/6bb80850e0b7edd6588d560758f17e0550893a1feaf436807d64d2da040f/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d27f20a0ca67a4bb34268a5e951033496c5b74870b868bacd046b1b8e0c3267b", size = 43015449, upload-time = "2025-09-04T08:28:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/4a/af/345fedb9f4c76c84ab4fa445b36bd4048a4d9db60e6bc76b4f913ff4b852/nvidia_cuda_nvrtc-13.0.88-py3-none-win_amd64.whl", hash = "sha256:6bcd4e7f8e205cbe644f5a98f2f799bef9556fefc89dd786e79a16312ce49872", size = 76807835, upload-time = "2025-09-04T08:39:15.274Z" }, +] + +[[package]] +name = "nvidia-cuda-runtime" +version = "13.0.96" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/4f/17d7b9b8e285199c58ce28e31b5c5bbaa4d8271af06a89b6405258245de2/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef9bcbe90493a2b9d810e43d249adb3d02e98dd30200d86607d8d02687c43f55", size = 2261060, upload-time = "2025-10-09T08:55:15.78Z" }, + { url = "https://files.pythonhosted.org/packages/2e/24/d1558f3b68b1d26e706813b1d10aa1d785e4698c425af8db8edc3dced472/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f82250d7782aa23b6cfe765ecc7db554bd3c2870c43f3d1821f1d18aebf0548", size = 2243632, upload-time = "2025-10-09T08:55:36.117Z" }, + { url = "https://files.pythonhosted.org/packages/b7/94/6b867483bec07da24ffa32736c79fabb94ef3a7af4d787a9d4a974868576/nvidia_cuda_runtime-13.0.96-py3-none-win_amd64.whl", hash = "sha256:f79298c8a098cec150a597c8eba58ecdab96e3bdc4b9bc4f9983635031740492", size = 2927037, upload-time = "2025-10-09T09:04:23.782Z" }, +] + +[[package]] +name = "nvidia-cudnn-cu13" +version = "9.20.0.48" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas", marker = "(sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-6-kgpipe-cpu') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, + { url = "https://files.pythonhosted.org/packages/6e/5e/edb9c0ae051602c3ccaffe424256463636d639e27d7f302dde9975ef9e7a/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0c45dd8eeb50b603f07995b1b300c62ffe6a1980482b82b3bcf94a4ca9d49304", size = 366173588, upload-time = "2026-03-09T19:29:34.474Z" }, + { url = "https://files.pythonhosted.org/packages/78/39/21507455b1bca8b5702a9e9fc6ce73735f216f558dac2c9ede58e4d456b8/nvidia_cudnn_cu13-9.20.0.48-py3-none-win_amd64.whl", hash = "sha256:af8139732b99c0118be65ea5aac97f0d46018f8c552889e49d2fb0c6261a4a24", size = 350712614, upload-time = "2026-03-09T19:31:11.398Z" }, +] + +[[package]] +name = "nvidia-cufft" +version = "12.0.0.61" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink", marker = "(sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-6-kgpipe-cpu') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2f/7b57e29836ea8714f81e9898409196f47d772d5ddedddf1592eadb8ab743/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6c44f692dce8fd5ffd3e3df134b6cdb9c2f72d99cf40b62c32dde45eea9ddad3", size = 214085489, upload-time = "2025-09-04T08:31:56.044Z" }, + { url = "https://files.pythonhosted.org/packages/85/b2/f8af21a2ed1beed337a6a02c5a28aeb85441f4d578ec3d529543c775ea4b/nvidia_cufft-12.0.0.61-py3-none-win_amd64.whl", hash = "sha256:2abce5b39d2f5ae12730fb7e5db6696533e36c26e2d3e8fd1750bdd2853364eb", size = 213342123, upload-time = "2025-09-04T08:40:51.145Z" }, +] + +[[package]] +name = "nvidia-cufile" +version = "1.15.1.6" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/70/4f193de89a48b71714e74602ee14d04e4019ad36a5a9f20c425776e72cd6/nvidia_cufile-1.15.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08a3ecefae5a01c7f5117351c64f17c7c62efa5fffdbe24fc7d298da19cd0b44", size = 1223672, upload-time = "2025-09-04T08:32:22.779Z" }, + { url = "https://files.pythonhosted.org/packages/ab/73/cc4a14c9813a8a0d509417cf5f4bdaba76e924d58beb9864f5a7baceefbf/nvidia_cufile-1.15.1.6-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:bdc0deedc61f548bddf7733bdc216456c2fdb101d020e1ab4b88d232d5e2f6d1", size = 1136992, upload-time = "2025-09-04T08:32:14.119Z" }, +] + +[[package]] +name = "nvidia-curand" +version = "10.4.0.35" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/72/7c2ae24fb6b63a32e6ae5d241cc65263ea18d08802aaae087d9f013335a2/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:133df5a7509c3e292aaa2b477afd0194f06ce4ea24d714d616ff36439cee349a", size = 61962106, upload-time = "2025-08-04T10:21:41.128Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9f/be0a41ca4a4917abf5cb9ae0daff1a6060cc5de950aec0396de9f3b52bc5/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:1aee33a5da6e1db083fe2b90082def8915f30f3248d5896bcec36a579d941bfc", size = 59544258, upload-time = "2025-08-04T10:22:03.992Z" }, + { url = "https://files.pythonhosted.org/packages/99/27/72103153b1ffc00e09fdc40ac970235343dcd1ea8bd762e84d2d73219ffa/nvidia_curand-10.4.0.35-py3-none-win_amd64.whl", hash = "sha256:65b1710aa6961d326b411e314b374290904c5ddf41dc3f766ebc3f1d7d4ca69f", size = 55242481, upload-time = "2025-08-04T10:30:41.831Z" }, +] + +[[package]] +name = "nvidia-cusolver" +version = "12.0.4.66" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas", marker = "(sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-6-kgpipe-cpu') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "nvidia-cusparse", marker = "(sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-6-kgpipe-cpu') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "nvidia-nvjitlink", marker = "(sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-6-kgpipe-cpu') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, + { url = "https://files.pythonhosted.org/packages/5f/67/cba3777620cdacb99102da4042883709c41c709f4b6323c10781a9c3aa34/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0a759da5dea5c0ea10fd307de75cdeb59e7ea4fcb8add0924859b944babf1112", size = 200941980, upload-time = "2025-09-04T08:33:22.767Z" }, + { url = "https://files.pythonhosted.org/packages/99/ef/332a0101260ca78a1daef046bf0b06199e8ed4dac1d2aa698289c358169c/nvidia_cusolver-12.0.4.66-py3-none-win_amd64.whl", hash = "sha256:16515bd33a8e76bb54d024cfa068fa68d30e80fc34b9e1090813ea9362e0cb65", size = 193551444, upload-time = "2025-09-04T08:41:46.813Z" }, +] + +[[package]] +name = "nvidia-cusparse" +version = "12.6.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink", marker = "(sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-6-kgpipe-cpu') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, + { url = "https://files.pythonhosted.org/packages/fa/18/623c77619c31d62efd55302939756966f3ecc8d724a14dab2b75f1508850/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b3c89c88d01ee0e477cb7f82ef60a11a4bcd57b6b87c33f789350b59759360b", size = 145942937, upload-time = "2025-09-04T08:33:58.029Z" }, + { url = "https://files.pythonhosted.org/packages/02/b0/b043d6f3480f102f885cf87fc3ffd3edcb5e23b855025a50e2ef4d059185/nvidia_cusparse-12.6.3.3-py3-none-win_amd64.whl", hash = "sha256:cbcf42feb737bd7ec15b4c0a63e62351886bd3f975027b8815d7f720a2b5ea79", size = 143783033, upload-time = "2025-09-04T08:42:12.391Z" }, +] + +[[package]] +name = "nvidia-cusparselt-cu13" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/e1/cdc1797eadf82d3a9a575a19b33fdc871a97edbec42c00b5b5e914f4aff4/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:4dca476c50bf4780d46cd0bfbd82e2bc10a08e4fef7950917ce8d7578d22a23f", size = 221051344, upload-time = "2025-09-05T18:49:51.289Z" }, + { url = "https://files.pythonhosted.org/packages/34/7d/2661f2fb3ac4302f3a246f5fc030213ac60c1fe0bce84f9783dbd831dbb7/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:786ce87568c303fadb5afcc7102d454cd3040d75f6f8626f5db460d1871f4dd0", size = 170148586, upload-time = "2025-09-05T18:50:50.248Z" }, + { url = "https://files.pythonhosted.org/packages/31/83/f3647ce26916c94a6ca4ff1810623e2c405cff2dea6e78d29516b2514df9/nvidia_cusparselt_cu13-0.8.1-py3-none-win_amd64.whl", hash = "sha256:dccbd362f91a7b9024d1f55ee9f548ac065027ff15d8c8b0db889ab3a8f31215", size = 156885108, upload-time = "2025-09-05T18:51:35.958Z" }, +] + +[[package]] +name = "nvidia-nccl-cu13" +version = "2.29.7" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/0d/daf50d44177ee0cbc7ff0a0c91eb5ff676c82be42f9a970bc7597f440c3a/nvidia_nccl_cu13-2.29.7-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:674a12383e3c38a1bcccae7d4f3633b37852230b6047883cb2f4c2d1b36d9bf5", size = 206014712, upload-time = "2026-03-03T05:34:20.843Z" }, + { url = "https://files.pythonhosted.org/packages/67/f4/58e4e91b6919367c7aafb8e36fce9aad1a3047e536bf7e2fd560927d3a4c/nvidia_nccl_cu13-2.29.7-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:edd81538446786ec3b73972543e53bb43bcaf0bfc8ef76cb679fcc390ffe136d", size = 205976000, upload-time = "2026-03-03T05:36:24.472Z" }, +] + +[[package]] +name = "nvidia-nvjitlink" +version = "13.0.88" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/7a/123e033aaff487c77107195fa5a2b8686795ca537935a24efae476c41f05/nvidia_nvjitlink-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:13a74f429e23b921c1109976abefacc69835f2f433ebd323d3946e11d804e47b", size = 40713933, upload-time = "2025-09-04T08:35:43.553Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2c/93c5250e64df4f894f1cbb397c6fd71f79813f9fd79d7cd61de3f97b3c2d/nvidia_nvjitlink-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e931536ccc7d467a98ba1d8b89ff7fa7f1fa3b13f2b0069118cd7f47bff07d0c", size = 38768748, upload-time = "2025-09-04T08:35:20.008Z" }, + { url = "https://files.pythonhosted.org/packages/e4/01/07530b0e37546231052e30234540289c42eaffa486f1a34a87fed340157b/nvidia_nvjitlink-13.0.88-py3-none-win_amd64.whl", hash = "sha256:634e96e3da9ef845ae744097a1f289238ecf946ce0b82e93cdce14b9782e682f", size = 36035115, upload-time = "2025-09-04T08:43:03.001Z" }, +] + +[[package]] +name = "nvidia-nvshmem-cu13" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/0f/05cc9c720236dcd2db9c1ab97fff629e96821be2e63103569da0c9b72f19/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dc2a197f38e5d0376ad52cd1a2a3617d3cdc150fd5966f4aee9bcebb1d68fe9", size = 60215947, upload-time = "2025-09-06T00:32:20.022Z" }, + { url = "https://files.pythonhosted.org/packages/3c/35/a9bf80a609e74e3b000fef598933235c908fcefcef9026042b8e6dfde2a9/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:290f0a2ee94c9f3687a02502f3b9299a9f9fe826e6d0287ee18482e78d495b80", size = 60412546, upload-time = "2025-09-06T00:32:41.564Z" }, +] + +[[package]] +name = "nvidia-nvtx" +version = "13.0.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/f3/d86c845465a2723ad7e1e5c36dcd75ddb82898b3f53be47ebd429fb2fa5d/nvidia_nvtx-13.0.85-py3-none-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4936d1d6780fbe68db454f5e72a42ff64d1fd6397df9f363ae786930fd5c1cd4", size = 148047, upload-time = "2025-09-04T08:29:01.761Z" }, + { url = "https://files.pythonhosted.org/packages/a8/64/3708a90d1ebe202ffdeb7185f878a3c84d15c2b2c31858da2ce0583e2def/nvidia_nvtx-13.0.85-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb7780edb6b14107373c835bf8b72e7a178bac7367e23da7acb108f973f157a6", size = 148878, upload-time = "2025-09-04T08:28:53.627Z" }, + { url = "https://files.pythonhosted.org/packages/d2/50/0e2220f8620a177de994211186ffc5bfa9f2ce1e1282797f8f90096f9f88/nvidia_nvtx-13.0.85-py3-none-win_amd64.whl", hash = "sha256:d66ea44254dd3c6eacc300047af6e1288d2269dd072b417e0adffbf479e18519", size = 137066, upload-time = "2025-09-04T08:39:25.649Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32' or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/f1/392f8c5bfc16f66a0d2d41561c01627c228fe7ed2a0d056ef11315042570/pandas-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09", size = 10357846, upload-time = "2026-05-11T18:52:36.143Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3d/b16412745651e855f357e5e66930248688378853a6e2698a214e331fba1f/pandas-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4", size = 9899550, upload-time = "2026-05-11T18:52:38.976Z" }, + { url = "https://files.pythonhosted.org/packages/31/a8/fa2535168fffcedf67f4f6de28d2dd903a747ca7c8ea6989451aaeb3a92f/pandas-3.0.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c", size = 10412965, upload-time = "2026-05-11T18:52:41.915Z" }, + { url = "https://files.pythonhosted.org/packages/65/b6/09b01cdbc15224e2850365192d17b7bdebb8bdbd8780ed221fcdf0d9a515/pandas-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9", size = 10894600, upload-time = "2026-05-11T18:52:45.02Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a4/2eb28f2fccb4ced4a2c79ab2a5dee9ade1ebf44922ebad6fea158c9f95d4/pandas-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf", size = 11422824, upload-time = "2026-05-11T18:52:48.058Z" }, + { url = "https://files.pythonhosted.org/packages/f8/45/830bb57f533a4604b355e07edcb8ea18cf88b5f94e5fca92f27052d7c597/pandas-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c", size = 11950889, upload-time = "2026-05-11T18:52:50.905Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c5/fc1b368f303087d20e8c9bf3d6ceb186263cfac0ade735cd938538bea839/pandas-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc", size = 9755463, upload-time = "2026-05-11T18:52:53.386Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/fda8f9705b1b09c6ebe14bfc0fa0e4ec8584d54ea673628f157ff55131af/pandas-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49", size = 9066158, upload-time = "2026-05-11T18:52:56.038Z" }, + { url = "https://files.pythonhosted.org/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa", size = 10331071, upload-time = "2026-05-11T18:52:58.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7", size = 9875690, upload-time = "2026-05-11T18:53:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634, upload-time = "2026-05-11T18:53:04.393Z" }, + { url = "https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a", size = 10891243, upload-time = "2026-05-11T18:53:07.643Z" }, + { url = "https://files.pythonhosted.org/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb", size = 11388659, upload-time = "2026-05-11T18:53:10.634Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2", size = 11942880, upload-time = "2026-05-11T18:53:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/2c/20/559ace4200982c3887d0b86bfd0d856a2143ef8ddab63cc07934951a964c/pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44", size = 9757091, upload-time = "2026-05-11T18:53:16.306Z" }, + { url = "https://files.pythonhosted.org/packages/3a/66/69055a09fe200f29f922a3eeec4804611900b95f52d932ece3393c3c0c19/pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e", size = 9057282, upload-time = "2026-05-11T18:53:18.768Z" }, + { url = "https://files.pythonhosted.org/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d", size = 10767016, upload-time = "2026-05-11T18:53:21.227Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066", size = 10420210, upload-time = "2026-05-11T18:53:23.982Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd", size = 10336126, upload-time = "2026-05-11T18:53:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085", size = 10728051, upload-time = "2026-05-11T18:53:29.125Z" }, + { url = "https://files.pythonhosted.org/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796, upload-time = "2026-05-11T18:53:32.056Z" }, + { url = "https://files.pythonhosted.org/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741, upload-time = "2026-05-11T18:53:34.985Z" }, + { url = "https://files.pythonhosted.org/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13", size = 10499958, upload-time = "2026-05-11T18:53:37.857Z" }, + { url = "https://files.pythonhosted.org/packages/86/54/effdcc3c0ff7a08037889200e148ebe94c16c4f653be078c7b3675955df1/pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac", size = 10336065, upload-time = "2026-05-11T18:53:41.099Z" }, + { url = "https://files.pythonhosted.org/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f", size = 9926101, upload-time = "2026-05-11T18:53:43.515Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e9/e35cf11c8a136e757b956f5f0efdcaa50aecde85ea055f1898dfc68262f3/pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb", size = 10457553, upload-time = "2026-05-11T18:53:46.394Z" }, + { url = "https://files.pythonhosted.org/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a", size = 10914065, upload-time = "2026-05-11T18:53:49.134Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1ef644445fcd72e3627bceec77e3560636f87ddce4ed841afe76b83b5bf9/pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360", size = 11459188, upload-time = "2026-05-11T18:53:52.527Z" }, + { url = "https://files.pythonhosted.org/packages/7e/49/4d8d4f42cbc9c4adc7a1870f269c02cbd6cd40d059622c06fb298addcbad/pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76", size = 11982966, upload-time = "2026-05-11T18:53:55.043Z" }, + { url = "https://files.pythonhosted.org/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5", size = 9876755, upload-time = "2026-05-11T18:53:58.067Z" }, + { url = "https://files.pythonhosted.org/packages/2a/af/33c469653b0ba03b50c3a98192d4c07f0c75c66b263ceb097fce0ee97d31/pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977", size = 9198658, upload-time = "2026-05-11T18:54:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fa/b8c257bd76b8bd060c3a9151c1fca05e9b9c5e3af5d0f549c0356f6d143d/pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04", size = 10787242, upload-time = "2026-05-11T18:54:03.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/eb/f19206ffb0bf1919002969aa448b4702c6594845156a6f8050674855aac3/pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6", size = 10436369, upload-time = "2026-05-11T18:54:06.311Z" }, + { url = "https://files.pythonhosted.org/packages/fd/24/c7c39fb4fe22b71a0c2d78bf0c585c600092d85f94f086d2b3b2f6ca27e2/pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c", size = 10358306, upload-time = "2026-05-11T18:54:09.085Z" }, + { url = "https://files.pythonhosted.org/packages/16/ec/dd2a9eb7fa1204df88c0864164e35b228ac581062ac612ba0a67fd812e4c/pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028", size = 10758394, upload-time = "2026-05-11T18:54:11.956Z" }, + { url = "https://files.pythonhosted.org/packages/95/6e/00c61ea8e85b4f6d8d35e11852a1a4998fc7fafc91c6a602d1cc9c972d64/pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d", size = 11375717, upload-time = "2026-05-11T18:54:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/31/89/8fc1c268969fac43688d65fd92e67df24bd128d53cb4d2eee534cd307399/pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a", size = 11828897, upload-time = "2026-05-11T18:54:17.146Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/e7d20dea247a3e6dc0bd8a6953854afbedc03951def4e7371e05e7263e25/pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1", size = 10900855, upload-time = "2026-05-11T18:54:19.72Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" }, +] + +[[package]] +name = "parso" +version = "0.8.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess", marker = "(sys_platform != 'emscripten' and sys_platform != 'win32') or (sys_platform == 'emscripten' and extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda') or (sys_platform == 'win32' and extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, +] + +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, +] + +[[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 = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "protobuf" +version = "7.34.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/6b/a0e95cad1ad7cc3f2c6821fcab91671bd5b78bd42afb357bb4765f29bc41/protobuf-7.34.1.tar.gz", hash = "sha256:9ce42245e704cc5027be797c1db1eb93184d44d1cdd71811fb2d9b25ad541280", size = 454708, upload-time = "2026-03-20T17:34:47.036Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/11/3325d41e6ee15bf1125654301211247b042563bcc898784351252549a8ad/protobuf-7.34.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8b2cc79c4d8f62b293ad9b11ec3aebce9af481fa73e64556969f7345ebf9fc7", size = 429247, upload-time = "2026-03-20T17:34:37.024Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9d/aa69df2724ff63efa6f72307b483ce0827f4347cc6d6df24b59e26659fef/protobuf-7.34.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:5185e0e948d07abe94bb76ec9b8416b604cfe5da6f871d67aad30cbf24c3110b", size = 325753, upload-time = "2026-03-20T17:34:38.751Z" }, + { url = "https://files.pythonhosted.org/packages/92/e8/d174c91fd48e50101943f042b09af9029064810b734e4160bbe282fa1caa/protobuf-7.34.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:403b093a6e28a960372b44e5eb081775c9b056e816a8029c61231743d63f881a", size = 340198, upload-time = "2026-03-20T17:34:39.871Z" }, + { url = "https://files.pythonhosted.org/packages/53/1b/3b431694a4dc6d37b9f653f0c64b0a0d9ec074ee810710c0c3da21d67ba7/protobuf-7.34.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:8ff40ce8cd688f7265326b38d5a1bed9bfdf5e6723d49961432f83e21d5713e4", size = 324267, upload-time = "2026-03-20T17:34:41.1Z" }, + { url = "https://files.pythonhosted.org/packages/85/29/64de04a0ac142fb685fd09999bc3d337943fb386f3a0ec57f92fd8203f97/protobuf-7.34.1-cp310-abi3-win32.whl", hash = "sha256:34b84ce27680df7cca9f231043ada0daa55d0c44a2ddfaa58ec1d0d89d8bf60a", size = 426628, upload-time = "2026-03-20T17:34:42.536Z" }, + { url = "https://files.pythonhosted.org/packages/4d/87/cb5e585192a22b8bd457df5a2c16a75ea0db9674c3a0a39fc9347d84e075/protobuf-7.34.1-cp310-abi3-win_amd64.whl", hash = "sha256:e97b55646e6ce5cbb0954a8c28cd39a5869b59090dfaa7df4598a7fba869468c", size = 437901, upload-time = "2026-03-20T17:34:44.112Z" }, + { url = "https://files.pythonhosted.org/packages/88/95/608f665226bca68b736b79e457fded9a2a38c4f4379a4a7614303d9db3bc/protobuf-7.34.1-py3-none-any.whl", hash = "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11", size = 170715, upload-time = "2026-03-20T17:34:45.384Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, +] + +[[package]] +name = "pulp" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/a8/6e63330798761e0c903091786681651168fda7365f029b8b5d66160861f2/pulp-3.3.1.tar.gz", hash = "sha256:a9ec237a56981b11c2096e8ba6bb72006833410ba5b400aa257426f85df5e293", size = 16304830, upload-time = "2026-05-05T12:25:43.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/23/5a77fe2b50d962213338ae0fdd9832960186ebc423388fff1a56680e5114/pulp-3.3.1-py3-none-any.whl", hash = "sha256:45aa73db3368eb13b156564e092784c8fa0c1feefa64c2afb0410d9dc0bb5cd9", size = 16390866, upload-time = "2026-05-05T12:25:39.83Z" }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, +] + +[[package]] +name = "pyarrow" +version = "24.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/13/13e1069b351bdc3881266e11147ffccf687505dbb0ea74036237f5d454a5/pyarrow-24.0.0.tar.gz", hash = "sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83", size = 1180261, upload-time = "2026-04-21T10:51:25.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/a9/9686d9f07837f91f775e8932659192e02c74f9d8920524b480b85212cc68/pyarrow-24.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6233c9ed9ab9d1db47de57d9753256d9dcffbf42db341576099f0fd9f6bf4810", size = 34981559, upload-time = "2026-04-21T10:47:22.17Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/0ddf0e9b6ead3474ab087ae598c76b031fc45532bf6a63f3a553440fb258/pyarrow-24.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:f7616236ec1bc2b15bfdec22a71ab38851c86f8f05ff64f379e1278cf20c634a", size = 36663654, upload-time = "2026-04-21T10:47:28.315Z" }, + { url = "https://files.pythonhosted.org/packages/7c/3b/926382efe8ce27ba729071d3566ade6dfb86bdf112f366000196b2f5780a/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1617043b99bd33e5318ae18eb2919af09c71322ef1ca46566cdafc6e6712fb66", size = 45679394, upload-time = "2026-04-21T10:47:34.821Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7a/829f7d9dfd37c207206081d6dad474d81dde29952401f07f2ba507814818/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6165461f55ef6314f026de6638d661188e3455d3ec49834556a0ebbdbace18bb", size = 48863122, upload-time = "2026-04-21T10:47:42.056Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e8/f88ce625fe8babaae64e8db2d417c7653adb3019b08aae85c5ed787dc816/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e", size = 49376032, upload-time = "2026-04-21T10:47:48.967Z" }, + { url = "https://files.pythonhosted.org/packages/36/7a/82c363caa145fff88fb475da50d3bf52bb024f61917be5424c3392eaf878/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6", size = 51929490, upload-time = "2026-04-21T10:47:55.981Z" }, + { url = "https://files.pythonhosted.org/packages/66/1c/e3e72c8014ad2743ca64a701652c733cc5cbcee15c0463a32a8c55518d9e/pyarrow-24.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826", size = 27355660, upload-time = "2026-04-21T10:48:01.718Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d3/a1abf004482026ddc17f4503db227787fa3cfe41ec5091ff20e4fea55e57/pyarrow-24.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:02b001b3ed4723caa44f6cd1af2d5c86aa2cf9971dacc2ffa55b21237713dfba", size = 34976759, upload-time = "2026-04-21T10:48:07.258Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4a/34f0a36d28a2dd32225301b79daad44e243dc1a2bb77d43b60749be255c4/pyarrow-24.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:04920d6a71aabd08a0417709efce97d45ea8e6fb733d9ca9ecffb13c67839f68", size = 36658471, upload-time = "2026-04-21T10:48:13.347Z" }, + { url = "https://files.pythonhosted.org/packages/1f/78/543b94712ae8bb1a6023bcc1acf1a740fbff8286747c289cd9468fced2a5/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a964266397740257f16f7bb2e4f08a0c81454004beab8ff59dd531b73610e9f2", size = 45675981, upload-time = "2026-04-21T10:48:20.201Z" }, + { url = "https://files.pythonhosted.org/packages/84/9f/8fb7c222b100d314137fa40ec050de56cd8c6d957d1cfff685ce72f15b17/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f066b179d68c413374294bc1735f68475457c933258df594443bb9d88ddc2a0", size = 48859172, upload-time = "2026-04-21T10:48:27.541Z" }, + { url = "https://files.pythonhosted.org/packages/a7/d3/1ea72538e6c8b3b475ed78d1049a2c518e655761ea50fe1171fc855fcab7/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1183baeb14c5f587b1ec52831e665718ce632caab84b7cd6b85fd44f96114495", size = 49385733, upload-time = "2026-04-21T10:48:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/c3/be/c3d8b06a1ba35f2260f8e1f771abbee7d5e345c0937aab90675706b1690a/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:806f24b4085453c197a5078218d1ee08783ebbba271badd153d1ae22a3ee804f", size = 51934335, upload-time = "2026-04-21T10:48:42.099Z" }, + { url = "https://files.pythonhosted.org/packages/9c/62/89e07a1e7329d2cde3e3c6994ba0839a24977a2beda8be6005ea3d860b99/pyarrow-24.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:e4505fc6583f7b05ab854934896bcac8253b04ac1171a77dfb73efef92076d91", size = 27271748, upload-time = "2026-04-21T10:49:42.532Z" }, + { url = "https://files.pythonhosted.org/packages/17/1a/cff3a59f80b5b1658549d46611b67163f65e0664431c076ad728bf9d5af4/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:1a4e45017efbf115032e4475ee876d525e0e36c742214fbe405332480ecd6275", size = 35238554, upload-time = "2026-04-21T10:48:48.526Z" }, + { url = "https://files.pythonhosted.org/packages/a8/99/cce0f42a327bfef2c420fb6078a3eb834826e5d6697bf3009fe11d2ad051/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:7986f1fa71cee060ad00758bcc79d3a93bab8559bf978fab9e53472a2e25a17b", size = 36782301, upload-time = "2026-04-21T10:48:55.181Z" }, + { url = "https://files.pythonhosted.org/packages/2a/66/8e560d5ff6793ca29aca213c53eec0dd482dd46cb93b2819e5aab52e4252/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d3e0b61e8efb24ed38898e5cdc5fffa9124be480008d401a1f8071500494ae42", size = 45721929, upload-time = "2026-04-21T10:49:03.676Z" }, + { url = "https://files.pythonhosted.org/packages/27/0c/a26e25505d030716e078d9f16eb74973cbf0b33b672884e9f9da1c83b871/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:55a3bc1e3df3b5567b7d27ef551b2283f0c68a5e86f1cd56abc569da4f31335b", size = 48825365, upload-time = "2026-04-21T10:49:11.714Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/771f9ecb0c65e73fe9dccdd1717901b9594f08c4515d000c7c62df573811/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:641f795b361874ac9da5294f8f443dfdbee355cf2bd9e3b8d97aaac2306b9b37", size = 49451819, upload-time = "2026-04-21T10:49:21.474Z" }, + { url = "https://files.pythonhosted.org/packages/48/da/61ae89a88732f5a785646f3ec6125dbb640fa98a540eb2b9889caa561403/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8adc8e6ce5fccf5dc707046ae4914fd537def529709cc0d285d37a7f9cd442ca", size = 51909252, upload-time = "2026-04-21T10:49:31.164Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1a/8dd5cafab7b66573fa91c03d06d213356ad4edd71813aa75e08ce2b3a844/pyarrow-24.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:9b18371ad2f44044b81a8d23bc2d8a9b6a6226dca775e8e16cfee640473d6c5d", size = 27388127, upload-time = "2026-04-21T10:49:37.334Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/d022a34ff05d2cbedd8ccf841fc1f532ecfa9eb5ed1711b56d0e0ea71fc9/pyarrow-24.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:1cc9057f0319e26333b357e17f3c2c022f1a83739b48a88b25bfd5fa2dc18838", size = 35007997, upload-time = "2026-04-21T10:49:48.796Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ff/f01485fda6f4e5d441afb8dd5e7681e4db18826c1e271852f5d3957d6a80/pyarrow-24.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e6f1278ee4785b6db21229374a1c9e54ec7c549de5d1efc9630b6207de7e170b", size = 36678720, upload-time = "2026-04-21T10:49:55.858Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/2d2d5fea814237923f71b36495211f20b43a1576f9a4d6da7e751a64ec6f/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:adbbedc55506cbdabb830890444fb856bfb0060c46c6f8026c6c2f2cf86ae795", size = 45741852, upload-time = "2026-04-21T10:50:04.624Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3a/28ba9c1c1ebdbb5f1b94dfebb46f207e52e6a554b7fe4132540fde29a3a0/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ae8a1145af31d903fa9bb166824d7abe9b4681a000b0159c9fb99c11bc11ad26", size = 48889852, upload-time = "2026-04-21T10:50:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/df/51/4a389acfd31dca009f8fb82d7f510bb4130f2b3a8e18cf00194d0687d8ac/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d7027eba1df3b2069e2e8d80f644fa0918b68c46432af3d088ddd390d063ecde", size = 49445207, upload-time = "2026-04-21T10:50:20.677Z" }, + { url = "https://files.pythonhosted.org/packages/19/4b/0bab2b23d2ae901b1b9a03c0efd4b2d070256f8ce3fc43f6e58c167b2081/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e56a1ffe9bf7b727432b89104cc0849c21582949dd7bdcb34f17b2001a351a76", size = 51954117, upload-time = "2026-04-21T10:50:29.14Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/f4e9145da0417b3d2c12035a8492b35ff4a3dbc653e614fcfb51d9dedb38/pyarrow-24.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:38be1808cdd068605b787e6ca9119b27eb275a0234e50212c3492331680c3b1e", size = 28001155, upload-time = "2026-04-21T10:51:22.337Z" }, + { url = "https://files.pythonhosted.org/packages/79/4f/46a49a63f43526da895b1a45bbb51d5baf8e4d77159f8528fc3e5490007f/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:418e48ce50a45a6a6c73c454677203a9c75c966cb1e92ca3370959185f197a05", size = 35250387, upload-time = "2026-04-21T10:50:35.552Z" }, + { url = "https://files.pythonhosted.org/packages/a0/da/d5e0cd5ef00796922404806d5f00325cdadc3441ce2c13fe7115f2df9a64/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:2f16197705a230a78270cdd4ea8a1d57e86b2fdcbc34a1f6aebc72e65c986f9a", size = 36797102, upload-time = "2026-04-21T10:50:42.417Z" }, + { url = "https://files.pythonhosted.org/packages/34/c7/5904145b0a593a05236c882933d439b5720f0a145381179063722fbfc123/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:fb24ac194bfc5e86839d7dcd52092ee31e5fe6733fe11f5e3b06ef0812b20072", size = 45745118, upload-time = "2026-04-21T10:50:49.324Z" }, + { url = "https://files.pythonhosted.org/packages/13/d3/cca42fe166d1c6e4d5b80e530b7949104d10e17508a90ae202dac205ce2a/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:9700ebd9a51f5895ce75ff4ac4b3c47a7d4b42bc618be8e713e5d56bacf5f931", size = 48844765, upload-time = "2026-04-21T10:50:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/b0/49/942c3b79878ba928324d1e17c274ed84581db8c0a749b24bcf4cbdf15bd3/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d8ddd2768da81d3ee08cfea9b597f4abb4e8e1dc8ae7e204b608d23a0d3ab699", size = 49471890, upload-time = "2026-04-21T10:51:02.439Z" }, + { url = "https://files.pythonhosted.org/packages/76/97/ff71431000a75d84135a1ace5ca4ba11726a231a8007bbb320a4c54075d5/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:61a3d7eaa97a14768b542f3d284dc6400dd2470d9f080708b13cd46b6ae18136", size = 51932250, upload-time = "2026-04-21T10:51:10.576Z" }, + { url = "https://files.pythonhosted.org/packages/51/be/6f79d55816d5c22557cf27533543d5d70dfe692adfbee4b99f2760674f38/pyarrow-24.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19", size = 28131282, upload-time = "2026-04-21T10:51:16.815Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, +] + +[[package]] +name = "pydeck" +version = "0.9.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/df/4e9e7f20f8034a37c6571c93809f6d22388c39978c98d174d656c1a18fd2/pydeck-0.9.2.tar.gz", hash = "sha256:c10d9035e81ead6385264cac8d19402471f6866a15ca1f7df1400f52142bcf87", size = 5849672, upload-time = "2026-04-16T18:30:30.089Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/24/b30ee7d723100fd822de1bb4c0adea62f3419884a75a536f35f355d1e7c0/pydeck-0.9.2-py2.py3-none-any.whl", hash = "sha256:8213dfeacc5f6bfe6825f61c8ee34e3850e8a31fc43924379ec98edb34a75b25", size = 11305615, upload-time = "2026-04-16T18:30:28.133Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pymdown-extensions" +version = "10.21.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/26/d1015444da4d952a1ca487a236b522eb979766f0295a0bd0c5fc089989a9/pymdown_extensions-10.21.3.tar.gz", hash = "sha256:72cfcf55f07aea0d4af2c4f11dd4e52466ddfb1bb819673146398e0bd3a77354", size = 854140, upload-time = "2026-05-13T12:57:32.267Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/85/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-py3-none-any.whl", hash = "sha256:d7a5d08014fc571e80ca21dd6f854e31f94c489800350564d55d15b3c41e76b6", size = 269002, upload-time = "2026-05-13T12:57:30.296Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.28" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/54/a85eb421fbdd5007bc5af39d0f4ed9fa609e0fedbfdc2adcf0b34526870e/python_multipart-0.0.28.tar.gz", hash = "sha256:8550da197eac0f7ab748961fc9509b999fa2662ea25cef857f05249f6893c0f8", size = 45314, upload-time = "2026-05-10T11:05:16.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/a2/43bbc5860b5034e2af4ef99a0e04d726ff329c43e192ef3abaa8d7ecfce5/python_multipart-0.0.28-py3-none-any.whl", hash = "sha256:10faac07eb966c3f48dc415f9dee46c04cb10d58d30a35677db8027c825ed9b6", size = 29438, upload-time = "2026-05-10T11:05:15.052Z" }, +] + +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, + { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, + { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, + { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, + { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[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/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 = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + +[[package]] +name = "pyzmq" +version = "27.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "implementation_name == 'pypy' or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" }, + { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" }, + { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" }, + { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" }, + { url = "https://files.pythonhosted.org/packages/60/cb/84a13459c51da6cec1b7b1dc1a47e6db6da50b77ad7fd9c145842750a011/pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5", size = 1122436, upload-time = "2025-09-08T23:08:20.801Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/94414759a69a26c3dd674570a81813c46a078767d931a6c70ad29fc585cb/pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6", size = 1156301, upload-time = "2025-09-08T23:08:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ad/15906493fd40c316377fd8a8f6b1f93104f97a752667763c9b9c1b71d42d/pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7", size = 1341197, upload-time = "2025-09-08T23:08:24.286Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/d343f3ce13db53a54cb8946594e567410b2125394dafcc0268d8dda027e0/pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05", size = 897275, upload-time = "2025-09-08T23:08:26.063Z" }, + { url = "https://files.pythonhosted.org/packages/69/2d/d83dd6d7ca929a2fc67d2c3005415cdf322af7751d773524809f9e585129/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9", size = 660469, upload-time = "2025-09-08T23:08:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cd/9822a7af117f4bc0f1952dbe9ef8358eb50a24928efd5edf54210b850259/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128", size = 847961, upload-time = "2025-09-08T23:08:29.672Z" }, + { url = "https://files.pythonhosted.org/packages/9a/12/f003e824a19ed73be15542f172fd0ec4ad0b60cf37436652c93b9df7c585/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39", size = 1650282, upload-time = "2025-09-08T23:08:31.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4a/e82d788ed58e9a23995cee70dbc20c9aded3d13a92d30d57ec2291f1e8a3/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97", size = 2024468, upload-time = "2025-09-08T23:08:33.543Z" }, + { url = "https://files.pythonhosted.org/packages/d9/94/2da0a60841f757481e402b34bf4c8bf57fa54a5466b965de791b1e6f747d/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db", size = 1885394, upload-time = "2025-09-08T23:08:35.51Z" }, + { url = "https://files.pythonhosted.org/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c", size = 574964, upload-time = "2025-09-08T23:08:37.178Z" }, + { url = "https://files.pythonhosted.org/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2", size = 641029, upload-time = "2025-09-08T23:08:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e", size = 561541, upload-time = "2025-09-08T23:08:42.668Z" }, + { url = "https://files.pythonhosted.org/packages/87/45/19efbb3000956e82d0331bafca5d9ac19ea2857722fa2caacefb6042f39d/pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a", size = 1341197, upload-time = "2025-09-08T23:08:44.973Z" }, + { url = "https://files.pythonhosted.org/packages/48/43/d72ccdbf0d73d1343936296665826350cb1e825f92f2db9db3e61c2162a2/pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea", size = 897175, upload-time = "2025-09-08T23:08:46.601Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2e/a483f73a10b65a9ef0161e817321d39a770b2acf8bcf3004a28d90d14a94/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96", size = 660427, upload-time = "2025-09-08T23:08:48.187Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d2/5f36552c2d3e5685abe60dfa56f91169f7a2d99bbaf67c5271022ab40863/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d", size = 847929, upload-time = "2025-09-08T23:08:49.76Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2a/404b331f2b7bf3198e9945f75c4c521f0c6a3a23b51f7a4a401b94a13833/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146", size = 1650193, upload-time = "2025-09-08T23:08:51.7Z" }, + { url = "https://files.pythonhosted.org/packages/1c/0b/f4107e33f62a5acf60e3ded67ed33d79b4ce18de432625ce2fc5093d6388/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd", size = 2024388, upload-time = "2025-09-08T23:08:53.393Z" }, + { url = "https://files.pythonhosted.org/packages/0d/01/add31fe76512642fd6e40e3a3bd21f4b47e242c8ba33efb6809e37076d9b/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a", size = 1885316, upload-time = "2025-09-08T23:08:55.702Z" }, + { url = "https://files.pythonhosted.org/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92", size = 587472, upload-time = "2025-09-08T23:08:58.18Z" }, + { url = "https://files.pythonhosted.org/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0", size = 661401, upload-time = "2025-09-08T23:08:59.802Z" }, + { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" }, +] + +[[package]] +name = "rdflib" +version = "7.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/f5/18bb77b7af9526add0c727a3b2048959847dc5fb030913e2918bf384fec3/rdflib-7.6.0.tar.gz", hash = "sha256:6c831288d5e4a5a7ece85d0ccde9877d512a3d0f02d7c06455d00d6d0ea379df", size = 4943826, upload-time = "2026-02-13T07:15:55.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl", hash = "sha256:30c0a3ebf4c0e09215f066be7246794b6492e054e782d7ac2a34c9f70a15e0dd", size = 615416, upload-time = "2026-02-13T07:15:46.487Z" }, +] + +[[package]] +name = "redis" +version = "7.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/7f/3759b1d0d72b7c92f0d70ffd9dc962b7b7b5ee74e135f9d7d8ab06b8a318/redis-7.4.0.tar.gz", hash = "sha256:64a6ea7bf567ad43c964d2c30d82853f8df927c5c9017766c55a1d1ed95d18ad", size = 4943913, upload-time = "2026-03-24T09:14:37.53Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec", size = 409772, upload-time = "2026-03-24T09:14:35.968Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "regex" +version = "2026.5.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/0e/49aee608ad09480e7fd276898c99ec6192985fa331abe4eb3a986094490b/regex-2026.5.9.tar.gz", hash = "sha256:a8234aa23ec39894bfe4a3f1b85616a7032481964a13ac6fc9f10de4f6fca270", size = 416074, upload-time = "2026-05-09T23:15:19.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/9b/6550044bc44e17c84d312c031c2ec42fbdb6a4ec4e29093be3a172d08772/regex-2026.5.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:57eeeb05db7979413dec5438f2db21d7ecbba787cde7a711df1a6f6df672aa06", size = 490451, upload-time = "2026-05-09T23:12:34.72Z" }, + { url = "https://files.pythonhosted.org/packages/1e/95/fc7ba4303b5a0f92446a12ee6778ef2c6c799233f5060042a31bf390cfe9/regex-2026.5.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:398c521292f4c7fb807001dcd54694d3a1fcafc179a36ad9cc56f98df85930b6", size = 292112, upload-time = "2026-05-09T23:12:36.285Z" }, + { url = "https://files.pythonhosted.org/packages/54/4b/ee27938d1b2c443e89a9a10e00d2d19aa5ee300cd3d61140644e93bb083e/regex-2026.5.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f7a7c26137296beba7784de6eba69c6a93a63ccebc385e4962fe67e267a91225", size = 289599, upload-time = "2026-05-09T23:12:38.089Z" }, + { url = "https://files.pythonhosted.org/packages/d8/dd/ba103dc19614e25f3880800ca67ce093d6e21b325d72b8383c7bf906e9fa/regex-2026.5.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6441cc660d76107934a09c22167200839a0e89604a6297f78a974e66e931d2c0", size = 796732, upload-time = "2026-05-09T23:12:40.062Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e7/f035b4fd858b050b0080bf302968dc0f59ba34e391872d54936758e6844e/regex-2026.5.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:91328f1c23d47595ca3ef0a7557fa129c5a23404b775c770697d2f35b33e0107", size = 865440, upload-time = "2026-05-09T23:12:42.059Z" }, + { url = "https://files.pythonhosted.org/packages/0a/51/8cd301ecc899aea28124357f729f4272f44de7806fc7ca02490bfbe253e8/regex-2026.5.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:93a7860539414dddaefba2b40f8771765ae17949d4c7182b876ce429e11a8309", size = 912329, upload-time = "2026-05-09T23:12:44.373Z" }, + { url = "https://files.pythonhosted.org/packages/cc/1e/3fbe2fa1e8cebd62f3bb7d3321cff1640aca2e240b51d9bd624aad949260/regex-2026.5.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd2810d22146b6d838acc5ec15602cb6b47920aa4e33015df3868eedfd20bab8", size = 801239, upload-time = "2026-05-09T23:12:46.268Z" }, + { url = "https://files.pythonhosted.org/packages/17/2f/6f6008682bf2cf98040a0d3153a8e557b6ab728d7713d045cee4ce544ab8/regex-2026.5.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daff2bdbaf1d23e52fdff7c0b7bc2048b68f978df6a4d107ac981f94caef2e66", size = 777054, upload-time = "2026-05-09T23:12:48.051Z" }, + { url = "https://files.pythonhosted.org/packages/19/2b/eee0d20a6842ba04df4b8847a920b57ef56853f14ef85405473e586b605a/regex-2026.5.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4eeb011098fcb77af513dcef521a3dbecbf8849b1e38940759d293b7a93f5026", size = 785098, upload-time = "2026-05-09T23:12:49.851Z" }, + { url = "https://files.pythonhosted.org/packages/4a/98/6fc1e6410feefb92159edaed5041992bfe390e8d26c721865434acbca558/regex-2026.5.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ea9c8ecfa1b73c73b626534d6626e5340d429630943672b8480724f44e84b962", size = 860095, upload-time = "2026-05-09T23:12:51.666Z" }, + { url = "https://files.pythonhosted.org/packages/18/a3/bd855e0f2cb1a978ecf6fa6bb69632dd9c3f6ea3b81cde62fde14c9daec7/regex-2026.5.9-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cd2846168eb9ee3c513902bc8225409cb1caab31d04728b145171fa1625d9621", size = 765762, upload-time = "2026-05-09T23:12:53.413Z" }, + { url = "https://files.pythonhosted.org/packages/dc/66/0ae8c092e60b14c79d24f8e0b7f0aea5bfbffdcab00b5483d13404d3c3a5/regex-2026.5.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:39617fb0cde9c0e6306dc70e3bfc096f3da793219879f7ae7aa341a69fbdcf6d", size = 852100, upload-time = "2026-05-09T23:12:55.256Z" }, + { url = "https://files.pythonhosted.org/packages/21/de/8dfde60fc1b21c946a893ba273403b72617edb261370cb1087099a83f088/regex-2026.5.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd03c4f0e33280d15cae17159b899245d6b7c53d21def19b263b39655061f5ce", size = 789479, upload-time = "2026-05-09T23:12:57.573Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1c/bdcc98f9a4af4fdd166c74941174619ccff4726d3ce32faa8e9a2ecd38dd/regex-2026.5.9-cp312-cp312-win32.whl", hash = "sha256:164eba9b755ea6f244b0d881196fbc1fac09714e9782c9e2732b813142033c8e", size = 266699, upload-time = "2026-05-09T23:12:59.14Z" }, + { url = "https://files.pythonhosted.org/packages/78/87/240d36864f9e48ace85f72e79ced97ceb7f27ce87739a947dcb834b4e6bc/regex-2026.5.9-cp312-cp312-win_amd64.whl", hash = "sha256:86f40a5d6444db30a125c9c9177e6b25dad981cbc37451fd838f145e6edac92e", size = 277783, upload-time = "2026-05-09T23:13:00.789Z" }, + { url = "https://files.pythonhosted.org/packages/4f/b5/7b30f312b0669dff5beebe5b0989dc2d1a312b1a44fab852199c387a5b96/regex-2026.5.9-cp312-cp312-win_arm64.whl", hash = "sha256:96f5f58b54a063d7ea9dca08e1cf57bfe10499c4d579ee672da284f57f5f0070", size = 270513, upload-time = "2026-05-09T23:13:02.426Z" }, + { url = "https://files.pythonhosted.org/packages/aa/da/797e91ecec6f84135da778ddce78c20e0af5d2a15c26f87a81bc3eadb6db/regex-2026.5.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d626b84406444b165fc0ba981604edea39f0588ff1f92baa23fe50799ea9afdb", size = 490303, upload-time = "2026-05-09T23:13:04.382Z" }, + { url = "https://files.pythonhosted.org/packages/44/da/bf30abaaa737b58f4a4b8c4a03659e02fd92092c822e0197ed9e0daab917/regex-2026.5.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d7bdc0ab8f3dd7e1b4f9ab88634e13374669db86bb3c72e8292f07ae313f539f", size = 292019, upload-time = "2026-05-09T23:13:06.022Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e7/d0eaf5713828417b9e5648cf81fa9bacd4961f6ab98c380c2034f8716e35/regex-2026.5.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a8820737949116ffff55fe18f9fc644530063ba6ebfcb8314239416e78f1347c", size = 289468, upload-time = "2026-05-09T23:13:08.214Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9b/b3fdd62b003baa1a9b593cd8c8699c9651c2e80cc21a5c715707983c42d7/regex-2026.5.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0fbdbac82cb3e4450d0ccde7d7a35607f4cb2dd9fba4b8b69bfaf8c9fa6aed", size = 796749, upload-time = "2026-05-09T23:13:10.573Z" }, + { url = "https://files.pythonhosted.org/packages/d4/30/66ab84588765f5b4b271a9ca09ef7ce2b87caa95176ec3d2ad65d7bc4902/regex-2026.5.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57e8915c7986aa33d25e4d3629cef711cd2863f2961b10409f0c04cb8b7d9020", size = 865445, upload-time = "2026-05-09T23:13:12.523Z" }, + { url = "https://files.pythonhosted.org/packages/1a/89/f05169e8588aac365f35ffc7f3bc3184f095ef4cfded7cfaa3c7fd5dbd89/regex-2026.5.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508f56a89ba9cb26e4168cbc37dbd60a28d82430a9e18ad1d25fe0883c314ca2", size = 912322, upload-time = "2026-05-09T23:13:14.281Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/c93444052cf41581f3c884ab3fb5823daf0992f11cd4388d4275ca610558/regex-2026.5.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6d189041f15691cfa2b6c4290448ec221244d225b3f5fe9e7771b34ffcdf6e2", size = 801269, upload-time = "2026-05-09T23:13:16.569Z" }, + { url = "https://files.pythonhosted.org/packages/50/fe/0cf96b882f540e62e8b9956599798203d599c44cf4c77917ca27400ff69b/regex-2026.5.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e82db382b44d0111b22601c509c89f64434816c9e0eef9d1989cda8cc6ff1c04", size = 777085, upload-time = "2026-05-09T23:13:18.675Z" }, + { url = "https://files.pythonhosted.org/packages/23/5c/d78d4924e7fc875557b9e9b768423925fdfaac5549d06da7810019a9bd26/regex-2026.5.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2acfb48634f64996b57f90f39afa692ff362162722581921fe92239a59960f3c", size = 785153, upload-time = "2026-05-09T23:13:20.525Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e0/5214774090e7b4524dcea3e3c4aa74141d43043f8beb49c1599db1c8b53a/regex-2026.5.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d29eebfc9525db68cad3c97eedd7f754fa265aa5cd0cf4f863b2421e1b48fc9f", size = 860164, upload-time = "2026-05-09T23:13:22.263Z" }, + { url = "https://files.pythonhosted.org/packages/6e/e1/4a57a83350319b1271f0d7a249b8672513ed928b237a741631270de6caea/regex-2026.5.9-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:debb893095e944091c16e641a6e33c1b0f4cb61ab945ec5afbf53ce7068834d8", size = 765731, upload-time = "2026-05-09T23:13:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/12/f4/499e74a20c156fc75836ee04a72a38d1a063978f600937f9760467beb1b0/regex-2026.5.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d659eee77986549c9ea45b861c7567e44d6287c3dc9a4565478853f7b9fe2ff6", size = 852062, upload-time = "2026-05-09T23:13:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/5b/92/7eebc0d0a01e78629695f342ba17e0deaff8fb45e79cc0d7b98287da6e3e/regex-2026.5.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2efa205e6d98b24d1f3ab395c11aa15cdf10935bca283d0285e0499c284fba21", size = 789577, upload-time = "2026-05-09T23:13:27.814Z" }, + { url = "https://files.pythonhosted.org/packages/05/a4/018e71f7d2ad48c1ebe6d3ae0026f9b7cb4802fd15c7cc02fdf724355102/regex-2026.5.9-cp313-cp313-win32.whl", hash = "sha256:f3844f134e834076677dd369976e9f5068679fcb8e50102fdf6b7ac96a3ec127", size = 266691, upload-time = "2026-05-09T23:13:29.549Z" }, + { url = "https://files.pythonhosted.org/packages/e6/1d/861a93719fb9ee7dbfc3761b3797b7a3e112a5d42c6129459d2d741be9b5/regex-2026.5.9-cp313-cp313-win_amd64.whl", hash = "sha256:3527bb4942d2c14552155406cdedd906567456821848aed1cb4933a391bf5eca", size = 277747, upload-time = "2026-05-09T23:13:31.859Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c6/0a2436ae4da1ba76e51cb98943c6838a9a721faa40ebe2dce07694ae34e3/regex-2026.5.9-cp313-cp313-win_arm64.whl", hash = "sha256:56a33f191f17d8c417f99945ebdc1e691d3af9605d86ec68c7e54a57e3e17af6", size = 270500, upload-time = "2026-05-09T23:13:33.525Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e9/d21346f7b60ed58789371358ed66b09d00f832e1bd7c06e55d9da5679882/regex-2026.5.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:01f28d868834624c934b8d2e0aa1c8341337e37831f4a012f18a5afcba4cbaf3", size = 494172, upload-time = "2026-05-09T23:13:35.935Z" }, + { url = "https://files.pythonhosted.org/packages/c4/43/fd1177a2032037c681baecdb3422ee4e1424aec4e4f470ef47793d325274/regex-2026.5.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:48036f6374aaa79eb3b754ec29c61d1c6b1606749d705a13f8854fa2539671f6", size = 293952, upload-time = "2026-05-09T23:13:38.307Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7d/9fbf919768368d3f8a4f6c692cf2aa61e482b2b81ec6a298ace4cbf02480/regex-2026.5.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b96350aa424e79d4fd6b567b344dcbe2b2d6bfc48dfe7717587e1fa6d43da6ff", size = 292314, upload-time = "2026-05-09T23:13:40.353Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6c/e41bfeecb589716843e7c4df09ba46ff2a42961457afece19059d85caeef/regex-2026.5.9-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f3af7a4903c5c04a11a196a5aa75cdd7dd3f8508132f9fb3259d9f5908e3b88", size = 811681, upload-time = "2026-05-09T23:13:42.543Z" }, + { url = "https://files.pythonhosted.org/packages/87/83/a5c1c525fba0aa656e88ad0face0b1829788ef4c2fb6b26df58aa1151b84/regex-2026.5.9-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e87577720152d2caae19fe2baaf1f8d5ca12091e9e229f03915c37d1e4b9178", size = 871135, upload-time = "2026-05-09T23:13:44.326Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/80882e799e440dd878b0979cbebf8fa4d54624a332c83037c7a701649e3f/regex-2026.5.9-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c8b9b9d294cfea3cd19c718ade7cc93492b2c4991abd9a68d0b3477ae6d8e100", size = 917265, upload-time = "2026-05-09T23:13:47.295Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ff/8db60211e2286e396aad7dc7725356c502bff0901ea05bd6cdc2e1a042b9/regex-2026.5.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:728d8bfd28a8845c8b6bc5dc7ce010453d206396786c0765c2740cb65f37791e", size = 816311, upload-time = "2026-05-09T23:13:49.885Z" }, + { url = "https://files.pythonhosted.org/packages/4c/47/742ef579c61730f8d268e5cf1f9ce0e37e2ea041ad0f5644724f2378e463/regex-2026.5.9-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7e30b874d341fac767d7df5a0870540541c2c054b80cfaac116e8d367a8a7ff2", size = 785498, upload-time = "2026-05-09T23:13:52.25Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ab/cb0999802dcb0fb95b1ab005e8d4163d8afdd67efc2cb6b6630ac13f8cb1/regex-2026.5.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fd190e88a895a8901325fad284a3f74ea52b1da8525b76cc811fa9b1edf0ce2b", size = 801348, upload-time = "2026-05-09T23:13:54.127Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/8ca59a24c55bc34d166eefaf3717bd77772f329fdbf984d86581e0a3571c/regex-2026.5.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:8e76e8161ad00694cfce6767d5dea860c6391ac5b83e5c3a39661e696f11fc7e", size = 866493, upload-time = "2026-05-09T23:13:56.067Z" }, + { url = "https://files.pythonhosted.org/packages/8d/3d/30f2ae62cef3278bb5bb821f467277a55fb73f01032cf85997e15e8289a8/regex-2026.5.9-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ddda5340e6c01a293027dd46232fa79eaff1b48058ce7a98f572b6445b088041", size = 772811, upload-time = "2026-05-09T23:13:57.867Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ae/7d2089bcd78ad0c0161bc684339df50032acb438a7bd3305e7ddb1193cec/regex-2026.5.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:205109e96b3cf5adf8f4cd62bedde9487feb282b9497a3535451e5a24cd706a0", size = 856584, upload-time = "2026-05-09T23:13:59.679Z" }, + { url = "https://files.pythonhosted.org/packages/a9/29/92ff47f75990131ea4f24ba17819e5a9d141e10819807e09addd73409af6/regex-2026.5.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbe4579b9f08036aa7d101d1835437a20783574ac66327e6b29b4018a138081", size = 803453, upload-time = "2026-05-09T23:14:01.978Z" }, + { url = "https://files.pythonhosted.org/packages/04/99/eff29f1037dcab36702c9ee5d6858cf1ce2336ea8ea2987f64245b99ea5e/regex-2026.5.9-cp313-cp313t-win32.whl", hash = "sha256:ed2c9e8068b614c574d8d30e543d617cf5379b0535d46f97ef00e904745a08b5", size = 269951, upload-time = "2026-05-09T23:14:03.661Z" }, + { url = "https://files.pythonhosted.org/packages/0e/9d/8870b8981d27b22cda77bb26a5ac7ebfa9c7d9e0dea195a834a82380e748/regex-2026.5.9-cp313-cp313t-win_amd64.whl", hash = "sha256:b46b0f094dc1d3b90356c85a0bd2c9bafc4a6a190b9d6f8ddd5a033b6e088ed4", size = 281240, upload-time = "2026-05-09T23:14:05.56Z" }, + { url = "https://files.pythonhosted.org/packages/72/b1/3379415e8f135c13ac551353397cc4fe97b4978f3cac73c5fcbcded548b8/regex-2026.5.9-cp313-cp313t-win_arm64.whl", hash = "sha256:872acc074bd29ffc9913ecdfedf6ea77502312ca44a4aa0d3779089c6069d8de", size = 272383, upload-time = "2026-05-09T23:14:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/13/3e/9c3cd292d8808b3645a2ce517e200179b6d0e903f176300bd8b542e14de5/regex-2026.5.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:1bd7587a2948b4085195d5a3374eaf4a425dc3e55784c038175355ecf3bbbf8a", size = 490376, upload-time = "2026-05-09T23:14:09.64Z" }, + { url = "https://files.pythonhosted.org/packages/60/70/d43ee8a2ca0a8b68d167f21658b85520ac0574617c7f320367c5047f7556/regex-2026.5.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:dea2e88e1cce4522496cce630e11e67b98b7076620bc4336c3f674bc21a375f4", size = 291964, upload-time = "2026-05-09T23:14:11.424Z" }, + { url = "https://files.pythonhosted.org/packages/21/91/9d50b433828d8e74196904e168a43abf1e6e88b2a15d47ed742456720c37/regex-2026.5.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2099f7e7ff7b6aa3192312650a56e91cc091e49d50b04e4f6f8b6e28b3b27f1c", size = 289682, upload-time = "2026-05-09T23:14:13.123Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/b835e3cafbb9d977736912436259ff551d60919f7d7b3d37d46659c63564/regex-2026.5.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecd353045824e4477562a2ac718c25799cdaaa41f7aa925a806a8a3e6848a5b9", size = 796996, upload-time = "2026-05-09T23:14:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/2c/a6/9f992d00019166b9de01c546dd4549bc679f2a68df11b877740b0760b7c2/regex-2026.5.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65c8c8c37377794bd5b2f3ebe51919042bf17aec802e23c833d89782ed0c78af", size = 866089, upload-time = "2026-05-09T23:14:17.757Z" }, + { url = "https://files.pythonhosted.org/packages/e0/08/4d32af657e049b19cb62b02e46e38fe1518797bfb2203ee93a510b21b0dc/regex-2026.5.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b73ab8afcf66c622db143d1c6fda4e58e4d537ee4f125229ad47b1ab80f34c0", size = 911530, upload-time = "2026-05-09T23:14:20.353Z" }, + { url = "https://files.pythonhosted.org/packages/d9/27/2af43dd1dc201d1fecefda64a45f4ad0995855b92724f795a777b402ee69/regex-2026.5.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0de5cf193997384ed2ca6f1cd4f78055b255d93d82d5a8cd6ba0d11c10b167e4", size = 800643, upload-time = "2026-05-09T23:14:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/a4/dd/23a249047013b5321d4a60c4d2437462086f601b061776a525e5fba2a59f/regex-2026.5.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d641a8c9a61618047796d572a39a79b26167b0411d2c3031937b2fe2d081e2cf", size = 777223, upload-time = "2026-05-09T23:14:24.179Z" }, + { url = "https://files.pythonhosted.org/packages/94/6a/e85ed9538cd19586d0465076a4578a12e093ce776d15f3f8ce92733a8dd6/regex-2026.5.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:24b2355ef5cc9aa5b8f07d17704face1c166fdcc2290fa7bd6e6c925655a8346", size = 785760, upload-time = "2026-05-09T23:14:26.065Z" }, + { url = "https://files.pythonhosted.org/packages/2a/c4/f25473209438638e947c55f9156fd8f236f74169229028cc99116380868e/regex-2026.5.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a24852d3c29ad9e47593593d8a247c44ccc3d0548ef12c822d6ed0810affe676", size = 860891, upload-time = "2026-05-09T23:14:28.17Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f7/f4f86e3c74419c37370e91f150ae0c2ef7d34b2e0e4cdd5da046a02e4022/regex-2026.5.9-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:916714069da19329ef7de197dcbc77bb3104145c7c2c864dbfbe318f46b88b14", size = 765891, upload-time = "2026-05-09T23:14:30.06Z" }, + { url = "https://files.pythonhosted.org/packages/26/70/704d8e13765939146b1cd0ef4e2feb71d7929727d2290f026eed10095955/regex-2026.5.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:fa411799ca8da32a8d38d020a88faa5b6f91657d284761352940ecf9f7c3bbdd", size = 851380, upload-time = "2026-05-09T23:14:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/26/29/1a13582a8460038edc38e49f64ceb0dd7c60f5caba77571f4bf6601965d9/regex-2026.5.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e6da47d679b7010ef27556b6e0f99771b744936db1792a10ceac6547ae1503e", size = 789350, upload-time = "2026-05-09T23:14:34.799Z" }, + { url = "https://files.pythonhosted.org/packages/73/56/3dcafe34fc72e271d62ad9a291801e88a1457bb251c132f15fcc2e5aad1a/regex-2026.5.9-cp314-cp314-win32.whl", hash = "sha256:98bd73080e8756255137e1bd3f3f00295bbc5aa383c0e0f973920e9134d7c4ad", size = 272130, upload-time = "2026-05-09T23:14:36.729Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/02eebf0be95efe416c664db7fb8b6b05b7a0b06a7544f2884f2558b0526f/regex-2026.5.9-cp314-cp314-win_amd64.whl", hash = "sha256:ff8d372ac2acdc048d1c19916f27ee61bc5722728458ba6ca5052f2c72d51763", size = 280999, upload-time = "2026-05-09T23:14:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/70/5a/1dd1abee76cb7a846a0bcf42fdc87e5720c3c33c24f3e37814310a513d9f/regex-2026.5.9-cp314-cp314-win_arm64.whl", hash = "sha256:e1d93bf647916292e8edcec150c07ddf3dc50179ccaf770c04a7f9e452155372", size = 273500, upload-time = "2026-05-09T23:14:41.059Z" }, + { url = "https://files.pythonhosted.org/packages/86/c1/c5f619b0057a7965cb78ec559c1d7a45ce8c99a35bea95483d64959a93d9/regex-2026.5.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:83d0ee4a57d1c87cb549e195ec300b8f0ec3a82eba66d835e4e2ed8634fe4499", size = 494269, upload-time = "2026-05-09T23:14:42.869Z" }, + { url = "https://files.pythonhosted.org/packages/05/2c/5d01f1aee33de4bbe60c8452945bfc8477ca7c5ae4450f6bfe711036cb36/regex-2026.5.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d3d7eb5c9a7f6df82ed3cfac9beb93882a5cbcb5b8b157b56cb2b3b276574ac1", size = 293954, upload-time = "2026-05-09T23:14:44.822Z" }, + { url = "https://files.pythonhosted.org/packages/7a/fe/e8988b2ae2108c6ef71bd4aa8d87fbe257976dd0810e826cd75f701c68b6/regex-2026.5.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:075160bf16658e16d35233300b8453aac25de4cbea808d22348b6979668e924d", size = 292405, upload-time = "2026-05-09T23:14:47.211Z" }, + { url = "https://files.pythonhosted.org/packages/79/34/d2b0937faa7859263f7f0a3c6b103a1296306be6952dc173d0154e9a2f49/regex-2026.5.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45375819235558a4ff1c4971dc32881f022613abdb180128f5cb4768c1765a1c", size = 811855, upload-time = "2026-05-09T23:14:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/80/fe/daf53a47457a8486db66c66c01ceb9c2303eecee3f87197f1e77eb1a736d/regex-2026.5.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ead4b163ac30a29574510cd4b3e2e985ac5290c05fc7095557d6a5f403fc31b5", size = 871189, upload-time = "2026-05-09T23:14:51.555Z" }, + { url = "https://files.pythonhosted.org/packages/1c/75/058fc4470cbfbf57d800aff1a0022b929a3f9fa553ee10a0cdf2070eb31f/regex-2026.5.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c6e4218fbdfbcd4f6c19efca40930d24a621bf4b48cb76bc6640543bd28ef20", size = 917485, upload-time = "2026-05-09T23:14:53.633Z" }, + { url = "https://files.pythonhosted.org/packages/88/e7/179cfda3a28bc843b5c6cfe7f79f23489c791ed95f151083803660878432/regex-2026.5.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6351571c8a42b505eb555c0dc47d740d0fb66977dc142919eea6f4325b7c56a0", size = 816369, upload-time = "2026-05-09T23:14:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/41/90/6f0cc422071688266d344fca8462d787cba0a2c144acb25721f9a61ec265/regex-2026.5.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:002205cafd2a9e78c6290c7d1df277bf3277b3b7a30e0b4bb0dac2e2e3f7cb2d", size = 785869, upload-time = "2026-05-09T23:14:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/02/67/a31f1760f09c27b251ef39e9beb541f462cf977381d067faa764c2c0e393/regex-2026.5.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8abd33fef90b2a9efac5557d6033ca82d1195ed3a15fea5af15ba7b463c6a63b", size = 801427, upload-time = "2026-05-09T23:15:00.642Z" }, + { url = "https://files.pythonhosted.org/packages/e3/c4/1a80654597b6bc1e1ea0494824c31200e8a956abe290afae9b19a166a148/regex-2026.5.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:31037c82eccb44b7ea2e9e221d7c01429430e989a1f4b91ea5a855f6017b509a", size = 866482, upload-time = "2026-05-09T23:15:03.384Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/960724e06482c08466ff5611e242e86f80062949cdf6b4b9cc317b9dd93d/regex-2026.5.9-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5604dfd046dc37eca90250fc3be938b076c8059fa772ac0ed6f499b0f0fb0415", size = 773022, upload-time = "2026-05-09T23:15:05.625Z" }, + { url = "https://files.pythonhosted.org/packages/50/a8/a9979c3e7918280e93159ebcab5ef1a65116dd4f3bd6091be0eae4a126e8/regex-2026.5.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e1b1b4e496afbb24f4a62aba855ee4f88f25578927697b340702e48c9ee6bc2", size = 856642, upload-time = "2026-05-09T23:15:07.966Z" }, + { url = "https://files.pythonhosted.org/packages/fe/d4/a9b732f2f0072c0ab12227483abb24fffcb9f73f8a2b203df0a6d0434735/regex-2026.5.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:be3372b9df6ddecff6486d37e19095a7b4973137caf5512407a89f4455361f41", size = 803552, upload-time = "2026-05-09T23:15:10.215Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fe/1b3113817447a1d4155e4ac76d2e072f42c0bcba2f43fa8a0e756ea2cd91/regex-2026.5.9-cp314-cp314t-win32.whl", hash = "sha256:3ddd90103f9e5c471c49c7852ecc1fe27c7e45eb99e977aefe7caa4e779f4f58", size = 275746, upload-time = "2026-05-09T23:15:12.609Z" }, + { url = "https://files.pythonhosted.org/packages/92/73/93d42045302636c91f2e5ef588b65b84b01428f28ec77de256b1dfdfbe5c/regex-2026.5.9-cp314-cp314t-win_amd64.whl", hash = "sha256:ca518ed29c46eecba6010b15f1b9a479314d2de409536e71b6a13aa04e3b8a77", size = 285685, upload-time = "2026-05-09T23:15:15.086Z" }, + { url = "https://files.pythonhosted.org/packages/da/80/35b4c33c804a165a7f55289afda3ea9e3eb6d15800341a2d66455c0f1f30/regex-2026.5.9-cp314-cp314t-win_arm64.whl", hash = "sha256:5e41809d2683fcde7d5a8c87a6567ba1fb1ce0de9f31bff578de00a4b2d76daa", size = 275713, upload-time = "2026-05-09T23:15:16.98Z" }, +] + +[[package]] +name = "requests" +version = "2.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/b8/7a707d60fea4c49094e40262cc0e2ca6c768cca21587e34d3f705afec47e/requests-2.34.0.tar.gz", hash = "sha256:7d62fe92f50eb82c529b0916bb445afa1531a566fc8f35ffdc64446e771b856a", size = 142436, upload-time = "2026-05-11T19:29:51.717Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/e6/e300fce5fe83c30520607a015dabd985df3251e188d234bfe9492e17a389/requests-2.34.0-py3-none-any.whl", hash = "sha256:917520a21b767485ce7c588f4ebb917c436b24a31231b44228715eaeb5a52c60", size = 73021, upload-time = "2026-05-11T19:29:49.923Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, + { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, + { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, + { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, + { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, + { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, + { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, +] + +[[package]] +name = "safetensors" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/29/9c/6e74567782559a63bd040a236edca26fd71bc7ba88de2ef35d75df3bca5e/safetensors-0.7.0.tar.gz", hash = "sha256:07663963b67e8bd9f0b8ad15bb9163606cd27cc5a1b96235a50d8369803b96b0", size = 200878, upload-time = "2025-11-19T15:18:43.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/47/aef6c06649039accf914afef490268e1067ed82be62bcfa5b7e886ad15e8/safetensors-0.7.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c82f4d474cf725255d9e6acf17252991c3c8aac038d6ef363a4bf8be2f6db517", size = 467781, upload-time = "2025-11-19T15:18:35.84Z" }, + { url = "https://files.pythonhosted.org/packages/e8/00/374c0c068e30cd31f1e1b46b4b5738168ec79e7689ca82ee93ddfea05109/safetensors-0.7.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:94fd4858284736bb67a897a41608b5b0c2496c9bdb3bf2af1fa3409127f20d57", size = 447058, upload-time = "2025-11-19T15:18:34.416Z" }, + { url = "https://files.pythonhosted.org/packages/f1/06/578ffed52c2296f93d7fd2d844cabfa92be51a587c38c8afbb8ae449ca89/safetensors-0.7.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e07d91d0c92a31200f25351f4acb2bc6aff7f48094e13ebb1d0fb995b54b6542", size = 491748, upload-time = "2025-11-19T15:18:09.79Z" }, + { url = "https://files.pythonhosted.org/packages/ae/33/1debbbb70e4791dde185edb9413d1fe01619255abb64b300157d7f15dddd/safetensors-0.7.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8469155f4cb518bafb4acf4865e8bb9d6804110d2d9bdcaa78564b9fd841e104", size = 503881, upload-time = "2025-11-19T15:18:16.145Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1c/40c2ca924d60792c3be509833df711b553c60effbd91da6f5284a83f7122/safetensors-0.7.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54bef08bf00a2bff599982f6b08e8770e09cc012d7bba00783fc7ea38f1fb37d", size = 623463, upload-time = "2025-11-19T15:18:21.11Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3a/13784a9364bd43b0d61eef4bea2845039bc2030458b16594a1bd787ae26e/safetensors-0.7.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42cb091236206bb2016d245c377ed383aa7f78691748f3bb6ee1bfa51ae2ce6a", size = 532855, upload-time = "2025-11-19T15:18:25.719Z" }, + { url = "https://files.pythonhosted.org/packages/a0/60/429e9b1cb3fc651937727befe258ea24122d9663e4d5709a48c9cbfceecb/safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac7252938f0696ddea46f5e855dd3138444e82236e3be475f54929f0c510d48", size = 507152, upload-time = "2025-11-19T15:18:33.023Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a8/4b45e4e059270d17af60359713ffd83f97900d45a6afa73aaa0d737d48b6/safetensors-0.7.0-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1d060c70284127fa805085d8f10fbd0962792aed71879d00864acda69dbab981", size = 541856, upload-time = "2025-11-19T15:18:31.075Z" }, + { url = "https://files.pythonhosted.org/packages/06/87/d26d8407c44175d8ae164a95b5a62707fcc445f3c0c56108e37d98070a3d/safetensors-0.7.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cdab83a366799fa730f90a4ebb563e494f28e9e92c4819e556152ad55e43591b", size = 674060, upload-time = "2025-11-19T15:18:37.211Z" }, + { url = "https://files.pythonhosted.org/packages/11/f5/57644a2ff08dc6325816ba7217e5095f17269dada2554b658442c66aed51/safetensors-0.7.0-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:672132907fcad9f2aedcb705b2d7b3b93354a2aec1b2f706c4db852abe338f85", size = 771715, upload-time = "2025-11-19T15:18:38.689Z" }, + { url = "https://files.pythonhosted.org/packages/86/31/17883e13a814bd278ae6e266b13282a01049b0c81341da7fd0e3e71a80a3/safetensors-0.7.0-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:5d72abdb8a4d56d4020713724ba81dac065fedb7f3667151c4a637f1d3fb26c0", size = 714377, upload-time = "2025-11-19T15:18:40.162Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d8/0c8a7dc9b41dcac53c4cbf9df2b9c83e0e0097203de8b37a712b345c0be5/safetensors-0.7.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0f6d66c1c538d5a94a73aa9ddca8ccc4227e6c9ff555322ea40bdd142391dd4", size = 677368, upload-time = "2025-11-19T15:18:41.627Z" }, + { url = "https://files.pythonhosted.org/packages/05/e5/cb4b713c8a93469e3c5be7c3f8d77d307e65fe89673e731f5c2bfd0a9237/safetensors-0.7.0-cp38-abi3-win32.whl", hash = "sha256:c74af94bf3ac15ac4d0f2a7c7b4663a15f8c2ab15ed0fc7531ca61d0835eccba", size = 326423, upload-time = "2025-11-19T15:18:45.74Z" }, + { url = "https://files.pythonhosted.org/packages/5d/e6/ec8471c8072382cb91233ba7267fd931219753bb43814cbc71757bfd4dab/safetensors-0.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:d1239932053f56f3456f32eb9625590cc7582e905021f94636202a864d470755", size = 341380, upload-time = "2025-11-19T15:18:44.427Z" }, +] + +[[package]] +name = "scikit-learn" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/74/e6a7cc4b820e95cc38cf36cd74d5aa2b42e8ffc2d21fe5a9a9c45c1c7630/scikit_learn-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5fb63362b5a7ddab88e52b6dbb47dac3fd7dafeee740dc6c8d8a446ddedade8e", size = 8548242, upload-time = "2025-12-10T07:07:51.568Z" }, + { url = "https://files.pythonhosted.org/packages/49/d8/9be608c6024d021041c7f0b3928d4749a706f4e2c3832bbede4fb4f58c95/scikit_learn-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5025ce924beccb28298246e589c691fe1b8c1c96507e6d27d12c5fadd85bfd76", size = 8079075, upload-time = "2025-12-10T07:07:53.697Z" }, + { url = "https://files.pythonhosted.org/packages/dd/47/f187b4636ff80cc63f21cd40b7b2d177134acaa10f6bb73746130ee8c2e5/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4496bb2cf7a43ce1a2d7524a79e40bc5da45cf598dbf9545b7e8316ccba47bb4", size = 8660492, upload-time = "2025-12-10T07:07:55.574Z" }, + { url = "https://files.pythonhosted.org/packages/97/74/b7a304feb2b49df9fafa9382d4d09061a96ee9a9449a7cbea7988dda0828/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0bcfe4d0d14aec44921545fd2af2338c7471de9cb701f1da4c9d85906ab847a", size = 8931904, upload-time = "2025-12-10T07:07:57.666Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c4/0ab22726a04ede56f689476b760f98f8f46607caecff993017ac1b64aa5d/scikit_learn-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:35c007dedb2ffe38fe3ee7d201ebac4a2deccd2408e8621d53067733e3c74809", size = 8019359, upload-time = "2025-12-10T07:07:59.838Z" }, + { url = "https://files.pythonhosted.org/packages/24/90/344a67811cfd561d7335c1b96ca21455e7e472d281c3c279c4d3f2300236/scikit_learn-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:8c497fff237d7b4e07e9ef1a640887fa4fb765647f86fbe00f969ff6280ce2bb", size = 7641898, upload-time = "2025-12-10T07:08:01.36Z" }, + { url = "https://files.pythonhosted.org/packages/03/aa/e22e0768512ce9255eba34775be2e85c2048da73da1193e841707f8f039c/scikit_learn-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d6ae97234d5d7079dc0040990a6f7aeb97cb7fa7e8945f1999a429b23569e0a", size = 8513770, upload-time = "2025-12-10T07:08:03.251Z" }, + { url = "https://files.pythonhosted.org/packages/58/37/31b83b2594105f61a381fc74ca19e8780ee923be2d496fcd8d2e1147bd99/scikit_learn-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:edec98c5e7c128328124a029bceb09eda2d526997780fef8d65e9a69eead963e", size = 8044458, upload-time = "2025-12-10T07:08:05.336Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5a/3f1caed8765f33eabb723596666da4ebbf43d11e96550fb18bdec42b467b/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b66d8689d52ed04c271e1329f0c61635bcaf5b926db9b12d58914cdc01fe57", size = 8610341, upload-time = "2025-12-10T07:08:07.732Z" }, + { url = "https://files.pythonhosted.org/packages/38/cf/06896db3f71c75902a8e9943b444a56e727418f6b4b4a90c98c934f51ed4/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fdf95767f989b0cfedb85f7ed8ca215d4be728031f56ff5a519ee1e3276dc2e", size = 8900022, upload-time = "2025-12-10T07:08:09.862Z" }, + { url = "https://files.pythonhosted.org/packages/1c/f9/9b7563caf3ec8873e17a31401858efab6b39a882daf6c1bfa88879c0aa11/scikit_learn-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:2de443b9373b3b615aec1bb57f9baa6bb3a9bd093f1269ba95c17d870422b271", size = 7989409, upload-time = "2025-12-10T07:08:12.028Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/1f4001503650e72c4f6009ac0c4413cb17d2d601cef6f71c0453da2732fc/scikit_learn-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:eddde82a035681427cbedded4e6eff5e57fa59216c2e3e90b10b19ab1d0a65c3", size = 7619760, upload-time = "2025-12-10T07:08:13.688Z" }, + { url = "https://files.pythonhosted.org/packages/d2/7d/a630359fc9dcc95496588c8d8e3245cc8fd81980251079bc09c70d41d951/scikit_learn-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7cc267b6108f0a1499a734167282c00c4ebf61328566b55ef262d48e9849c735", size = 8826045, upload-time = "2025-12-10T07:08:15.215Z" }, + { url = "https://files.pythonhosted.org/packages/cc/56/a0c86f6930cfcd1c7054a2bc417e26960bb88d32444fe7f71d5c2cfae891/scikit_learn-1.8.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:fe1c011a640a9f0791146011dfd3c7d9669785f9fed2b2a5f9e207536cf5c2fd", size = 8420324, upload-time = "2025-12-10T07:08:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/46/1e/05962ea1cebc1cf3876667ecb14c283ef755bf409993c5946ade3b77e303/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72358cce49465d140cc4e7792015bb1f0296a9742d5622c67e31399b75468b9e", size = 8680651, upload-time = "2025-12-10T07:08:19.952Z" }, + { url = "https://files.pythonhosted.org/packages/fe/56/a85473cd75f200c9759e3a5f0bcab2d116c92a8a02ee08ccd73b870f8bb4/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80832434a6cc114f5219211eec13dcbc16c2bac0e31ef64c6d346cde3cf054cb", size = 8925045, upload-time = "2025-12-10T07:08:22.11Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b7/64d8cfa896c64435ae57f4917a548d7ac7a44762ff9802f75a79b77cb633/scikit_learn-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ee787491dbfe082d9c3013f01f5991658b0f38aa8177e4cd4bf434c58f551702", size = 8507994, upload-time = "2025-12-10T07:08:23.943Z" }, + { url = "https://files.pythonhosted.org/packages/5e/37/e192ea709551799379958b4c4771ec507347027bb7c942662c7fbeba31cb/scikit_learn-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf97c10a3f5a7543f9b88cbf488d33d175e9146115a451ae34568597ba33dcde", size = 7869518, upload-time = "2025-12-10T07:08:25.71Z" }, + { url = "https://files.pythonhosted.org/packages/24/05/1af2c186174cc92dcab2233f327336058c077d38f6fe2aceb08e6ab4d509/scikit_learn-1.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c22a2da7a198c28dd1a6e1136f19c830beab7fdca5b3e5c8bba8394f8a5c45b3", size = 8528667, upload-time = "2025-12-10T07:08:27.541Z" }, + { url = "https://files.pythonhosted.org/packages/a8/25/01c0af38fe969473fb292bba9dc2b8f9b451f3112ff242c647fee3d0dfe7/scikit_learn-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:6b595b07a03069a2b1740dc08c2299993850ea81cce4fe19b2421e0c970de6b7", size = 8066524, upload-time = "2025-12-10T07:08:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/be/ce/a0623350aa0b68647333940ee46fe45086c6060ec604874e38e9ab7d8e6c/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29ffc74089f3d5e87dfca4c2c8450f88bdc61b0fc6ed5d267f3988f19a1309f6", size = 8657133, upload-time = "2025-12-10T07:08:31.865Z" }, + { url = "https://files.pythonhosted.org/packages/b8/cb/861b41341d6f1245e6ca80b1c1a8c4dfce43255b03df034429089ca2a2c5/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb65db5d7531bccf3a4f6bec3462223bea71384e2cda41da0f10b7c292b9e7c4", size = 8923223, upload-time = "2025-12-10T07:08:34.166Z" }, + { url = "https://files.pythonhosted.org/packages/76/18/a8def8f91b18cd1ba6e05dbe02540168cb24d47e8dcf69e8d00b7da42a08/scikit_learn-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:56079a99c20d230e873ea40753102102734c5953366972a71d5cb39a32bc40c6", size = 8096518, upload-time = "2025-12-10T07:08:36.339Z" }, + { url = "https://files.pythonhosted.org/packages/d1/77/482076a678458307f0deb44e29891d6022617b2a64c840c725495bee343f/scikit_learn-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:3bad7565bc9cf37ce19a7c0d107742b320c1285df7aab1a6e2d28780df167242", size = 7754546, upload-time = "2025-12-10T07:08:38.128Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d1/ef294ca754826daa043b2a104e59960abfab4cf653891037d19dd5b6f3cf/scikit_learn-1.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4511be56637e46c25721e83d1a9cea9614e7badc7040c4d573d75fbe257d6fd7", size = 8848305, upload-time = "2025-12-10T07:08:41.013Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e2/b1f8b05138ee813b8e1a4149f2f0d289547e60851fd1bb268886915adbda/scikit_learn-1.8.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:a69525355a641bf8ef136a7fa447672fb54fe8d60cab5538d9eb7c6438543fb9", size = 8432257, upload-time = "2025-12-10T07:08:42.873Z" }, + { url = "https://files.pythonhosted.org/packages/26/11/c32b2138a85dcb0c99f6afd13a70a951bfdff8a6ab42d8160522542fb647/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2656924ec73e5939c76ac4c8b026fc203b83d8900362eb2599d8aee80e4880f", size = 8678673, upload-time = "2025-12-10T07:08:45.362Z" }, + { url = "https://files.pythonhosted.org/packages/c7/57/51f2384575bdec454f4fe4e7a919d696c9ebce914590abf3e52d47607ab8/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15fc3b5d19cc2be65404786857f2e13c70c83dd4782676dd6814e3b89dc8f5b9", size = 8922467, upload-time = "2025-12-10T07:08:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/35/4d/748c9e2872637a57981a04adc038dacaa16ba8ca887b23e34953f0b3f742/scikit_learn-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:00d6f1d66fbcf4eba6e356e1420d33cc06c70a45bb1363cd6f6a8e4ebbbdece2", size = 8774395, upload-time = "2025-12-10T07:08:49.337Z" }, + { url = "https://files.pythonhosted.org/packages/60/22/d7b2ebe4704a5e50790ba089d5c2ae308ab6bb852719e6c3bd4f04c3a363/scikit_learn-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f28dd15c6bb0b66ba09728cf09fd8736c304be29409bd8445a080c1280619e8c", size = 8002647, upload-time = "2025-12-10T07:08:51.601Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + +[[package]] +name = "seaborn" +version = "0.13.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "matplotlib" }, + { name = "numpy" }, + { name = "pandas" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/59/a451d7420a77ab0b98f7affa3a1d78a313d2f7281a57afb1a34bae8ab412/seaborn-0.13.2.tar.gz", hash = "sha256:93e60a40988f4d65e9f4885df477e2fdaff6b73a9ded434c1ab356dd57eefff7", size = 1457696, upload-time = "2024-01-25T13:21:52.551Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/11/00d3c3dfc25ad54e731d91449895a79e4bf2384dc3ac01809010ba88f6d5/seaborn-0.13.2-py3-none-any.whl", hash = "sha256:636f8336facf092165e27924f223d3c62ca560b1f2bb5dff7ab7fad265361987", size = 294914, upload-time = "2024-01-25T13:21:49.598Z" }, +] + +[[package]] +name = "sentence-transformers" +version = "5.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "scikit-learn" }, + { name = "scipy" }, + { name = "torch", version = "2.11.0", source = { registry = "https://pypi.org/simple" }, marker = "(sys_platform == 'emscripten' and extra != 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda') or (sys_platform == 'win32' and extra != 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "torch", version = "2.12.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(sys_platform == 'darwin' and extra == 'extra-6-kgpipe-cpu') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "torch", version = "2.12.0", source = { registry = "https://pypi.org/simple" }, marker = "(sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "torch", version = "2.12.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(sys_platform != 'darwin' and extra == 'extra-6-kgpipe-cpu') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "torch", version = "2.12.0+cu130", source = { registry = "https://download.pytorch.org/whl/cu130" }, marker = "extra == 'extra-6-kgpipe-cuda'" }, + { name = "tqdm" }, + { name = "transformers" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/27/16d127a61303e05847d878b23687f3371868c76e738557fa80b4373a8c2b/sentence_transformers-5.5.0.tar.gz", hash = "sha256:9cec675e68bfe09d07466d1f13ab06d1d79d60a0f45b154baf433bde6ae159cb", size = 444908, upload-time = "2026-05-12T14:05:42.383Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/20/18416624bcbae866ec0b111979766cebabe8e5ff7563ab953ecbaf3ff9e7/sentence_transformers-5.5.0-py3-none-any.whl", hash = "sha256:75313fdcc2397ec4b58297c25d6187fcca5a6b2aeb09570a72eff5a3223d8d58", size = 588665, upload-time = "2026-05-12T14:05:40.899Z" }, +] + +[[package]] +name = "setuptools" +version = "81.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/1c/73e719955c59b8e424d015ab450f51c0af856ae46ea2da83eba51cc88de1/setuptools-81.0.0.tar.gz", hash = "sha256:487b53915f52501f0a79ccfd0c02c165ffe06631443a886740b91af4b7a5845a", size = 1198299, upload-time = "2026-02-06T21:10:39.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "smmap" +version = "5.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, +] + +[[package]] +name = "sparqlwrapper" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rdflib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/cc/453752fffa759ef41a3ceadb3f167e13dae1a74c1db057d9f6a7affa9240/SPARQLWrapper-2.0.0.tar.gz", hash = "sha256:3fed3ebcc77617a4a74d2644b86fd88e0f32e7f7003ac7b2b334c026201731f1", size = 98429, upload-time = "2022-03-13T23:14:00.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/89/176e3db96e31e795d7dfd91dd67749d3d1f0316bb30c6931a6140e1a0477/SPARQLWrapper-2.0.0-py3-none-any.whl", hash = "sha256:c99a7204fff676ee28e6acef327dc1ff8451c6f7217dcd8d49e8872f324a8a20", size = 28620, upload-time = "2022-03-13T23:13:58.969Z" }, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, +] + +[[package]] +name = "starlette" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + +[[package]] +name = "streamlit" +version = "1.57.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altair" }, + { name = "anyio" }, + { name = "blinker" }, + { name = "cachetools" }, + { name = "click" }, + { name = "gitpython" }, + { name = "httptools" }, + { name = "itsdangerous" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "pillow" }, + { name = "protobuf" }, + { name = "pyarrow" }, + { name = "pydeck" }, + { name = "python-multipart" }, + { name = "requests" }, + { name = "starlette" }, + { name = "tenacity" }, + { name = "toml" }, + { name = "typing-extensions" }, + { name = "uvicorn" }, + { name = "watchdog", marker = "sys_platform != 'darwin' or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/f8/b2daf7a5f8ae15527daf94406e771bb6075e958a01c3dde9eba79dc3c9a3/streamlit-1.57.0.tar.gz", hash = "sha256:0b028d305c1a1a757071b2c9504966787602842fc8af6e873795ca58d2b4d12f", size = 8678859, upload-time = "2026-04-28T22:13:32.238Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/1a/3ca2293d8552bacea3e67e9600d2d1df7df4a325059769ad83d91c279595/streamlit-1.57.0-py3-none-any.whl", hash = "sha256:0d1d41972aeade5637dbb0e7f0eefa5312272f85304923d240a1b1f0475249c8", size = 9194216, upload-time = "2026-04-28T22:13:29.624Z" }, +] + +[[package]] +name = "streamlit-elements" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "streamlit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8d/53/6ecfba409b61cdf246d4138bd9c1bef4b79a03fbeeee513ab0c7d43f18cb/streamlit-elements-0.1.0.tar.gz", hash = "sha256:5f9f116f22df3ce4a8636b1dee7c2fd3dc3cb0c66267fd28c3e0314aa1d303a7", size = 6649428, upload-time = "2022-04-25T18:32:53.523Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/0d/eecce69faeb4aec152f4036afe8a0f4f67b0af4fe0ff709243591ad2533d/streamlit_elements-0.1.0-py3-none-any.whl", hash = "sha256:593c4b88c399c55879aa76f7f42970f30106f66acaa4baada6338ae5571790df", size = 7833353, upload-time = "2022-04-25T18:32:49.447Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + +[[package]] +name = "tiktoken" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "regex" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806, upload-time = "2025-10-06T20:22:45.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/85/be65d39d6b647c79800fd9d29241d081d4eeb06271f383bb87200d74cf76/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8", size = 1050728, upload-time = "2025-10-06T20:21:52.756Z" }, + { url = "https://files.pythonhosted.org/packages/4a/42/6573e9129bc55c9bf7300b3a35bef2c6b9117018acca0dc760ac2d93dffe/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b", size = 994049, upload-time = "2025-10-06T20:21:53.782Z" }, + { url = "https://files.pythonhosted.org/packages/66/c5/ed88504d2f4a5fd6856990b230b56d85a777feab84e6129af0822f5d0f70/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37", size = 1129008, upload-time = "2025-10-06T20:21:54.832Z" }, + { url = "https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad", size = 1152665, upload-time = "2025-10-06T20:21:56.129Z" }, + { url = "https://files.pythonhosted.org/packages/a3/fe/26df24ce53ffde419a42f5f53d755b995c9318908288c17ec3f3448313a3/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5", size = 1194230, upload-time = "2025-10-06T20:21:57.546Z" }, + { url = "https://files.pythonhosted.org/packages/20/cc/b064cae1a0e9fac84b0d2c46b89f4e57051a5f41324e385d10225a984c24/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3", size = 1254688, upload-time = "2025-10-06T20:21:58.619Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd", size = 878694, upload-time = "2025-10-06T20:21:59.876Z" }, + { url = "https://files.pythonhosted.org/packages/00/61/441588ee21e6b5cdf59d6870f86beb9789e532ee9718c251b391b70c68d6/tiktoken-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:775c2c55de2310cc1bc9a3ad8826761cbdc87770e586fd7b6da7d4589e13dab3", size = 1050802, upload-time = "2025-10-06T20:22:00.96Z" }, + { url = "https://files.pythonhosted.org/packages/1f/05/dcf94486d5c5c8d34496abe271ac76c5b785507c8eae71b3708f1ad9b45a/tiktoken-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a01b12f69052fbe4b080a2cfb867c4de12c704b56178edf1d1d7b273561db160", size = 993995, upload-time = "2025-10-06T20:22:02.788Z" }, + { url = "https://files.pythonhosted.org/packages/a0/70/5163fe5359b943f8db9946b62f19be2305de8c3d78a16f629d4165e2f40e/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:01d99484dc93b129cd0964f9d34eee953f2737301f18b3c7257bf368d7615baa", size = 1128948, upload-time = "2025-10-06T20:22:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/0c/da/c028aa0babf77315e1cef357d4d768800c5f8a6de04d0eac0f377cb619fa/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4a1a4fcd021f022bfc81904a911d3df0f6543b9e7627b51411da75ff2fe7a1be", size = 1151986, upload-time = "2025-10-06T20:22:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5a/886b108b766aa53e295f7216b509be95eb7d60b166049ce2c58416b25f2a/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:981a81e39812d57031efdc9ec59fa32b2a5a5524d20d4776574c4b4bd2e9014a", size = 1194222, upload-time = "2025-10-06T20:22:06.265Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f8/4db272048397636ac7a078d22773dd2795b1becee7bc4922fe6207288d57/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9baf52f84a3f42eef3ff4e754a0db79a13a27921b457ca9832cf944c6be4f8f3", size = 1255097, upload-time = "2025-10-06T20:22:07.403Z" }, + { url = "https://files.pythonhosted.org/packages/8e/32/45d02e2e0ea2be3a9ed22afc47d93741247e75018aac967b713b2941f8ea/tiktoken-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8a0cd0c789a61f31bf44851defbd609e8dd1e2c8589c614cc1060940ef1f697", size = 879117, upload-time = "2025-10-06T20:22:08.418Z" }, + { url = "https://files.pythonhosted.org/packages/ce/76/994fc868f88e016e6d05b0da5ac24582a14c47893f4474c3e9744283f1d5/tiktoken-0.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d5f89ea5680066b68bcb797ae85219c72916c922ef0fcdd3480c7d2315ffff16", size = 1050309, upload-time = "2025-10-06T20:22:10.939Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b8/57ef1456504c43a849821920d582a738a461b76a047f352f18c0b26c6516/tiktoken-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b4e7ed1c6a7a8a60a3230965bdedba8cc58f68926b835e519341413370e0399a", size = 993712, upload-time = "2025-10-06T20:22:12.115Z" }, + { url = "https://files.pythonhosted.org/packages/72/90/13da56f664286ffbae9dbcfadcc625439142675845baa62715e49b87b68b/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:fc530a28591a2d74bce821d10b418b26a094bf33839e69042a6e86ddb7a7fb27", size = 1128725, upload-time = "2025-10-06T20:22:13.541Z" }, + { url = "https://files.pythonhosted.org/packages/05/df/4f80030d44682235bdaecd7346c90f67ae87ec8f3df4a3442cb53834f7e4/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:06a9f4f49884139013b138920a4c393aa6556b2f8f536345f11819389c703ebb", size = 1151875, upload-time = "2025-10-06T20:22:14.559Z" }, + { url = "https://files.pythonhosted.org/packages/22/1f/ae535223a8c4ef4c0c1192e3f9b82da660be9eb66b9279e95c99288e9dab/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e", size = 1194451, upload-time = "2025-10-06T20:22:15.545Z" }, + { url = "https://files.pythonhosted.org/packages/78/a7/f8ead382fce0243cb625c4f266e66c27f65ae65ee9e77f59ea1653b6d730/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25", size = 1253794, upload-time = "2025-10-06T20:22:16.624Z" }, + { url = "https://files.pythonhosted.org/packages/93/e0/6cc82a562bc6365785a3ff0af27a2a092d57c47d7a81d9e2295d8c36f011/tiktoken-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f", size = 878777, upload-time = "2025-10-06T20:22:18.036Z" }, + { url = "https://files.pythonhosted.org/packages/72/05/3abc1db5d2c9aadc4d2c76fa5640134e475e58d9fbb82b5c535dc0de9b01/tiktoken-0.12.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a90388128df3b3abeb2bfd1895b0681412a8d7dc644142519e6f0a97c2111646", size = 1050188, upload-time = "2025-10-06T20:22:19.563Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7b/50c2f060412202d6c95f32b20755c7a6273543b125c0985d6fa9465105af/tiktoken-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:da900aa0ad52247d8794e307d6446bd3cdea8e192769b56276695d34d2c9aa88", size = 993978, upload-time = "2025-10-06T20:22:20.702Z" }, + { url = "https://files.pythonhosted.org/packages/14/27/bf795595a2b897e271771cd31cb847d479073497344c637966bdf2853da1/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:285ba9d73ea0d6171e7f9407039a290ca77efcdb026be7769dccc01d2c8d7fff", size = 1129271, upload-time = "2025-10-06T20:22:22.06Z" }, + { url = "https://files.pythonhosted.org/packages/f5/de/9341a6d7a8f1b448573bbf3425fa57669ac58258a667eb48a25dfe916d70/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d186a5c60c6a0213f04a7a802264083dea1bbde92a2d4c7069e1a56630aef830", size = 1151216, upload-time = "2025-10-06T20:22:23.085Z" }, + { url = "https://files.pythonhosted.org/packages/75/0d/881866647b8d1be4d67cb24e50d0c26f9f807f994aa1510cb9ba2fe5f612/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:604831189bd05480f2b885ecd2d1986dc7686f609de48208ebbbddeea071fc0b", size = 1194860, upload-time = "2025-10-06T20:22:24.602Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1e/b651ec3059474dab649b8d5b69f5c65cd8fcd8918568c1935bd4136c9392/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8f317e8530bb3a222547b85a58583238c8f74fd7a7408305f9f63246d1a0958b", size = 1254567, upload-time = "2025-10-06T20:22:25.671Z" }, + { url = "https://files.pythonhosted.org/packages/80/57/ce64fd16ac390fafde001268c364d559447ba09b509181b2808622420eec/tiktoken-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:399c3dd672a6406719d84442299a490420b458c44d3ae65516302a99675888f3", size = 921067, upload-time = "2025-10-06T20:22:26.753Z" }, + { url = "https://files.pythonhosted.org/packages/ac/a4/72eed53e8976a099539cdd5eb36f241987212c29629d0a52c305173e0a68/tiktoken-0.12.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2c714c72bc00a38ca969dae79e8266ddec999c7ceccd603cc4f0d04ccd76365", size = 1050473, upload-time = "2025-10-06T20:22:27.775Z" }, + { url = "https://files.pythonhosted.org/packages/e6/d7/0110b8f54c008466b19672c615f2168896b83706a6611ba6e47313dbc6e9/tiktoken-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbb9a3ba275165a2cb0f9a83f5d7025afe6b9d0ab01a22b50f0e74fee2ad253e", size = 993855, upload-time = "2025-10-06T20:22:28.799Z" }, + { url = "https://files.pythonhosted.org/packages/5f/77/4f268c41a3957c418b084dd576ea2fad2e95da0d8e1ab705372892c2ca22/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:dfdfaa5ffff8993a3af94d1125870b1d27aed7cb97aa7eb8c1cefdbc87dbee63", size = 1129022, upload-time = "2025-10-06T20:22:29.981Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2b/fc46c90fe5028bd094cd6ee25a7db321cb91d45dc87531e2bdbb26b4867a/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:584c3ad3d0c74f5269906eb8a659c8bfc6144a52895d9261cdaf90a0ae5f4de0", size = 1150736, upload-time = "2025-10-06T20:22:30.996Z" }, + { url = "https://files.pythonhosted.org/packages/28/c0/3c7a39ff68022ddfd7d93f3337ad90389a342f761c4d71de99a3ccc57857/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:54c891b416a0e36b8e2045b12b33dd66fb34a4fe7965565f1b482da50da3e86a", size = 1194908, upload-time = "2025-10-06T20:22:32.073Z" }, + { url = "https://files.pythonhosted.org/packages/ab/0d/c1ad6f4016a3968c048545f5d9b8ffebf577774b2ede3e2e352553b685fe/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5edb8743b88d5be814b1a8a8854494719080c28faaa1ccbef02e87354fe71ef0", size = 1253706, upload-time = "2025-10-06T20:22:33.385Z" }, + { url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" }, +] + +[[package]] +name = "tokenizers" +version = "0.22.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" }, + { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" }, + { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" }, + { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" }, + { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" }, + { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" }, + { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" }, + { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" }, + { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" }, +] + +[[package]] +name = "toml" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, +] + +[[package]] +name = "torch" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform == 'emscripten'", +] +dependencies = [ + { name = "filelock", marker = "(sys_platform == 'emscripten' and extra != 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda') or (sys_platform == 'win32' and extra != 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "fsspec", marker = "(sys_platform == 'emscripten' and extra != 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda') or (sys_platform == 'win32' and extra != 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "jinja2", marker = "(sys_platform == 'emscripten' and extra != 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda') or (sys_platform == 'win32' and extra != 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "networkx", marker = "(sys_platform == 'emscripten' and extra != 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda') or (sys_platform == 'win32' and extra != 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "setuptools", marker = "(sys_platform == 'emscripten' and extra != 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda') or (sys_platform == 'win32' and extra != 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "sympy", marker = "(sys_platform == 'emscripten' and extra != 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda') or (sys_platform == 'win32' and extra != 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "typing-extensions", marker = "(sys_platform == 'emscripten' and extra != 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda') or (sys_platform == 'win32' and extra != 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/8b/69e3008d78e5cee2b30183340cc425081b78afc5eff3d080daab0adda9aa/torch-2.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b5866312ee6e52ea625cd211dcb97d6a2cdc1131a5f15cc0d87eec948f6dd34", size = 80606338, upload-time = "2026-03-23T18:11:34.781Z" }, + { url = "https://files.pythonhosted.org/packages/13/16/42e5915ebe4868caa6bac83a8ed59db57f12e9a61b7d749d584776ed53d5/torch-2.11.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f99924682ef0aa6a4ab3b1b76f40dc6e273fca09f367d15a524266db100a723f", size = 419731115, upload-time = "2026-03-23T18:11:06.944Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c9/82638ef24d7877510f83baf821f5619a61b45568ce21c0a87a91576510aa/torch-2.11.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:0f68f4ac6d95d12e896c3b7a912b5871619542ec54d3649cf48cc1edd4dd2756", size = 530712279, upload-time = "2026-03-23T18:10:31.481Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ff/6756f1c7ee302f6d202120e0f4f05b432b839908f9071157302cedfc5232/torch-2.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:fbf39280699d1b869f55eac536deceaa1b60bd6788ba74f399cc67e60a5fab10", size = 114556047, upload-time = "2026-03-23T18:10:55.931Z" }, + { url = "https://files.pythonhosted.org/packages/87/89/5ea6722763acee56b045435fb84258db7375c48165ec8be7880ab2b281c5/torch-2.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1e6debd97ccd3205bbb37eb806a9d8219e1139d15419982c09e23ef7d4369d18", size = 80606801, upload-time = "2026-03-23T18:10:18.649Z" }, + { url = "https://files.pythonhosted.org/packages/32/d1/8ed2173589cbfe744ed54e5a73efc107c0085ba5777ee93a5f4c1ab90553/torch-2.11.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:63a68fa59de8f87acc7e85a5478bb2dddbb3392b7593ec3e78827c793c4b73fd", size = 419732382, upload-time = "2026-03-23T18:08:30.835Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e1/b73f7c575a4b8f87a5928f50a1e35416b5e27295d8be9397d5293e7e8d4c/torch-2.11.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:cc89b9b173d9adfab59fd227f0ab5e5516d9a52b658ae41d64e59d2e55a418db", size = 530711509, upload-time = "2026-03-23T18:08:47.213Z" }, + { url = "https://files.pythonhosted.org/packages/66/82/3e3fcdd388fbe54e29fd3f991f36846ff4ac90b0d0181e9c8f7236565f82/torch-2.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:4dda3b3f52d121063a731ddb835f010dc137b920d7fec2778e52f60d8e4bf0cd", size = 114555842, upload-time = "2026-03-23T18:09:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/db/38/8ac78069621b8c2b4979c2f96dc8409ef5e9c4189f6aac629189a78677ca/torch-2.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8b394322f49af4362d4f80e424bcaca7efcd049619af03a4cf4501520bdf0fb4", size = 80959574, upload-time = "2026-03-23T18:10:14.214Z" }, + { url = "https://files.pythonhosted.org/packages/6d/6c/56bfb37073e7136e6dd86bfc6af7339946dd684e0ecf2155ac0eee687ae1/torch-2.11.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:2658f34ce7e2dabf4ec73b45e2ca68aedad7a5be87ea756ad656eaf32bf1e1ea", size = 419732324, upload-time = "2026-03-23T18:09:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/07/f4/1b666b6d61d3394cca306ea543ed03a64aad0a201b6cd159f1d41010aeb1/torch-2.11.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:98bb213c3084cfe176302949bdc360074b18a9da7ab59ef2edc9d9f742504778", size = 530596026, upload-time = "2026-03-23T18:09:20.842Z" }, + { url = "https://files.pythonhosted.org/packages/48/6b/30d1459fa7e4b67e9e3fe1685ca1d8bb4ce7c62ef436c3a615963c6c866c/torch-2.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a97b94bbf62992949b4730c6cd2cc9aee7b335921ee8dc207d930f2ed09ae2db", size = 114793702, upload-time = "2026-03-23T18:09:47.304Z" }, + { url = "https://files.pythonhosted.org/packages/26/0d/8603382f61abd0db35841148ddc1ffd607bf3100b11c6e1dab6d2fc44e72/torch-2.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:01018087326984a33b64e04c8cb5c2795f9120e0d775ada1f6638840227b04d7", size = 80573442, upload-time = "2026-03-23T18:09:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/c7/86/7cd7c66cb9cec6be330fff36db5bd0eef386d80c031b581ec81be1d4b26c/torch-2.11.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:2bb3cc54bd0dea126b0060bb1ec9de0f9c7f7342d93d436646516b0330cd5be7", size = 419749385, upload-time = "2026-03-23T18:07:33.77Z" }, + { url = "https://files.pythonhosted.org/packages/47/e8/b98ca2d39b2e0e4730c0ee52537e488e7008025bc77ca89552ff91021f7c/torch-2.11.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:4dc8b3809469b6c30b411bb8c4cad3828efd26236153d9beb6a3ec500f211a60", size = 530716756, upload-time = "2026-03-23T18:07:50.02Z" }, + { url = "https://files.pythonhosted.org/packages/78/88/d4a4cda8362f8a30d1ed428564878c3cafb0d87971fbd3947d4c84552095/torch-2.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:2b4e811728bd0cc58fb2b0948fe939a1ee2bf1422f6025be2fca4c7bd9d79718", size = 114552300, upload-time = "2026-03-23T18:09:05.617Z" }, + { url = "https://files.pythonhosted.org/packages/bf/46/4419098ed6d801750f26567b478fc185c3432e11e2cad712bc6b4c2ab0d0/torch-2.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8245477871c3700d4370352ffec94b103cfcb737229445cf9946cddb7b2ca7cd", size = 80959460, upload-time = "2026-03-23T18:09:00.818Z" }, + { url = "https://files.pythonhosted.org/packages/fd/66/54a56a4a6ceaffb567231994a9745821d3af922a854ed33b0b3a278e0a99/torch-2.11.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:ab9a8482f475f9ba20e12db84b0e55e2f58784bdca43a854a6ccd3fd4b9f75e6", size = 419735835, upload-time = "2026-03-23T18:07:18.974Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e7/0b6665f533aa9e337662dc190425abc0af1fe3234088f4454c52393ded61/torch-2.11.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:563ed3d25542d7e7bbc5b235ccfacfeb97fb470c7fee257eae599adb8005c8a2", size = 530613405, upload-time = "2026-03-23T18:08:07.014Z" }, + { url = "https://files.pythonhosted.org/packages/cf/bf/c8d12a2c86dbfd7f40fb2f56fbf5a505ccf2d9ce131eb559dfc7c51e1a04/torch-2.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b2a43985ff5ef6ddd923bbcf99943e5f58059805787c5c9a2622bf05ca2965b0", size = 114792991, upload-time = "2026-03-23T18:08:19.216Z" }, +] + +[[package]] +name = "torch" +version = "2.12.0" +source = { registry = "https://download.pytorch.org/whl/cpu" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'darwin'", + "python_full_version < '3.14' and sys_platform == 'darwin'", +] +dependencies = [ + { name = "filelock", marker = "(sys_platform == 'darwin' and extra == 'extra-6-kgpipe-cpu') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "fsspec", marker = "(sys_platform == 'darwin' and extra == 'extra-6-kgpipe-cpu') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "jinja2", marker = "(sys_platform == 'darwin' and extra == 'extra-6-kgpipe-cpu') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "networkx", marker = "(sys_platform == 'darwin' and extra == 'extra-6-kgpipe-cpu') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "setuptools", marker = "(sys_platform == 'darwin' and extra == 'extra-6-kgpipe-cpu') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "sympy", marker = "(sys_platform == 'darwin' and extra == 'extra-6-kgpipe-cpu') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "typing-extensions", marker = "(sys_platform == 'darwin' and extra == 'extra-6-kgpipe-cpu') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, +] +wheels = [ + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b41339df93d491435e790ff8bcbae1c0ce777175889bfd1281d119862793e6a2", upload-time = "2026-05-12T16:20:12Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:90dd587a5f61bfe1307148b581e2084fc5bc4a06e2b90a20e9a36b81087ff16b", upload-time = "2026-05-12T16:20:17Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:10ee1448a9f304d3b987eb4656f664ba6e4d7b410ca7a5a7c642199777a2cf88", upload-time = "2026-05-12T16:20:21Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f7dfae4a519197dfa050e98d8e36378a0fb5899625a875c2b54445005a2e404e", upload-time = "2026-05-12T16:20:26Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:b4556715c8572758625d62b6e0ae3b1f76c440221913a6fb5e100f321fb4fb02", upload-time = "2026-05-12T16:20:31Z" }, +] + +[[package]] +name = "torch" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "cuda-bindings", marker = "(sys_platform == 'linux' and extra != 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "(sys_platform == 'linux' and extra != 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "filelock", marker = "(sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "fsspec", marker = "(sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "jinja2", marker = "(sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "networkx", marker = "(sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "nvidia-cublas", marker = "(sys_platform == 'linux' and extra != 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "nvidia-cudnn-cu13", marker = "(sys_platform == 'linux' and extra != 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "nvidia-cusparselt-cu13", marker = "(sys_platform == 'linux' and extra != 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "nvidia-nccl-cu13", marker = "(sys_platform == 'linux' and extra != 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "nvidia-nvshmem-cu13", marker = "(sys_platform == 'linux' and extra != 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "setuptools", marker = "(sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "sympy", marker = "(sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "triton", marker = "(sys_platform == 'linux' and extra != 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "typing-extensions", marker = "(sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-6-kgpipe-cpu' and extra != 'extra-6-kgpipe-cuda') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/bb/285d643f254731294c9b595a007eac39db4600a98682d7bca688f42ca164/torch-2.12.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b41339df93d491435e790ff8bcbae1c0ce777175889bfd1281d119862793e6a2", size = 88010197, upload-time = "2026-05-13T14:55:35.414Z" }, + { url = "https://files.pythonhosted.org/packages/79/81/76debf1db1343bd929bbb5d74c89fb437c2ed88eb144712557e7bd3eea45/torch-2.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:8fbef9f108a863e7722a73740998967e3b074742a834fc5be3a535a2befa7057", size = 426376751, upload-time = "2026-05-13T14:55:03.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/f0/80026028b603c4650ff270fc3785bdef4bd6738765a9cc5a0f5a637d65a2/torch-2.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:4b4f64c2c2b11f7510d93dd6412b87025ff6eddd6bb61c3b5a3d892ea20c4756", size = 532261691, upload-time = "2026-05-13T14:52:54.453Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c2/64b06cbb7830fb3cd9be13e1158b31a3f36b68e6a209105ee3c9d9480be0/torch-2.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:8b958caff4a14d3a3b0b2dfc6a378f64dda9728a9dad28c08a0db9ce4dafb549", size = 122988114, upload-time = "2026-05-13T14:54:42.153Z" }, + { url = "https://files.pythonhosted.org/packages/86/ca/01896c80ba921676aa45886b2c5b8d774912de2a1f719de48169c6f755cd/torch-2.12.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:90dd587a5f61bfe1307148b581e2084fc5bc4a06e2b90a20e9a36b81087ff16b", size = 88009511, upload-time = "2026-05-13T14:54:47.411Z" }, + { url = "https://files.pythonhosted.org/packages/a5/04/52bdaf4787eab6ac7d7f5851dff934e4def0bc8ead9c8fd2b69b3e529699/torch-2.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:864392c73b7654f4d2b3ae712f607937d0dbb1101c4555fbb41848106b297f39", size = 426383231, upload-time = "2026-05-13T14:53:32.129Z" }, + { url = "https://files.pythonhosted.org/packages/49/8a/94bdecd13f5aaa90d45920b89789d9fe7c6f4af8c3cdd7ce01fcb59908fc/torch-2.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5d6b560dfa7d56291c07d615c3bb73e8d9943d9b6d87f76cd0d9d570c4797fa6", size = 532269288, upload-time = "2026-05-13T14:53:49.423Z" }, + { url = "https://files.pythonhosted.org/packages/3e/2f/bdbaaa267de519ef1b73054bf590d8c93c37a266c9a4e24a01bd38b6918f/torch-2.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:3fee918902090ade827643e758e98363278815de583c75d111fdd665ebffde9f", size = 122987706, upload-time = "2026-05-13T14:54:00.335Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ad/e95e822f3538171e22640a7fbe839a1fdb666600bf6487025de2ff03b11a/torch-2.12.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:10ee1448a9f304d3b987eb4656f664ba6e4d7b410ca7a5a7c642199777a2cf88", size = 88319556, upload-time = "2026-05-13T14:54:05.574Z" }, + { url = "https://files.pythonhosted.org/packages/b7/07/055d06d985b445d67422d25b033c11cf55bbb81785d4c4e68e28bca5820e/torch-2.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:af68dbf403439cae9ceaeaaf92f8352b460787dcd27b92aa05c40dd4a19c0f1e", size = 426397656, upload-time = "2026-05-13T14:52:38.84Z" }, + { url = "https://files.pythonhosted.org/packages/43/94/b0b4fdc3014122e0a7302fb90086d352aa48f2576f0b252561ebb38c01a8/torch-2.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:a6a2eebb237d3b1d9ad3b378e86d9b9e0782afdea8b1e0eba6a13646b9b49c07", size = 532183124, upload-time = "2026-05-13T14:53:16.178Z" }, + { url = "https://files.pythonhosted.org/packages/d8/c8/052405e6ad05d3237bfe5a4df78f917773956f8e17813a2d44c059068b74/torch-2.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2140e373e9a51a3e22ef62e8d14366d0b470d18f0adf19fdc757368077133a34", size = 123232462, upload-time = "2026-05-13T14:52:27.26Z" }, + { url = "https://files.pythonhosted.org/packages/67/dc/ac069f8d6e8be701535921141055293b0d4819d3d7f224a4612cf157c7f9/torch-2.12.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f7dfae4a519197dfa050e98d8e36378a0fb5899625a875c2b54445005a2e404e", size = 88027282, upload-time = "2026-05-13T14:53:05.258Z" }, + { url = "https://files.pythonhosted.org/packages/33/c3/1c1eb00e34555b536dddf792676026a988d710ed36981aa00499b36b0620/torch-2.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:891c769072637c74e9a5a77a3bc782894696d8ffec83b938df8536dee7f0ba78", size = 426386961, upload-time = "2026-05-13T14:51:28.406Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d4/7e730dba0c7032a4154dc9056b76cf9625515e030e269cfbf8098fcfee7d/torch-2.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:e2ad3eb85d39c3cab62dfa93ed5a73516e6a53c6713cb97d004004fe089f0f1f", size = 532272265, upload-time = "2026-05-13T14:51:59.308Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b4/92c80d1bbfee1c0036c06d1d2155a3065bd2423134c83bf8a47e65cd6b9b/torch-2.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:c66696857e987efb8bc1777a37357ec4f60ab5e8af6250b83d6034437fa2d8f3", size = 122987138, upload-time = "2026-05-13T14:51:45.942Z" }, + { url = "https://files.pythonhosted.org/packages/7b/78/2e12b37ce50a19a037d7bc62d652a5a8f27385a7b05859d6bc9204f20cfe/torch-2.12.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:b4556715c8572758625d62b6e0ae3b1f76c440221913a6fb5e100f321fb4fb02", size = 88320100, upload-time = "2026-05-13T14:51:39.955Z" }, + { url = "https://files.pythonhosted.org/packages/56/5e/83c450ec7b0bb40a7b74611c1b5440f9260e33c54c90d556fd4a1f0fd955/torch-2.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a43ac605a5e13116c72b64c359644cce0229f213dde48d2ae0ae5eb5becf7feb", size = 426391871, upload-time = "2026-05-13T14:52:14.989Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e9/1a0b575d98d0afedd8f157d23fa3d2759421483660448e60d0a4b10b6daa/torch-2.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6a7512adfdd7f6732e40de1c620831e3c75b39b98cef60b11d0c5f0a76473ec5", size = 532192241, upload-time = "2026-05-13T14:51:07.795Z" }, + { url = "https://files.pythonhosted.org/packages/88/21/afadd25ecd81b3cea1e11c73cf1ab41a983a50271548c3ec7ec3b9efc3e9/torch-2.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5f96b63f8287f66a005dd1b5a6abba2920f11156c5e5c4d815f3e2050fd1aa16", size = 123231092, upload-time = "2026-05-13T14:51:18.854Z" }, +] + +[[package]] +name = "torch" +version = "2.12.0+cpu" +source = { registry = "https://download.pytorch.org/whl/cpu" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version < '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "filelock", marker = "(sys_platform != 'darwin' and extra == 'extra-6-kgpipe-cpu') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "fsspec", marker = "(sys_platform != 'darwin' and extra == 'extra-6-kgpipe-cpu') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "jinja2", marker = "(sys_platform != 'darwin' and extra == 'extra-6-kgpipe-cpu') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "networkx", marker = "(sys_platform != 'darwin' and extra == 'extra-6-kgpipe-cpu') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "setuptools", marker = "(sys_platform != 'darwin' and extra == 'extra-6-kgpipe-cpu') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "sympy", marker = "(sys_platform != 'darwin' and extra == 'extra-6-kgpipe-cpu') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "typing-extensions", marker = "(sys_platform != 'darwin' and extra == 'extra-6-kgpipe-cpu') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, +] +wheels = [ + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp312-cp312-linux_s390x.whl", hash = "sha256:b9d0e8eed0af9321ffb12b75f4aca371b071254f12cf75875d5a8e7cc8f52b51", upload-time = "2026-05-12T23:16:33Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:ce2ddb880b0813fcc91a737f08fdd973a8115a74c64ccb34e9c09a7964b4d448", upload-time = "2026-05-12T23:16:40Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:5e3dc83725581fa38b7b2e45c58692e30b2a3cde19191af54b675ffcac3840a6", upload-time = "2026-05-12T23:16:48Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp312-cp312-win_amd64.whl", hash = "sha256:70ead47f538417323a230c0e743e5cbb6d91f11bd8339abf8c05c9d02f8409bc", upload-time = "2026-05-12T23:16:56Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp312-cp312-win_arm64.whl", hash = "sha256:5a3b24f429d126a08acafd5cfe8b719409618ad57c49bf4f20df4f8cd32cd682", upload-time = "2026-05-12T23:17:00Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp313-cp313-linux_s390x.whl", hash = "sha256:5e0da19e1c3bfdc9b92638c552579eac678354485d61fc8921b0461fd6c40449", upload-time = "2026-05-12T23:17:05Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:68b7ddd4db4603a03e106e74c7098c8d8c8943d33c1e5ada009ca4cd885759c3", upload-time = "2026-05-12T23:17:12Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:ada78018bdfa30d1c766596cd32d910dbf5b03424cd859231b6d2a00533de922", upload-time = "2026-05-12T23:17:20Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp313-cp313-win_amd64.whl", hash = "sha256:59bc266826e683899d49ee0af9829f3eafd0a16e15b5db9dc591c8d955003b66", upload-time = "2026-05-12T23:17:27Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp313-cp313-win_arm64.whl", hash = "sha256:97a5160abf3ca9d59a2cd7b4b4de89d9dfe290d36a1ac720262a55fbcee10b6c", upload-time = "2026-05-12T23:17:31Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp313-cp313t-linux_s390x.whl", hash = "sha256:32b9b7a0974cd6149cb98def0a28a49d92d7c14a384273d5539da9624239e950", upload-time = "2026-05-12T23:17:36Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:93ed8dc52c113580daf6124982b3232629045dccc5cd83a8f5ed478f7bac7340", upload-time = "2026-05-12T23:17:43Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:6a1c86abd4ed15a0736cf2663ad69642ae5d1288c99e30346070e6241018a0a9", upload-time = "2026-05-12T23:17:54Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp313-cp313t-win_amd64.whl", hash = "sha256:768dce4b7b3353795f667d1cb0dd7dfba06f570cd39539576097335e05bb71fe", upload-time = "2026-05-12T23:18:02Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp314-cp314-linux_s390x.whl", hash = "sha256:ee1f329acfd0c2a1ccaa3393bcaf9857ea58759549bb2d67e271a6eab42382b3", upload-time = "2026-05-12T23:18:08Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:797c066367792c92eb97cafba7fd0caa8d7455e6078a4ee880630077378dc372", upload-time = "2026-05-12T23:18:15Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:a8f419ce3f25388d36e67153ec63b3a1b17059c49f5a7759a7e91ac4843660d3", upload-time = "2026-05-12T23:18:22Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp314-cp314-win_amd64.whl", hash = "sha256:1dd196c43e74e7b3b526ff434e7efbdef3f3792a2efbecfc983d7dce501840d2", upload-time = "2026-05-12T23:18:30Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp314-cp314t-linux_s390x.whl", hash = "sha256:d0d2080cb13c94ebc0c884d237e404490743d0f40192c8a180abf3b6b6f334cf", upload-time = "2026-05-12T23:18:35Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f7bc15972acad257723775237cdd120024cca844b8bc64701822fa596bcb7e14", upload-time = "2026-05-12T23:18:42Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4d79f961250d1763487ecbc90af019a80009f9e87cadc5366b3ec4ba5671fea6", upload-time = "2026-05-12T23:18:50Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp314-cp314t-win_amd64.whl", hash = "sha256:46b8f4c41ac36bb5d5b47f5437b3de5541b313275e59c1d2aefd3bef32b0f531", upload-time = "2026-05-12T23:18:58Z" }, +] + +[[package]] +name = "torch" +version = "2.12.0+cu130" +source = { registry = "https://download.pytorch.org/whl/cu130" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "cuda-bindings", marker = "(sys_platform == 'linux' and extra == 'extra-6-kgpipe-cuda') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "(sys_platform == 'linux' and extra == 'extra-6-kgpipe-cuda') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "filelock", marker = "extra == 'extra-6-kgpipe-cuda'" }, + { name = "fsspec", marker = "extra == 'extra-6-kgpipe-cuda'" }, + { name = "jinja2", marker = "extra == 'extra-6-kgpipe-cuda'" }, + { name = "networkx", marker = "extra == 'extra-6-kgpipe-cuda'" }, + { name = "nvidia-cublas", marker = "(sys_platform == 'linux' and extra == 'extra-6-kgpipe-cuda') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "nvidia-cudnn-cu13", marker = "(sys_platform == 'linux' and extra == 'extra-6-kgpipe-cuda') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "nvidia-cusparselt-cu13", marker = "(sys_platform == 'linux' and extra == 'extra-6-kgpipe-cuda') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "nvidia-nccl-cu13", marker = "(sys_platform == 'linux' and extra == 'extra-6-kgpipe-cuda') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "nvidia-nvshmem-cu13", marker = "(sys_platform == 'linux' and extra == 'extra-6-kgpipe-cuda') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "setuptools", marker = "extra == 'extra-6-kgpipe-cuda'" }, + { name = "sympy", marker = "extra == 'extra-6-kgpipe-cuda'" }, + { name = "triton", marker = "(sys_platform == 'linux' and extra == 'extra-6-kgpipe-cuda') or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, + { name = "typing-extensions", marker = "extra == 'extra-6-kgpipe-cuda'" }, +] +wheels = [ + { url = "https://download-r2.pytorch.org/whl/cu130/torch-2.12.0%2Bcu130-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:cb95bd4626150e41aeea2b60e4635a878ebe01e63f3344409f4b7353fdb7998c", upload-time = "2026-05-12T23:49:12Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torch-2.12.0%2Bcu130-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:9f512ea51c170a7cc1a0487c08f0154b78defba4eb8619cad0130c8615ed8526", upload-time = "2026-05-12T23:49:40Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torch-2.12.0%2Bcu130-cp312-cp312-win_amd64.whl", hash = "sha256:24e75a0c3ea4243067d7560955f2eef6466e9365de7dd4a3a4b8693c9ac4bccf", upload-time = "2026-05-12T23:50:41Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torch-2.12.0%2Bcu130-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:bf5f067d3a4d713b75ccd6a0141f8133c7495a016b917ce6dcec1492e3da98b0", upload-time = "2026-05-12T23:51:35Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torch-2.12.0%2Bcu130-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:fe5fefb784a370d1ba4959de6e87bcd3b35441040a99bffe32f5cd03bbc834c0", upload-time = "2026-05-12T23:52:00Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torch-2.12.0%2Bcu130-cp313-cp313-win_amd64.whl", hash = "sha256:6e728c5fdeffa19b3fa6a759ff585147851772789f3dc84dec5f8cbde0f7a5b0", upload-time = "2026-05-12T23:53:01Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torch-2.12.0%2Bcu130-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:fce821712a2881eafcfe9ddf646d953683ae39f2e4c9f9066c6ebe4adcc76495", upload-time = "2026-05-12T23:53:55Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torch-2.12.0%2Bcu130-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:180389b4cebb5d8988e453ca35df8fbbf709734c35e882b6e9f4abaca979454a", upload-time = "2026-05-12T23:54:20Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torch-2.12.0%2Bcu130-cp313-cp313t-win_amd64.whl", hash = "sha256:eb22ad632b19f6ab9e0852aa2229e9b1c7f5bab5220e39012b3056c31391ea02", upload-time = "2026-05-12T23:55:24Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torch-2.12.0%2Bcu130-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:05be17ab4c1bd335cff4b6fb4c78eba6ff26ef7d8c997887e5cb59b0c29427b2", upload-time = "2026-05-12T23:56:26Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torch-2.12.0%2Bcu130-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:3ff7366f6919232f099ef702c3ebd3509c91ab37c367e408cb3799c6bed214a4", upload-time = "2026-05-12T23:56:52Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torch-2.12.0%2Bcu130-cp314-cp314-win_amd64.whl", hash = "sha256:b30d09337048750c1bf10c2abc8cb3d3bf9bb5163d6a34df7c2eb6e9ee32c603", upload-time = "2026-05-12T23:57:58Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torch-2.12.0%2Bcu130-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4526d2f200d9e7c7f0a04bfdeff982c4e86f7a0bac3c190ce48cd8caa3d5c888", upload-time = "2026-05-12T23:58:58Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torch-2.12.0%2Bcu130-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a255426cf47827e73975378fd03f3a581fc1d21241f294d6ab43b7f610ccd49c", upload-time = "2026-05-12T23:59:20Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torch-2.12.0%2Bcu130-cp314-cp314t-win_amd64.whl", hash = "sha256:0fc041b5bed1e50ea54216f4e86dec696e773aea9b82c612d91fe024ad3af3f1", upload-time = "2026-05-13T00:00:20Z" }, +] + +[[package]] +name = "torchaudio" +version = "2.11.0" +source = { registry = "https://download.pytorch.org/whl/cpu" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'darwin'", + "python_full_version < '3.14' and sys_platform == 'darwin'", +] +wheels = [ + { url = "https://download-r2.pytorch.org/whl/cpu/torchaudio-2.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a1cf1acc883bee9cb906a933572fed6a8a933f86ef34e9ea7d803f72317e8c1b", upload-time = "2026-03-23T15:50:10Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchaudio-2.11.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:e3f9696a9ef1d49acc452159b052370c636406d072e9d8f10895fda87b591ea9", upload-time = "2026-03-23T15:50:10Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchaudio-2.11.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:bda09ea630ae7207384fb0f28c35e4f8c0d82dd6eba020b6b335ad0caa9fed49", upload-time = "2026-03-23T15:50:10Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchaudio-2.11.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:cc09cd1f6015b8549e7fe255fb1be5346b57e7fee06541d3f3dbb012d8c4715f", upload-time = "2026-03-23T15:50:10Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchaudio-2.11.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:da2725e250866da42a12934c9a6552f65a18b7187fd7a6221387f0e605fb3b96", upload-time = "2026-03-23T15:50:10Z" }, +] + +[[package]] +name = "torchaudio" +version = "2.11.0+cpu" +source = { registry = "https://download.pytorch.org/whl/cpu" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version < '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +wheels = [ + { url = "https://download-r2.pytorch.org/whl/cpu/torchaudio-2.11.0%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:b9dd2c6ac144001dc6dac38b564c1de73ac26ef0c195d5037c4a94990b0e2b5a", upload-time = "2026-03-23T15:50:10Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchaudio-2.11.0%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:2354248848d06a9ae1e7a12165f800f0dda7df60ecac9fca892322b722b922c0", upload-time = "2026-03-23T15:50:10Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchaudio-2.11.0%2Bcpu-cp312-cp312-win_amd64.whl", hash = "sha256:95d517bd1a0a28dacd1c37550ced95cab64f3a7a4ef9b8219b41049388a71163", upload-time = "2026-03-23T15:50:10Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchaudio-2.11.0%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:2faa8d8f251d1fa44813765b00791048b617e9dc06e6cd9222aba81023929119", upload-time = "2026-03-23T15:50:10Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchaudio-2.11.0%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:3c0175d0ed054bf0dc3b154a744b1a127c94291b3f3b7bdd0639b4b238c89445", upload-time = "2026-03-23T15:50:10Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchaudio-2.11.0%2Bcpu-cp313-cp313-win_amd64.whl", hash = "sha256:fa0c22f58f60c3537b7d28ed2501e0995acb2a65d9af2708f21edaad67186cd8", upload-time = "2026-03-23T15:50:10Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchaudio-2.11.0%2Bcpu-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:5d665234def325e22c15518c581b0107a651c9f843176e3192360b092ebcb656", upload-time = "2026-03-23T15:50:10Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchaudio-2.11.0%2Bcpu-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:a3df9c41706a22de0f43fe2734f54db31cb5a7314cc92ecc84657a8492b3ff8d", upload-time = "2026-03-23T15:50:10Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchaudio-2.11.0%2Bcpu-cp313-cp313t-win_amd64.whl", hash = "sha256:18818a326b779abc7bfd5cfb9fe88501916dde144cb1944fc9e7b4fb6208dfc7", upload-time = "2026-03-23T15:50:10Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchaudio-2.11.0%2Bcpu-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:591ed8279f0170ef28933873f65e9f5f8c439287088ef6285cda65988b0ba614", upload-time = "2026-03-23T15:50:10Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchaudio-2.11.0%2Bcpu-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:34c5dcd704e17a2b01c097b4fe3b5f83c5cdbc42b9f2abd095e026c588f873d4", upload-time = "2026-03-23T15:50:10Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchaudio-2.11.0%2Bcpu-cp314-cp314-win_amd64.whl", hash = "sha256:be19f467eb7a173264653369426e7ecc4745e28d15f9c52eea2ad5316ec685eb", upload-time = "2026-03-23T15:50:10Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchaudio-2.11.0%2Bcpu-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:ad3e6141c1078c5c9c8ae7c3cdf6c80eb35612c99c8cf3f76fe295845f45bb9d", upload-time = "2026-03-23T15:50:10Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchaudio-2.11.0%2Bcpu-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:69db7c3d86af1bc8224f7053395ace3e2bd8c56b0c3922bc7b798114440c88e5", upload-time = "2026-03-23T15:50:10Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchaudio-2.11.0%2Bcpu-cp314-cp314t-win_amd64.whl", hash = "sha256:b6bfca873bd67d02fbbaa254312283be48dc1ca5532013152527210db432ef8c", upload-time = "2026-03-23T15:50:10Z" }, +] + +[[package]] +name = "torchaudio" +version = "2.11.0+cu130" +source = { registry = "https://download.pytorch.org/whl/cu130" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +wheels = [ + { url = "https://download-r2.pytorch.org/whl/cu130/torchaudio-2.11.0%2Bcu130-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:7171f810887e7cd1a4763974d5a1f2e1466692404315bb70705e0f49fb3a28e0", upload-time = "2026-03-23T15:50:26Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torchaudio-2.11.0%2Bcu130-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:3fba988f4301fe13547fe5e99c76d9ae36a27e19ded82eeffed9d2456e12edef", upload-time = "2026-03-23T15:50:26Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torchaudio-2.11.0%2Bcu130-cp312-cp312-win_amd64.whl", hash = "sha256:f74949f9ace1e4a6cf9468bdb3211b9cfa0af6ea348125471ac71c8621d6c77d", upload-time = "2026-03-23T15:50:26Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torchaudio-2.11.0%2Bcu130-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:23498b01097648e304e78d6495a9f5bdce8441a802afc3025e2561973d74c025", upload-time = "2026-03-23T15:50:26Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torchaudio-2.11.0%2Bcu130-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e9c07cfdab691454092ff12d21dd1407a4bb8ad081d38f222cf6fcf6abcc18c8", upload-time = "2026-03-23T15:50:26Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torchaudio-2.11.0%2Bcu130-cp313-cp313-win_amd64.whl", hash = "sha256:ce09a7b144b7982b46c8fe399cf5f91d43dda571e9d6ddba67e928567551f614", upload-time = "2026-03-23T15:50:26Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torchaudio-2.11.0%2Bcu130-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:f9b277a0d3b2ab4385778146b7e879716f36b6f2080f7190ec744e3383511791", upload-time = "2026-03-23T15:50:26Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torchaudio-2.11.0%2Bcu130-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:d07c4cbe4bec3e15bb18ba163058038f5f5fc1775c3061685c194439af4d2e9f", upload-time = "2026-03-23T15:50:26Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torchaudio-2.11.0%2Bcu130-cp313-cp313t-win_amd64.whl", hash = "sha256:b9dd151f06842ca77dc341aed94ea2f5d13a89e5027aa032a47198d073bcf3db", upload-time = "2026-03-23T15:50:26Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torchaudio-2.11.0%2Bcu130-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0a36531ffb90f0b1870e994bc57643bf6466fd8c290b5b9b2b36dadc3445d0c0", upload-time = "2026-03-23T15:50:26Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torchaudio-2.11.0%2Bcu130-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:378b49671b581114a2d25d40928f12a150872feadf11669a63f573e81c78019a", upload-time = "2026-03-23T15:50:26Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torchaudio-2.11.0%2Bcu130-cp314-cp314-win_amd64.whl", hash = "sha256:b345401d76a371031b2fe965bd9579b9621c9d87902ee3e586be665df04c179e", upload-time = "2026-03-23T15:50:26Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torchaudio-2.11.0%2Bcu130-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:94e90c94c93a626e21686c3dc76d4597c3e4ad178a911611fed4f14c8de07293", upload-time = "2026-03-23T15:50:26Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torchaudio-2.11.0%2Bcu130-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1cef8561494c44b12eb6a390d9f2ff52f6fa540b9a7aba6c8951c074056a2f11", upload-time = "2026-03-23T15:50:26Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torchaudio-2.11.0%2Bcu130-cp314-cp314t-win_amd64.whl", hash = "sha256:ac4988a74921500f9f2dc6502867876bf1bf15d845a518f4711d21ec81a1efef", upload-time = "2026-03-23T15:50:26Z" }, +] + +[[package]] +name = "torchvision" +version = "0.27.0" +source = { registry = "https://download.pytorch.org/whl/cpu" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'darwin'", + "python_full_version < '3.14' and sys_platform == 'darwin'", +] +dependencies = [ + { name = "numpy", marker = "sys_platform == 'darwin'" }, + { name = "pillow", marker = "sys_platform == 'darwin'" }, + { name = "torch", version = "2.12.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" }, +] +wheels = [ + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.27.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:1a6dd742a150645126df9e0b2e449874c1d635897c773b322c2e067e98382dfe", upload-time = "2026-05-12T16:20:37Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.27.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:41d6dae73e1af09fa82ded597ae57f2a2314285acde54b25890a8f8e51b999d7", upload-time = "2026-05-12T16:20:37Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.27.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:c5121f1b9ab09a7f73e837871deb8321551f7eaeb19d87aa00de9191968eae44", upload-time = "2026-05-12T16:20:37Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.27.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:c1fac0fc2a7adf29481fc1938a0e7845c57ba1147a986784109c4d98f434ea8c", upload-time = "2026-05-12T16:20:37Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.27.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:2c4099a15150143b9b034730b404a56d572efe0b79489b4c765d929cb4eac7f3", upload-time = "2026-05-12T16:20:37Z" }, +] + +[[package]] +name = "torchvision" +version = "0.27.0+cpu" +source = { registry = "https://download.pytorch.org/whl/cpu" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version < '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", marker = "sys_platform != 'darwin'" }, + { name = "pillow", marker = "sys_platform != 'darwin'" }, + { name = "torch", version = "2.12.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform != 'darwin'" }, +] +wheels = [ + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.27.0%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1b06f42d48b62098114923d8a3fe9fa864182715db06584a515155db0aa8eb30", upload-time = "2026-05-12T16:20:36Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.27.0%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:912a8b008d95fc9f8f8507e0663238aa01abfeb29f38e218116217560a1b6401", upload-time = "2026-05-12T16:20:36Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.27.0%2Bcpu-cp312-cp312-win_amd64.whl", hash = "sha256:4c87d4bb691765173f186f5a9882fb85a75301547c4186dd75688bba907076e4", upload-time = "2026-05-12T16:20:36Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.27.0%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:69093b64b2762c43df17be2db2be163029963d90bc3f1801500fdeb723e54833", upload-time = "2026-05-12T16:20:36Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.27.0%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:ba77816bbde883c0c2075a1e284cf2e6f324472d4523442f5e3ae0812a98ae1e", upload-time = "2026-05-12T16:20:36Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.27.0%2Bcpu-cp313-cp313-win_amd64.whl", hash = "sha256:159c7d32de5256ed946b4188335b00d11e0c3f7838998f94eaa98384b0249600", upload-time = "2026-05-12T16:20:36Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.27.0%2Bcpu-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:1777906f08c75be6daf9a11946894b63ef368951e7aa52da83f8bfc824856123", upload-time = "2026-05-12T16:20:36Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.27.0%2Bcpu-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:4388677d0903db4892587d5c87ab6f6cb31d19bac4838e083db022682036e152", upload-time = "2026-05-12T16:20:36Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.27.0%2Bcpu-cp313-cp313t-win_amd64.whl", hash = "sha256:1d38651bd624dbbaf5ad77ccac93a60b16fc81f1ccacf2f5268bbc0dd2e7e1d7", upload-time = "2026-05-12T16:20:37Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.27.0%2Bcpu-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:c7e3c5662278ab5d64d150cc17694060e9ce5875b8739094dadf07a4ba45b90e", upload-time = "2026-05-12T16:20:37Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.27.0%2Bcpu-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:1417b91354698b045a4127cf7108b1dc8af1351a3002ecc5661c3ca69df577d4", upload-time = "2026-05-12T16:20:37Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.27.0%2Bcpu-cp314-cp314-win_amd64.whl", hash = "sha256:58759994610e69773b1d7b038dfb2c72e8bf237fb3ba765f2ab1d6af7835dd88", upload-time = "2026-05-12T16:20:37Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.27.0%2Bcpu-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4d2effc9740e711ea9b2db99e8874977eb6a3baa8b830b8a7416b2fde416de64", upload-time = "2026-05-12T16:20:37Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.27.0%2Bcpu-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ae89bf1a81f3c7fdd222fbbddd8135a054d24ddd13f3b410e4f96d75f72952df", upload-time = "2026-05-12T16:20:37Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.27.0%2Bcpu-cp314-cp314t-win_amd64.whl", hash = "sha256:c8b3c995d4294551b5ab33cbcf60d700819dc23d53b21a9c74936e521c88de33", upload-time = "2026-05-12T16:20:37Z" }, +] + +[[package]] +name = "torchvision" +version = "0.27.0+cu130" +source = { registry = "https://download.pytorch.org/whl/cu130" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, + { name = "torch", version = "2.12.0+cu130", source = { registry = "https://download.pytorch.org/whl/cu130" } }, +] +wheels = [ + { url = "https://download-r2.pytorch.org/whl/cu130/torchvision-0.27.0%2Bcu130-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:0a839a2921410b1135add4c3d90f784c9d1e9e9f3c7b401b216d356ddca23ab2", upload-time = "2026-05-12T16:20:44Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torchvision-0.27.0%2Bcu130-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:664dff46fac97a730c90a976a370ae2cad52780df6ae40fad74be77eee8b4528", upload-time = "2026-05-12T16:20:44Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torchvision-0.27.0%2Bcu130-cp312-cp312-win_amd64.whl", hash = "sha256:a79f78d23557b5299c1a1eceeef846d6799ea0a3afe30c600c80ebd26a80bbf8", upload-time = "2026-05-13T02:00:45Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torchvision-0.27.0%2Bcu130-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:da81245777c47f6dfd60e02f510d9778fb7f6e23119e2fc1ea1bb06777aae338", upload-time = "2026-05-12T16:20:44Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torchvision-0.27.0%2Bcu130-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:afa4128f37066b83af9d426841a53147dd3c208efea893c93dc3eb6fa2af2287", upload-time = "2026-05-12T16:20:44Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torchvision-0.27.0%2Bcu130-cp313-cp313-win_amd64.whl", hash = "sha256:31533c28f23bf642989a9ae12caa40a2f8cc9b443d556ba2ffb7a51f759e6a11", upload-time = "2026-05-13T02:00:46Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torchvision-0.27.0%2Bcu130-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:bb511f033cd3d6f304dc25753d2a28a1d77aa4dd54a219242d9df7fa57d8dd0a", upload-time = "2026-05-12T16:20:44Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torchvision-0.27.0%2Bcu130-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:0c375ac4e9a1c09308f81b73d111d50b76eec335dc91a1811ae370467db2cf47", upload-time = "2026-05-12T16:20:45Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torchvision-0.27.0%2Bcu130-cp313-cp313t-win_amd64.whl", hash = "sha256:34d108e1ce8255e017bf1f732a51ab2e9ddffb443d118db499a0fbbeb0164650", upload-time = "2026-05-13T02:00:47Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torchvision-0.27.0%2Bcu130-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:5226fd6b558bb06a594959948e76e19ac73eec3d7ee0acc7c7b1ae3e061b5698", upload-time = "2026-05-12T16:20:45Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torchvision-0.27.0%2Bcu130-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:74c2e33effcdad257800c178f87f22cff311efe7037568b3feae7b4e191ce209", upload-time = "2026-05-12T16:20:45Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torchvision-0.27.0%2Bcu130-cp314-cp314-win_amd64.whl", hash = "sha256:a87da1d0019ad7481e8d1ec0071e8c2f51145898248e8c590a1d886feee82129", upload-time = "2026-05-13T02:00:47Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torchvision-0.27.0%2Bcu130-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:00fc9eaf3231a11ee566162483325b18f32635cab48babd7e0d775ab1fb047e4", upload-time = "2026-05-12T16:20:45Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torchvision-0.27.0%2Bcu130-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:541e9ae408b5a43d623313eee52ef39732e04d1284807b926724e5e391a45d1b", upload-time = "2026-05-12T16:20:45Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torchvision-0.27.0%2Bcu130-cp314-cp314t-win_amd64.whl", hash = "sha256:3641b3bb5ad0150e694c9d7042b8a2fb5e0683d5bcf701fa99a2200f98b7c91b", upload-time = "2026-05-13T02:00:48Z" }, +] + +[[package]] +name = "tornado" +version = "6.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/64/24/95ec527ad67b76d59299e5465b3935d05e4294b7e0290a3924b7487df30b/tornado-6.5.7.tar.gz", hash = "sha256:66c513a76cda70d53907bc27cf1447557699c2e95aa48ba27a442ff61c3ddfc2", size = 519252, upload-time = "2026-06-08T17:34:51.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/dc/c7043cab6fed8ae159fc1923ce829ada35c4dbd797d408a43858ffaf9639/tornado-6.5.7-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:148b2eb15c2c765a50796172c1e499649b35f30d2e3c3d3e15913cfa56bfb163", size = 448543, upload-time = "2026-06-08T17:34:38.052Z" }, + { url = "https://files.pythonhosted.org/packages/92/4f/090b1431e5a43df696feceffc268c5383cc079ecb5f08ce58f917109aafe/tornado-6.5.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9da38de27f1da3b78a966f0dae12b5a1ea9afe72ca805d84ff06508272ddf100", size = 446707, upload-time = "2026-06-08T17:34:39.594Z" }, + { url = "https://files.pythonhosted.org/packages/37/d8/ef374952fd5da67d4463122c2b8e5a96536ec10b4b339254c6dcde81d01c/tornado-6.5.7-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d759e71906ee783f8867b93bf26a265743da4c1e2f4a018464c1ba019862972", size = 449774, upload-time = "2026-06-08T17:34:41.204Z" }, + { url = "https://files.pythonhosted.org/packages/35/37/d434c73f4c6e014b745b9b37085f34f40c022f007efff3d7fe65991899f3/tornado-6.5.7-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a46347a18f23fb92b396beebe0fb78f61dda0cc302445202c16203d8a18848b", size = 450745, upload-time = "2026-06-08T17:34:42.531Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/56b9aff361d7f1ab728a805ec7d7ea835f8807afa9f5cc690ea0e630efb9/tornado-6.5.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7778b30bef919231265e91c69963ce0f49a1e9c07ac900bbe75b19ce2575ba92", size = 450578, upload-time = "2026-06-08T17:34:43.787Z" }, + { url = "https://files.pythonhosted.org/packages/02/30/a7444fb23aa76860a14198fab96ac79f1866b0a6e19e26c4381b0938e50f/tornado-6.5.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e726f0c75da7726eec023aa62751ff8878bd2737e34fbdd33b1ae5897d2200f5", size = 449985, upload-time = "2026-06-08T17:34:45.326Z" }, + { url = "https://files.pythonhosted.org/packages/5c/42/5f0e56c01e8d9d36f4e23f367b85ae6cae0c1ecddd5e6977d8388ad27488/tornado-6.5.7-cp39-abi3-win32.whl", hash = "sha256:f8de3bf12d3efdd0cbe7c8887868198f8a91415e3f29fcf258d9b8eb7b1d9ae4", size = 451047, upload-time = "2026-06-08T17:34:46.784Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a4/b393076ffb21b469eec5b328a0534cf03a3b90bfc6b1f09507cdd075d938/tornado-6.5.7-cp39-abi3-win_amd64.whl", hash = "sha256:de942f843533a039ef9fa3d9c88c7cd8a7c94553fb5ad0154270989b3d99a2c4", size = 451485, upload-time = "2026-06-08T17:34:48.248Z" }, + { url = "https://files.pythonhosted.org/packages/71/2e/7b1c769803121b809112cf9a00681c472eae1d80e32d7ec0e0bd61d0d0e1/tornado-6.5.7-cp39-abi3-win_arm64.whl", hash = "sha256:ff934fce95643af5f11efdae618eaa73d469dc588641e5c8d19295a0c65c4796", size = 450506, upload-time = "2026-06-08T17:34:49.702Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + +[[package]] +name = "traitlets" +version = "5.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/a9/a2584b8313b89f94869ddb3c4074617a691de1812a614d2d50e32ca5a7a6/traitlets-5.15.1.tar.gz", hash = "sha256:7b1c07854fe25acb39e009bae49f11b79ff6cbb2f27999104e9110e7a6b53722", size = 163344, upload-time = "2026-06-03T12:26:06.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/8d/1080ee4c231f361b6ce4470d556c8c435b67c7e0753aaa641497ee92f88b/traitlets-5.15.1-py3-none-any.whl", hash = "sha256:770a53705f84b81ac107e83a1b3328ff2dae16094d8fc3cfc004e4b22dfd8e92", size = 85858, upload-time = "2026-06-03T12:26:04.395Z" }, +] + +[[package]] +name = "transformers" +version = "5.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "regex" }, + { name = "safetensors" }, + { name = "tokenizers" }, + { name = "tqdm" }, + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/e6/4134ea2fbea322cddc7ffc94a0d8ee47fe32ce8e876b320cd37d88edfc4d/transformers-5.8.1.tar.gz", hash = "sha256:4dd5b6de4105725104d84fd6abd74b305f4debfc251b38c648ee5dd087cf543b", size = 8532019, upload-time = "2026-05-13T03:21:57.234Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/b1/8be7e7ef0b5200491312201918b6125ef9c9df9dd0f0240ccef9ac824e6b/transformers-5.8.1-py3-none-any.whl", hash = "sha256:5340fb95962162cdfdae5cc91d7f8fedd92ed75216c1154c5e1f590fcf56dd0e", size = 10632882, upload-time = "2026-05-13T03:21:52.876Z" }, +] + +[[package]] +name = "triton" +version = "3.7.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/13/ec05adfcd87311d532ba61e3af143e8be59fcd26675884c4682841406a20/triton-3.7.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4bf49b00a7a377a68a6da603a876e797614e6455a80e9021669c476a953ad9a", size = 188505104, upload-time = "2026-05-07T19:05:09.843Z" }, + { url = "https://files.pythonhosted.org/packages/62/7b/468a576e35beef1426e0828e28e9ba9e65f5474d496f16ee126c15646324/triton-3.7.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f111161d49bf903c0eaedde3962353a3d841c08a836839b7cc1025b8426efcf", size = 201457567, upload-time = "2026-05-07T18:46:13.505Z" }, + { url = "https://files.pythonhosted.org/packages/01/e1/a59a583de59b8f62c495d67c80ee3ea97d09e91ac80c4c6e76456ed8d8ac/triton-3.7.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abdf6beaa89b1bcfb9a43cd990536ce66091a997841a4814b260b7bee4c88c3c", size = 188503209, upload-time = "2026-05-07T19:05:17.935Z" }, + { url = "https://files.pythonhosted.org/packages/30/b1/b7507bb9815d403927c8dd51d4158ed2e11751a92dbc118a044f247b6848/triton-3.7.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a35d7afe3f3f058e7ec49fcce09794049e0ffc5c59019ac25ec3413741b8c4e7", size = 201453566, upload-time = "2026-05-07T18:46:20.427Z" }, + { url = "https://files.pythonhosted.org/packages/a6/8f/0bea7a6a0c989315c9135a1d7fb37e41905cfb3a17cbc1f10044ebd4cc3a/triton-3.7.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc1d61c172d257db80ddf42595131fb196ad2e9bdd751e90fe2ef13531734e8b", size = 188612899, upload-time = "2026-05-07T19:05:24.955Z" }, + { url = "https://files.pythonhosted.org/packages/e1/02/d96f57828d0912aec733b9bc7e0e7dbfd2c6f079a8fa433ac25cb93d1a30/triton-3.7.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70fb9bbdc9f400afc54bbf6eb2670af28829a6ae3996863317964783141daf56", size = 201553816, upload-time = "2026-05-07T18:46:27.49Z" }, + { url = "https://files.pythonhosted.org/packages/40/fb/82a802dac4689f2a2fb2e69302e6a138eecc3e175bbe976ba3cfc717683a/triton-3.7.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a44a8476d0d3571eac4e4d1048e1ff75aad81a09ff4602ccfc56c6dea1672e", size = 188507879, upload-time = "2026-05-07T19:05:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/8f/af/9904ec6d3c93d9b24e5ec360445bbdf758b7f00bfbeedb89cb0eb64eb8bb/triton-3.7.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9b85e72968a9d8bba5ddb24e9b64aaabaf48affb042f2755cb7cfa92b7531ce", size = 201460637, upload-time = "2026-05-07T18:46:34.749Z" }, + { url = "https://files.pythonhosted.org/packages/a1/f9/4835a8ea746b88727d8899f4e3ccce4f9cacb38abfc3bb0a638266c53111/triton-3.7.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18a160de426fd99f92b0baf509045360afbd3bfaa0b4a5171dde800ec9f09684", size = 188608706, upload-time = "2026-05-07T19:05:39.218Z" }, + { url = "https://files.pythonhosted.org/packages/c1/68/fa86e5a39608000f645535b2c124920126327ab731f8c4fafd5b07ff8d4b/triton-3.7.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce061073102714b725f3660ec6939d94a1da7984b3aa99c921417cae273672f5", size = 201546766, upload-time = "2026-05-07T18:46:42.088Z" }, +] + +[[package]] +name = "typer" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.46.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/93/041fca8274050e40e6791f267d82e0e2e27dd165627bd640d3e0e378d877/uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d", size = 88758, upload-time = "2026-04-23T07:16:00.151Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/a3/5b1562db76a5a488274b2332a97199b32d0442aca0ed193697fd47786316/uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", size = 70926, upload-time = "2026-04-23T07:15:58.355Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] From c67a0579e7691777435d582e24bb1689e011e778 Mon Sep 17 00:00:00 2001 From: Marvin Hofer Date: Mon, 20 Jul 2026 23:52:08 +0200 Subject: [PATCH 86/96] exp(params): added consistency metrics to aggregate config --- .../param-opti/src/kgpipe_search/evaluation.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/experiments/param-opti/src/kgpipe_search/evaluation.py b/experiments/param-opti/src/kgpipe_search/evaluation.py index 0d3de5a..a7383e9 100644 --- a/experiments/param-opti/src/kgpipe_search/evaluation.py +++ b/experiments/param-opti/src/kgpipe_search/evaluation.py @@ -20,12 +20,17 @@ ], "aggregation": "mean" }, - # "consistency": { - # "measurements": [ - # {"metric": "ConsistencyMetric", "measurement": "consistency_score"} - # ], - # "aggregation": "mean" - # }, + "consistency": { + "measurements": [ + "DisjointDomainMetric.normalized_score", + "DomainMetric.normalized_score", + "RangeMetric.normalized_score", + "DatatypeFormatMetric.normalized_score", + "DatatypeMetric.normalized_score", + "RelationDirectionMetric.normalized_score" + ], + "aggregation": "mean" + }, # "cleanliness": { # "measurements": [ # {"metric": "DuplicateMetric", "measurement": "duplicates_ratio", "transform": "invert"} From bdeffbe39351a3dd97d5872788c68903b08c8e0f Mon Sep 17 00:00:00 2001 From: Marvin Hofer Date: Tue, 21 Jul 2026 23:32:26 +0200 Subject: [PATCH 87/96] exp(params): added llm search strategy --- .../src/kgpipe_search/strategies/__init__.py | 8 +- .../kgpipe_search/strategies/llm_client.py | 98 +++++++++ .../kgpipe_search/strategies/llm_strategy.py | 190 ++++++++++++++++++ .../strategies/llm_validation.py | 141 +++++++++++++ .../kgpipe_search/strategies/strategies.py | 2 +- 5 files changed, 437 insertions(+), 2 deletions(-) create mode 100644 experiments/param-opti/src/kgpipe_search/strategies/llm_client.py create mode 100644 experiments/param-opti/src/kgpipe_search/strategies/llm_strategy.py create mode 100644 experiments/param-opti/src/kgpipe_search/strategies/llm_validation.py diff --git a/experiments/param-opti/src/kgpipe_search/strategies/__init__.py b/experiments/param-opti/src/kgpipe_search/strategies/__init__.py index e27115f..efcc119 100644 --- a/experiments/param-opti/src/kgpipe_search/strategies/__init__.py +++ b/experiments/param-opti/src/kgpipe_search/strategies/__init__.py @@ -1,4 +1,10 @@ """Search strategies and initialization routines for KGpipe configuration search.""" -"""Search strategies and initialization routines for KGpipe configuration search.""" +from kgpipe_search.strategies.llm_strategy import propose_pipeline_config_with_llm, run_llm +from kgpipe_search.strategies.strategies import SearchRun +__all__ = [ + "SearchRun", + "propose_pipeline_config_with_llm", + "run_llm", +] diff --git a/experiments/param-opti/src/kgpipe_search/strategies/llm_client.py b/experiments/param-opti/src/kgpipe_search/strategies/llm_client.py new file mode 100644 index 0000000..f50c113 --- /dev/null +++ b/experiments/param-opti/src/kgpipe_search/strategies/llm_client.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import json +import os +import urllib.error +import urllib.request +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Protocol + + +class ChatCompletionClient(Protocol): + def complete(self, *, system: str, user: str) -> str: + ... + + +def _env_first(*names: str) -> Optional[str]: + for name in names: + value = os.environ.get(name) + if value: + return value + return None + + +@dataclass +class OpenAICompatibleClient: + """ + Minimal OpenAI-compatible chat client. + + Configuration via environment variables: + - endpoint: KGPipe_SEARCH_LLM_ENDPOINT, OPENAI_BASE_URL, OPENAI_API_BASE + - token: KGPipe_SEARCH_LLM_TOKEN, OPENAI_API_KEY + - model: KGPipe_SEARCH_LLM_MODEL (default: gpt-4o-mini) + """ + + endpoint: str + token: str + model: str = "gpt-4o-mini" + timeout_s: float = 60.0 + + @classmethod + def from_env(cls) -> "OpenAICompatibleClient": + endpoint = _env_first( + "KGPipe_SEARCH_LLM_ENDPOINT", + "OPENAI_BASE_URL", + "OPENAI_API_BASE", + ) + token = _env_first("KGPipe_SEARCH_LLM_TOKEN", "OPENAI_API_KEY") + if not endpoint: + raise ValueError( + "LLM endpoint not configured. Set KGPipe_SEARCH_LLM_ENDPOINT or OPENAI_BASE_URL." + ) + if not token: + raise ValueError( + "LLM token not configured. Set KGPipe_SEARCH_LLM_TOKEN or OPENAI_API_KEY." + ) + + model = os.environ.get("KGPipe_SEARCH_LLM_MODEL", "gpt-4o-mini") + return cls(endpoint=endpoint.rstrip("/"), token=token, model=model) + + def complete(self, *, system: str, user: str) -> str: + url = f"{self.endpoint}/chat/completions" + payload: Dict[str, Any] = { + "model": self.model, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + "temperature": 0.2, + } + body = json.dumps(payload).encode("utf-8") + request = urllib.request.Request( + url, + data=body, + headers={ + "Authorization": f"Bearer {self.token}", + "Content-Type": "application/json", + }, + method="POST", + ) + + try: + with urllib.request.urlopen(request, timeout=self.timeout_s) as response: + raw = json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + raise RuntimeError(f"LLM request failed ({exc.code}): {detail}") from exc + except urllib.error.URLError as exc: + raise RuntimeError(f"LLM request failed: {exc}") from exc + + choices: List[Dict[str, Any]] = raw.get("choices") or [] + if not choices: + raise RuntimeError(f"LLM response missing choices: {raw!r}") + + message = choices[0].get("message") or {} + content = message.get("content") + if not isinstance(content, str) or not content.strip(): + raise RuntimeError(f"LLM response missing message content: {raw!r}") + return content diff --git a/experiments/param-opti/src/kgpipe_search/strategies/llm_strategy.py b/experiments/param-opti/src/kgpipe_search/strategies/llm_strategy.py new file mode 100644 index 0000000..ede80cf --- /dev/null +++ b/experiments/param-opti/src/kgpipe_search/strategies/llm_strategy.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +import json +import random +import re +from typing import Any, Dict, List, Optional, Set, Tuple + +from kgpipe_search.configuration import ( + pipeline_config_from_snapshot, + pipeline_config_snapshot_key, + pipeline_config_to_snapshot, + sample_valid_pipeline_config, + task_keys_from_pipeline_config, +) +from kgpipe_search.definitions import PipelineConfig, PipelineLayout +from kgpipe_search.strategies.llm_client import ChatCompletionClient, OpenAICompatibleClient +from kgpipe_search.strategies.llm_validation import ( + search_space_description, + validate_pipeline_config_snapshot, +) +from kgpipe_search.strategies.strategies import EvaluateFn, Observation, SearchRun + +_JSON_BLOCK_RE = re.compile(r"```(?:json)?\s*([\s\S]*?)\s*```", re.IGNORECASE) + + +def _extract_json_object(text: str) -> Dict[str, Any]: + stripped = text.strip() + candidates = [stripped] + for match in _JSON_BLOCK_RE.finditer(text): + candidates.append(match.group(1).strip()) + + last_error: Optional[Exception] = None + for candidate in candidates: + try: + parsed = json.loads(candidate) + if isinstance(parsed, dict): + return parsed + except json.JSONDecodeError as exc: + last_error = exc + continue + + raise ValueError(f"Could not parse JSON object from LLM response: {text!r}") from last_error + + +def _history_summary(history: List[Observation]) -> List[Dict[str, Any]]: + ranked = sorted(history, key=lambda item: item[0], reverse=True) + summary: List[Dict[str, Any]] = [] + for score, cfg in ranked[:5]: + task_keys = task_keys_from_pipeline_config(cfg) + summary.append( + { + "score": score, + "snapshot": pipeline_config_to_snapshot(task_keys, cfg), + } + ) + return summary + + +def _build_prompt( + *, + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, + history: List[Observation], + evaluated_keys: Set[str], + attempt: int, + last_error: str, +) -> Tuple[str, str]: + system = ( + "You propose valid KGpipe pipeline configurations. " + "Respond with a single JSON object only. " + "Every task_key must be chosen from valid_task_combinations. " + "Every parameter value must be one of the allowed values in tasks." + ) + payload = { + "search_space": search_space_description(search_space, pipeline_layout), + "attempt": attempt, + "already_evaluated_count": len(evaluated_keys), + "best_observations": _history_summary(history), + "last_validation_error": last_error or None, + "instructions": [ + "Pick one valid task_keys combination from valid_task_combinations.", + "For each selected task with parameters, provide bindings using only allowed values.", + "Prefer configs that differ from already evaluated ones when possible.", + "Return JSON matching output_schema.", + ], + } + return system, json.dumps(payload, indent=2) + + +def propose_pipeline_config_with_llm( + *, + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, + client: ChatCompletionClient, + history: Optional[List[Observation]] = None, + evaluated_keys: Optional[Set[str]] = None, + max_retries: int = 3, +) -> Tuple[PipelineConfig, str]: + """ + Ask an LLM for a pipeline config snapshot, validate it, and retry on failure. + """ + if max_retries < 1: + raise ValueError("max_retries must be >= 1") + + observations = history or [] + seen = evaluated_keys or set() + last_error = "" + + for attempt in range(1, max_retries + 1): + system, user = _build_prompt( + search_space=search_space, + pipeline_layout=pipeline_layout, + history=observations, + evaluated_keys=seen, + attempt=attempt, + last_error=last_error, + ) + raw = client.complete(system=system, user=user) + try: + snapshot = _extract_json_object(raw) + except ValueError as exc: + last_error = str(exc) + continue + + is_valid, error = validate_pipeline_config_snapshot( + snapshot, search_space, pipeline_layout + ) + if not is_valid: + last_error = error + continue + + config = pipeline_config_from_snapshot(snapshot) + key = pipeline_config_snapshot_key(config, search_space) + if key in seen: + last_error = "configuration was already evaluated" + continue + + return config, f"llm(attempt={attempt})" + + raise RuntimeError( + f"LLM failed to produce a valid unevaluated config after {max_retries} attempts. " + f"Last error: {last_error}" + ) + + +def run_llm( + *, + budget: int, + evaluate_fn: EvaluateFn, + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, + max_retries: int = 3, + client: Optional[ChatCompletionClient] = None, + rng: Optional[random.Random] = None, +) -> SearchRun: + if budget <= 0: + return SearchRun(strategy="llm", history=[], budget=0, decisions=[]) + + draw = rng or random.Random() + llm_client = client or OpenAICompatibleClient.from_env() + history: List[Observation] = [] + decisions: List[str] = [] + evaluated_keys: Set[str] = set() + + for _ in range(budget): + try: + candidate, decision = propose_pipeline_config_with_llm( + search_space=search_space, + pipeline_layout=pipeline_layout, + client=llm_client, + history=history, + evaluated_keys=evaluated_keys, + max_retries=max_retries, + ) + except RuntimeError: + candidate = sample_valid_pipeline_config(search_space, pipeline_layout, rng=draw) + decision = "fallback(random)" + + key = pipeline_config_snapshot_key(candidate, search_space) + if key in evaluated_keys: + candidate = sample_valid_pipeline_config(search_space, pipeline_layout, rng=draw) + decision = f"{decision}+dedupe(random)" + + key = pipeline_config_snapshot_key(candidate, search_space) + score = evaluate_fn(candidate) + history.append((score, candidate)) + evaluated_keys.add(key) + decisions.append(decision) + + return SearchRun(strategy="llm", history=history, budget=budget, decisions=decisions) diff --git a/experiments/param-opti/src/kgpipe_search/strategies/llm_validation.py b/experiments/param-opti/src/kgpipe_search/strategies/llm_validation.py new file mode 100644 index 0000000..8d0cdbe --- /dev/null +++ b/experiments/param-opti/src/kgpipe_search/strategies/llm_validation.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +from typing import Any, Dict, Tuple + +from kgpipe_search.configuration import ( + _task_categories_list, + enumerate_valid_task_combinations, + pipeline_config_from_snapshot, +) +from kgpipe_search.definitions import PipelineLayout, task_dict + + +def validate_pipeline_config_snapshot( + snapshot: Dict[str, Any], + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, +) -> Tuple[bool, str]: + """ + Validate an LLM-produced pipeline config snapshot against the search space and layout. + + Returns (is_valid, error_message). error_message is empty when valid. + """ + task_keys = snapshot.get("task_keys") + if not isinstance(task_keys, list) or not task_keys: + return False, "snapshot must contain a non-empty task_keys list" + if not all(isinstance(key, str) for key in task_keys): + return False, "task_keys must contain only strings" + + unknown = [key for key in task_keys if key not in search_space] + if unknown: + return False, f"unknown task keys: {unknown}" + + valid_combos = { + tuple(combo) + for combo in enumerate_valid_task_combinations(search_space, pipeline_layout) + } + if tuple(task_keys) not in valid_combos: + return False, f"task_keys {task_keys!r} is not a valid implementation assignment" + + covered: set[str] = set() + for task_key in task_keys: + covered.update(_task_categories_list(search_space, task_key)) + + required = set(pipeline_layout.allowed_task_categories) + if not required.issubset(covered): + missing = sorted(required - covered) + return False, f"pipeline does not cover required categories: {missing}" + + profiles = snapshot.get("profiles") + if profiles is None: + profiles = {} + if not isinstance(profiles, dict): + return False, "profiles must be an object when present" + + for task_key in task_keys: + task = task_dict[task_key] + task_space = search_space[task_key] + param_names = [ + name + for name, values in task_space.items() + if name != "category" and isinstance(values, list) + ] + + if not param_names: + continue + + if getattr(task, "config_spec", None) is None: + continue + + profile = profiles.get(task.name) + if profile is None: + return False, f"missing profile for task {task.name!r}" + + bindings = profile.get("bindings") + if not isinstance(bindings, list): + return False, f"profile for {task.name!r} must have bindings list" + + binding_map: Dict[str, Any] = {} + for binding in bindings: + if not isinstance(binding, dict): + return False, f"invalid binding entry for {task.name!r}" + param = binding.get("parameter") + value = binding.get("value") + if not isinstance(param, str): + return False, f"binding parameter must be a string for {task.name!r}" + binding_map[param] = value + + for param_name in param_names: + allowed = task_space[param_name] + if param_name not in binding_map: + return False, f"missing parameter {param_name!r} for task {task_key!r}" + if binding_map[param_name] not in allowed: + return False, ( + f"invalid value for {task_key!r}.{param_name}: " + f"{binding_map[param_name]!r} not in {allowed!r}" + ) + + extra = set(binding_map) - set(param_names) + if extra: + return False, f"unexpected parameters for {task_key!r}: {sorted(extra)}" + + try: + pipeline_config_from_snapshot(snapshot) + except Exception as exc: # noqa: BLE001 - surface parse errors to caller + return False, f"failed to build pipeline config: {exc}" + + return True, "" + + +def search_space_description( + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, +) -> Dict[str, Any]: + """Serialize search space and layout for LLM prompts.""" + tasks: Dict[str, Any] = {} + for task_key, task_space in search_space.items(): + entry: Dict[str, Any] = {"category": task_space.get("category")} + for name, values in task_space.items(): + if name == "category": + continue + if isinstance(values, list): + entry[name] = values + tasks[task_key] = entry + + valid_combos = enumerate_valid_task_combinations(search_space, pipeline_layout) + return { + "pipeline_layout": { + "allowed_task_categories": pipeline_layout.allowed_task_categories, + }, + "tasks": tasks, + "valid_task_combinations": valid_combos, + "output_schema": { + "task_keys": ["", "..."], + "profiles": { + "": { + "profile_name": "", + "bindings": [{"parameter": "", "value": ""}], + } + }, + }, + } diff --git a/experiments/param-opti/src/kgpipe_search/strategies/strategies.py b/experiments/param-opti/src/kgpipe_search/strategies/strategies.py index e338175..f7b5436 100644 --- a/experiments/param-opti/src/kgpipe_search/strategies/strategies.py +++ b/experiments/param-opti/src/kgpipe_search/strategies/strategies.py @@ -20,7 +20,7 @@ Observation = Tuple[float, PipelineConfig] EvaluateFn = Callable[[PipelineConfig], float] -SearchStrategy = Literal["random", "implementation_aware", "qgns", "hnr", "bayesian"] +SearchStrategy = Literal["random", "implementation_aware", "qgns", "hnr", "bayesian", "llm"] @dataclass From 7d0869e86b8d448461b989a2f0904a3a2126f3dc Mon Sep 17 00:00:00 2001 From: Marvin Hofer Date: Tue, 21 Jul 2026 23:32:59 +0200 Subject: [PATCH 88/96] exp(params): update --- experiments/param-opti/src/experiment.py | 21 +++- .../kgpipe_search/dev/tasks/text_helpers.py | 97 ++++++++++++--- .../src/kgpipe_search/evaluation.py | 12 +- .../param-opti/src/kgpipe_search/search.py | 23 ++++ .../kgpipe_search/test/test_llm_strategy.py | 111 ++++++++++++++++++ 5 files changed, 242 insertions(+), 22 deletions(-) create mode 100644 experiments/param-opti/src/kgpipe_search/test/test_llm_strategy.py diff --git a/experiments/param-opti/src/experiment.py b/experiments/param-opti/src/experiment.py index 7ebf689..cb118b0 100644 --- a/experiments/param-opti/src/experiment.py +++ b/experiments/param-opti/src/experiment.py @@ -46,6 +46,7 @@ bayesian_optimization, hnr_search, implementation_aware_search, + llm_search, qgns_search, random_search, ) @@ -54,7 +55,7 @@ import execute as pipeline_execute PipelineType = Literal["rdf", "text"] -SearchStrategyName = Literal["random", "implementation_aware", "qgns", "hnr", "bayesian"] +SearchStrategyName = Literal["random", "implementation_aware", "qgns", "hnr", "bayesian", "llm"] TasksTmpScope = Literal["config", "pipeline", "shared"] InitStrategy = Literal["random", "implementation_aware"] @@ -81,6 +82,7 @@ def _run_search( rho: float, pool_size: int, beta: float, + llm_max_retries: int, rng: random.Random, ) -> SearchRun: common = { @@ -128,6 +130,12 @@ def _run_search( beta=beta, ) + if strategy == "llm": + return llm_search( + **common, + max_retries=llm_max_retries, + ) + raise ValueError(f"Unknown search strategy {strategy!r}") @@ -148,6 +156,7 @@ def run_search_experiment( rho: float, pool_size: int, beta: float, + llm_max_retries: int, rng_seed: int, tasks_tmp_scope: TasksTmpScope, results_path: Optional[Path], @@ -280,6 +289,7 @@ def evaluate_fn(pipeline_config: PipelineConfig) -> float: rho=rho, pool_size=pool_size, beta=beta, + llm_max_retries=llm_max_retries, rng=rng, ) @@ -387,7 +397,7 @@ def build_parser() -> argparse.ArgumentParser: ) parser.add_argument( "--strategy", - choices=["random", "implementation_aware", "qgns", "hnr", "bayesian"], + choices=["random", "implementation_aware", "qgns", "hnr", "bayesian", "llm"], default="random", help=( "Search strategy to use. " @@ -433,6 +443,12 @@ def build_parser() -> argparse.ArgumentParser: default=0.5, help="Acquisition beta for Bayesian optimization", ) + parser.add_argument( + "--llm-max-retries", + type=int, + default=3, + help="Validation retries per LLM proposal when using --strategy llm", + ) parser.add_argument( "--rng-seed", type=int, @@ -499,6 +515,7 @@ def main(argv: Optional[List[str]] = None) -> int: rho=args.rho, pool_size=args.pool_size, beta=args.beta, + llm_max_retries=args.llm_max_retries, rng_seed=args.rng_seed, tasks_tmp_scope=args.tasks_tmp_scope, results_path=args.results, diff --git a/experiments/param-opti/src/kgpipe_search/dev/tasks/text_helpers.py b/experiments/param-opti/src/kgpipe_search/dev/tasks/text_helpers.py index 0bef3f4..52fb1ac 100644 --- a/experiments/param-opti/src/kgpipe_search/dev/tasks/text_helpers.py +++ b/experiments/param-opti/src/kgpipe_search/dev/tasks/text_helpers.py @@ -19,36 +19,87 @@ logger = logging.getLogger(__name__) +def _file_stem_key(filename: str) -> str: + """Basename without extensions, e.g. 'hash.te.json' -> 'hash'.""" + return filename.split(".", 1)[0] + + +def _index_dir_by_stem(dir_path: Path) -> Dict[str, Path]: + """Map stem -> file path for files in a directory (first match wins).""" + by_stem: Dict[str, Path] = {} + for entry in dir_path.iterdir(): + if entry.is_file(): + stem = _file_stem_key(entry.name) + if stem not in by_stem: + by_stem[stem] = entry + return by_stem + + +def __aggregate_x_te_json( + input_paths: List[Path], + output_path: Path, + match_by_stem: bool = False, +): + """ + Merge TE_Document JSON from files or directories. -def __aggregate_x_te_json(input_paths: List[Path], output_path: Path): - + When all inputs are directories and ``match_by_stem`` is True, files are + paired by stem (name before the first ``.``), so e.g. + ``hash.te.json`` and ``hash.txt.json`` are merged even though the + full filenames differ. + """ if len(input_paths) == 0: raise Exception("No input paths provided") - if not all(os.path.exists(path) for path in input_paths): + if not all(path.exists() for path in input_paths): raise Exception("All input paths must exist") - path_is_dir_list = [os.path.isdir(path) for path in input_paths] + path_is_dir_list = [path.is_dir() for path in input_paths] if all(path_is_dir_list): - os.makedirs(output_path, exist_ok=True) - for file in os.listdir(input_paths[0]): - sub_file_paths = [Path(os.path.join(path, file)) for path in input_paths] - file_exists = [os.path.exists(path) for path in sub_file_paths] - if all(file_exists): - __aggregate_x_te_json(sub_file_paths, Path(os.path.join(output_path, file))) - else: - logger.warning(f"File {file} does not exist in all input paths") - filtered_sub_file_paths = [path for path in sub_file_paths if os.path.exists(path)] - __aggregate_x_te_json(filtered_sub_file_paths, Path(os.path.join(output_path, file))) - elif not all(path_is_dir_list): + output_path.mkdir(parents=True, exist_ok=True) + + if match_by_stem: + stem_indexes = [_index_dir_by_stem(path) for path in input_paths] + for stem, primary_file in stem_indexes[0].items(): + matched = [idx[stem] for idx in stem_indexes if stem in idx] + if len(matched) < len(input_paths): + logger.warning( + f"Stem '{stem}' does not exist in all input paths " + f"(found in {len(matched)}/{len(input_paths)})" + ) + # Keep the first directory's filename for the output + __aggregate_x_te_json( + matched, + output_path / primary_file.name, + match_by_stem=match_by_stem, + ) + else: + for file in input_paths[0].iterdir(): + if not file.is_file(): + continue + sub_file_paths = [path / file.name for path in input_paths] + existing = [p for p in sub_file_paths if p.exists()] + if len(existing) < len(input_paths): + logger.warning( + f"File {file.name} does not exist in all input paths" + ) + __aggregate_x_te_json( + existing, + output_path / file.name, + match_by_stem=match_by_stem, + ) + elif not any(path_is_dir_list): merged_doc = TE_Document() for file in input_paths: - doc = TE_Document(**json.load(open(file))) + with open(file) as f: + doc = TE_Document(**json.load(f)) merged_doc.chains += doc.chains merged_doc.links += doc.links merged_doc.triples += doc.triples with open(output_path, "w") as f: f.write(merged_doc.model_dump_json()) - logger.info(f"Aggregated {", ".join([str(path) for path in input_paths])} to {output_path}") + logger.info( + f"Aggregated {', '.join(str(p) for p in input_paths)} to {output_path}" + ) else: raise Exception("All inputs must be either directories or files") @@ -64,7 +115,11 @@ def __aggregate_x_te_json(input_paths: List[Path], output_path: Path): def aggregate3_text_tasks_task_function(inputs: Dict[str, Data], outputs: Dict[str, Data]): - __aggregate_x_te_json([inputs["json1"].path, inputs["json2"].path, inputs["json3"].path], outputs["output"].path) + __aggregate_x_te_json( + [inputs["json1"].path, inputs["json2"].path, inputs["json3"].path], + outputs["output"].path, + match_by_stem=True, + ) aggregate_text_tasks_task = KgTask( name="aggregate_text_tasks_task", @@ -74,7 +129,11 @@ def aggregate3_text_tasks_task_function(inputs: Dict[str, Data], outputs: Dict[s ) def aggregate2_text_tasks_task_function(inputs: Dict[str, Data], outputs: Dict[str, Data]): - __aggregate_x_te_json([inputs["json1"].path, inputs["json2"].path], outputs["output"].path) + __aggregate_x_te_json( + [inputs["json1"].path, inputs["json2"].path], + outputs["output"].path, + match_by_stem=True, + ) aggregate_entity_linking_task = KgTask( name="aggregate_entity_linking_task", diff --git a/experiments/param-opti/src/kgpipe_search/evaluation.py b/experiments/param-opti/src/kgpipe_search/evaluation.py index a7383e9..06f81d8 100644 --- a/experiments/param-opti/src/kgpipe_search/evaluation.py +++ b/experiments/param-opti/src/kgpipe_search/evaluation.py @@ -1,6 +1,10 @@ from kgpipe_eval.evaluator import Evaluator from kgpipe_eval.utils.kg_utils import KgLike, KgManager -from kgpipe_eval.utils.score_utils import aggregate_scores_from_json, aggregate_scores_from_results +from kgpipe_eval.utils.score_utils import ( + AggregateScore, + aggregate_scores_from_json, + aggregate_scores_from_results, +) from kgpipe_search.definitions import PipelineConfig import os @@ -70,6 +74,12 @@ def evaluate_pipeline(pipeline_config: PipelineConfig, result_kg: KgLike, refere result_graph = KgManager.load_kg(result_kg) result_no_seed_graph = KgManager.substract_kg(result_graph, source_seed_graph) + # Empty after seed subtract: alignment encode/dot and some consistency metrics break. + if len(result_no_seed_graph.get_graph()) == 0: + KgManager.unload_kg(result_graph) + KgManager.unload_kg(result_no_seed_graph) + return AggregateScore(final_score=0.0) + consistency_violations_config = ConsistencyViolationsConfig( reference_kg=None, ontology_path=os.getenv("ONTOLOGY_PATH") diff --git a/experiments/param-opti/src/kgpipe_search/search.py b/experiments/param-opti/src/kgpipe_search/search.py index 2f67b22..7868b5f 100644 --- a/experiments/param-opti/src/kgpipe_search/search.py +++ b/experiments/param-opti/src/kgpipe_search/search.py @@ -13,6 +13,7 @@ implementation_aware_initialization, random_initialization, ) +from kgpipe_search.strategies.llm_strategy import run_llm from kgpipe_search.strategies.strategies import ( EvaluateFn, SearchRun, @@ -34,6 +35,7 @@ "qgns_search", "hnr_search", "bayesian_optimization", + "llm_search", ] @@ -157,6 +159,27 @@ def neighborhood_optimization( ) +def llm_search( + *, + budget: int, + evaluate_fn: EvaluateFn, + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, + max_retries: int = 3, + client: Any = None, + rng: Any = None, +) -> SearchRun: + return run_llm( + budget=budget, + evaluate_fn=evaluate_fn, + search_space=search_space, + pipeline_layout=pipeline_layout, + max_retries=max_retries, + client=client, + rng=rng, + ) + + def bayesian_optimization( budget: int, evaluate_fn: EvaluateFn, diff --git a/experiments/param-opti/src/kgpipe_search/test/test_llm_strategy.py b/experiments/param-opti/src/kgpipe_search/test/test_llm_strategy.py new file mode 100644 index 0000000..220d0dc --- /dev/null +++ b/experiments/param-opti/src/kgpipe_search/test/test_llm_strategy.py @@ -0,0 +1,111 @@ +import json +import random +from dataclasses import dataclass +from typing import List + +from kgpipe_search.definitions import RDF_PIPELINE_LAYOUT, RDF_SEARCH_SPACE +from kgpipe_search.evaluation import execute_and_dummy_evaluate_pipeline +from kgpipe_search.search import llm_search +from kgpipe_search.strategies.llm_strategy import propose_pipeline_config_with_llm +from kgpipe_search.strategies.llm_validation import validate_pipeline_config_snapshot + + +@dataclass +class ScriptedLlmClient: + responses: List[str] + calls: int = 0 + + def complete(self, *, system: str, user: str) -> str: + del system, user + if self.calls >= len(self.responses): + raise RuntimeError("no more scripted responses") + response = self.responses[self.calls] + self.calls += 1 + return response + + +def _valid_snapshot( + *, + entity_threshold: float = 0.7, + relation_threshold: float = 0.6, +) -> dict: + return { + "task_keys": ["paris_graph_alignment_task", "fusion_first_value_task"], + "profiles": { + "paris_graph_alignment_task": { + "profile_name": ( + "paris_graph_alignment_entity_matching_threshold=" + f"{entity_threshold},relation_matching_threshold={relation_threshold}" + ), + "bindings": [ + {"parameter": "entity_matching_threshold", "value": entity_threshold}, + {"parameter": "relation_matching_threshold", "value": relation_threshold}, + ], + } + }, + } + + +def test_validate_pipeline_config_snapshot_accepts_valid_config(): + snapshot = _valid_snapshot() + is_valid, error = validate_pipeline_config_snapshot( + snapshot, RDF_SEARCH_SPACE, RDF_PIPELINE_LAYOUT + ) + assert is_valid, error + + +def test_validate_pipeline_config_snapshot_rejects_invalid_parameter(): + snapshot = _valid_snapshot() + snapshot["profiles"]["paris_graph_alignment_task"]["bindings"][0]["value"] = 0.42 + + is_valid, error = validate_pipeline_config_snapshot( + snapshot, RDF_SEARCH_SPACE, RDF_PIPELINE_LAYOUT + ) + assert not is_valid + assert "invalid value" in error + + +def test_propose_pipeline_config_with_llm_retries_until_valid(): + invalid = {"task_keys": ["not_a_real_task"]} + client = ScriptedLlmClient( + responses=[ + "not json", + json.dumps(invalid), + json.dumps(_valid_snapshot()), + ] + ) + + config, decision = propose_pipeline_config_with_llm( + search_space=RDF_SEARCH_SPACE, + pipeline_layout=RDF_PIPELINE_LAYOUT, + client=client, + max_retries=3, + ) + + assert client.calls == 3 + assert decision == "llm(attempt=3)" + assert [task.name for task in config.tasks] == [ + "paris_graph_alignment_task", + "fusion_first_value_task", + ] + + +def test_llm_search_with_mocked_client(): + client = ScriptedLlmClient( + responses=[ + json.dumps(_valid_snapshot(entity_threshold=0.7, relation_threshold=0.6)), + json.dumps(_valid_snapshot(entity_threshold=0.8, relation_threshold=0.5)), + json.dumps(_valid_snapshot(entity_threshold=0.9, relation_threshold=0.7)), + ] + ) + run = llm_search( + budget=3, + evaluate_fn=execute_and_dummy_evaluate_pipeline, + search_space=RDF_SEARCH_SPACE, + pipeline_layout=RDF_PIPELINE_LAYOUT, + client=client, + rng=random.Random(0), + ) + + assert len(run.history) == 3 + assert all(decision.startswith("llm(") for decision in run.decisions) From 75808f55154fc668d3975f7b1fca8c63936e2868 Mon Sep 17 00:00:00 2001 From: Marvin Hofer Date: Wed, 22 Jul 2026 12:28:05 +0200 Subject: [PATCH 89/96] feat(eval): fix corner case align util --- src/kgpipe_eval/utils/alignment_utils.py | 12 +- uv.lock | 426 ----------------------- 2 files changed, 7 insertions(+), 431 deletions(-) diff --git a/src/kgpipe_eval/utils/alignment_utils.py b/src/kgpipe_eval/utils/alignment_utils.py index 0f9359d..86774e4 100644 --- a/src/kgpipe_eval/utils/alignment_utils.py +++ b/src/kgpipe_eval/utils/alignment_utils.py @@ -113,16 +113,18 @@ def load_entity_uri_label_type_pairs(config: EntityAlignmentConfig) -> list[UriL # Specific alignment methods def align_entities_by_label_embedding(tg: TripleGraph, config: EntityAlignmentConfig) -> list[EntityAlignment]: - model = get_model() ref_entity_uri_label_type_pairs = load_entity_uri_label_type_pairs(config) - ref_labels = [pair.label for pair in ref_entity_uri_label_type_pairs] - ref_labels_embeddings = model.encode(ref_labels, convert_to_numpy=True, show_progress_bar=False) - gen_entity_uri_label_type_pairs = list(get_entity_uri_label_type_pairs(tg, config.ignored_entities)) + # encode([]) yields shape (0,) which cannot matmul against (d, n_ref) + if not ref_entity_uri_label_type_pairs or not gen_entity_uri_label_type_pairs: + return [] + + model = get_model() + ref_labels = [pair.label for pair in ref_entity_uri_label_type_pairs] gen_labels = [pair.label for pair in gen_entity_uri_label_type_pairs] + ref_labels_embeddings = model.encode(ref_labels, convert_to_numpy=True, show_progress_bar=False) gen_labels_embeddings = model.encode(gen_labels, convert_to_numpy=True, show_progress_bar=False) - sims = np.dot(gen_labels_embeddings, ref_labels_embeddings.T) alignments = [] diff --git a/uv.lock b/uv.lock index d5f7acc..127684f 100644 --- a/uv.lock +++ b/uv.lock @@ -75,24 +75,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] -[[package]] -name = "appnope" -version = "0.1.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170, upload-time = "2024-02-06T09:43:11.258Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" }, -] - -[[package]] -name = "asttokens" -version = "3.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, -] - [[package]] name = "attrs" version = "26.1.0" @@ -183,63 +165,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, ] -[[package]] -name = "cffi" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy' or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, - { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, - { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, - { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, - { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, - { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, - { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, - { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, - { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, - { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, -] - [[package]] name = "charset-normalizer" version = "3.4.7" @@ -334,15 +259,6 @@ 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 = "comm" -version = "0.2.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, -] - [[package]] name = "contourpy" version = "1.3.3" @@ -572,36 +488,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, ] -[[package]] -name = "debugpy" -version = "1.8.21" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f2/aa/12037145b7a56eaa5b29b41872f7a21b538e807e13f32c4d3c46e59be084/debugpy-1.8.21.tar.gz", hash = "sha256:a3c53278e84c94e11bd87c53970ec391d1a67396c8b22609fcac576520e611a6", size = 1697577, upload-time = "2026-06-01T19:30:35.156Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/df/bf625547431a9cadc9f4cbfeda38866e2b17f6aed147b625377e87834449/debugpy-1.8.21-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:9f96713896f39c3dff0ee841f47320c3f2983d33c341e009361bb0ebc79adc4e", size = 2483609, upload-time = "2026-06-01T19:30:50.794Z" }, - { url = "https://files.pythonhosted.org/packages/bf/09/59324b903599031ff9faaec1758292409f6561a0ec2492fe4b703327705a/debugpy-1.8.21-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:c193d474f0a211191f2b4449d2d06157c689013035bd952f3b617e0ef422b176", size = 3968900, upload-time = "2026-06-01T19:30:52.341Z" }, - { url = "https://files.pythonhosted.org/packages/14/cd/27f65b805d7fe005c44e1a36b9183ecdfbcdbf9d3e721a5115d461ecc7ee/debugpy-1.8.21-cp312-cp312-win32.whl", hash = "sha256:4743373c1cac7f9e74a1b9915bf1dbe0e900eca657ffb170ae07ac8363205ae9", size = 5336340, upload-time = "2026-06-01T19:30:54.047Z" }, - { url = "https://files.pythonhosted.org/packages/77/1d/c84e30c0c674184948b66f076ab271c01d940618a2824c23cd035a27bc20/debugpy-1.8.21-cp312-cp312-win_amd64.whl", hash = "sha256:bd7ba9dd3daa7c2f942c6ca8d4695a16bf9ac16b63615261c7982bc74f7ed20c", size = 5374751, upload-time = "2026-06-01T19:30:55.891Z" }, - { url = "https://files.pythonhosted.org/packages/77/6b/d817e1f8cc77aa055d37fba092e0febfdff40fe652d8d53d4cd7a86ad98d/debugpy-1.8.21-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:13678151fc401e2d68c9880b91e28714f797d40422994572b24560ef80910a88", size = 2477398, upload-time = "2026-06-01T19:30:57.644Z" }, - { url = "https://files.pythonhosted.org/packages/48/57/412421516afc3055fa577516f00beec3d663f9b0ab330639547ae6c57720/debugpy-1.8.21-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:ecbd158386c31ffe71d46f72d44d56e66331ab9b16cad649156d514368f23ab2", size = 3962096, upload-time = "2026-06-01T19:30:59.235Z" }, - { url = "https://files.pythonhosted.org/packages/c1/62/2c616337cf6ba7b07ebbc97f02c6c945a8e2f76b365e33ee809c32ee36d1/debugpy-1.8.21-cp313-cp313-win32.whl", hash = "sha256:2c2ae706dec41d99a9ca1f7ebc987a83e65578363be6f6b3ac9067504917fae1", size = 5336288, upload-time = "2026-06-01T19:31:00.79Z" }, - { url = "https://files.pythonhosted.org/packages/f8/99/9175103392f84c4b1bf7622888cdc68da07f0ff7d9e581266428f6776033/debugpy-1.8.21-cp313-cp313-win_amd64.whl", hash = "sha256:aa648733047443eb1d07682c4ef287d36a54507b643ffdf38b09a3ef002c72a0", size = 5376567, upload-time = "2026-06-01T19:31:02.56Z" }, - { url = "https://files.pythonhosted.org/packages/ce/3d/f4bbb323a548bfab2af3d6b4ffd9bf22636e55956a1285d317a1de643aad/debugpy-1.8.21-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9bb2a685287a2ac9b181cde89edcec64845cb51de7faaa75badb9a698bc24782", size = 2477209, upload-time = "2026-06-01T19:31:04.157Z" }, - { url = "https://files.pythonhosted.org/packages/8c/2d/6e7ec524984a1702777868de49a4c53202bddac2a432a76a093469587750/debugpy-1.8.21-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:3d6922439bf33fd38a3e2c447869ebc7b97da5cd3d329ff1ef9bc06c4903437e", size = 3927115, upload-time = "2026-06-01T19:31:05.863Z" }, - { url = "https://files.pythonhosted.org/packages/97/47/d1aa6d64005a98a9144647d99306b419396f9ad7bf1d73c119e17a81fb4d/debugpy-1.8.21-cp314-cp314-win32.whl", hash = "sha256:15d4963bd5ffa48f0da0947fd06757fa7621945048a14ad7705431566d3c0e7c", size = 5336724, upload-time = "2026-06-01T19:31:07.711Z" }, - { url = "https://files.pythonhosted.org/packages/5f/67/b905b90d163af11878c1af8abafa4a25206335e112e284e413454543a6da/debugpy-1.8.21-cp314-cp314-win_amd64.whl", hash = "sha256:fe0744a12353406de0ae8ccff0d0a4a666f00801a3db8fd04e7a5f761cd520e8", size = 5373803, upload-time = "2026-06-01T19:31:09.469Z" }, - { url = "https://files.pythonhosted.org/packages/95/51/67e7cf11a53e40694f720457d5b3a1cdaaa3d5a9a633e482f225456b93ff/debugpy-1.8.21-py2.py3-none-any.whl", hash = "sha256:b1e37d333663c8851516a47364ef473da127f9caebe4417e6df6f5825a7e9a92", size = 5352888, upload-time = "2026-06-01T19:31:25.186Z" }, -] - -[[package]] -name = "decorator" -version = "5.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, -] - [[package]] name = "docker" version = "7.1.0" @@ -627,15 +513,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892, upload-time = "2025-02-19T22:15:01.647Z" }, ] -[[package]] -name = "executing" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, -] - [[package]] name = "fastapi" version = "0.136.1" @@ -892,64 +769,6 @@ 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 = "ipykernel" -version = "7.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "appnope", marker = "sys_platform == 'darwin' or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, - { name = "comm" }, - { name = "debugpy" }, - { name = "ipython" }, - { name = "jupyter-client" }, - { name = "jupyter-core" }, - { name = "matplotlib-inline" }, - { name = "nest-asyncio2" }, - { name = "packaging" }, - { name = "psutil" }, - { name = "pyzmq" }, - { name = "tornado" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3d/c4/e4a38f579de4225a561305666f7541cdabb30075def2aa1ac17bd73c1fb5/ipykernel-7.3.0.tar.gz", hash = "sha256:9acaaaf97d16355166e4085afe9d225bfbdf2b7ef520f9df3be8f2b248275e09", size = 184899, upload-time = "2026-06-10T08:41:25.481Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/02/77b271f5dc58bfbc0b577c877b2365d1ffea2afe66a80c13f2312820348c/ipykernel-7.3.0-py3-none-any.whl", hash = "sha256:897eb64da762549ef610698fca5e9675195ec6ac8ec7f19d81ce1ca20c876057", size = 120583, upload-time = "2026-06-10T08:41:23.648Z" }, -] - -[[package]] -name = "ipython" -version = "9.15.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, - { name = "decorator" }, - { name = "ipython-pygments-lexers" }, - { name = "jedi" }, - { name = "matplotlib-inline" }, - { name = "pexpect", marker = "(sys_platform != 'emscripten' and sys_platform != 'win32') or (sys_platform == 'emscripten' and extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda') or (sys_platform == 'win32' and extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, - { name = "prompt-toolkit" }, - { name = "psutil", marker = "(sys_platform != 'cygwin' and sys_platform != 'emscripten') or (sys_platform == 'cygwin' and extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda') or (sys_platform == 'emscripten' and extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, - { name = "pygments" }, - { name = "stack-data" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/53/59/165d3b4d75cc34add3122c4417ecb229085140ac573103c223cd01dde96f/ipython-9.15.0.tar.gz", hash = "sha256:da2819ce2aa83135257df830660b1176d986c3d2876db24df01974fa955b2756", size = 4442580, upload-time = "2026-06-26T11:03:35.913Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/40/3a/948263ca3b9d65bb2b1b0c521b3a49fad5d59ada58724bd87d2bd5ff3f36/ipython-9.15.0-py3-none-any.whl", hash = "sha256:515ad9c3cdf0c932a5a9f6245419e8aba706b7bd03c3e1d3a1c83d9351d6aa6e", size = 630895, upload-time = "2026-06-26T11:03:33.809Z" }, -] - -[[package]] -name = "ipython-pygments-lexers" -version = "1.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, -] - [[package]] name = "itsdangerous" version = "2.2.0" @@ -959,18 +778,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, ] -[[package]] -name = "jedi" -version = "0.20.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "parso" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" }, -] - [[package]] name = "jinja2" version = "3.1.6" @@ -1028,36 +835,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] -[[package]] -name = "jupyter-client" -version = "8.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jupyter-core" }, - { name = "python-dateutil" }, - { name = "pyzmq" }, - { name = "tornado" }, - { name = "traitlets" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7d/dc/5512503b088997c2250b8bf18258fba9d9ce5ead641183700960d3c9d342/jupyter_client-8.9.1.tar.gz", hash = "sha256:a58f730dd9e728ba16ba1d62ebccf7ffe1ebbdbce4e95cfae941b7321ae1f4fa", size = 359256, upload-time = "2026-06-09T13:15:01.033Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/6f/56d39bf385c5c27988aebaf0c18a2a17e960575740100973511018bd904e/jupyter_client-8.9.1-py3-none-any.whl", hash = "sha256:0b7a295bc46e8751e9adae84781f726c851c1d911bd793edc4a3bde942e3da81", size = 109828, upload-time = "2026-06-09T13:14:58.835Z" }, -] - -[[package]] -name = "jupyter-core" -version = "5.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "platformdirs" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, -] - [[package]] name = "kgcore" version = "0.1.0" @@ -1079,7 +856,6 @@ dependencies = [ { name = "docker" }, { name = "dotenv" }, { name = "fastapi" }, - { name = "ipykernel" }, { name = "jsonpath-ng" }, { name = "kgcore" }, { name = "matplotlib" }, @@ -1138,7 +914,6 @@ requires-dist = [ { name = "docker", specifier = ">=7.0.0" }, { name = "dotenv", specifier = ">=0.9.9" }, { name = "fastapi", specifier = ">=0.135.1" }, - { name = "ipykernel", specifier = ">=7.3.0" }, { name = "jsonpath-ng", specifier = ">=1.7.0" }, { name = "kgcore", git = "https://github.com/Vehnem/kgcore.git" }, { name = "matplotlib", specifier = ">=3.5.0" }, @@ -1399,18 +1174,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/23/92493c3e6e1b635ccfff146f7b99e674808787915420373ac399283764c2/matplotlib-3.10.9-cp314-cp314t-win_arm64.whl", hash = "sha256:a49f1eadc84ca85fd72fa4e89e70e61bf86452df6f971af04b12c60761a0772c", size = 8324785, upload-time = "2026-04-24T00:13:53.633Z" }, ] -[[package]] -name = "matplotlib-inline" -version = "0.2.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, -] - [[package]] name = "mdurl" version = "0.1.2" @@ -1575,15 +1338,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/e1/68c2256b69a314eba133673377ba9118c356f6342a0c02b61de449cf2bf2/narwhals-2.21.0-py3-none-any.whl", hash = "sha256:1e6617d0fca68ae1fda29e5397c4eaacd3ffc9fffe6bcd6ded0c690475e853be", size = 451943, upload-time = "2026-05-08T12:29:01.058Z" }, ] -[[package]] -name = "nest-asyncio2" -version = "1.7.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b4/73/731debf26e27e0a0323d7bda270dc2f634b398e38f040a09da1f4351d0aa/nest_asyncio2-1.7.2.tar.gz", hash = "sha256:1921d70b92cc4612c374928d081552efb59b83d91b2b789d935c665fa01729a8", size = 14743, upload-time = "2026-02-13T00:34:04.386Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/3c/3179b85b0e1c3659f0369940200cd6d0fa900e6cefcc7ea0bc6dd0e29ffb/nest_asyncio2-1.7.2-py3-none-any.whl", hash = "sha256:f5dfa702f3f81f6a03857e9a19e2ba578c0946a4ad417b4c50a24d7ba641fe01", size = 7843, upload-time = "2026-02-13T00:34:02.691Z" }, -] - [[package]] name = "networkx" version = "3.6.1" @@ -1888,15 +1642,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" }, ] -[[package]] -name = "parso" -version = "0.8.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" }, -] - [[package]] name = "pathspec" version = "1.1.1" @@ -1906,18 +1651,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] -[[package]] -name = "pexpect" -version = "4.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ptyprocess", marker = "(sys_platform != 'emscripten' and sys_platform != 'win32') or (sys_platform == 'emscripten' and extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda') or (sys_platform == 'win32' and extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, -] - [[package]] name = "pillow" version = "12.2.0" @@ -2005,18 +1738,6 @@ 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 = "prompt-toolkit" -version = "3.0.52" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "wcwidth" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, -] - [[package]] name = "protobuf" version = "7.34.1" @@ -2032,43 +1753,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/95/608f665226bca68b736b79e457fded9a2a38c4f4379a4a7614303d9db3bc/protobuf-7.34.1-py3-none-any.whl", hash = "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11", size = 170715, upload-time = "2026-03-20T17:34:45.384Z" }, ] -[[package]] -name = "psutil" -version = "7.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, - { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, - { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, - { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, - { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, - { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, - { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, - { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, - { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, - { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, - { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, - { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, - { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, - { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, - { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, - { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, - { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, - { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, - { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, - { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, -] - -[[package]] -name = "ptyprocess" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, -] - [[package]] name = "pulp" version = "3.3.1" @@ -2078,15 +1762,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/23/5a77fe2b50d962213338ae0fdd9832960186ebc423388fff1a56680e5114/pulp-3.3.1-py3-none-any.whl", hash = "sha256:45aa73db3368eb13b156564e092784c8fa0c1feefa64c2afb0410d9dc0bb5cd9", size = 16390866, upload-time = "2026-05-05T12:25:39.83Z" }, ] -[[package]] -name = "pure-eval" -version = "0.2.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, -] - [[package]] name = "pyarrow" version = "24.0.0" @@ -2130,15 +1805,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/be/6f79d55816d5c22557cf27533543d5d70dfe692adfbee4b99f2760674f38/pyarrow-24.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19", size = 28131282, upload-time = "2026-04-21T10:51:16.815Z" }, ] -[[package]] -name = "pycparser" -version = "3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, -] - [[package]] name = "pydantic" version = "2.13.4" @@ -2448,49 +2114,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, ] -[[package]] -name = "pyzmq" -version = "27.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "implementation_name == 'pypy' or (extra == 'extra-6-kgpipe-cpu' and extra == 'extra-6-kgpipe-cuda')" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" }, - { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" }, - { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" }, - { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" }, - { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" }, - { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" }, - { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" }, - { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" }, - { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" }, - { url = "https://files.pythonhosted.org/packages/60/cb/84a13459c51da6cec1b7b1dc1a47e6db6da50b77ad7fd9c145842750a011/pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5", size = 1122436, upload-time = "2025-09-08T23:08:20.801Z" }, - { url = "https://files.pythonhosted.org/packages/dc/b6/94414759a69a26c3dd674570a81813c46a078767d931a6c70ad29fc585cb/pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6", size = 1156301, upload-time = "2025-09-08T23:08:22.47Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ad/15906493fd40c316377fd8a8f6b1f93104f97a752667763c9b9c1b71d42d/pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7", size = 1341197, upload-time = "2025-09-08T23:08:24.286Z" }, - { url = "https://files.pythonhosted.org/packages/14/1d/d343f3ce13db53a54cb8946594e567410b2125394dafcc0268d8dda027e0/pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05", size = 897275, upload-time = "2025-09-08T23:08:26.063Z" }, - { url = "https://files.pythonhosted.org/packages/69/2d/d83dd6d7ca929a2fc67d2c3005415cdf322af7751d773524809f9e585129/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9", size = 660469, upload-time = "2025-09-08T23:08:27.623Z" }, - { url = "https://files.pythonhosted.org/packages/3e/cd/9822a7af117f4bc0f1952dbe9ef8358eb50a24928efd5edf54210b850259/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128", size = 847961, upload-time = "2025-09-08T23:08:29.672Z" }, - { url = "https://files.pythonhosted.org/packages/9a/12/f003e824a19ed73be15542f172fd0ec4ad0b60cf37436652c93b9df7c585/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39", size = 1650282, upload-time = "2025-09-08T23:08:31.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/4a/e82d788ed58e9a23995cee70dbc20c9aded3d13a92d30d57ec2291f1e8a3/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97", size = 2024468, upload-time = "2025-09-08T23:08:33.543Z" }, - { url = "https://files.pythonhosted.org/packages/d9/94/2da0a60841f757481e402b34bf4c8bf57fa54a5466b965de791b1e6f747d/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db", size = 1885394, upload-time = "2025-09-08T23:08:35.51Z" }, - { url = "https://files.pythonhosted.org/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c", size = 574964, upload-time = "2025-09-08T23:08:37.178Z" }, - { url = "https://files.pythonhosted.org/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2", size = 641029, upload-time = "2025-09-08T23:08:40.595Z" }, - { url = "https://files.pythonhosted.org/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e", size = 561541, upload-time = "2025-09-08T23:08:42.668Z" }, - { url = "https://files.pythonhosted.org/packages/87/45/19efbb3000956e82d0331bafca5d9ac19ea2857722fa2caacefb6042f39d/pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a", size = 1341197, upload-time = "2025-09-08T23:08:44.973Z" }, - { url = "https://files.pythonhosted.org/packages/48/43/d72ccdbf0d73d1343936296665826350cb1e825f92f2db9db3e61c2162a2/pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea", size = 897175, upload-time = "2025-09-08T23:08:46.601Z" }, - { url = "https://files.pythonhosted.org/packages/2f/2e/a483f73a10b65a9ef0161e817321d39a770b2acf8bcf3004a28d90d14a94/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96", size = 660427, upload-time = "2025-09-08T23:08:48.187Z" }, - { url = "https://files.pythonhosted.org/packages/f5/d2/5f36552c2d3e5685abe60dfa56f91169f7a2d99bbaf67c5271022ab40863/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d", size = 847929, upload-time = "2025-09-08T23:08:49.76Z" }, - { url = "https://files.pythonhosted.org/packages/c4/2a/404b331f2b7bf3198e9945f75c4c521f0c6a3a23b51f7a4a401b94a13833/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146", size = 1650193, upload-time = "2025-09-08T23:08:51.7Z" }, - { url = "https://files.pythonhosted.org/packages/1c/0b/f4107e33f62a5acf60e3ded67ed33d79b4ce18de432625ce2fc5093d6388/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd", size = 2024388, upload-time = "2025-09-08T23:08:53.393Z" }, - { url = "https://files.pythonhosted.org/packages/0d/01/add31fe76512642fd6e40e3a3bd21f4b47e242c8ba33efb6809e37076d9b/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a", size = 1885316, upload-time = "2025-09-08T23:08:55.702Z" }, - { url = "https://files.pythonhosted.org/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92", size = 587472, upload-time = "2025-09-08T23:08:58.18Z" }, - { url = "https://files.pythonhosted.org/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0", size = 661401, upload-time = "2025-09-08T23:08:59.802Z" }, - { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" }, -] - [[package]] name = "rdflib" version = "7.6.0" @@ -2960,20 +2583,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/31/89/176e3db96e31e795d7dfd91dd67749d3d1f0316bb30c6931a6140e1a0477/SPARQLWrapper-2.0.0-py3-none-any.whl", hash = "sha256:c99a7204fff676ee28e6acef327dc1ff8451c6f7217dcd8d49e8872f324a8a20", size = 28620, upload-time = "2022-03-13T23:13:58.969Z" }, ] -[[package]] -name = "stack-data" -version = "0.6.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "asttokens" }, - { name = "executing" }, - { name = "pure-eval" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, -] - [[package]] name = "starlette" version = "1.0.0" @@ -3521,23 +3130,6 @@ wheels = [ { url = "https://download-r2.pytorch.org/whl/cu130/torchvision-0.27.0%2Bcu130-cp314-cp314t-win_amd64.whl", hash = "sha256:3641b3bb5ad0150e694c9d7042b8a2fb5e0683d5bcf701fa99a2200f98b7c91b", upload-time = "2026-05-13T02:00:48Z" }, ] -[[package]] -name = "tornado" -version = "6.5.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/64/24/95ec527ad67b76d59299e5465b3935d05e4294b7e0290a3924b7487df30b/tornado-6.5.7.tar.gz", hash = "sha256:66c513a76cda70d53907bc27cf1447557699c2e95aa48ba27a442ff61c3ddfc2", size = 519252, upload-time = "2026-06-08T17:34:51.232Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/dc/c7043cab6fed8ae159fc1923ce829ada35c4dbd797d408a43858ffaf9639/tornado-6.5.7-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:148b2eb15c2c765a50796172c1e499649b35f30d2e3c3d3e15913cfa56bfb163", size = 448543, upload-time = "2026-06-08T17:34:38.052Z" }, - { url = "https://files.pythonhosted.org/packages/92/4f/090b1431e5a43df696feceffc268c5383cc079ecb5f08ce58f917109aafe/tornado-6.5.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9da38de27f1da3b78a966f0dae12b5a1ea9afe72ca805d84ff06508272ddf100", size = 446707, upload-time = "2026-06-08T17:34:39.594Z" }, - { url = "https://files.pythonhosted.org/packages/37/d8/ef374952fd5da67d4463122c2b8e5a96536ec10b4b339254c6dcde81d01c/tornado-6.5.7-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d759e71906ee783f8867b93bf26a265743da4c1e2f4a018464c1ba019862972", size = 449774, upload-time = "2026-06-08T17:34:41.204Z" }, - { url = "https://files.pythonhosted.org/packages/35/37/d434c73f4c6e014b745b9b37085f34f40c022f007efff3d7fe65991899f3/tornado-6.5.7-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a46347a18f23fb92b396beebe0fb78f61dda0cc302445202c16203d8a18848b", size = 450745, upload-time = "2026-06-08T17:34:42.531Z" }, - { url = "https://files.pythonhosted.org/packages/b6/2b/56b9aff361d7f1ab728a805ec7d7ea835f8807afa9f5cc690ea0e630efb9/tornado-6.5.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7778b30bef919231265e91c69963ce0f49a1e9c07ac900bbe75b19ce2575ba92", size = 450578, upload-time = "2026-06-08T17:34:43.787Z" }, - { url = "https://files.pythonhosted.org/packages/02/30/a7444fb23aa76860a14198fab96ac79f1866b0a6e19e26c4381b0938e50f/tornado-6.5.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e726f0c75da7726eec023aa62751ff8878bd2737e34fbdd33b1ae5897d2200f5", size = 449985, upload-time = "2026-06-08T17:34:45.326Z" }, - { url = "https://files.pythonhosted.org/packages/5c/42/5f0e56c01e8d9d36f4e23f367b85ae6cae0c1ecddd5e6977d8388ad27488/tornado-6.5.7-cp39-abi3-win32.whl", hash = "sha256:f8de3bf12d3efdd0cbe7c8887868198f8a91415e3f29fcf258d9b8eb7b1d9ae4", size = 451047, upload-time = "2026-06-08T17:34:46.784Z" }, - { url = "https://files.pythonhosted.org/packages/c9/a4/b393076ffb21b469eec5b328a0534cf03a3b90bfc6b1f09507cdd075d938/tornado-6.5.7-cp39-abi3-win_amd64.whl", hash = "sha256:de942f843533a039ef9fa3d9c88c7cd8a7c94553fb5ad0154270989b3d99a2c4", size = 451485, upload-time = "2026-06-08T17:34:48.248Z" }, - { url = "https://files.pythonhosted.org/packages/71/2e/7b1c769803121b809112cf9a00681c472eae1d80e32d7ec0e0bd61d0d0e1/tornado-6.5.7-cp39-abi3-win_arm64.whl", hash = "sha256:ff934fce95643af5f11efdae618eaa73d469dc588641e5c8d19295a0c65c4796", size = 450506, upload-time = "2026-06-08T17:34:49.702Z" }, -] - [[package]] name = "tqdm" version = "4.67.3" @@ -3550,15 +3142,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, ] -[[package]] -name = "traitlets" -version = "5.15.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/a9/a2584b8313b89f94869ddb3c4074617a691de1812a614d2d50e32ca5a7a6/traitlets-5.15.1.tar.gz", hash = "sha256:7b1c07854fe25acb39e009bae49f11b79ff6cbb2f27999104e9110e7a6b53722", size = 163344, upload-time = "2026-06-03T12:26:06.181Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/96/8d/1080ee4c231f361b6ce4470d556c8c435b67c7e0753aaa641497ee92f88b/traitlets-5.15.1-py3-none-any.whl", hash = "sha256:770a53705f84b81ac107e83a1b3328ff2dae16094d8fc3cfc004e4b22dfd8e92", size = 85858, upload-time = "2026-06-03T12:26:04.395Z" }, -] - [[package]] name = "transformers" version = "5.8.1" @@ -3687,15 +3270,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, ] -[[package]] -name = "wcwidth" -version = "0.8.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, -] - [[package]] name = "websockets" version = "16.0" From efa3477248bcb065ed6a1f24d2febf55b94f2307 Mon Sep 17 00:00:00 2001 From: christen Date: Wed, 22 Jul 2026 14:42:23 +0200 Subject: [PATCH 90/96] add modified hnr strategy --- .../kgpipe_search/strategies/strategies.py | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/experiments/param-opti/src/kgpipe_search/strategies/strategies.py b/experiments/param-opti/src/kgpipe_search/strategies/strategies.py index f7b5436..e64a0c7 100644 --- a/experiments/param-opti/src/kgpipe_search/strategies/strategies.py +++ b/experiments/param-opti/src/kgpipe_search/strategies/strategies.py @@ -599,6 +599,129 @@ def run_hnr( return SearchRun(strategy="hnr", history=history, budget=budget, decisions=decisions) +def run_hnr_2( + *, + budget: int, + evaluate_fn: EvaluateFn, + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, + init_budget: int, + init_strategy: Literal["random", "implementation_aware"] = "implementation_aware", + y: int = 1, + rho: float = 0.2, + min_quality_delta = 0.05, + min_iterations_wo_improvement = 2, + rng: Optional[random.Random] = None, +) -> SearchRun: + if budget <= 0: + return SearchRun(strategy="hnr_2", history=[], budget=0, decisions=[]) + if init_budget <= 0: + raise ValueError("HNR requires init_budget > 0") + + draw = rng or random.Random() + history: List[Observation] = [] + decisions: List[str] = [] + evaluated_keys: Set[str] = set() + + if init_strategy == "implementation_aware": + init_set = implementation_aware_initialization( + search_space, + pipeline_layout, + budget=min(init_budget, budget), + y=y, + rng=draw, + ) + else: + init_set = random_initialization( + search_space, + pipeline_layout, + budget=min(init_budget, budget), + rng=draw, + ) + + for cfg in init_set: + key = pipeline_config_snapshot_key(cfg, search_space) + if key in evaluated_keys: + continue + score = evaluate_fn(cfg) + history.append((score, cfg)) + evaluated_keys.add(key) + decisions.append(f"init({init_strategy})") + if len(history) >= budget: + return SearchRun(strategy="hnr_2", history=history, budget=budget, decisions=decisions) + + best_score, best_cfg = max(history, key=lambda item: item[0]) + current_task_index = 0 + quality_delta = 0 + iterations_wo_improvement = 0 + while len(history) < budget: + improved = False + if len(history) >= budget: + break + task_neighbors = _restricted_implementation_neighbors_for_index( + best_cfg, search_space, pipeline_layout, draw, index=current_task_index + ) + task_candidates = [ + n + for n in task_neighbors + if pipeline_config_snapshot_key(n, search_space) not in evaluated_keys + ] + + if task_candidates: + candidate = draw.choice(task_candidates) + decision = f"task_neighbor(idx={current_task_index})" + else: + param_neighbors = _restricted_parameter_neighbors_for_index( + best_cfg, search_space, index=current_task_index + ) + param_candidates = [ + n + for n in param_neighbors + if pipeline_config_snapshot_key(n, search_space) not in evaluated_keys + ] + if param_candidates: + candidate = draw.choice(param_candidates) + decision = f"param_neighbor(idx={current_task_index})" + else: + candidate = sample_unevaluated_config( + draw, search_space, pipeline_layout, evaluated_keys + ) + decision = f"explore(fallback,idx={current_task_index})" + + key = pipeline_config_snapshot_key(candidate, search_space) + score = evaluate_fn(candidate) + history.append((score, candidate)) + evaluated_keys.add(key) + decisions.append(decision) + + if score > best_score: + best_score, best_cfg = score, candidate + improved = True + quality_delta = score - best_score # current quality delta + + if quality_delta < min_quality_delta: # if the delta is below the require the min quality delta consider this + # run as no improvement + iterations_wo_improvement += 1 + else: + iterations_wo_improvement = 0 + if iterations_wo_improvement >= min_iterations_wo_improvement: # if the number of no improvements we consider the next task + if current_task_index < len(best_cfg.tasks): + current_task_index += 1 + iterations_wo_improvement = 0 + + if not improved and len(history) < budget and draw.random() < rho: + candidate = sample_unevaluated_config( + draw, search_space, pipeline_layout, evaluated_keys + ) + key = pipeline_config_snapshot_key(candidate, search_space) + score = evaluate_fn(candidate) + history.append((score, candidate)) + evaluated_keys.add(key) + decisions.append("explore(post_sweep)") + if score > best_score: + best_score, best_cfg = score, candidate + + return SearchRun(strategy="hnr", history=history, budget=budget, decisions=decisions) def run_bayesian( *, From 889992f81bfb8326c3353705140b5e793ea5f866 Mon Sep 17 00:00:00 2001 From: christen Date: Thu, 23 Jul 2026 09:05:20 +0200 Subject: [PATCH 91/96] bug fix The quality delta was only computed if the current score is higher than the best score. Consequently, the quality delta computation might be skipped and the old one is used. --- .../src/kgpipe_search/strategies/strategies.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/experiments/param-opti/src/kgpipe_search/strategies/strategies.py b/experiments/param-opti/src/kgpipe_search/strategies/strategies.py index e64a0c7..423987b 100644 --- a/experiments/param-opti/src/kgpipe_search/strategies/strategies.py +++ b/experiments/param-opti/src/kgpipe_search/strategies/strategies.py @@ -609,7 +609,7 @@ def run_hnr_2( init_strategy: Literal["random", "implementation_aware"] = "implementation_aware", y: int = 1, rho: float = 0.2, - min_quality_delta = 0.05, + min_quality_delta = 0.03, min_iterations_wo_improvement = 2, rng: Optional[random.Random] = None, ) -> SearchRun: @@ -693,13 +693,11 @@ def run_hnr_2( history.append((score, candidate)) evaluated_keys.add(key) decisions.append(decision) - + quality_delta = score - best_score # current quality delta if score > best_score: best_score, best_cfg = score, candidate improved = True - quality_delta = score - best_score # current quality delta - - if quality_delta < min_quality_delta: # if the delta is below the require the min quality delta consider this + if quality_delta < min_quality_delta: # if the delta is below the required min quality delta consider this # run as no improvement iterations_wo_improvement += 1 else: @@ -708,7 +706,9 @@ def run_hnr_2( if current_task_index < len(best_cfg.tasks): current_task_index += 1 iterations_wo_improvement = 0 - + else: + # We are not able to improve the last task anymore. Therefore, we can also stop the runs. + break if not improved and len(history) < budget and draw.random() < rho: candidate = sample_unevaluated_config( draw, search_space, pipeline_layout, evaluated_keys From b31defc7703c97cd7eb21cbec14e6a9118712669 Mon Sep 17 00:00:00 2001 From: Marvin Hofer Date: Thu, 23 Jul 2026 10:50:04 +0200 Subject: [PATCH 92/96] exp(params): connected hnr_2 with experiment interface --- experiments/param-opti/src/experiment.py | 13 +- .../param-opti/src/kgpipe_search/search.py | 26 ++ .../kgpipe_search/strategies/strategies.py | 6 +- .../param-opti/src/plot_search_evolution.py | 73 ++- .../src/plot_search_evolution_aggregate.py | 436 ++++++++++++++++++ 5 files changed, 512 insertions(+), 42 deletions(-) create mode 100644 experiments/param-opti/src/plot_search_evolution_aggregate.py diff --git a/experiments/param-opti/src/experiment.py b/experiments/param-opti/src/experiment.py index cb118b0..d204f1a 100644 --- a/experiments/param-opti/src/experiment.py +++ b/experiments/param-opti/src/experiment.py @@ -45,6 +45,7 @@ from kgpipe_search.search import ( bayesian_optimization, hnr_search, + hnr_2_search, implementation_aware_search, llm_search, qgns_search, @@ -55,7 +56,7 @@ import execute as pipeline_execute PipelineType = Literal["rdf", "text"] -SearchStrategyName = Literal["random", "implementation_aware", "qgns", "hnr", "bayesian", "llm"] +SearchStrategyName = Literal["random", "implementation_aware", "qgns", "hnr", "hnr_2", "bayesian", "llm"] TasksTmpScope = Literal["config", "pipeline", "shared"] InitStrategy = Literal["random", "implementation_aware"] @@ -120,6 +121,14 @@ def _run_search( rho=rho, ) + if strategy == "hnr_2": + return hnr_2_search( + **common, + init_budget=init_budget, + init_strategy=init_strategy, + y=y, + rho=rho, + ) if strategy == "bayesian": return bayesian_optimization( **common, @@ -397,7 +406,7 @@ def build_parser() -> argparse.ArgumentParser: ) parser.add_argument( "--strategy", - choices=["random", "implementation_aware", "qgns", "hnr", "bayesian", "llm"], + choices=["random", "implementation_aware", "qgns", "hnr", "hnr_2", "bayesian", "llm"], default="random", help=( "Search strategy to use. " diff --git a/experiments/param-opti/src/kgpipe_search/search.py b/experiments/param-opti/src/kgpipe_search/search.py index 7868b5f..793eb10 100644 --- a/experiments/param-opti/src/kgpipe_search/search.py +++ b/experiments/param-opti/src/kgpipe_search/search.py @@ -19,6 +19,7 @@ SearchRun, run_bayesian, run_hnr, + run_hnr_2, run_implementation_aware, run_qgns, run_random, @@ -34,6 +35,7 @@ "neighborhood_optimization", "qgns_search", "hnr_search", + "hnr_2_search", "bayesian_optimization", "llm_search", ] @@ -129,6 +131,30 @@ def hnr_search( ) +def hnr_2_search( + *, + budget: int, + evaluate_fn: EvaluateFn, + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, + init_budget: int, + init_strategy: str = "implementation_aware", + y: int = 1, + rho: float = 0.2, + rng: Any = None, +) -> SearchRun: + return run_hnr_2( + budget=budget, + evaluate_fn=evaluate_fn, + search_space=search_space, + pipeline_layout=pipeline_layout, + init_budget=init_budget, + init_strategy="random" if init_strategy == "random" else "implementation_aware", + y=y, + rho=rho, + rng=rng, + ) + def neighborhood_optimization( budget: int, evaluate_fn: EvaluateFn, diff --git a/experiments/param-opti/src/kgpipe_search/strategies/strategies.py b/experiments/param-opti/src/kgpipe_search/strategies/strategies.py index e64a0c7..87e2482 100644 --- a/experiments/param-opti/src/kgpipe_search/strategies/strategies.py +++ b/experiments/param-opti/src/kgpipe_search/strategies/strategies.py @@ -490,7 +490,7 @@ def run_hnr( init_budget: int, init_strategy: Literal["random", "implementation_aware"] = "implementation_aware", y: int = 1, - rho: float = 0.2, + rho: float = 0.0, rng: Optional[random.Random] = None, ) -> SearchRun: if budget <= 0: @@ -581,6 +581,7 @@ def run_hnr( evaluated_keys.add(key) decisions.append(decision) + print(f"INFO [HNR] score: {score}, best_score: {best_score}, task_idx: {idx}") if score > best_score: best_score, best_cfg = score, candidate improved = True @@ -608,7 +609,7 @@ def run_hnr_2( init_budget: int, init_strategy: Literal["random", "implementation_aware"] = "implementation_aware", y: int = 1, - rho: float = 0.2, + rho: float = 0.0, min_quality_delta = 0.05, min_iterations_wo_improvement = 2, rng: Optional[random.Random] = None, @@ -694,6 +695,7 @@ def run_hnr_2( evaluated_keys.add(key) decisions.append(decision) + print(f"INFO [HNR_2] score: {score}, best_score: {best_score}, task_idx: {current_task_index}") if score > best_score: best_score, best_cfg = score, candidate improved = True diff --git a/experiments/param-opti/src/plot_search_evolution.py b/experiments/param-opti/src/plot_search_evolution.py index de2bfe9..61251fa 100644 --- a/experiments/param-opti/src/plot_search_evolution.py +++ b/experiments/param-opti/src/plot_search_evolution.py @@ -14,9 +14,12 @@ DEFAULT_RESULTS_DIR = Path(__file__).resolve().parent.parent / "search-results" -DEFAULT_OUTPUT = Path(__file__).resolve().parent.parent / "search-results" / "search-evolution.png" -DEFAULT_TABLE_CSV = Path(__file__).resolve().parent.parent / "search-results" / "search-evolution-table.csv" -DEFAULT_TABLE_MD = Path(__file__).resolve().parent.parent / "search-results" / "search-evolution-table.md" +DEFAULT_OUT_DIR = Path(__file__).resolve().parent.parent / "search-results" + +PLOT_FILENAME = "search-evolution.png" +PLOT_CHRONOLOGICAL_FILENAME = "search-evolution-chronological.png" +TABLE_CSV_FILENAME = "search-evolution-table.csv" +TABLE_MD_FILENAME = "search-evolution-table.md" STRATEGY_LABELS = { "bayes-offline.json": "Bayesian optimization", @@ -129,6 +132,7 @@ class StrategyMetrics: strategy: str q_best: float evals_to_95pct: Optional[int] + evals_to_best: Optional[int] aoc: float @@ -180,17 +184,19 @@ def _metrics_for_report( strategy=_label_for(path, report), q_best=max(ys), evals_to_95pct=_evals_to_fraction(xs, ys, fraction=target_fraction), + evals_to_best=_evals_to_fraction(xs, ys, fraction=1.0), aoc=_area_under_curve(xs, ys), ) def _format_metrics_table(rows: Sequence[StrategyMetrics]) -> List[List[str]]: - header = ["Strategy", "Q best", "Evals to 95%", "AOC"] + header = ["Strategy", "Q best", "Evals to 95%", "Evals to best", "AOC"] body = [ [ row.strategy, f"{row.q_best:.4f}", str(row.evals_to_95pct) if row.evals_to_95pct is not None else "—", + str(row.evals_to_best) if row.evals_to_best is not None else "—", f"{row.aoc:.2f}", ] for row in rows @@ -212,9 +218,17 @@ def _write_metrics_csv(path: Path, rows: Sequence[StrategyMetrics]) -> None: path.parent.mkdir(parents=True, exist_ok=True) with path.open("w", encoding="utf-8", newline="") as f: writer = csv.writer(f) - writer.writerow(["strategy", "q_best", "evals_to_95pct", "aoc"]) + writer.writerow(["strategy", "q_best", "evals_to_95pct", "evals_to_best", "aoc"]) for row in rows: - writer.writerow([row.strategy, f"{row.q_best:.6f}", row.evals_to_95pct, f"{row.aoc:.4f}"]) + writer.writerow( + [ + row.strategy, + f"{row.q_best:.6f}", + row.evals_to_95pct, + row.evals_to_best, + f"{row.aoc:.4f}", + ] + ) def _write_metrics_markdown(path: Path, rows: Sequence[StrategyMetrics]) -> None: @@ -229,10 +243,6 @@ def _write_metrics_markdown(path: Path, rows: Sequence[StrategyMetrics]) -> None path.write_text("\n".join(lines) + "\n", encoding="utf-8") -def _chronological_out_path(out: Path) -> Path: - return out.with_name(f"{out.stem}-chronological{out.suffix}") - - def _label_for(path: Path, report: dict[str, Any]) -> str: if path.name in STRATEGY_LABELS: return STRATEGY_LABELS[path.name] @@ -298,16 +308,10 @@ def build_parser() -> argparse.ArgumentParser: help="Directory containing *-offline.json or *-results.json reports.", ) p.add_argument( - "--out", - type=Path, - default=DEFAULT_OUTPUT, - help="Output image path for init-sorted plot.", - ) - p.add_argument( - "--out-chronological", + "--out-dir", type=Path, - default=None, - help="Output image path for chronological-init plot (default: -chronological).", + default=DEFAULT_OUT_DIR, + help="Directory for generated figures and tables.", ) p.add_argument( "--skip-chronological-plot", @@ -319,18 +323,6 @@ def build_parser() -> argparse.ArgumentParser: default="Search evolution", help="Plot title.", ) - p.add_argument( - "--table-csv", - type=Path, - default=DEFAULT_TABLE_CSV, - help="CSV path for strategy summary metrics.", - ) - p.add_argument( - "--table-md", - type=Path, - default=DEFAULT_TABLE_MD, - help="Markdown path for strategy summary metrics.", - ) p.add_argument( "--target-fraction", type=float, @@ -343,6 +335,7 @@ def build_parser() -> argparse.ArgumentParser: def main(argv: Sequence[str] | None = None) -> int: args = build_parser().parse_args(argv) results_dir: Path = args.results_dir + out_dir: Path = args.out_dir if not results_dir.is_dir(): raise SystemExit(f"Results directory not found: {results_dir}") @@ -353,6 +346,7 @@ def main(argv: Sequence[str] | None = None) -> int: ) reports = [(path, _read_report(path)) for path in report_paths] + out_dir.mkdir(parents=True, exist_ok=True) metrics: List[StrategyMetrics] = [] for path, report in reports: @@ -365,17 +359,18 @@ def main(argv: Sequence[str] | None = None) -> int: if row is not None: metrics.append(row) + plot_out = out_dir / PLOT_FILENAME plot_reports( reports, reorder_init=True, running_best=True, - out=args.out, + out=plot_out, title=str(args.title), ) - print(f"wrote: {args.out}") + print(f"wrote: {plot_out}") if not args.skip_chronological_plot: - chrono_out = args.out_chronological or _chronological_out_path(args.out) + chrono_out = out_dir / PLOT_CHRONOLOGICAL_FILENAME chrono_title = f"{args.title} (chronological scores)" plot_reports( reports, @@ -387,10 +382,12 @@ def main(argv: Sequence[str] | None = None) -> int: print(f"wrote: {chrono_out}") if metrics: - _write_metrics_csv(args.table_csv, metrics) - _write_metrics_markdown(args.table_md, metrics) - print(f"wrote: {args.table_csv}") - print(f"wrote: {args.table_md}") + table_csv = out_dir / TABLE_CSV_FILENAME + table_md = out_dir / TABLE_MD_FILENAME + _write_metrics_csv(table_csv, metrics) + _write_metrics_markdown(table_md, metrics) + print(f"wrote: {table_csv}") + print(f"wrote: {table_md}") print() _print_metrics_table(metrics) diff --git a/experiments/param-opti/src/plot_search_evolution_aggregate.py b/experiments/param-opti/src/plot_search_evolution_aggregate.py new file mode 100644 index 0000000..a5415e6 --- /dev/null +++ b/experiments/param-opti/src/plot_search_evolution_aggregate.py @@ -0,0 +1,436 @@ +#!/usr/bin/env python3 +"""Aggregate search-evolution plots/tables across RNG seed runs. + +Expects a parent results directory whose subdirectories are per-seed configs, +e.g.:: + + rdf-search-results/ + init_3_budget_20_seed_0/ + init_3_budget_20_seed_42/ + init_3_budget_20_seed_1337/ + +For each config group (everything before ``_seed_``), writes a mean curve +plot with a shaded band and a table of mean ± std metrics. +""" + +from __future__ import annotations + +import argparse +import csv +import math +import re +import statistics +from collections import defaultdict +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, List, Optional, Sequence, Tuple + +import matplotlib.pyplot as plt + +from plot_search_evolution import ( + _discover_report_paths, + _evolution_curve, + _init_budget, + _label_for, + _metrics_for_report, + _read_report, + StrategyMetrics, +) + +DEFAULT_RESULTS_DIR = Path(__file__).resolve().parent.parent / "rdf-search-results" + +SEED_DIR_RE = re.compile(r"^(?P.+)_seed_(?P\d+)$") + +PLOT_FILENAME = "search-evolution-aggregated.png" +TABLE_CSV_FILENAME = "search-evolution-aggregated-table.csv" +TABLE_MD_FILENAME = "search-evolution-aggregated-table.md" + + +@dataclass(frozen=True) +class RunCurve: + strategy: str + seed: str + xs: List[int] + ys: List[float] + init_budget: int + metrics: StrategyMetrics + + +@dataclass(frozen=True) +class AggregatedMetrics: + strategy: str + n: int + q_best_mean: float + q_best_std: float + evals_to_95pct_mean: float + evals_to_95pct_std: float + evals_to_best_mean: float + evals_to_best_std: float + aoc_mean: float + aoc_std: float + + +def _mean(values: Sequence[float]) -> float: + return statistics.fmean(values) if values else float("nan") + + +def _std(values: Sequence[float]) -> float: + if len(values) < 2: + return 0.0 + return statistics.stdev(values) + + +def _fmt_mean_std(mean: float, std: float, *, digits: int) -> str: + return f"{mean:.{digits}f} ± {std:.{digits}f}" + + +def _discover_seed_dirs(results_dir: Path) -> Dict[str, List[Tuple[str, Path]]]: + """Map config key -> list of (seed, path) for ``*_seed_`` subdirs.""" + groups: Dict[str, List[Tuple[str, Path]]] = defaultdict(list) + for path in sorted(p for p in results_dir.iterdir() if p.is_dir()): + match = SEED_DIR_RE.match(path.name) + if not match: + continue + groups[match.group("config")].append((match.group("seed"), path)) + return dict(groups) + + +def _load_run_curves( + run_dir: Path, + *, + seed: str, + reorder_init: bool, + target_fraction: float, +) -> List[RunCurve]: + curves: List[RunCurve] = [] + for path in _discover_report_paths(run_dir): + report = _read_report(path) + metrics = _metrics_for_report( + path, + report, + reorder_init=reorder_init, + target_fraction=target_fraction, + ) + if metrics is None: + continue + + history = report.get("history") + if not isinstance(history, list) or not history: + continue + scores = [float(item["score"]) for item in history if isinstance(item, dict) and "score" in item] + if not scores: + continue + + init_budget = _init_budget(report) + xs, ys = _evolution_curve( + scores, + init_budget=init_budget, + reorder_init=reorder_init, + running_best=True, + ) + curves.append( + RunCurve( + strategy=_label_for(path, report), + seed=seed, + xs=xs, + ys=ys, + init_budget=init_budget, + metrics=metrics, + ) + ) + return curves + + +def _band_bounds( + values: Sequence[float], + *, + band: str, +) -> Tuple[float, float, float]: + mean = _mean(values) + if band == "range": + return mean, min(values), max(values) + if band == "std": + s = _std(values) + return mean, mean - s, mean + s + if band == "sem": + s = _std(values) + sem = s / math.sqrt(len(values)) if values else 0.0 + return mean, mean - sem, mean + sem + raise ValueError(f"Unknown band mode: {band}") + + +def _aggregate_curves( + curves: Sequence[RunCurve], + *, + band: str, +) -> Tuple[List[int], List[float], List[float], List[float], int]: + if not curves: + return [], [], [], [], 0 + + min_len = min(len(c.ys) for c in curves) + xs = list(range(1, min_len + 1)) + means: List[float] = [] + lowers: List[float] = [] + uppers: List[float] = [] + for i in range(min_len): + vals = [c.ys[i] for c in curves] + mean, lo, hi = _band_bounds(vals, band=band) + means.append(mean) + lowers.append(lo) + uppers.append(hi) + return xs, means, lowers, uppers, len(curves) + + +def _aggregate_metrics(curves: Sequence[RunCurve]) -> AggregatedMetrics: + q_best = [c.metrics.q_best for c in curves] + aoc = [c.metrics.aoc for c in curves] + to_95 = [float(c.metrics.evals_to_95pct) for c in curves if c.metrics.evals_to_95pct is not None] + to_best = [float(c.metrics.evals_to_best) for c in curves if c.metrics.evals_to_best is not None] + return AggregatedMetrics( + strategy=curves[0].strategy, + n=len(curves), + q_best_mean=_mean(q_best), + q_best_std=_std(q_best), + evals_to_95pct_mean=_mean(to_95), + evals_to_95pct_std=_std(to_95), + evals_to_best_mean=_mean(to_best), + evals_to_best_std=_std(to_best), + aoc_mean=_mean(aoc), + aoc_std=_std(aoc), + ) + + +def _format_metrics_table(rows: Sequence[AggregatedMetrics]) -> List[List[str]]: + header = ["Strategy", "n", "Q best", "Evals to 95%", "Evals to best", "AOC"] + body = [ + [ + row.strategy, + str(row.n), + _fmt_mean_std(row.q_best_mean, row.q_best_std, digits=4), + _fmt_mean_std(row.evals_to_95pct_mean, row.evals_to_95pct_std, digits=2), + _fmt_mean_std(row.evals_to_best_mean, row.evals_to_best_std, digits=2), + _fmt_mean_std(row.aoc_mean, row.aoc_std, digits=2), + ] + for row in rows + ] + return [header, *body] + + +def _print_metrics_table(rows: Sequence[AggregatedMetrics]) -> None: + table = _format_metrics_table(rows) + widths = [max(len(row[i]) for row in table) for i in range(len(table[0]))] + for row_idx, row in enumerate(table): + line = " ".join(cell.ljust(widths[i]) for i, cell in enumerate(row)) + print(line) + if row_idx == 0: + print(" ".join("-" * widths[i] for i in range(len(widths)))) + + +def _write_metrics_csv(path: Path, rows: Sequence[AggregatedMetrics]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8", newline="") as f: + writer = csv.writer(f) + writer.writerow( + [ + "strategy", + "n", + "q_best_mean", + "q_best_std", + "evals_to_95pct_mean", + "evals_to_95pct_std", + "evals_to_best_mean", + "evals_to_best_std", + "aoc_mean", + "aoc_std", + ] + ) + for row in rows: + writer.writerow( + [ + row.strategy, + row.n, + f"{row.q_best_mean:.6f}", + f"{row.q_best_std:.6f}", + f"{row.evals_to_95pct_mean:.4f}", + f"{row.evals_to_95pct_std:.4f}", + f"{row.evals_to_best_mean:.4f}", + f"{row.evals_to_best_std:.4f}", + f"{row.aoc_mean:.4f}", + f"{row.aoc_std:.4f}", + ] + ) + + +def _write_metrics_markdown(path: Path, rows: Sequence[AggregatedMetrics]) -> None: + table = _format_metrics_table(rows) + path.parent.mkdir(parents=True, exist_ok=True) + lines = [ + "| " + " | ".join(table[0]) + " |", + "| " + " | ".join("---" for _ in table[0]) + " |", + ] + for row in table[1:]: + lines.append("| " + " | ".join(row) + " |") + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def plot_aggregated( + by_strategy: Dict[str, List[RunCurve]], + *, + band: str, + out: Path, + title: str, +) -> None: + fig, ax = plt.subplots(figsize=(9, 5.5)) + init_budget: Optional[int] = None + + for strategy in sorted(by_strategy): + curves = by_strategy[strategy] + xs, means, lowers, uppers, n = _aggregate_curves(curves, band=band) + if not xs: + continue + if init_budget is None and curves: + init_budget = curves[0].init_budget + + (line,) = ax.plot(xs, means, marker="o", markersize=3, linewidth=1.8, label=f"{strategy} (n={n})") + ax.fill_between(xs, lowers, uppers, color=line.get_color(), alpha=0.2, linewidth=0) + + if init_budget and init_budget > 0: + ax.axvline(init_budget + 0.5, color="0.75", linestyle=":", linewidth=0.8) + + band_label = {"range": "min–max range", "std": "±1 std", "sem": "±1 SEM"}[band] + ax.set_xlabel("Iteration") + ax.set_ylabel("Best quality score so far (mean)") + ax.set_title(f"{title}\n(shaded: {band_label} across seeds)") + ax.grid(True, alpha=0.3) + ax.legend(loc="lower right", fontsize=9) + fig.tight_layout() + + out.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(out, dpi=160) + plt.close(fig) + + +def _process_config_group( + config: str, + seed_dirs: Sequence[Tuple[str, Path]], + *, + out_dir: Path, + title: str, + band: str, + target_fraction: float, +) -> None: + by_strategy: Dict[str, List[RunCurve]] = defaultdict(list) + for seed, run_dir in seed_dirs: + for curve in _load_run_curves( + run_dir, + seed=seed, + reorder_init=True, + target_fraction=target_fraction, + ): + by_strategy[curve.strategy].append(curve) + + if not by_strategy: + print(f"skip {config}: no usable reports in {[p.name for _, p in seed_dirs]}") + return + + metrics = [_aggregate_metrics(curves) for _, curves in sorted(by_strategy.items())] + metrics.sort(key=lambda row: row.strategy) + + out_dir.mkdir(parents=True, exist_ok=True) + plot_out = out_dir / PLOT_FILENAME + plot_aggregated(by_strategy, band=band, out=plot_out, title=title) + print(f"wrote: {plot_out}") + + table_csv = out_dir / TABLE_CSV_FILENAME + table_md = out_dir / TABLE_MD_FILENAME + _write_metrics_csv(table_csv, metrics) + _write_metrics_markdown(table_md, metrics) + print(f"wrote: {table_csv}") + print(f"wrote: {table_md}") + print() + print(f"[{config}] seeds={[s for s, _ in seed_dirs]}") + _print_metrics_table(metrics) + print() + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + description="Aggregate search-evolution plots/tables across seed runs." + ) + p.add_argument( + "--results-dir", + type=Path, + default=DEFAULT_RESULTS_DIR, + help="Parent directory containing *_seed_ subdirectories.", + ) + p.add_argument( + "--out-dir", + type=Path, + default=None, + help="Output directory (default: // or if one config).", + ) + p.add_argument( + "--config", + default=None, + help="Only aggregate this config prefix (e.g. init_3_budget_20).", + ) + p.add_argument( + "--band", + choices=("range", "std", "sem"), + default="range", + help="Shaded band around the mean curve: min-max range, ±1 std, or ±1 SEM (default: range).", + ) + p.add_argument( + "--title", + default=None, + help="Plot title prefix (default derived from config).", + ) + p.add_argument( + "--target-fraction", + type=float, + default=0.95, + help="Fraction of Q best used for evals-to-target (default: 0.95).", + ) + return p + + +def main(argv: Sequence[str] | None = None) -> int: + args = build_parser().parse_args(argv) + results_dir: Path = args.results_dir + if not results_dir.is_dir(): + raise SystemExit(f"Results directory not found: {results_dir}") + + groups = _discover_seed_dirs(results_dir) + if not groups: + raise SystemExit( + f"No *_seed_ subdirectories found in {results_dir}" + ) + + if args.config is not None: + if args.config not in groups: + raise SystemExit( + f"Config {args.config!r} not found. Available: {sorted(groups)}" + ) + groups = {args.config: groups[args.config]} + + for config, seed_dirs in sorted(groups.items()): + if args.out_dir is not None: + out_dir = args.out_dir if len(groups) == 1 else args.out_dir / config + else: + out_dir = results_dir / config if len(groups) > 1 else results_dir + + title = args.title or f"Search evolution ({config}, aggregated over seeds)" + _process_config_group( + config, + seed_dirs, + out_dir=out_dir, + title=title, + band=str(args.band), + target_fraction=float(args.target_fraction), + ) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From f7fb821dfc2919fa30473b8a7d591339bd987acb Mon Sep 17 00:00:00 2001 From: Marvin Hofer Date: Sat, 25 Jul 2026 12:34:07 +0200 Subject: [PATCH 93/96] exp(params): latest paper experiment config and mini fixes --- experiments/param-opti/experiment.ipynb | 53 -- experiments/param-opti/src/experiment.py | 60 ++- .../src/kgpipe_search/configuration.py | 24 + .../src/kgpipe_search/evaluation.py | 135 +++-- .../src/kgpipe_search/ranking_conf.py | 113 +++++ .../src/kgpipe_search/reaggregate_evals.py | 463 ++++++++++++++++++ .../param-opti/src/kgpipe_search/search.py | 4 + .../kgpipe_search/strategies/strategies.py | 37 +- .../param-opti/src/plot_search_evolution.py | 10 +- .../src/plot_search_evolution_aggregate.py | 163 ++++-- src/kgpipe_eval/utils/score_utils.py | 14 +- 11 files changed, 924 insertions(+), 152 deletions(-) delete mode 100644 experiments/param-opti/experiment.ipynb create mode 100644 experiments/param-opti/src/kgpipe_search/ranking_conf.py create mode 100644 experiments/param-opti/src/kgpipe_search/reaggregate_evals.py diff --git a/experiments/param-opti/experiment.ipynb b/experiments/param-opti/experiment.ipynb deleted file mode 100644 index 2acd687..0000000 --- a/experiments/param-opti/experiment.ipynb +++ /dev/null @@ -1,53 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "2d5609eb", - "metadata": {}, - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8a1d7641", - "metadata": {}, - "outputs": [], - "source": [ - "# imports\n", - "import src.kgpipe_search\n", - "from src.kgpipe_search.configuration import enumerate_exhaustive_pipeline_config_snapshots" - ] - }, - { - "cell_type": "markdown", - "id": "7a82e13b", - "metadata": {}, - "source": [ - "Enumerate all pipeline configs" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4eff5d27", - "metadata": {}, - "outputs": [], - "source": [ - "enumerate_exhaustive_pipeline_config_snapshots()" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": ".venv", - "language": "python", - "name": "python3" - }, - "language_info": { - "name": "python", - "version": "3.12.0" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/experiments/param-opti/src/experiment.py b/experiments/param-opti/src/experiment.py index d204f1a..afb346b 100644 --- a/experiments/param-opti/src/experiment.py +++ b/experiments/param-opti/src/experiment.py @@ -41,7 +41,8 @@ TEXT_SEARCH_SPACE, PipelineConfig, ) -from kgpipe_search.evaluation import evaluate_pipeline +from kgpipe_search.evaluation import aggregate_from_cached_evaluation, evaluate_pipeline +from kgpipe_search.ranking_conf import AGGREGATION_CONFIGS, get_aggregation_config from kgpipe_search.search import ( bayesian_optimization, hnr_search, @@ -85,6 +86,8 @@ def _run_search( beta: float, llm_max_retries: int, rng: random.Random, + min_quality_delta: float = 0.05, + min_iterations_wo_improvement: int = 2, ) -> SearchRun: common = { "budget": budget, @@ -128,6 +131,8 @@ def _run_search( init_strategy=init_strategy, y=y, rho=rho, + min_quality_delta=min_quality_delta, + min_iterations_wo_improvement=min_iterations_wo_improvement, ) if strategy == "bayesian": return bayesian_optimization( @@ -170,10 +175,14 @@ def run_search_experiment( tasks_tmp_scope: TasksTmpScope, results_path: Optional[Path], reuse_existing: bool = True, + min_quality_delta: float = 0.05, + min_iterations_wo_improvement: int = 2, + rank_aggregation: str = "default", ) -> Dict[str, Any]: pipeline_execute._set_ontology_env(ontology_path) search_space, pipeline_layout, run_pipeline = _pipeline_context(pipeline_type) + aggregation_config = get_aggregation_config(rank_aggregation) output_dir.mkdir(parents=True, exist_ok=True) rng = random.Random(rng_seed) @@ -216,6 +225,7 @@ def evaluate_fn(pipeline_config: PipelineConfig) -> float: "tasks_tmp_dir": str(tasks_tmp_dir), "status": "ok", "cached": False, + "rank_aggregation": rank_aggregation, } try: @@ -229,14 +239,22 @@ def evaluate_fn(pipeline_config: PipelineConfig) -> float: score = float(cached["score"]) print(f"cached error: {entry['error']}") else: - entry["evaluation"] = cached["evaluation"] - score = float(cached["score"]) - print(f"cached score: {score:.6f}") + # Re-rank from stored measurements so a different aggregation + # (e.g. flat_hmean) can be used without re-running evaluation. + aggregate_score = aggregate_from_cached_evaluation( + cached["evaluation"], + aggregation_config, + ) + evaluation = pipeline_execute._to_jsonable(aggregate_score) + entry["evaluation"] = evaluation + score = float(aggregate_score.final_score) + print(f"cached score ({rank_aggregation}): {score:.6f}") elif reuse_existing and result_path.exists(): aggregate_score = evaluate_pipeline( pipeline_config, result_path, reference_path, + aggregation=aggregation_config, ) evaluation = pipeline_execute._to_jsonable(aggregate_score) entry["evaluation"] = evaluation @@ -258,6 +276,7 @@ def evaluate_fn(pipeline_config: PipelineConfig) -> float: pipeline_config, result_path, reference_path, + aggregation=aggregation_config, ) evaluation = pipeline_execute._to_jsonable(aggregate_score) entry["evaluation"] = evaluation @@ -284,6 +303,7 @@ def evaluate_fn(pipeline_config: PipelineConfig) -> float: print(f"reference: {reference_path}") print(f"output_dir: {output_dir}") print(f"tasks_tmp_scope: {tasks_tmp_scope}") + print(f"rank_aggregation: {rank_aggregation}") search_run = _run_search( strategy=strategy, @@ -300,6 +320,8 @@ def evaluate_fn(pipeline_config: PipelineConfig) -> float: beta=beta, llm_max_retries=llm_max_retries, rng=rng, + min_quality_delta=min_quality_delta, + min_iterations_wo_improvement=min_iterations_wo_improvement, ) result_by_hash = {item["config_hash"]: item for item in run_results} @@ -356,6 +378,7 @@ def evaluate_fn(pipeline_config: PipelineConfig) -> float: "best_score": running_best, "cache_hits": cache_hits, "reuse_existing": reuse_existing, + "rank_aggregation": rank_aggregation, } resolved_results_path = results_path or (output_dir / "results.json") @@ -438,7 +461,7 @@ def build_parser() -> argparse.ArgumentParser: "--rho", type=float, default=0.2, - help="Exploration probability for QGNS/HNR", + help="Exploration probability for RNS", ) parser.add_argument( "--pool-size", @@ -486,6 +509,30 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", help="Re-run pipelines and evaluation even when cached result/eval files exist", ) + parser.add_argument( + "--min-quality-delta", + type=float, + default=0.01, + help="Minimum quality delta for HNR_2", + ) + parser.add_argument( + "--min-iterations-wo-improvement", + type=int, + default=3, + help="Minimum iterations without improvement for HNR_2", + ) + parser.add_argument( + "--rank-aggregation", + choices=sorted(AGGREGATION_CONFIGS), + default="default", + help=( + "How to turn per-metric measurements into the search objective. " + "'default' = subgroup means then weighted mean; " + "'flat_hmean' = harmonic mean over all measurements; " + "'custom' = custom aggregation config. " + "Cached .eval.json files are re-ranked from stored measurements." + ), + ) return parser @@ -529,6 +576,9 @@ def main(argv: Optional[List[str]] = None) -> int: tasks_tmp_scope=args.tasks_tmp_scope, results_path=args.results, reuse_existing=not args.force_rerun, + min_quality_delta=args.min_quality_delta, + min_iterations_wo_improvement=args.min_iterations_wo_improvement, + rank_aggregation=args.rank_aggregation, ) failed = sum(1 for item in payload["results"] if item["status"] != "ok") diff --git a/experiments/param-opti/src/kgpipe_search/configuration.py b/experiments/param-opti/src/kgpipe_search/configuration.py index 0e2b39d..d120b69 100644 --- a/experiments/param-opti/src/kgpipe_search/configuration.py +++ b/experiments/param-opti/src/kgpipe_search/configuration.py @@ -611,6 +611,30 @@ def sample_unique_pipeline_config_snapshots_per_combo( return snapshots, stats +def enumerate_exhaustive_pipeline_configs( + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, +) -> List[PipelineConfig]: + """ + Enumerate every valid pipeline config in the search space. + + Unlike hierarchical sampling (task combo first, then params), this flattens + the full Cartesian product so each leaf config is equally likely when sampled. + """ + configs: List[PipelineConfig] = [] + for combo in enumerate_valid_task_combinations(search_space, pipeline_layout): + per_task_assignments = [ + _task_param_assignments(search_space, task_key) for task_key in combo + ] + for assignment_tuple in itertools.product(*per_task_assignments): + configs.append( + _pipeline_config_for_combo_and_params( + search_space, combo, assignment_tuple + ) + ) + return configs + + def enumerate_exhaustive_pipeline_config_snapshots( search_space: Dict[str, Dict[str, Any]], pipeline_layout: PipelineLayout, diff --git a/experiments/param-opti/src/kgpipe_search/evaluation.py b/experiments/param-opti/src/kgpipe_search/evaluation.py index 06f81d8..e4d5926 100644 --- a/experiments/param-opti/src/kgpipe_search/evaluation.py +++ b/experiments/param-opti/src/kgpipe_search/evaluation.py @@ -1,57 +1,23 @@ +from __future__ import annotations + +from typing import Any, Mapping + from kgpipe_eval.evaluator import Evaluator from kgpipe_eval.utils.kg_utils import KgLike, KgManager +from kgpipe_eval.utils.metric_utils import MeasurementKey from kgpipe_eval.utils.score_utils import ( AggregateScore, + aggregate_scores, aggregate_scores_from_json, aggregate_scores_from_results, ) from kgpipe_search.definitions import PipelineConfig +from kgpipe_search.ranking_conf import DEFAULT_AGGREGATION_CONFIG, get_aggregation_config import os -aggregation_config = { - "subgroups": { - "coverage": { - "measurements": [ - {"metric": "EntityAlignmentMetric", "measurement": "recall"}, - {"metric": "TripleAlignmentMetric", "measurement": "recall"} - ], - "aggregation": "mean" - }, - "correctness": { - "measurements": [ - "EntityAlignmentMetric.precision", - "TripleAlignmentMetric.precision" - ], - "aggregation": "mean" - }, - "consistency": { - "measurements": [ - "DisjointDomainMetric.normalized_score", - "DomainMetric.normalized_score", - "RangeMetric.normalized_score", - "DatatypeFormatMetric.normalized_score", - "DatatypeMetric.normalized_score", - "RelationDirectionMetric.normalized_score" - ], - "aggregation": "mean" - }, - # "cleanliness": { - # "measurements": [ - # {"metric": "DuplicateMetric", "measurement": "duplicates_ratio", "transform": "invert"} - # ], - # "aggregation": "mean" - # } - }, - "final": { - "aggregation": "weighted_mean", - "weights": { - "coverage": 0.3333, - "correctness": 0.3333, - "consistency": 0.3333 - # "cleanliness": 0.3333 - } - } -} +# Backwards-compatible alias for the historical default aggregation. +aggregation_config = DEFAULT_AGGREGATION_CONFIG + def test_aggregate_results(): result = aggregate_scores_from_json('data/eval_results.json', aggregation_config) @@ -61,7 +27,75 @@ def test_aggregate_results(): for m in sg.measurements: print(f' {m.metric}.{m.measurement} = {m.value:.6f}') -def evaluate_pipeline(pipeline_config: PipelineConfig, result_kg: KgLike, reference_kg: KgLike): + +def measurements_from_cached_evaluation(evaluation: Mapping[str, Any]) -> dict[MeasurementKey, float]: + """Extract raw metric measurements from a cached AggregateScore JSON payload.""" + lookup: dict[MeasurementKey, float] = {} + subgroups = evaluation.get("subgroups") + if not isinstance(subgroups, Mapping): + return lookup + for subgroup in subgroups.values(): + if not isinstance(subgroup, Mapping): + continue + measurements = subgroup.get("measurements") + if not isinstance(measurements, list): + continue + for item in measurements: + if not isinstance(item, Mapping): + continue + metric = item.get("metric") + measurement = item.get("measurement") + value = item.get("value") + if not isinstance(metric, str) or not isinstance(measurement, str): + continue + if not isinstance(value, (int, float)): + continue + lookup[MeasurementKey(metric=metric, measurement=measurement, unit="")] = float(value) + return lookup + + +def aggregate_from_cached_evaluation( + evaluation: Mapping[str, Any], + config: Mapping[str, Any] | str | None = None, +) -> AggregateScore: + """ + Re-aggregate a cached eval snapshot with ``config``. + + ``config`` may be an aggregation dict or a named config from ranking_conf + (``default``, ``flat_hmean``). Defaults to the historical subgroup aggregation. + Falls back to the stored ``final_score`` when measurements are missing. + """ + if config is None: + resolved = DEFAULT_AGGREGATION_CONFIG + elif isinstance(config, str): + resolved = get_aggregation_config(config) + else: + resolved = config + + lookup = measurements_from_cached_evaluation(evaluation) + if not lookup: + final_score = evaluation.get("final_score") + if isinstance(final_score, (int, float)): + return AggregateScore(final_score=float(final_score)) + raise ValueError("cached evaluation has neither measurements nor final_score") + + return aggregate_scores(lookup, resolved) + + +def score_from_cached_evaluation( + evaluation: Mapping[str, Any], + config: Mapping[str, Any] | str | None = None, +) -> float: + """Convenience wrapper returning only the final score.""" + return float(aggregate_from_cached_evaluation(evaluation, config).final_score) + + +def evaluate_pipeline( + pipeline_config: PipelineConfig, + result_kg: KgLike, + reference_kg: KgLike, + aggregation: Mapping[str, Any] | str | None = None, +): from kgpipe_eval.metrics.statistics import CountMetric from kgpipe_eval.metrics.triple_alignment import TripleAlignmentMetric, TripleAlignmentConfig from kgpipe_eval.metrics.entity_alignment import EntityAlignmentMetric, EntityAlignmentConfig @@ -69,6 +103,13 @@ def evaluate_pipeline(pipeline_config: PipelineConfig, result_kg: KgLike, refere from kgpipe_eval.utils.kg_utils import KgManager + if aggregation is None: + resolved_config = DEFAULT_AGGREGATION_CONFIG + elif isinstance(aggregation, str): + resolved_config = get_aggregation_config(aggregation) + else: + resolved_config = aggregation + source_seed_path: KgLike = os.getenv("SOURCE_SEED_PATH") source_seed_graph = KgManager.load_kg(source_seed_path) result_graph = KgManager.load_kg(result_kg) @@ -115,7 +156,7 @@ def evaluate_pipeline(pipeline_config: PipelineConfig, result_kg: KgLike, refere KgManager.unload_kg(result_graph) KgManager.unload_kg(result_no_seed_graph) - return aggregate_scores_from_results(results, aggregation_config) + return aggregate_scores_from_results(results, resolved_config) import random @@ -128,4 +169,4 @@ def _execute_pipeline(pipeline_config: PipelineConfig): def execute_and_dummy_evaluate_pipeline(pipeline_config: PipelineConfig): result = _execute_pipeline(pipeline_config) - return dummy_evaluate_pipeline(pipeline_config, None, None) \ No newline at end of file + return dummy_evaluate_pipeline(pipeline_config, None, None) diff --git a/experiments/param-opti/src/kgpipe_search/ranking_conf.py b/experiments/param-opti/src/kgpipe_search/ranking_conf.py new file mode 100644 index 0000000..da8d60d --- /dev/null +++ b/experiments/param-opti/src/kgpipe_search/ranking_conf.py @@ -0,0 +1,113 @@ +"""Named score-aggregation configs used when ranking pipeline evaluation results.""" + +from __future__ import annotations + +from typing import Any, Dict, Mapping + +# All measurements that participate in the default ranking (10 values across 3 subgroups). +_ALL_MEASUREMENTS = [ + "EntityAlignmentMetric.recall", + "TripleAlignmentMetric.recall", + "EntityAlignmentMetric.precision", + "TripleAlignmentMetric.precision", + "DisjointDomainMetric.normalized_score", + "DomainMetric.normalized_score", + "RangeMetric.normalized_score", + "DatatypeFormatMetric.normalized_score", + "DatatypeMetric.normalized_score", + "RelationDirectionMetric.normalized_score", +] + +# Subgroup means, then equal-weight mean of the three subgroup scores. +DEFAULT_AGGREGATION_CONFIG: Dict[str, Any] = { + "subgroups": { + "coverage": { + "measurements": [ + {"metric": "EntityAlignmentMetric", "measurement": "recall"}, + {"metric": "TripleAlignmentMetric", "measurement": "recall"}, + ], + "aggregation": "mean", + }, + "correctness": { + "measurements": [ + "EntityAlignmentMetric.precision", + "TripleAlignmentMetric.precision", + ], + "aggregation": "mean", + }, + "consistency": { + "measurements": [ + "DisjointDomainMetric.normalized_score", + "DomainMetric.normalized_score", + "RangeMetric.normalized_score", + "DatatypeFormatMetric.normalized_score", + "DatatypeMetric.normalized_score", + "RelationDirectionMetric.normalized_score", + ], + "aggregation": "mean", + }, + }, + "final": { + "aggregation": "weighted_mean", + "weights": { + "coverage": 0.3333, + "correctness": 0.3333, + "consistency": 0.3333, + }, + }, +} + +# CUSTOM AGGREGATION CONFIG +CUSTOM_AGGREGATION_CONFIG: Dict[str, Any] = { + "subgroups": { + "coverage_and_correctness": { + "measurements": [ + "EntityAlignmentMetric.recall", "TripleAlignmentMetric.recall", + "EntityAlignmentMetric.precision", "TripleAlignmentMetric.precision" + ], + "aggregation": "harmonic_mean", + }, + "consistency_and_correctness": { + "measurements": [ + "DisjointDomainMetric.normalized_score", + "DomainMetric.normalized_score", + "RangeMetric.normalized_score", + "DatatypeFormatMetric.normalized_score", + "DatatypeMetric.normalized_score", + "RelationDirectionMetric.normalized_score", + ], + "aggregation": "harmonic_mean", + }, + }, + "final": { + "aggregation": "harmonic_mean", + }, +} + + +# Flat harmonic mean over all measurements (no subgroup intermediate scores). +FLAT_HMEAN_AGGREGATION_CONFIG: Dict[str, Any] = { + "subgroups": { + "all": { + "measurements": list(_ALL_MEASUREMENTS), + "aggregation": "harmonic_mean", + }, + }, + "final": { + "aggregation": "mean", + }, +} + +AGGREGATION_CONFIGS: Dict[str, Dict[str, Any]] = { + "default": DEFAULT_AGGREGATION_CONFIG, + "flat_hmean": FLAT_HMEAN_AGGREGATION_CONFIG, + "custom": CUSTOM_AGGREGATION_CONFIG, +} + + +def get_aggregation_config(name: str) -> Mapping[str, Any]: + try: + return AGGREGATION_CONFIGS[name] + except KeyError as exc: + known = ", ".join(sorted(AGGREGATION_CONFIGS)) + raise ValueError(f"Unknown rank aggregation {name!r}; choose one of: {known}") from exc diff --git a/experiments/param-opti/src/kgpipe_search/reaggregate_evals.py b/experiments/param-opti/src/kgpipe_search/reaggregate_evals.py new file mode 100644 index 0000000..fe174ad --- /dev/null +++ b/experiments/param-opti/src/kgpipe_search/reaggregate_evals.py @@ -0,0 +1,463 @@ +#!/usr/bin/env python3 +"""Re-aggregate cached ``*.eval.json`` snapshots under alternate ranking configs. + +Cached eval files store per-metric measurements (and a ``final_score`` under the +default aggregation). This script recomputes ``final_score`` for every named +aggregation in ``ranking_conf.AGGREGATION_CONFIGS`` (or a chosen subset) and +writes sorted score lists — without re-running pipelines or metrics. + +It also plots sorted score curves (x = config index 1..N, y = final_score) for +each aggregation, optionally side-by-side for RDF and text. + +Example:: + + PYTHONPATH=src python -m kgpipe_search.reaggregate_evals \\ + --eval-dir runs/rdf runs/text \\ + --out-dir runs/score_curves +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple + +import matplotlib.pyplot as plt + +from kgpipe_search.evaluation import score_from_cached_evaluation +from kgpipe_search.ranking_conf import AGGREGATION_CONFIGS, get_aggregation_config + +# Match paper-style sizing used by plot_search_evolution_aggregate. +COL_WIDTH_IN = 3.5 +COL_HEIGHT_IN = 2.6 +PAPER_DPI = 300 +PAPER_RC = { + "font.size": 9, + "axes.labelsize": 9, + "axes.titlesize": 9, + "xtick.labelsize": 8, + "ytick.labelsize": 8, + "legend.fontsize": 7, + "axes.linewidth": 0.8, + "lines.linewidth": 1.5, + "grid.linewidth": 0.5, +} + +AGG_LABELS = { + "default": "default", + "custom": "custom", + "flat_hmean": "flat hmean", +} + +AGG_LINESTYLES = { + "default": "-", + "custom": "--", + "flat_hmean": ":", +} + + +def _config_hash_from_eval_path(path: Path) -> str: + name = path.name + suffix = ".eval.json" + if name.endswith(suffix): + return name[: -len(suffix)] + return path.stem + + +def discover_eval_files(eval_dir: Path) -> List[Path]: + files = sorted(eval_dir.glob("*.eval.json")) + if not files: + raise FileNotFoundError(f"No *.eval.json files found in {eval_dir}") + return files + + +def load_evaluation(path: Path) -> Optional[Mapping[str, Any]]: + """Load a cached eval payload, or ``None`` for error / unusable snapshots.""" + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + print(f"warning: skip {path.name}: {exc}", file=sys.stderr) + return None + if not isinstance(payload, dict): + print(f"warning: skip {path.name}: expected JSON object", file=sys.stderr) + return None + if payload.get("status") == "error": + print(f"warning: skip {path.name}: cached error", file=sys.stderr) + return None + if "subgroups" not in payload and not isinstance(payload.get("final_score"), (int, float)): + print(f"warning: skip {path.name}: no measurements or final_score", file=sys.stderr) + return None + return payload + + +def reaggregate_eval_dir( + eval_dir: Path, + *, + aggregations: Sequence[str], +) -> Dict[str, List[Dict[str, Any]]]: + """ + Return ``aggregation ->`` sorted list of ``{config_hash, final_score, eval_path}``. + + Lists are sorted by ``final_score`` descending (ties broken by config hash). + """ + for name in aggregations: + get_aggregation_config(name) # validate early + + rows_by_agg: Dict[str, List[Dict[str, Any]]] = {name: [] for name in aggregations} + skipped = 0 + + for path in discover_eval_files(eval_dir): + evaluation = load_evaluation(path) + if evaluation is None: + skipped += 1 + continue + config_hash = _config_hash_from_eval_path(path) + scores: Dict[str, float] = {} + try: + for name in aggregations: + scores[name] = float(score_from_cached_evaluation(evaluation, name)) + except Exception as exc: + print(f"warning: skip {path.name}: {exc}", file=sys.stderr) + skipped += 1 + continue + for name, score in scores.items(): + rows_by_agg[name].append( + { + "config_hash": config_hash, + "final_score": score, + "eval_path": str(path), + } + ) + + for name, rows in rows_by_agg.items(): + rows.sort(key=lambda r: (-float(r["final_score"]), str(r["config_hash"]))) + for rank, row in enumerate(rows, start=1): + row["rank"] = rank + + if skipped: + print(f"skipped {skipped} eval file(s)", file=sys.stderr) + return rows_by_agg + + +def _summary(rows: Sequence[Mapping[str, Any]]) -> Dict[str, Any]: + if not rows: + return {"n": 0, "max": None, "min": None, "best_config_hash": None} + return { + "n": len(rows), + "max": float(rows[0]["final_score"]), + "min": float(rows[-1]["final_score"]), + "best_config_hash": rows[0]["config_hash"], + } + + +def write_outputs( + rows_by_agg: Mapping[str, List[Dict[str, Any]]], + *, + eval_dir: Path, + out_dir: Path, + also_scores_only: bool = True, +) -> Path: + """Write combined JSON plus per-aggregation sorted lists under ``out_dir``.""" + out_dir.mkdir(parents=True, exist_ok=True) + + payload: Dict[str, Any] = { + "eval_dir": str(eval_dir.resolve()), + "summary": {name: _summary(rows) for name, rows in rows_by_agg.items()}, + "rankings": { + name: [ + { + "rank": row["rank"], + "config_hash": row["config_hash"], + "final_score": row["final_score"], + } + for row in rows + ] + for name, rows in rows_by_agg.items() + }, + } + combined_path = out_dir / "reaggregated_scores.json" + combined_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + + for name, rows in rows_by_agg.items(): + ranked_path = out_dir / f"scores_{name}.json" + ranked_path.write_text( + json.dumps( + [ + { + "rank": row["rank"], + "config_hash": row["config_hash"], + "final_score": row["final_score"], + } + for row in rows + ], + indent=2, + ) + + "\n", + encoding="utf-8", + ) + if also_scores_only: + # Ascending score list in the same style as runs/*_final_score_dist. + scores_asc = sorted(float(r["final_score"]) for r in rows) + dist_path = out_dir / f"scores_{name}.final_score_dist" + dist_path.write_text( + "".join(f' "final_score": {s},\n' for s in scores_asc), + encoding="utf-8", + ) + tsv_path = out_dir / f"scores_{name}.tsv" + tsv_path.write_text( + "rank\tconfig_hash\tfinal_score\n" + + "".join( + f"{row['rank']}\t{row['config_hash']}\t{row['final_score']}\n" + for row in rows + ), + encoding="utf-8", + ) + + return combined_path + + +def _domain_label(eval_dir: Path) -> str: + name = eval_dir.name.lower() + if "rdf" in name: + return "RDF" + if "text" in name: + return "Text" + return eval_dir.name + + +def _sorted_scores_asc(rows: Sequence[Mapping[str, Any]]) -> List[float]: + return sorted(float(r["final_score"]) for r in rows) + + +def _plot_curves_on_ax( + ax: Any, + rows_by_agg: Mapping[str, List[Dict[str, Any]]], + *, + aggregations: Sequence[str], + y_full: bool = False, +) -> None: + all_ys: List[float] = [] + for name in aggregations: + rows = rows_by_agg.get(name) or [] + if not rows: + continue + ys = _sorted_scores_asc(rows) + all_ys.extend(ys) + xs = list(range(1, len(ys) + 1)) + ax.plot( + xs, + ys, + linestyle=AGG_LINESTYLES.get(name, "-"), + linewidth=1.5, + label=f"{AGG_LABELS.get(name, name)} (n={len(ys)})", + ) + ax.set_xlabel("Configs (sorted by score)") + ax.set_ylabel("Final score") + if y_full: + ax.set_ylim(0.0, 1.0) + elif all_ys: + lo, hi = min(all_ys), max(all_ys) + pad = max(0.02, 0.05 * (hi - lo) if hi > lo else 0.05) + ax.set_ylim(max(0.0, lo - pad), min(1.0, hi + pad)) + ax.grid(True, alpha=0.3) + ax.legend(loc="lower right", frameon=False) + + +def plot_sorted_score_curves( + rows_by_agg: Mapping[str, List[Dict[str, Any]]], + *, + aggregations: Sequence[str], + out: Path, + title: str, + y_full: bool = False, +) -> Path: + """Plot ascending sorted score curves for each aggregation into ``out``.""" + out.parent.mkdir(parents=True, exist_ok=True) + with plt.rc_context(PAPER_RC): + fig, ax = plt.subplots(figsize=(COL_WIDTH_IN, COL_HEIGHT_IN)) + _plot_curves_on_ax(ax, rows_by_agg, aggregations=aggregations, y_full=y_full) + if title: + ax.set_title(title) + fig.tight_layout() + fig.savefig(out, dpi=PAPER_DPI) + plt.close(fig) + return out + + +def plot_sorted_score_curves_panel( + panels: Sequence[Tuple[str, Mapping[str, List[Dict[str, Any]]]]], + *, + aggregations: Sequence[str], + out: Path, + title: str = "", + y_full: bool = False, +) -> Path: + """Side-by-side sorted score curves (e.g. RDF | Text).""" + if not panels: + raise ValueError("panels must be non-empty") + out.parent.mkdir(parents=True, exist_ok=True) + n = len(panels) + with plt.rc_context(PAPER_RC): + fig, axes = plt.subplots( + 1, + n, + figsize=(COL_WIDTH_IN * n, COL_HEIGHT_IN), + sharey=False, + squeeze=False, + ) + for ax, (panel_title, rows_by_agg) in zip(axes[0], panels): + _plot_curves_on_ax( + ax, rows_by_agg, aggregations=aggregations, y_full=y_full + ) + ax.set_title(panel_title) + if title: + fig.suptitle(title, y=1.02) + fig.tight_layout() + fig.savefig(out, dpi=PAPER_DPI, bbox_inches="tight") + plt.close(fig) + return out + + +def _resolve_out_dir(eval_dir: Path, out_dir: Optional[Path], *, multi: bool) -> Path: + if out_dir is None: + return eval_dir.parent / f"{eval_dir.name}_reaggregated" + if multi: + return out_dir / eval_dir.name + return out_dir + + +def build_parser() -> argparse.ArgumentParser: + known = ", ".join(sorted(AGGREGATION_CONFIGS)) + p = argparse.ArgumentParser( + description=( + "Recompute final_score for cached *.eval.json files under one or more " + "rank-aggregation configs, write sorted score lists, and plot curves." + ) + ) + p.add_argument( + "--eval-dir", + type=Path, + nargs="+", + required=True, + help="One or more directories containing *.eval.json (e.g. runs/rdf runs/text)", + ) + p.add_argument( + "--out-dir", + type=Path, + default=None, + help=( + "Output directory. With one --eval-dir: used directly " + "(default _reaggregated). With several: per-domain " + "subdirs are created under this path." + ), + ) + p.add_argument( + "--aggregations", + nargs="+", + choices=sorted(AGGREGATION_CONFIGS), + default=sorted(AGGREGATION_CONFIGS), + help=f"Aggregation config names to recompute (default: all of {known})", + ) + p.add_argument( + "--top", + type=int, + default=10, + help="Print top-N scores per aggregation to stdout (0 to silence)", + ) + p.add_argument( + "--no-scores-only", + action="store_true", + help="Do not write .final_score_dist / .tsv companion files", + ) + p.add_argument( + "--no-plot", + action="store_true", + help="Skip writing sorted-score curve plots", + ) + p.add_argument( + "--plot-title", + type=str, + default="", + help="Optional title for the combined RDF|Text panel plot", + ) + p.add_argument( + "--y-full", + action="store_true", + help="Force y-axis to [0, 1] instead of fitting each panel's score range", + ) + return p + + +def main(argv: Optional[Sequence[str]] = None) -> int: + args = build_parser().parse_args(argv) + eval_dirs = [p.resolve() for p in args.eval_dir] + for eval_dir in eval_dirs: + if not eval_dir.is_dir(): + raise SystemExit(f"--eval-dir is not a directory: {eval_dir}") + + multi = len(eval_dirs) > 1 + root_out = args.out_dir.resolve() if args.out_dir is not None else None + if multi and root_out is None: + # Shared parent next to the first eval dir, e.g. runs/score_curves + root_out = eval_dirs[0].parent / "score_curves" + + panel_data: List[Tuple[str, Dict[str, List[Dict[str, Any]]]]] = [] + + for eval_dir in eval_dirs: + out_dir = _resolve_out_dir(eval_dir, root_out, multi=multi) + rows_by_agg = reaggregate_eval_dir(eval_dir, aggregations=args.aggregations) + combined = write_outputs( + rows_by_agg, + eval_dir=eval_dir, + out_dir=out_dir, + also_scores_only=not args.no_scores_only, + ) + print(f"wrote {combined}") + for name, rows in rows_by_agg.items(): + summary = _summary(rows) + print( + f" {name}: n={summary['n']} max={summary['max']} " + f"best={summary['best_config_hash']}" + ) + if args.top > 0 and rows: + print(f" top {min(args.top, len(rows))} ({name}):") + for row in rows[: args.top]: + print( + f" {row['rank']:4d} {row['final_score']:.10f} {row['config_hash']}" + ) + + domain = _domain_label(eval_dir) + n_configs = len(next(iter(rows_by_agg.values()), [])) + panel_data.append((f"{domain} (n={n_configs})", rows_by_agg)) + + if not args.no_plot: + plot_path = plot_sorted_score_curves( + rows_by_agg, + aggregations=args.aggregations, + out=out_dir / "sorted_score_curve.png", + title=f"{domain} sorted final scores", + y_full=args.y_full, + ) + print(f"wrote {plot_path}") + + if not args.no_plot and len(panel_data) > 1: + panel_out = (root_out or eval_dirs[0].parent) / "sorted_score_curves.png" + path = plot_sorted_score_curves_panel( + panel_data, + aggregations=args.aggregations, + out=panel_out, + title=args.plot_title, + y_full=args.y_full, + ) + print(f"wrote {path}") + + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except KeyboardInterrupt: + raise SystemExit(130) diff --git a/experiments/param-opti/src/kgpipe_search/search.py b/experiments/param-opti/src/kgpipe_search/search.py index 793eb10..a220a32 100644 --- a/experiments/param-opti/src/kgpipe_search/search.py +++ b/experiments/param-opti/src/kgpipe_search/search.py @@ -141,6 +141,8 @@ def hnr_2_search( init_strategy: str = "implementation_aware", y: int = 1, rho: float = 0.2, + min_quality_delta: float = 0.05, + min_iterations_wo_improvement: int = 2, rng: Any = None, ) -> SearchRun: return run_hnr_2( @@ -152,6 +154,8 @@ def hnr_2_search( init_strategy="random" if init_strategy == "random" else "implementation_aware", y=y, rho=rho, + min_quality_delta=min_quality_delta, + min_iterations_wo_improvement=min_iterations_wo_improvement, rng=rng, ) diff --git a/experiments/param-opti/src/kgpipe_search/strategies/strategies.py b/experiments/param-opti/src/kgpipe_search/strategies/strategies.py index df628ba..ec5fd81 100644 --- a/experiments/param-opti/src/kgpipe_search/strategies/strategies.py +++ b/experiments/param-opti/src/kgpipe_search/strategies/strategies.py @@ -6,6 +6,7 @@ from kgpipe.common.model.configuration import ConfigurationProfile, ParameterBinding from kgpipe_search.configuration import ( build_pipeline_config_for_task_combo, + enumerate_exhaustive_pipeline_configs, enumerate_valid_task_combinations, pipeline_config_snapshot_key, sample_valid_pipeline_config, @@ -308,19 +309,31 @@ def run_random( pipeline_layout: PipelineLayout, rng: Optional[random.Random] = None, ) -> SearchRun: + """ + Uniform random search over the exhaustive valid config set. + + Enumerates every valid (task combo × parameter) config, then samples + ``budget`` distinct configs without replacement. Unlike hierarchical + sampling (task first, then params), each leaf config is equally likely. + """ draw = rng or random.Random() + if budget <= 0: + return SearchRun(strategy="random", history=[], budget=0, decisions=[]) + + all_configs = enumerate_exhaustive_pipeline_configs(search_space, pipeline_layout) + if not all_configs: + raise RuntimeError("Exhaustive config enumeration produced no valid configs") + if budget > len(all_configs): + raise ValueError( + f"budget={budget} exceeds exhaustive search space size ({len(all_configs)})" + ) + + selected = draw.sample(all_configs, k=budget) history: List[Observation] = [] decisions: List[str] = [] - evaluated_keys: Set[str] = set() - - for _ in range(budget): - candidate = sample_unevaluated_config( - draw, search_space, pipeline_layout, evaluated_keys - ) - key = pipeline_config_snapshot_key(candidate, search_space) + for candidate in selected: score = evaluate_fn(candidate) history.append((score, candidate)) - evaluated_keys.add(key) decisions.append("sample") return SearchRun(strategy="random", history=history, budget=budget, decisions=decisions) @@ -493,6 +506,8 @@ def run_hnr( rho: float = 0.0, rng: Optional[random.Random] = None, ) -> SearchRun: + rho = 0.0 + print(f"INFO [HNR] rho: {rho}, budget: {budget}, init_budget: {init_budget}, init_strategy: {init_strategy}, y: {y}") if budget <= 0: return SearchRun(strategy="hnr", history=[], budget=0, decisions=[]) if init_budget <= 0: @@ -610,10 +625,12 @@ def run_hnr_2( init_strategy: Literal["random", "implementation_aware"] = "implementation_aware", y: int = 1, rho: float = 0.0, - min_quality_delta = 0.03, + min_quality_delta = 0.003, min_iterations_wo_improvement = 2, rng: Optional[random.Random] = None, ) -> SearchRun: + rho = 0.0 + print(f"INFO [HNR-2] budget: {budget}, init_budget: {init_budget}, init_strategy: {init_strategy}, y: {y}, rho: {rho}, min_quality_delta: {min_quality_delta}, min_iterations_wo_improvement: {min_iterations_wo_improvement}") if budget <= 0: return SearchRun(strategy="hnr_2", history=[], budget=0, decisions=[]) if init_budget <= 0: @@ -711,7 +728,7 @@ def run_hnr_2( iterations_wo_improvement = 0 else: # We are not able to improve the last task anymore. Therefore, we can also stop the runs. - break + pass # TODO: implement a proper stopping criterion if not improved and len(history) < budget and draw.random() < rho: candidate = sample_unevaluated_config( draw, search_space, pipeline_layout, evaluated_keys diff --git a/experiments/param-opti/src/plot_search_evolution.py b/experiments/param-opti/src/plot_search_evolution.py index 61251fa..0b82e51 100644 --- a/experiments/param-opti/src/plot_search_evolution.py +++ b/experiments/param-opti/src/plot_search_evolution.py @@ -24,10 +24,12 @@ STRATEGY_LABELS = { "bayes-offline.json": "Bayesian optimization", "bayesian-results.json": "Bayesian optimization", - "hnr-offline.json": "HNR", - "hnr-results.json": "HNR", - "qgns-offline.json": "QGNS", - "qgns-results.json": "QGNS", + "hnr-offline.json": "HNR-1", + "hnr-results.json": "HNR-1", + "hnr_2-offline.json": "HNR-2", + "hnr_2-results.json": "HNR-2", + "qgns-offline.json": "RNS", + "qgns-results.json": "RNS", "random-implementation-aware-offline.json": "Random (implementation-aware)", "implementation-aware-results.json": "Implementation-aware", "random-random-offline.json": "Random", diff --git a/experiments/param-opti/src/plot_search_evolution_aggregate.py b/experiments/param-opti/src/plot_search_evolution_aggregate.py index a5415e6..1852a7a 100644 --- a/experiments/param-opti/src/plot_search_evolution_aggregate.py +++ b/experiments/param-opti/src/plot_search_evolution_aggregate.py @@ -11,6 +11,10 @@ For each config group (everything before ``_seed_``), writes a mean curve plot with a shaded band and a table of mean ± std metrics. + +The table also reports how often each strategy reaches the known expected +maximum quality score (``TEXT_EXPECTED_MAX`` / ``RDF_EXPECTED_MAX``), inferred +from whether the results directory name contains ``text`` or ``rdf``. """ from __future__ import annotations @@ -39,12 +43,40 @@ DEFAULT_RESULTS_DIR = Path(__file__).resolve().parent.parent / "rdf-search-results" +# Known global maxima (exhaustive / reference best quality scores). +# TEXT wo seed ref default 0.8503806701 custom 0.3802856400657162 +# TEXT with seed ref default 0.8503806701 custom 0.849341845483141 +TEXT_EXPECTED_MAX =0.3802856400657162 +# RDF wo seed ref default 0.8018846725409015 custom 0.7712140467593951 +# RDF with seed ref default 0.9615967544 custom 0.967927789101375 +RDF_EXPECTED_MAX = 0.7712140467593951 + SEED_DIR_RE = re.compile(r"^(?P.+)_seed_(?P\d+)$") PLOT_FILENAME = "search-evolution-aggregated.png" TABLE_CSV_FILENAME = "search-evolution-aggregated-table.csv" TABLE_MD_FILENAME = "search-evolution-aggregated-table.md" +# Absolute tolerance when comparing run Q-best to the expected max. +EXPECTED_MAX_ABS_TOL = 1e-9 + +# Single-column figure for double-column papers (~3.5" column width). +# Size fonts for 1:1 print (do not shrink a wide figure in LaTeX). +COL_WIDTH_IN = 3.5 +COL_HEIGHT_IN = 2.6 +PAPER_DPI = 300 +PAPER_RC = { + "font.size": 9, + "axes.labelsize": 9, + "axes.titlesize": 9, + "xtick.labelsize": 8, + "ytick.labelsize": 8, + "legend.fontsize": 7, + "axes.linewidth": 0.8, + "lines.linewidth": 1.5, + "grid.linewidth": 0.5, +} + @dataclass(frozen=True) class RunCurve: @@ -62,6 +94,8 @@ class AggregatedMetrics: n: int q_best_mean: float q_best_std: float + hits_expected_max: int + expected_max: Optional[float] evals_to_95pct_mean: float evals_to_95pct_std: float evals_to_best_mean: float @@ -84,6 +118,20 @@ def _fmt_mean_std(mean: float, std: float, *, digits: int) -> str: return f"{mean:.{digits}f} ± {std:.{digits}f}" +def _expected_max_for_results_dir(results_dir: Path) -> Optional[float]: + """Pick TEXT/RDF expected max from the results directory name.""" + name = results_dir.name.lower() + if "text" in name: + return TEXT_EXPECTED_MAX + if "rdf" in name: + return RDF_EXPECTED_MAX + return None + + +def _reaches_expected_max(q_best: float, expected_max: float) -> bool: + return math.isclose(q_best, expected_max, rel_tol=0.0, abs_tol=EXPECTED_MAX_ABS_TOL) + + def _discover_seed_dirs(results_dir: Path) -> Dict[str, List[Tuple[str, Path]]]: """Map config key -> list of (seed, path) for ``*_seed_`` subdirs.""" groups: Dict[str, List[Tuple[str, Path]]] = defaultdict(list) @@ -181,16 +229,27 @@ def _aggregate_curves( return xs, means, lowers, uppers, len(curves) -def _aggregate_metrics(curves: Sequence[RunCurve]) -> AggregatedMetrics: +def _aggregate_metrics( + curves: Sequence[RunCurve], + *, + expected_max: Optional[float], +) -> AggregatedMetrics: q_best = [c.metrics.q_best for c in curves] aoc = [c.metrics.aoc for c in curves] to_95 = [float(c.metrics.evals_to_95pct) for c in curves if c.metrics.evals_to_95pct is not None] to_best = [float(c.metrics.evals_to_best) for c in curves if c.metrics.evals_to_best is not None] + hits = ( + sum(1 for q in q_best if _reaches_expected_max(q, expected_max)) + if expected_max is not None + else 0 + ) return AggregatedMetrics( strategy=curves[0].strategy, n=len(curves), q_best_mean=_mean(q_best), q_best_std=_std(q_best), + hits_expected_max=hits, + expected_max=expected_max, evals_to_95pct_mean=_mean(to_95), evals_to_95pct_std=_std(to_95), evals_to_best_mean=_mean(to_best), @@ -200,13 +259,20 @@ def _aggregate_metrics(curves: Sequence[RunCurve]) -> AggregatedMetrics: ) +def _fmt_hits(row: AggregatedMetrics) -> str: + if row.expected_max is None: + return "—" + return f"{row.hits_expected_max}/{row.n}" + + def _format_metrics_table(rows: Sequence[AggregatedMetrics]) -> List[List[str]]: - header = ["Strategy", "n", "Q best", "Evals to 95%", "Evals to best", "AOC"] + header = ["Strategy", "n", "Q best", "Hits max", "Evals to 95%", "Evals to best", "AOC"] body = [ [ row.strategy, str(row.n), _fmt_mean_std(row.q_best_mean, row.q_best_std, digits=4), + _fmt_hits(row), _fmt_mean_std(row.evals_to_95pct_mean, row.evals_to_95pct_std, digits=2), _fmt_mean_std(row.evals_to_best_mean, row.evals_to_best_std, digits=2), _fmt_mean_std(row.aoc_mean, row.aoc_std, digits=2), @@ -236,6 +302,8 @@ def _write_metrics_csv(path: Path, rows: Sequence[AggregatedMetrics]) -> None: "n", "q_best_mean", "q_best_std", + "hits_expected_max", + "expected_max", "evals_to_95pct_mean", "evals_to_95pct_std", "evals_to_best_mean", @@ -251,6 +319,8 @@ def _write_metrics_csv(path: Path, rows: Sequence[AggregatedMetrics]) -> None: row.n, f"{row.q_best_mean:.6f}", f"{row.q_best_std:.6f}", + row.hits_expected_max, + f"{row.expected_max:.10f}" if row.expected_max is not None else "", f"{row.evals_to_95pct_mean:.4f}", f"{row.evals_to_95pct_std:.4f}", f"{row.evals_to_best_mean:.4f}", @@ -280,34 +350,50 @@ def plot_aggregated( out: Path, title: str, ) -> None: - fig, ax = plt.subplots(figsize=(9, 5.5)) - init_budget: Optional[int] = None - - for strategy in sorted(by_strategy): - curves = by_strategy[strategy] - xs, means, lowers, uppers, n = _aggregate_curves(curves, band=band) - if not xs: - continue - if init_budget is None and curves: - init_budget = curves[0].init_budget - - (line,) = ax.plot(xs, means, marker="o", markersize=3, linewidth=1.8, label=f"{strategy} (n={n})") - ax.fill_between(xs, lowers, uppers, color=line.get_color(), alpha=0.2, linewidth=0) - - if init_budget and init_budget > 0: - ax.axvline(init_budget + 0.5, color="0.75", linestyle=":", linewidth=0.8) - - band_label = {"range": "min–max range", "std": "±1 std", "sem": "±1 SEM"}[band] - ax.set_xlabel("Iteration") - ax.set_ylabel("Best quality score so far (mean)") - ax.set_title(f"{title}\n(shaded: {band_label} across seeds)") - ax.grid(True, alpha=0.3) - ax.legend(loc="lower right", fontsize=9) - fig.tight_layout() + with plt.rc_context(PAPER_RC): + fig, ax = plt.subplots(figsize=(COL_WIDTH_IN, COL_HEIGHT_IN)) + init_budget: Optional[int] = None + + for strategy in sorted(by_strategy): + curves = by_strategy[strategy] + xs, means, lowers, uppers, n = _aggregate_curves(curves, band=band) + if not xs: + continue + if init_budget is None and curves: + init_budget = curves[0].init_budget + + (line,) = ax.plot( + xs, + means, + marker="o", + markersize=2.5, + linewidth=1.5, + label=f"{strategy} (n={n})", + ) + ax.fill_between(xs, lowers, uppers, color=line.get_color(), alpha=0.2, linewidth=0) + + if init_budget and init_budget > 0: + ax.axvline(init_budget + 0.5, color="0.75", linestyle=":", linewidth=0.8) + + band_label = {"range": "min–max range", "std": "±1 std", "sem": "±1 SEM"}[band] + ax.set_xlabel("Iteration") + ax.set_ylabel("Best quality (mean)") + ax.set_title(f"{title}\n(shaded: {band_label})") + ax.grid(True, alpha=0.3) + ax.legend( + loc="lower right", + frameon=True, + borderpad=0.3, + labelspacing=0.25, + handlelength=1.2, + handletextpad=0.4, + borderaxespad=0.3, + ) + fig.tight_layout(pad=0.35) - out.parent.mkdir(parents=True, exist_ok=True) - fig.savefig(out, dpi=160) - plt.close(fig) + out.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(out, dpi=PAPER_DPI, bbox_inches="tight") + plt.close(fig) def _process_config_group( @@ -318,6 +404,7 @@ def _process_config_group( title: str, band: str, target_fraction: float, + expected_max: Optional[float], ) -> None: by_strategy: Dict[str, List[RunCurve]] = defaultdict(list) for seed, run_dir in seed_dirs: @@ -333,7 +420,10 @@ def _process_config_group( print(f"skip {config}: no usable reports in {[p.name for _, p in seed_dirs]}") return - metrics = [_aggregate_metrics(curves) for _, curves in sorted(by_strategy.items())] + metrics = [ + _aggregate_metrics(curves, expected_max=expected_max) + for _, curves in sorted(by_strategy.items()) + ] metrics.sort(key=lambda row: row.strategy) out_dir.mkdir(parents=True, exist_ok=True) @@ -348,7 +438,10 @@ def _process_config_group( print(f"wrote: {table_csv}") print(f"wrote: {table_md}") print() - print(f"[{config}] seeds={[s for s, _ in seed_dirs]}") + expected_label = ( + f"expected_max={expected_max:.10f}" if expected_max is not None else "expected_max=none" + ) + print(f"[{config}] seeds={[s for s, _ in seed_dirs]} {expected_label}") _print_metrics_table(metrics) print() @@ -413,6 +506,13 @@ def main(argv: Sequence[str] | None = None) -> int: ) groups = {args.config: groups[args.config]} + expected_max = _expected_max_for_results_dir(results_dir) + if expected_max is None: + print( + f"warning: could not infer TEXT/RDF expected max from {results_dir.name!r}; " + "Hits max column will be empty" + ) + for config, seed_dirs in sorted(groups.items()): if args.out_dir is not None: out_dir = args.out_dir if len(groups) == 1 else args.out_dir / config @@ -427,6 +527,7 @@ def main(argv: Sequence[str] | None = None) -> int: title=title, band=str(args.band), target_fraction=float(args.target_fraction), + expected_max=expected_max, ) return 0 diff --git a/src/kgpipe_eval/utils/score_utils.py b/src/kgpipe_eval/utils/score_utils.py index 7980706..47ef814 100644 --- a/src/kgpipe_eval/utils/score_utils.py +++ b/src/kgpipe_eval/utils/score_utils.py @@ -35,7 +35,9 @@ class AggregateScore: subgroups: dict[str, SubgroupScore] = field(default_factory=dict) -_AGGREGATIONS = frozenset({"mean", "weighted_mean", "min", "max", "geometric_mean", "product"}) +_AGGREGATIONS = frozenset( + {"mean", "weighted_mean", "min", "max", "geometric_mean", "harmonic_mean", "product"} +) _TRANSFORMS = frozenset({None, "identity", "invert", "one_minus"}) @@ -91,6 +93,13 @@ def _aggregate(values: Sequence[float], method: str, weights: Sequence[float] | return 0.0 return math.exp(sum(math.log(v) for v in values) / len(values)) + if method == "harmonic_mean": + if any(v < 0 for v in values): + raise ValueError("harmonic_mean requires non-negative values") + if any(v == 0 for v in values): + return 0.0 + return len(values) / sum(1.0 / v for v in values) + raise ValueError(f"Unsupported aggregation method: {method!r}") @@ -240,7 +249,8 @@ def aggregate_scores( } Measurement refs may be objects or shorthand strings like ``MetricName.measurement``. - Supported subgroup/final aggregations: mean, weighted_mean, min, max, geometric_mean, product. + Supported subgroup/final aggregations: mean, weighted_mean, min, max, geometric_mean, + harmonic_mean, product. Supported transforms: identity (default), invert / one_minus (``1 - value``). """ lookup = _coerce_measurement_lookup(measurements) From 595acb9cf364e41b4c4f6639614074a6f335c5b1 Mon Sep 17 00:00:00 2001 From: Marvin Hofer Date: Sat, 25 Jul 2026 12:34:42 +0200 Subject: [PATCH 94/96] exp(params): update README.md for paper --- experiments/param-opti/README.md | 88 +++++++++++++++++++++++++++++--- 1 file changed, 81 insertions(+), 7 deletions(-) diff --git a/experiments/param-opti/README.md b/experiments/param-opti/README.md index d7dcbb1..b7b6d9b 100644 --- a/experiments/param-opti/README.md +++ b/experiments/param-opti/README.md @@ -1,17 +1,91 @@ -# Pipeline Search and Optimization +# Pipeline configuration search -This experiment extracts and analyzes configuration parameters from open-source data integration tools using the `kgpipe_parameters` extraction module. +Search over KG integration pipeline configs (task selection + parameters) to maximize evaluation quality against a reference KG. Supports **RDF** (graph alignment / fusion) and **text** (IE → linking → RDF → fusion) pipelines on the MovieKG benchmark. -## Usage +## Layout +| Path | Role | +|------|------| +| `src/experiment.py` | Live search: propose configs → run pipeline → evaluate → write results | +| `src/execute.py` | Run/evaluate fixed config fixtures (sampled or exhaustive) | +| `src/analyse.py` | Offline search simulation on a cached `results.json` | +| `src/plot_search_evolution.py` | Per-seed evolution plots/tables | +| `src/plot_search_evolution_aggregate.py` | Mean ± band across RNG seeds | +| `src/kgpipe_search/` | Search space, strategies, ranking, evaluation | +| `scripts/` | Reproducible experiment drivers | +| `data/` | Symlink to MovieKG bench data (`kgpipe-parameters/latest`) | +| `runs/` | Pipeline artifacts + search result summaries | -## +## Search strategies -## Contribution +| Flag | Behavior | +|------|----------| +| `random` | Uniform sample over exhaustive valid configs | +| `implementation_aware` | Systematic task-combo coverage, random params | +| `qgns` | Restricted neighborhood search (RNS in plots) | +| `hnr` / `hnr_2` | Hierarchical neighborhood refinement | +| `bayesian` | Surrogate + acquisition over a candidate pool | +| `llm` | LLM-proposed configs (needs `KGPipe_SEARCH_LLM_*` env vars) | + +Objective score comes from `--rank-aggregation` (`default` | `flat_hmean` | `custom`), applied to cached metric measurements in `.eval.json`. + +## Quick start + +From the **repo root**, with `.venv` and `experiments/param-opti/data` pointing at the bench dataset: + +```bash +cd experiments/param-opti + +# One seed, all strategies (RDF or text) +bash scripts/rdf_experiments.sh 0 +bash scripts/text_experiments.sh 0 + +# Multi-seed sweep (seeds: 0 42 1337 1–7) +bash full.sh + +# Per-seed + aggregated plots +bash scripts/call_plot.sh 0 +``` + +Results land under: -Linting check ``` +runs/{rdf,text}/ # pipeline caches (by config hash) +runs/{rdf,text}-search-results_rank_/init__budget__seed_/ + {random,implementation-aware,qgns,hnr,hnr_2,bayesian}-results.json +``` + +## Single experiment + +```bash +export PYTHONPATH=src:/src +python src/experiment.py \ + --seed data/bench/moviekg/split_0/kg/seed/data.nt \ + --source data/bench/moviekg/split_1/sources/rdf/data.nt \ + --reference data/bench/moviekg/split_1/kg/reference/data_agg.nt \ + --ontology data/bench/moviekg/ontology.ttl \ + --pipeline-type rdf \ + --strategy hnr_2 \ + --budget 20 --init-budget 1 \ + --init-strategy implementation_aware \ + --rank-aggregation custom \ + --rng-seed 0 \ + --output-dir runs/rdf \ + --results runs/rdf-search-results_rank_custom/init_1_budget_20_seed_0/hnr_2-results.json +``` + +For text, use `--pipeline-type text` and `--source data/bench/moviekg/split_1/sources/text/data/`. + +## Related scripts + +- `scripts/call_execute.sh` / `call_text_execute.sh` — fixture execution via `execute.py` +- `scripts/call_analyse-offline.sh` — replay strategies on a cached results file +- `scripts/rdf_hnr2_params.sh` / `run_multi` — HNR-2 hyperparameter sweeps + +## Lint + +```bash .venv/bin/ruff check --fix experiments/param-opti/src/kgpipe_search ``` -The `kgpipe_search.dev` module will be migrated into the core KGpipe API. \ No newline at end of file +`kgpipe_search.dev` is intended to move into the core KGpipe API. From e30acabbeeab57783c529aba404c232a4bd31819 Mon Sep 17 00:00:00 2001 From: Marvin Hofer Date: Sat, 25 Jul 2026 12:52:48 +0200 Subject: [PATCH 95/96] Revise README with new resources and installation details Updated README.md to include additional benchmarks, datasets, and papers related to KGpipe. Enhanced quickstart and installation sections for clarity. --- README.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 3c463c3..18f26c1 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,17 @@ # KGpipe: A Framework for Knowledge Graph Integration Pipelines -## Related benchmarks & datasets - -- **KGI-Bench**: benchmark specification + tooling for KG integration evaluation. See `https://github.com/ScaDS/KGI-Bench`. -- **KGI-Bench (Movies)**: Movie-domain benchmark dataset release (Zenodo). See `https://doi.org/10.5281/zenodo.17246357`. - - KGpipe is an open-source framework for defining, executing, and evaluating knowledge graph (KG) integration pipelines. It enables the reuse and composition of existing tools (e.g., OpenIE, PARIS, JedAI) and Large Language Models (LLMs) into modular pipelines that integrate heterogeneous data sources into a unified KG. ![KGpipe workflow](docs/workflow.png) +## Related benchmarks, datasets, and papers + +- [**KGI-Bench**](https://github.com/ScaDS/KGI-Bench): benchmark specification + tooling for KG integration evaluation. +- [**KGI-Bench (Movies)**](https://doi.org/10.5281/zenodo.17246357): Movie-domain benchmark dataset release (Zenodo). +- [**KGpipe Explorer**](https://vehnem.github.io/kgpipe-explorer/): a demo exploring results of KGI-Bench executed with KGpipe. +- [**Framework Paper**](https://arxiv.org/abs/2511.18364): framework core paper; revised version accepted at QDB 2026 (to appear). + **Who is this for?** - You have multiple heterogeneous sources (RDF/JSON/text) and want a **reproducible, modular pipeline**. - You want to **reuse existing tooling** (Python libs, Dockerized CLIs, remote APIs/LLMs) without rewriting everything. From b13314730adfe43ea7fac3af406c41368f54c4d5 Mon Sep 17 00:00:00 2001 From: Marvin Hofer Date: Sat, 25 Jul 2026 12:53:44 +0200 Subject: [PATCH 96/96] Update README with experiment results link Added link to experiment results for parameter search. --- experiments/param-opti/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/experiments/param-opti/README.md b/experiments/param-opti/README.md index b7b6d9b..4c5f40a 100644 --- a/experiments/param-opti/README.md +++ b/experiments/param-opti/README.md @@ -2,6 +2,8 @@ Search over KG integration pipeline configs (task selection + parameters) to maximize evaluation quality against a reference KG. Supports **RDF** (graph alignment / fusion) and **text** (IE → linking → RDF → fusion) pipelines on the MovieKG benchmark. +Experiment results can be found at https://github.com/Vehnem/kgpipe-experiment-results/tree/main/parameter_search + ## Layout | Path | Role |