Download src/sr_harness/tools/base_tool.py from YuMeow/SRHarness: direct link, hf CLI and curl.
- Browser
- Download file 40.2 kB
-
https://huggingface.co/spaces/YuMeow/SRHarness/resolve/main/src/sr_harness/tools/base_tool.py
- Command line
-
hf download hf://spaces/YuMeow/SRHarness/src/sr_harness/tools/base_tool.py
-
curl -L -o base_tool.py https://huggingface.co/spaces/YuMeow/SRHarness/resolve/main/src/sr_harness/tools/base_tool.py
40.2 kB
| # Copyright (c) 2026-present, Yumeow. Licensed under the MIT License. | |
| """工具基类定义。 | |
| 所有工具都应继承自 BaseTool,并提供统一的接口。 | |
| """ | |
| from __future__ import annotations | |
| import re | |
| import time | |
| import warnings | |
| from numbers import Real | |
| import numpy as np | |
| import sr_harness_engine as engine | |
| from pathlib import Path | |
| from logging import getLogger | |
| from scipy import stats | |
| from docstring_parser import DocstringStyle, parse | |
| from abc import ABC, abstractmethod | |
| from types import NoneType, UnionType | |
| from inspect import Parameter, signature | |
| from typing import TYPE_CHECKING, Any, Callable, Dict, List, Literal, Union, get_args, get_origin, get_type_hints | |
| from ..utils import FactoryMixin, log_exception | |
| from ..core import AgentContext | |
| from ..core.tool import ToolCallResult, ToolMetadata | |
| from ..evaluator import utils as evaluator_utils | |
| if TYPE_CHECKING: | |
| from ..skills import SkillManager | |
| _logger = getLogger(f'sr_harness.{__name__}') | |
| _ANSI_ESCAPE_RE = re.compile( | |
| r"(?:\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|(?:\x1b\[|\x9b)[0-?]*[ -/]*[@-~]|\x1b[@-_])" | |
| ) | |
| class ToolRunAbort(RuntimeError): | |
| """Raise from a tool to bypass BaseTool.__call__ error handling.""" | |
| class BaseTool(ABC, FactoryMixin): | |
| """工具基类。所有工具都应继承此类,并设置 / 实现以下字段和方法: | |
| - metadata: ToolMetadata 实例,提供工具的名称、描述和参数 schema(若不提供则尝试自动推断) | |
| - execute(): 工具的核心执行方法,接受 LLM 生成的参数并返回结果字典。工具的 execute 方法应该尽量保持参数简单,复杂的上下文信息(如数据)可以通过工具实例的 context 属性传入。 | |
| - format_result_dict(): 可选的类方法,用于将 execute 的结果字典格式化为字符串,供 LLM 阅读。默认实现是直接转换为字符串,不同工具可以根据需要重写此方法以提供更友好的结果展示。 | |
| """ | |
| metadata: ToolMetadata = None | |
| MAX_FORMULA_LENGTH = 10000 | |
| MAX_RESULT_STR_LENGTH = 64 * 1024 | |
| FORMULA_DISPLAY_NUMBER_FORMAT = ".8g" | |
| def __init_subclass__(cls, **kwargs: Any) -> None: | |
| """在子类定义时自动设置元数据""" | |
| super().__init_subclass__(**kwargs) | |
| if cls.metadata is None: | |
| cls.metadata = ToolMetadata() | |
| if cls.metadata.description is None: | |
| cls.metadata.description = cls.infer_tool_description() | |
| if cls.metadata.parameters is None: | |
| cls.metadata.parameters = cls.infer_tool_parameters() | |
| def __init__(self, context: AgentContext | None = None) -> None: | |
| """Initialize a tool with its authoritative runtime context. | |
| Args: | |
| context: Context used by the tool. An empty context is created when | |
| omitted for tools that do not require runtime data. | |
| Raises: | |
| TypeError: If ``context`` is not an :class:`AgentContext`. | |
| """ | |
| if context is not None and not isinstance(context, AgentContext): | |
| raise TypeError("context must be an AgentContext or None") | |
| self.context = context if context is not None else AgentContext() | |
| def get_doc(cls) -> dict[str, str] | None: | |
| """Return optional documentation to expose as a runtime skill.""" | |
| return None | |
| def execute(self) -> Dict[str, Any]: | |
| """Execute the tool and return its result. | |
| The parameters of this method are generated by the LLM and should therefore remain simple. Pass complex runtime context, such as data, through the tool instance's ``context`` attribute. Implementations must be safe for concurrent process or thread execution. | |
| The text before ``Args`` supplies ``metadata.description``; argument descriptions supply the parameter schema descriptions. The signature and type hints supply its JSON schema. Automatic inference supports common scalar, list, and dictionary types. Define ``ToolMetadata.parameters`` explicitly for more complex schemas. | |
| Returns: | |
| A structured result dictionary.""" | |
| pass | |
| def format_result_dict(cls, result: Dict[str, Any]) -> str: | |
| """Format a tool result as text for the language model. | |
| Subclasses may override this method to provide a clearer presentation. | |
| Args: | |
| result: Structured result returned by ``execute``. | |
| Returns: | |
| Text to append to the model conversation.""" | |
| return str(result) | |
| def cancel(self) -> None: | |
| """Request cancellation when a tool offers no stronger primitive.""" | |
| def __call__(self, *args, **kwargs) -> ToolCallResult: | |
| """工具调用入口""" | |
| start_time = time.time() | |
| try: | |
| cancellation_event = getattr(self, "cancel_event", None) | |
| if cancellation_event is not None and cancellation_event.is_set(): | |
| raise InterruptedError("用户强制中止") | |
| result = self.execute(*args, **kwargs) | |
| if cancellation_event is not None and cancellation_event.is_set(): | |
| raise InterruptedError("用户强制中止") | |
| if not isinstance(result, dict): | |
| _logger.critical(f"Tool {self.metadata.name} execute() should return a dict, but got {type(result)}, please check the implementation.") | |
| result_str = self.format_result_dict(result) | |
| result_str = self.truncate_result_str(_ANSI_ESCAPE_RE.sub("", str(result_str))) | |
| meta_data = { | |
| "timestamp": start_time, | |
| "execution_time": time.time() - start_time, | |
| "tool": self.metadata.name | |
| } | |
| return ToolCallResult(ok=True, result=result, result_str=result_str, meta_data=meta_data) | |
| except ToolRunAbort: | |
| raise | |
| except Exception as e: | |
| _logger.error(f"Error executing {self.metadata.name}: {log_exception(e)}") | |
| exception_message = str(e).strip() or repr(e) | |
| error_msg = ( | |
| f"Error executing {self.metadata.name}:\n" | |
| f" {type(e).__name__}: {exception_message}" | |
| ) | |
| error_msg = self.truncate_result_str(_ANSI_ESCAPE_RE.sub("", error_msg)) | |
| meta_data = { | |
| "timestamp": start_time, | |
| "execution_time": time.time() - start_time, | |
| "tool": self.metadata.name | |
| } | |
| return ToolCallResult(ok=False, result={'error': error_msg}, result_str=error_msg, meta_data=meta_data) | |
| def normalize_formula(cls, eq: str, *, strip_modules: bool = True) -> str: | |
| """Validate and normalize an agent-provided formula before parsing it. | |
| Args: | |
| eq: Formula text supplied by the agent. | |
| strip_modules: Whether to remove supported module prefixes. | |
| Returns: | |
| The normalized formula text.""" | |
| if not isinstance(eq, str): | |
| raise TypeError(f"Formula must be a string, got {type(eq).__name__}.") | |
| eq = eq.strip() | |
| if len(eq) > cls.MAX_FORMULA_LENGTH: | |
| raise ValueError( | |
| f"Formula is too long: {len(eq)} characters; " | |
| f"maximum allowed is {cls.MAX_FORMULA_LENGTH}." | |
| ) | |
| eq = eq.replace("^", "**") | |
| if strip_modules: | |
| eq = eq.replace("np.", "").replace("numpy.", "").replace("math.", "") | |
| return eq | |
| def parse_formula(cls, eq: str) -> engine.Expression: | |
| """Normalize and parse a formula with constants supported by all tools. | |
| Args: | |
| eq: Formula text supplied by the agent. | |
| Returns: | |
| The parsed symbolic expression.""" | |
| return engine.parse( | |
| cls.normalize_formula(eq), | |
| symbols={"pi": np.pi, "e": np.e}, | |
| ) | |
| def truncate_result_str(cls, text: str) -> str: | |
| """Bound text sent to the LLM while preserving the full raw result. | |
| Args: | |
| text: Tool-result text to limit. | |
| Returns: | |
| The original text or a length-limited prefix with a truncation note.""" | |
| text = str(text) | |
| limit = cls.MAX_RESULT_STR_LENGTH | |
| if len(text) <= limit: | |
| return text | |
| suffix = "... (characters ignored)" | |
| keep = max(0, limit - len(suffix) - 32) | |
| ignored = len(text) - keep | |
| suffix = f"... ({ignored} characters ignored)" | |
| keep = max(0, limit - len(suffix)) | |
| ignored = len(text) - keep | |
| suffix = f"... ({ignored} characters ignored)" | |
| return text[: max(0, limit - len(suffix))] + suffix | |
| def to_tool_list(cls, tools_used: list[str] | None = None) -> list[dict]: | |
| """Load OpenAI-compatible function-tool definitions. | |
| Args: | |
| tools_used: Tool names to include, or ``None`` for all registered tools. | |
| Returns: | |
| Function-tool definitions in OpenAI-compatible format.""" | |
| tool_list = [] | |
| for name, tool_cls in cls.REGISTRY_DICT.items(): | |
| if tools_used is None or tool_cls.metadata.name in tools_used: | |
| tool_list.append(tool_cls.to_dict()) | |
| return tool_list | |
| def load_tool_list(cls, tools_used: list[str] | None = None) -> list[dict]: | |
| """Load tool metadata for text and JSON parsers. | |
| Args: | |
| tools_used: Tool names to include, or ``None`` for all registered tools. | |
| Returns: | |
| Names, descriptions, and parameter schemas for the selected tools.""" | |
| tool_list = [] | |
| for name, tool_cls in cls.REGISTRY_DICT.items(): | |
| if tools_used is None or tool_cls.metadata.name in tools_used: | |
| tool_list.append({ | |
| "name": tool_cls.metadata.name, | |
| "description": tool_cls.metadata.description, | |
| "parameters": tool_cls.metadata.parameters, | |
| }) | |
| return tool_list | |
| def load_tool_classes(cls, tools_used: list[str] | None = None) -> list[type["BaseTool"]]: | |
| """Load tool classes for native function calling. | |
| Args: | |
| tools_used: Tool names to include, or ``None`` for all registered tools. | |
| Returns: | |
| The selected registered tool classes.""" | |
| tool_list = [] | |
| for name, tool_cls in cls.REGISTRY_DICT.items(): | |
| if tools_used is None or tool_cls.metadata.name in tools_used: | |
| tool_list.append(tool_cls) | |
| return tool_list | |
| def load_custom_tool(cls, source: str, *, path: str | Path) -> dict: | |
| """Inspect one sandboxed ``BaseTool`` stored in a skill's ``tool.py``. | |
| Args: | |
| source: Python source defining the custom tool. | |
| path: Origin path used for diagnostics and source identity. | |
| Returns: | |
| Metadata and a sandbox-backed proxy class for the tool.""" | |
| path = Path(path).resolve() | |
| if path.suffix != ".py": | |
| raise ValueError(f"Custom tool source must be a Python file: {path}") | |
| from .sandboxed_custom_tool import create_sandboxed_custom_tool | |
| for registered_name, registered_cls in list(cls.REGISTRY_DICT.items()): | |
| if getattr(registered_cls, "source_path", None) == path: | |
| del cls.REGISTRY_DICT[registered_name] | |
| registry_before = dict(cls.REGISTRY_DICT) | |
| try: | |
| if len(source) > 50_000: | |
| raise ValueError("Custom tool source exceeds 50000 characters") | |
| # Parsing is side-effect free and preserves a useful SyntaxError for authors; | |
| # importing and inspecting the module still happens only in the sandbox. | |
| compile(source, str(path), "exec", dont_inherit=True) | |
| tool_cls = create_sandboxed_custom_tool(source, path) | |
| tool_name = tool_cls.metadata.name.strip() | |
| if tool_name in registry_before: | |
| raise ValueError(f"Tool name '{tool_name}' is already registered.") | |
| cls.register(tool_name)(tool_cls) | |
| return { | |
| "tool_name": tool_name, | |
| "path": str(path), | |
| "class_name": tool_cls.__name__, | |
| "tool_class": tool_cls, | |
| } | |
| except Exception: | |
| cls.REGISTRY_DICT.clear() | |
| cls.REGISTRY_DICT.update(registry_before) | |
| raise | |
| def discover_custom_tools(cls, skill_manager: SkillManager) -> list[type["BaseTool"]]: | |
| # 将 skill_manager 中发现的自定义工具注册到 BaseTool,并返回 tool class list | |
| """Discover custom tools. | |
| Args: | |
| skill_manager: The skill manager value. | |
| Returns: | |
| list[type['BaseTool']]: The operation result. | |
| """ | |
| custom_tool_cls_list = [] | |
| for skill, source in skill_manager.load_tool_skills(): | |
| tool_path = skill.skill_directory / "tool.py" | |
| try: | |
| loaded = cls.load_custom_tool(source, path=tool_path) | |
| custom_tool_cls_list.append(cls.REGISTRY_DICT[loaded["tool_name"]]) | |
| except Exception as e: | |
| _logger.warning(f"Skipping custom tool {tool_path}: {log_exception(e)}") | |
| return custom_tool_cls_list | |
| def to_dict(cls) -> dict: | |
| """Export an OpenAI-compatible function-tool definition. | |
| Returns: | |
| The tool name, description, and JSON parameter schema.""" | |
| return { | |
| "type": "function", | |
| "function": { | |
| "name": cls.metadata.name, | |
| "description": cls.metadata.description, | |
| "parameters": cls.metadata.parameters | |
| }, | |
| } | |
| def infer_tool_description(cls) -> str: | |
| """Extract a tool description from the text before ``Args`` in ``execute``. | |
| Returns: | |
| The inferred description or a placeholder when none is provided.""" | |
| doc = getattr(cls.execute, "__doc__", None) or "" | |
| description_lines = [] | |
| for line in doc.splitlines(): | |
| line = line.strip() | |
| if line == "Args:": | |
| break | |
| if line: | |
| description_lines.append(line) | |
| return "\n".join(description_lines) if description_lines else "(no description provided)" | |
| def infer_tool_parameters(cls) -> Dict[str, Any]: | |
| """Infer a parameter schema from the ``execute`` signature and docstring. | |
| Automatic inference covers common Python and typing annotations. Complex constraints, enumerations, and formats should be declared explicitly in ``ToolMetadata.parameters``. | |
| Returns: | |
| An object-shaped JSON schema for model-supplied arguments.""" | |
| properties = {} | |
| required = [] | |
| try: | |
| sig = signature(cls.execute) | |
| type_hints = get_type_hints(cls.execute) | |
| descriptions = cls.parse_args_docstring(cls.execute) | |
| except Exception as e: | |
| _logger.warning(f"Failed to parse signature or type hints for {cls.__name__} since [{type(e).__name__}] {e}") | |
| return {} | |
| for param_name, param in sig.parameters.items(): | |
| if param_name == 'self': | |
| continue | |
| if param.kind in (Parameter.VAR_POSITIONAL, Parameter.VAR_KEYWORD): | |
| _logger.warning( | |
| f"Parameter '{param_name}' in {cls.__name__}.execute is a variable positional or keyword parameter, " | |
| f"which is not supported for automatic schema inference and will be ignored. " | |
| f"Please specify its schema manually in ToolMetadata.parameters if needed." | |
| ) | |
| continue | |
| schema = {} | |
| annotation = type_hints.get(param_name, param.annotation) # e.g., List[int], Optional[str], etc. | |
| schema |= cls.parse_args_typehints(annotation) # e.g., {"type": "array", "items": {"type": "integer"}} for List[int] | |
| schema |= {'description': descriptions[param_name]} | |
| if param.default is not Parameter.empty: | |
| schema |= {'default': param.default} | |
| else: | |
| required.append(param_name) | |
| properties[param_name] = schema | |
| return { | |
| "type": "object", | |
| "properties": properties, | |
| "required": required, | |
| } | |
| def parse_args_docstring(func: callable) -> dict[str, str]: | |
| """Extract argument descriptions from a Google-style docstring. | |
| Args: | |
| func: Callable whose docstring describes its parameters. | |
| Returns: | |
| Descriptions keyed by parameter name.""" | |
| docstring = getattr(func, "__doc__", None) | |
| parsed = parse(docstring, style=DocstringStyle.GOOGLE) | |
| description_dict = {} | |
| for param in parsed.params: | |
| if param.description is not None: | |
| description_dict[param.arg_name] = param.description | |
| set1 = set(description_dict.keys()) | |
| set2 = set(signature(func).parameters.keys()) - {"self"} | |
| if set1 - set2: | |
| _logger.warning( | |
| f"Descriptions found for parameters {set1 - set2} in {func.__qualname__} docstring, " | |
| f"but these parameters are not in the function signature. Please check for typos or remove these descriptions." | |
| ) | |
| if set2 - set1: | |
| _logger.warning( | |
| f"Parameters {set2 - set1} in {func.__qualname__} signature do not have descriptions in the docstring. " | |
| f"Please add descriptions for better LLM understanding." | |
| ) | |
| description_dict = description_dict | {param: "(no description provided)" for param in set2 - set1} | |
| return description_dict | |
| def parse_args_typehints(cls, annotation: Any) -> Dict[str, Any]: | |
| """Convert a common Python type annotation to JSON Schema. | |
| Args: | |
| annotation: Type annotation to convert. | |
| Returns: | |
| A JSON Schema fragment.""" | |
| if annotation is Parameter.empty or annotation is Any: | |
| return {} | |
| origin = get_origin(annotation) # e.g., list, dict, Union, etc. | |
| args = get_args(annotation) # e.g., (int,) for List[int], (str, NoneType) for Optional[str], etc. | |
| if origin is Literal: | |
| values = list(args) | |
| schema = {"enum": values} | |
| if non_none_values := [v for v in values if v is not None]: | |
| schema["type"] = cls.parse_json_type(type(non_none_values[0])) | |
| return schema | |
| elif origin is list: | |
| return {"type": "array", "items": cls.parse_args_typehints(args[0] if args else Any)} | |
| elif origin is dict: | |
| return {"type": "object"} | |
| elif origin is tuple: | |
| # Tuple without type args | |
| if not args: | |
| return {"type": "array", "items": {}} | |
| # Variable-length tuple, e.g. Tuple[str, ...] | |
| elif args[-1] is Ellipsis: | |
| return {"type": "array", "items": cls.parse_args_typehints(args[0] if args else Any)} | |
| item_schemas = [cls.parse_args_typehints(arg) for arg in args] | |
| # Homogeneous fixed tuple, e.g. Tuple[str, str] | |
| if all(schema == item_schemas[0] for schema in item_schemas): | |
| return {"type": "array", "items": item_schemas[0], "minItems": len(args), "maxItems": len(args)} | |
| # Heterogeneous fixed tuple, e.g. Tuple[str, int] | |
| else: | |
| # This is less position-strict than prefixItems, but more compatible with OpenAI/Azure/OpenRouter tool schemas. | |
| # It means each item may match any of the tuple element schemas, while minItems/maxItems still enforce tuple length. | |
| _logger.warning( | |
| f"Tuple parameter with heterogeneous types detected in {cls.__name__}.execute. " | |
| f"This is not recommended since it may lead to less precise schema and LLM confusion. " | |
| f"Please consider using a more specific schema or adding descriptions to clarify the expected format." | |
| ) | |
| return {"type": "array", "items": {"anyOf": item_schemas}, "minItems": len(args), "maxItems": len(args)} | |
| elif origin in (Union, UnionType): | |
| schemas = [{"type": "null"} if arg is NoneType else cls.parse_args_typehints(arg) for arg in args] | |
| return {"anyOf": schemas} | |
| elif json_type := cls.parse_json_type(annotation): | |
| return {"type": json_type} | |
| else: | |
| _logger.warning( | |
| f"Unsupported type annotation '{annotation}' in {cls.__name__}.execute. " | |
| f"Automatic schema inference will not be able to parse this type, and it will be treated as an untyped parameter. " | |
| f"Please consider using basic types (int, float, str, bool) or common generics (List, Dict) for better compatibility, " | |
| f"or directly specify the parameter schema in ToolMetadata.parameters if a more complex structure is needed." | |
| ) | |
| return {} | |
| def parse_json_type(value_type: Any) -> str: | |
| """Map a Python literal type to a JSON Schema type name. | |
| Args: | |
| value_type: Python type to inspect. | |
| Returns: | |
| A JSON type name, or an empty string for an unsupported type.""" | |
| if value_type is bool: | |
| return "boolean" | |
| if value_type is int: | |
| return "integer" | |
| if value_type is float: | |
| return "number" | |
| if value_type is str: | |
| return "string" | |
| if value_type is list: | |
| return "array" | |
| if value_type is dict: | |
| return "object" | |
| return "" | |
| def calculate_metrics(cls, f: engine.Expression, y_true: np.ndarray, y_pred: np.ndarray) -> Dict[str, Any]: | |
| """Calculate metrics for precomputed target and prediction arrays. | |
| This helper lets code-defined models reuse the symbolic tools' metric definitions. | |
| Args: | |
| f: Candidate expression used to compute structural complexity. | |
| y_true: Observed target values. | |
| y_pred: Predicted target values. | |
| Returns: | |
| Fit, correlation, information-criterion, and complexity metrics.""" | |
| return evaluator_utils.regression_metrics(f, y_true, y_pred) | |
| def evaluate( | |
| self, | |
| f: engine.Expression, | |
| y: engine.Expression, | |
| show_diagnostics: bool = True, | |
| fit: bool = False, | |
| ) -> Dict[str, Any]: | |
| """Evaluate a symbolic prediction against a symbolic target. | |
| Metrics, including formula complexity, are supplied by the active evaluator. Residual diagnostics include an error profile, worst samples, and strong residual-variable correlations. | |
| Args: | |
| f: Symbolic prediction expression. | |
| y: Symbolic target expression. | |
| show_diagnostics: Whether to include residual diagnostics. | |
| fit: Whether to fit parameters on the training split before scoring. | |
| Returns: | |
| Candidate eligibility and metrics for each available data split.""" | |
| if not isinstance(f, engine.Expression) or not isinstance(y, engine.Expression): | |
| raise TypeError("f and y must both be sr_harness_engine.Expression instances.") | |
| if self.context.target is None: | |
| raise ValueError("context.target must be configured before evaluating formulas") | |
| evaluator = self.context.evaluator | |
| target = self.context.target | |
| var_names = {var.name for var in f.iter_preorder() if isinstance(var, engine.Variable)} | |
| ineligibility_reasons = [] | |
| if y.to_str() != target: | |
| ineligibility_reasons.append(f"the left-hand side of the equation is not {target}") | |
| if target in var_names: | |
| ineligibility_reasons.append(f"the right-hand side of the equation depends on {target}") | |
| is_candidate = not ineligibility_reasons | |
| isolated_evaluation = getattr(evaluator, "_evaluate_formula_isolated", None) | |
| isolated_metrics = None | |
| if isolated_evaluation is not None: | |
| fitted_f, isolated_metrics, isolated_splits = isolated_evaluation( | |
| f, y, self.context, fit=fit, candidate=is_candidate, | |
| ) | |
| train_context = isolated_splits["train"] | |
| validation_context = isolated_splits["validation"] | |
| self.context._split_cache = isolated_splits | |
| else: | |
| train_context = self.context.train_split | |
| validation_context = self.context.validation_split | |
| fitted_f = fitted_f if isolated_metrics is not None else f | |
| if fit and isolated_metrics is None: | |
| fitted_f = ( | |
| evaluator.fit_candidate(f, train_context) | |
| if is_candidate | |
| else evaluator.fit(f, y, train_context) | |
| ) | |
| if not isinstance(fitted_f, engine.Expression): | |
| raise TypeError(f"{type(evaluator).__name__}.fit() must return an Expression") | |
| if missing := engine.unbound_parameters(fitted_f): | |
| raise ValueError(f"unbound parameters: {', '.join(missing)}") | |
| def evaluate_split(split_context: AgentContext, *, max_samples: int | None = None): | |
| result = isolated_metrics.get("train" if split_context is train_context else "validation") if isolated_metrics is not None else ( | |
| evaluator.evaluate_candidate(fitted_f, split_context) | |
| if is_candidate | |
| else evaluator.evaluate(fitted_f, y, split_context) | |
| ) | |
| if not isinstance(result, dict): | |
| raise TypeError(f"{type(evaluator).__name__}.evaluate() must return a dictionary") | |
| metrics = {} | |
| for name, value in result.items(): | |
| if isinstance(value, (bool, np.bool_)) or not isinstance(value, Real): | |
| raise TypeError(f"Evaluator metric {name!r} must be a float or int") | |
| metrics[str(name)] = value.item() if isinstance(value, np.generic) else value | |
| split = {"metrics": metrics} | |
| if show_diagnostics: | |
| num_nodes = split_context.num_nodes | |
| split["diagnostics"] = self.residual_diagnostics( | |
| y_true=np.asarray(y.eval(split_context.data, num_nodes=num_nodes)), | |
| y_pred=np.asarray(fitted_f.evaluate(split_context.data, num_nodes=num_nodes)), | |
| data=split_context.data, | |
| target_expression=y.to_str(), | |
| **({"max_samples": max_samples} if max_samples is not None else {}), | |
| ) | |
| return split | |
| data_split_results = {"train": evaluate_split(train_context, max_samples=10)} | |
| if validation_context.data: | |
| data_split_results["validation"] = evaluate_split(validation_context) | |
| for split_name, split_result in data_split_results.items(): | |
| metrics = split_result.get("metrics") or {} | |
| if not np.isfinite(metrics.get("mse", float("nan"))): | |
| ineligibility_reasons.append( | |
| f"the formula does not produce a finite MSE on the {split_name} set" | |
| ) | |
| evaluation = { | |
| "formula": fitted_f.to_str(number_format='.8g'), | |
| "target_expression": y.to_str(number_format='.8g'), | |
| "is_candidate": not ineligibility_reasons, | |
| "candidate_ineligibility_reasons": ineligibility_reasons, | |
| "data_split_results": data_split_results, | |
| } | |
| return evaluation | |
| def failed_evaluation(self, formula: str = "(None)") -> Dict[str, Any]: | |
| """Build the common result shape when a formula-producing backend fails. | |
| Args: | |
| formula: Formula or placeholder to report. | |
| Returns: | |
| A non-candidate result with infinite error metrics.""" | |
| empty_metrics = { | |
| "mse": float("inf"), | |
| "rmse": float("inf"), | |
| "mae": float("inf"), | |
| "mape": float("inf"), | |
| "r2": -float("inf"), | |
| "aic": float("inf"), | |
| "bic": float("inf"), | |
| "pearson_r": -float("inf"), | |
| "spearman_r": -float("inf"), | |
| "complexity": float("inf"), | |
| } | |
| data_split_results = { | |
| 'train': {'metrics': None}, | |
| 'validation': {'metrics': None}, | |
| } | |
| if self.context.target is None: | |
| raise ValueError("context.target must be configured before reporting a failed evaluation") | |
| data_split_results['train']['metrics'] = empty_metrics | |
| if not self.context.validation_split.data: | |
| data_split_results.pop('validation') | |
| else: | |
| data_split_results['validation']['metrics'] = empty_metrics | |
| return { | |
| "formula": formula, | |
| "target_expression": self.context.target, | |
| "is_candidate": False, | |
| "candidate_ineligibility_reasons": ["no valid formula was produced"], | |
| "data_split_results": data_split_results, | |
| } | |
| def format_evaluation_result(cls, result: Dict[str, Any], title: str = "Formula evaluation") -> str: | |
| """Format the common formula-evaluation schema for an LLM. | |
| Args: | |
| result: Evaluation mapping produced by ``evaluate``. | |
| title: Heading shown above the formatted result. | |
| Returns: | |
| Concise formula, metric, warning, and diagnostic text.""" | |
| split_results = result["data_split_results"] | |
| train_result = split_results["train"] | |
| metrics = train_result["metrics"] | |
| lhs = result.get("target_expression", "LHS") | |
| formula = result["formula"] | |
| try: | |
| formula = engine.parse(formula).to_str( | |
| number_format=cls.FORMULA_DISPLAY_NUMBER_FORMAT | |
| ) | |
| except Exception: | |
| # Some tools use a free-form model description instead of an | |
| # SRHarness Engine expression. Preserve those descriptions verbatim. | |
| pass | |
| lines = [ | |
| f"{title}:", | |
| f" {lhs} = {formula}", | |
| ] | |
| for reason in result.get("candidate_ineligibility_reasons", []): | |
| lines.append(f" (Note: this formula is not eligible for submission since {reason}.)") | |
| validation_result = split_results.get("validation") | |
| validation_metrics = validation_result["metrics"] if validation_result else None | |
| def three_significant_digits(value: Any) -> str: | |
| if isinstance(value, (int, float, np.number)): | |
| return format(value, "#.3g").removesuffix(".") | |
| return str(value) | |
| def metric_pair(name: str) -> str: | |
| train_value = three_significant_digits(metrics.get(name, float("nan"))) | |
| validation_value = ( | |
| three_significant_digits(validation_metrics.get(name, float("nan"))) | |
| if validation_metrics else "N/A" | |
| ) | |
| return f"{train_value} | {validation_value}" | |
| lines.extend([ | |
| "Fit quality (Train-set | Validation-set):", | |
| f" RMSE={metric_pair('rmse')};", | |
| f" MAE={metric_pair('mae')};", | |
| f" R2={metric_pair('r2')};", | |
| f" Formula Complexity={three_significant_digits(metrics.get('complexity', '?'))};", | |
| ]) | |
| standard_metric_names = { | |
| "mse", "rmse", "mae", "mape", "r2", "aic", "bic", | |
| "pearson_r", "spearman_r", "complexity", | |
| } | |
| custom_metric_names = sorted( | |
| (set(metrics) | set(validation_metrics or {})) - standard_metric_names | |
| ) | |
| if custom_metric_names: | |
| lines.append("Evaluator-specific metrics (Train-set | Validation-set):") | |
| lines.extend( | |
| f" {name}={metric_pair(name)};" for name in custom_metric_names | |
| ) | |
| if validation_metrics: | |
| train_rmse = metrics.get("rmse", float("nan")) | |
| validation_rmse = validation_metrics.get("rmse", float("nan")) | |
| if (np.isfinite(train_rmse) and np.isfinite(validation_rmse) | |
| and validation_rmse > max(2.0 * train_rmse, train_rmse + 1e-12)): | |
| lines.append( | |
| "Warning: validation error is substantially larger than training error; " | |
| "the candidate may be overfitting or the validation domain may differ." | |
| ) | |
| if diagnostics := train_result.get("diagnostics"): | |
| samples = diagnostics.get("worst_samples", [])[:10] | |
| if samples: | |
| input_names = result.get("analyzed_input_expressions") or [ | |
| name for name in samples[0]["row"] if name != lhs | |
| ] | |
| lines.extend([ | |
| "Error extremes (Top-10 sorted by |residual|):", | |
| f" ({' | '.join([*input_names, lhs, 'residual'])})", | |
| ]) | |
| for sample in samples: | |
| values = [sample["row"].get(name, float("nan")) for name in input_names] | |
| values.extend([sample["y_true"], sample["y_pred"] - sample["y_true"]]) | |
| lines.append(" " + " | ".join(three_significant_digits(value) for value in values)) | |
| if correlations := diagnostics.get("strongest_residual_correlations"): | |
| strongest = correlations[0] | |
| lines.extend([ | |
| "Largest residual correlation among other analyzed variables " | |
| "(criterion=max(|Pearson|, |Spearman|)):", | |
| f" variable={strongest['variable']}", | |
| f" Pearson(residual, {strongest['variable']})={three_significant_digits(strongest['pearson'])};", | |
| f" Spearman(residual, {strongest['variable']})={three_significant_digits(strongest['spearman'])};", | |
| ]) | |
| return "\n".join(lines) | |
| def residual_diagnostics( | |
| cls, | |
| y_true: np.ndarray, | |
| y_pred: np.ndarray, | |
| data: Dict[str, Any] = None, | |
| target_expression: str = None, | |
| max_samples: int = 3, | |
| max_correlations: int = 5, | |
| ) -> Dict[str, Any]: | |
| """Return compact diagnostics for prediction residuals. | |
| Args: | |
| y_true: Observed target values. | |
| y_pred: Predicted target values. | |
| data: Optional variables used to explain residual patterns. | |
| target_expression: Target label included in diagnostic rows. | |
| max_samples: Maximum number of worst samples to include. | |
| max_correlations: Maximum number of residual correlations to include. | |
| Returns: | |
| Error profile, worst samples, and strongest residual correlations.""" | |
| y_true, y_pred = np.broadcast_arrays( | |
| np.asarray(y_true, dtype=float), | |
| np.asarray(y_pred, dtype=float), | |
| ) | |
| y_true = y_true.flatten() | |
| y_pred = y_pred.flatten() | |
| residual = y_pred - y_true | |
| absolute_error = np.abs(residual) | |
| finite_error = np.isfinite(residual) | |
| target_scale = float(np.median(np.abs(y_true[np.isfinite(y_true)]))) if np.any(np.isfinite(y_true)) else 0.0 | |
| relative_floor = max(np.finfo(float).eps, target_scale * 1e-12) | |
| relative_valid = finite_error & np.isfinite(y_true) & (np.abs(y_true) > relative_floor) | |
| relative_error = np.full_like(absolute_error, np.nan, dtype=float) | |
| relative_error[relative_valid] = absolute_error[relative_valid] / np.abs(y_true[relative_valid]) | |
| finite_absolute = absolute_error[finite_error] | |
| finite_relative = relative_error[np.isfinite(relative_error)] | |
| error_profile = { | |
| "median_signed_residual": float(np.median(residual[finite_error])) if np.any(finite_error) else float("nan"), | |
| "p95_absolute_error": float(np.quantile(finite_absolute, 0.95)) if finite_absolute.size else float("nan"), | |
| "max_absolute_error": float(np.max(finite_absolute)) if finite_absolute.size else float("nan"), | |
| "p95_relative_error": float(np.quantile(finite_relative, 0.95)) if finite_relative.size else float("nan"), | |
| "max_relative_error": float(np.max(finite_relative)) if finite_relative.size else float("nan"), | |
| "relative_error_defined_ratio": float(np.mean(relative_valid)), | |
| } | |
| numeric_data = {} | |
| for name, values in (data or {}).items(): | |
| array = np.asarray(values) | |
| if array.size == y_true.size and np.issubdtype(array.dtype, np.number): | |
| numeric_data[name] = array.flatten() | |
| rank_score = np.where(finite_error, absolute_error, -np.inf) | |
| sample_limit = max(0, int(max_samples)) | |
| worst_indices = ( | |
| np.argsort(rank_score)[-sample_limit:][::-1] | |
| if sample_limit else np.array([], dtype=int) | |
| ) | |
| worst_samples = [] | |
| for index in worst_indices: | |
| if not np.isfinite(rank_score[index]): | |
| continue | |
| worst_samples.append({ | |
| "index": int(index), | |
| "row": {name: float(values[index]) for name, values in numeric_data.items()}, | |
| "y_true": float(y_true[index]), | |
| "y_pred": float(y_pred[index]), | |
| "absolute_error": float(absolute_error[index]), | |
| "relative_error": float(relative_error[index]), | |
| }) | |
| correlation_arrays = dict(numeric_data) | |
| if target_expression and target_expression not in correlation_arrays: | |
| correlation_arrays[target_expression] = y_true | |
| correlations = [] | |
| for name, values in correlation_arrays.items(): | |
| pearson, spearman = cls.correlation_coefficients(values, residual) | |
| strength = max(abs(pearson), abs(spearman)) | |
| if np.isfinite(strength): | |
| correlations.append({ | |
| "variable": name, | |
| "pearson": pearson, | |
| "spearman": spearman, | |
| "strength": strength, | |
| }) | |
| correlations.sort(key=lambda item: item["strength"], reverse=True) | |
| return { | |
| "error_profile": error_profile, | |
| "worst_samples": worst_samples, | |
| "strongest_residual_correlations": correlations[:max(0, int(max_correlations))], | |
| } | |
| def correlation_coefficients(x: np.ndarray, y: np.ndarray) -> tuple[float, float]: | |
| """Return finite-sample Pearson and Spearman coefficients. | |
| Undefined correlations, including constant inputs, are returned as NaN. Warnings for these mathematically valid edge cases are suppressed. | |
| Args: | |
| x: First numeric sample. | |
| y: Second numeric sample. | |
| Returns: | |
| Pearson and Spearman correlation coefficients.""" | |
| x = np.asarray(x, dtype=float).flatten() | |
| y = np.asarray(y, dtype=float).flatten() | |
| finite = np.isfinite(x) & np.isfinite(y) | |
| if np.count_nonzero(finite) < 2: | |
| return float("nan"), float("nan") | |
| x, y = x[finite], y[finite] | |
| with np.errstate(invalid="ignore", divide="ignore"), warnings.catch_warnings(): | |
| warnings.simplefilter("ignore") | |
| pearson = float(np.corrcoef(x, y)[0, 1]) | |
| spearman = float(stats.spearmanr(x, y).statistic) | |
| return pearson, spearman | |
| def is_numeric_array(data: List[Any]) -> bool: | |
| """Run the ``is numeric array`` operation. | |
| Args: | |
| data: Data arrays keyed by variable name. | |
| Returns: | |
| bool: The operation result. | |
| """ | |
| arr = np.asarray(data) | |
| return np.issubdtype(arr.dtype, np.integer) or np.issubdtype(arr.dtype, np.floating) | |