# -*- coding: utf-8 -*- """FeatureSpec 声明式特征定义引擎。 对应 issue #35(父 EPIC #5「③ AI 模型框架 配置化重构」)与 PRD 5.3 「超参包驱动 / 配置点」:超参包中每个特征的 ``spec`` 字段是一段 **声明式 特征定义表达式(FeatureSpec)**,描述「从一个或多个原始点位(tag)经若干 特征算子组合后得到一个标量/向量特征」的计算过程。 ``core/model-framework/hyperparam.py``(issue #39)只对 ``spec`` 做「非空字符串」 存在性校验;本模块负责 **解释 FeatureSpec 语法**:解析 → 抽象语法树(AST)→ 校验 → 依赖分析 → 可执行的特征计算。这样: 1. 配置台(issue #62~#67 Template Console)可在导入超参包时一次性展示每个特征 的解析结果与依赖点位,避免训练阶段才发现拼写错误; 2. 训练/推理流水线(issue #40)拿到 AST 后可直接 materialize 为按点位拉取 → 算子计算的特征管道; 3. 同一内核切换模板仅改超参包,特征逻辑零代码(对齐 PRD「配置化」核心目标)。 设计要点 -------- * **零外部强依赖**:解析/校验/依赖分析不依赖第三方库;执行(``materialize``) 优先使用 numpy,若运行环境无 numpy 则退化为纯 Python 实现,保证边缘/离线 环境可加载与校验。 * **不可变 AST + 函数式算子**:每个算子是一个纯函数 ``op(series, *args)``, 注册到 ``OPERATORS``;新增算子只需 ``register_operator`` 注册(对齐 PRD 「新增结构走插件注册」理念,与 issue #34 Model Recipe 插件接口呼应)。 * **安全解析**:手写递归下降解析器,**绝不使用 ``eval``/``exec``**——FeatureSpec 是数据而非代码,避免任意表达式注入。 * **确定性**:相同 spec 解析结果稳定,``__repr__``/``to_dict`` 可序列化往返。 FeatureSpec 语法(对齐 PRD 5.3 示例) ------------------------------------ :: (, , ...) # 一元/多元算子 := | | | (...) := 标识符,允许中文/点号/连字符 # 点位名,如 CLF-01.TEMP / 炉压 := 整数或浮点(含负号),如 3、-0.5、1e-3 := <正数><单位>,单位 d/h/m/s,如 5m、180d、10s 内置算子(覆盖 PRD 5.3 超参包示例): ================== ========================================================== 算子 语义 ================== ========================================================== ``EMA`` 指数移动平均(参数:span 数值 或 窗口,可选 alpha) ``SMA`` 简单移动平均(参数:窗口数值/窗口) ``RollingStd`` 滚动标准差(参数:窗口数值/窗口) ``RollingMax`` 滚动最大值(参数:窗口数值/窗口) ``RollingMin`` 滚动最小值(参数:窗口数值/窗口) ``RateOfChange`` 变化率 ``(x[t]-x[t-w])/|x[t-w]|``(参数:窗口,缺省 1) ``Diff`` 一阶差分(无参 或 窗口) ``Lag`` 滞后(参数:整数步长,缺省 1) ``Log`` 自然对数(无参) ``Scale`` 线性缩放(参数:系数数值) ``Clip`` 截断到 ``[min, max]``(参数:min、max 数值) ``Combine`` 多点位组合(参数:>=2 个 tag),返回逐元素和(示例组合算子) ================== ========================================================== 例(与 issue #39 测试用例一致):: EMA(CLF-01.TEMP, 5m) # CLF-01.TEMP 的 5 分钟指数移动平均 RollingStd(CLF-01.CL2, 10) # CLF-01.CL2 的 10 步滚动标准差 RateOfChange(炉压) # 炉压的 1 步变化率 Combine(A.tank1, A.tank2) # 两个罐位之和 """ from __future__ import annotations import math import re from dataclasses import dataclass, field from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union # numpy 为可选依赖:有则执行用向量化实现,无则退化为 list 计算 try: # pragma: no cover - 环境相关 import numpy as _np # type: ignore _HAS_NUMPY = True except Exception: # pragma: no cover _np = None # type: ignore _HAS_NUMPY = False __all__ = [ "ParseError", "SpecIssue", "TagRef", "Number", "Window", "OpCall", "FeatureAST", "parse", "parse_feature", "validate", "materialize", "resolve_inputs", "describe", "register_operator", "OPERATORS", ] # --------------------------------------------------------------------------- # 语法层面的合法取值 # --------------------------------------------------------------------------- #: 窗口单位 → 秒(用于把 ``5m`` 这类窗口折算为可比较的时长,仅在需要时使用)。 _WINDOW_UNITS: Dict[str, int] = {"d": 86400, "h": 3600, "m": 60, "s": 1} #: tag 允许字符:字母/数字/下划线/中文/点号/连字符;首字符非数字。 # 点位名在工业现场常含 ``CLF-01.TEMP`` 这类带设备层级与量纲的命名,故放宽。 _TAG_RE = re.compile(r"^[A-Za-z\u4e00-\u9fff_][A-Za-z0-9\u4e00-\u9fff_.\-]*$") #: 算子名:字母开头,可含下划线。 _OPNAME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_]*$") #: 窗口字面量:<正数><单位>。 _WINDOW_RE = re.compile(r"^(\d+(?:\.\d+)?)([dhms])$") # --------------------------------------------------------------------------- # AST 节点 # --------------------------------------------------------------------------- class _Node: """AST 基类。所有节点不可变(仅持有基础类型),可安全序列化往返。""" def to_dict(self) -> Dict[str, Any]: # pragma: no cover - 子类覆盖 raise NotImplementedError @dataclass(frozen=True) class TagRef(_Node): """原始点位引用,如 ``CLF-01.TEMP`` / ``炉压``。""" name: str def to_dict(self) -> Dict[str, Any]: return {"kind": "tag", "name": self.name} def __repr__(self) -> str: return self.name @dataclass(frozen=True) class Number(_Node): """数值字面量(整数或浮点)。""" value: Union[int, float] def to_dict(self) -> Dict[str, Any]: return {"kind": "number", "value": self.value} def __repr__(self) -> str: v = self.value return repr(v) @dataclass(frozen=True) class Window(_Node): """窗口字面量,如 ``5m`` / ``180d``。 ``steps`` 为窗口数值,``unit`` 为单位;``seconds`` 折算为秒(用于排序/比较)。 """ steps: float unit: str def to_dict(self) -> Dict[str, Any]: return { "kind": "window", "steps": self.steps, "unit": self.unit, "seconds": self.seconds, } @property def seconds(self) -> int: return int(self.steps * _WINDOW_UNITS[self.unit]) def __repr__(self) -> str: # 整数步长省略小数点,保持与输入一致 s = self.steps text = str(int(s)) if float(s).is_integer() else str(s) return f"{text}{self.unit}" @dataclass(frozen=True) class OpCall(_Node): """算子调用,如 ``EMA(CLF-01.TEMP, 5m)``。""" name: str args: Tuple[Any, ...] # 元素为 _Node 子类实例 def to_dict(self) -> Dict[str, Any]: return { "kind": "op", "name": self.name, "args": [a.to_dict() for a in self.args], } def __repr__(self) -> str: inner = ", ".join(repr(a) for a in self.args) return f"{self.name}({inner})" #: 一棵 FeatureSpec 解析后的 AST 根节点。 FeatureAST = Union[TagRef, Number, Window, OpCall] # --------------------------------------------------------------------------- # 解析错误与校验问题 # --------------------------------------------------------------------------- class ParseError(ValueError): """FeatureSpec 语法解析错误。 带可选的 ``position``(出错字符在原 spec 中的偏移,便于配置台高亮)。 """ def __init__(self, message: str, position: Optional[int] = None) -> None: self.position = position self.message = message super().__init__(message if position is None else f"{message}(位置 {position})") @dataclass class SpecIssue: """单条 FeatureSpec 校验/语义问题。""" code: str # unknown_operator / bad_arg / arity / ... message: str context: str = "" # 出错子表达式的人类可读表示 # --------------------------------------------------------------------------- # Tokenizer # --------------------------------------------------------------------------- # Token 类型 _T_NAME = "NAME" # 标识符(算子名 或 tag) _T_NUMBER = "NUMBER" _T_WINDOW = "WINDOW" _T_LPAREN = "LPAREN" # ( _T_RPAREN = "RPAREN" # ) _T_COMMA = "COMMA" # , _T_EOF = "EOF" _TOKEN_RE = re.compile( r""" \s*(?: (?P\() | (?P\)) | (?P,) | (?P\d+(?:\.\d+)?[dhms]) | (?P[-+]?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?) | (?P[A-Za-z\u4e00-\u9fff_][A-Za-z0-9\u4e00-\u9fff_.\-]*) ) """, re.VERBOSE, ) def _tokenize(spec: str) -> List[Tuple[str, str, int]]: """把 FeatureSpec 文本切分为 token 列表。 返回 ``[(type, value, pos), ...]``,``pos`` 为 token 起始偏移。空格被跳过。 遇到无法识别的字符抛 ``ParseError``(带位置)。 """ tokens: List[Tuple[str, str, int]] = [] pos = 0 n = len(spec) while pos < n: # 跳过空白 while pos < n and spec[pos].isspace(): pos += 1 if pos >= n: break m = _TOKEN_RE.match(spec, pos) if not m or m.end() == pos: raise ParseError(f"无法识别的字符 '{spec[pos]}'", pos) if m.lastgroup == "LPAREN": tokens.append((_T_LPAREN, "(", pos)) elif m.lastgroup == "RPAREN": tokens.append((_T_RPAREN, ")", pos)) elif m.lastgroup == "COMMA": tokens.append((_T_COMMA, ",", pos)) elif m.lastgroup == "WINDOW": tokens.append((_T_WINDOW, m.group("WINDOW"), pos)) elif m.lastgroup == "NUMBER": tokens.append((_T_NUMBER, m.group("NUMBER"), pos)) elif m.lastgroup == "NAME": tokens.append((_T_NAME, m.group("NAME"), pos)) pos = m.end() tokens.append((_T_EOF, "", pos)) return tokens # --------------------------------------------------------------------------- # Parser(递归下降) # --------------------------------------------------------------------------- class _Parser: """递归下降解析器。 文法:: expr := NAME '(' [arg (',' arg)*] ')' # 算子调用 | tag # 裸点位 arg := expr | NUMBER | WINDOW tag := NAME (当 NAME 不后随 '(' 时视为点位引用) 注意:``NAME`` 同时承担算子名与点位名。判定规则——若 ``NAME`` 紧跟 ``(`` 则为 算子调用,否则为点位引用。这样 ``EMA(...)`` 与 ``炉压`` 可在同一文法中共存。 """ def __init__(self, tokens: List[Tuple[str, str, int]]) -> None: self.tokens = tokens self.i = 0 def _peek(self) -> Tuple[str, str, int]: return self.tokens[self.i] def _next(self) -> Tuple[str, str, int]: tok = self.tokens[self.i] self.i += 1 return tok def parse_expr(self) -> FeatureAST: ttype, tval, tpos = self._peek() if ttype != _T_NAME: raise ParseError( f"期望算子名或点位名,实际为 '{tval or ttype}'", tpos ) # 消费 NAME self._next() nt = self._peek() if nt[0] == _T_LPAREN: # 算子调用 if not _OPNAME_RE.match(tval): raise ParseError(f"算子名 '{tval}' 含非法字符", tpos) self._next() # 消费 '(' args: List[Any] = [] if self._peek()[0] == _T_RPAREN: # 无参算子,如 Diff() self._next() return OpCall(tval, tuple(args)) args.append(self.parse_arg()) while self._peek()[0] == _T_COMMA: self._next() args.append(self.parse_arg()) if self._peek()[0] != _T_RPAREN: raise ParseError("缺少右括号 ')'", self._peek()[2]) self._next() # 消费 ')' return OpCall(tval, tuple(args)) else: # 点位引用 if not _TAG_RE.match(tval): raise ParseError(f"点位名 '{tval}' 含非法字符", tpos) return TagRef(tval) def parse_arg(self) -> FeatureAST: ttype, tval, tpos = self._peek() if ttype == _T_NUMBER: self._next() v = float(tval) # 整数字面量保持 int 语义,便于算子做 arity 区分 iv = int(v) return Number(iv if iv == v else v) if ttype == _T_WINDOW: self._next() m = _WINDOW_RE.match(tval) assert m is not None # tokenizer 保证 steps = float(m.group(1)) return Window(steps, m.group(2)) if ttype == _T_NAME: return self.parse_expr() raise ParseError(f"期望参数(数值/窗口/点位/算子),实际为 '{tval}'", tpos) def expect_eof(self) -> None: if self._peek()[0] != _T_EOF: tok = self._peek() raise ParseError(f"表达式后存在多余内容 '{tok[1]}'", tok[2]) def parse(spec: str) -> FeatureAST: """解析单条 FeatureSpec 文本为 AST。 失败抛 ``ParseError``(带位置)。``spec`` 为空或非字符串抛 ``ValueError``。 """ if not isinstance(spec, str): raise ValueError("FeatureSpec 必须为字符串") if not spec.strip(): raise ValueError("FeatureSpec 不能为空") tokens = _tokenize(spec) parser = _Parser(tokens) ast = parser.parse_expr() parser.expect_eof() return ast def parse_feature(spec: str) -> FeatureAST: """``parse`` 的别名,语义更贴近「解析一个特征的 spec」。""" return parse(spec) # --------------------------------------------------------------------------- # 算子注册表与语义校验 # --------------------------------------------------------------------------- #: 算子签名:``OpSignature = (min_arity, max_arity, arg_kinds)``。 #: ``arg_kinds`` 为每参数位置允许的 AST kind(``"tag"``/``"number"``/``"window"`` #: /``"op"``),``None`` 表示任意。用于 validate 阶段检查参数形态。 OpSignature = Tuple[Optional[int], Optional[int], Tuple[Optional[Tuple[str, ...]], ...]] #: 算子执行函数签名:``fn(series_map, args) -> result``。 #: 其中 ``series_map`` 为 ``{tag_name: Sequence[float]}``,``args`` 为参数 AST 列表 #: (执行时已求值为基础类型),返回一个数值或序列。 OpFunc = Callable[[Dict[str, Sequence[float]], Tuple[Any, ...]], Any] @dataclass class OperatorDef: """算子定义:签名 + 执行函数 + 文档。""" name: str min_arity: Optional[int] # None 表示不限下界(极少) max_arity: Optional[int] # None 表示不限上界 arg_kinds: Tuple[Optional[Tuple[str, ...]], ...] # 每参数允许的 kind func: OpFunc doc: str = "" def arity_ok(self, n: int) -> bool: if self.min_arity is not None and n < self.min_arity: return False if self.max_arity is not None and n > self.max_arity: return False return True # 全局算子注册表 OPERATORS: Dict[str, OperatorDef] = {} def register_operator( name: str, *, min_arity: Optional[int], max_arity: Optional[int], arg_kinds: Sequence[Optional[Sequence[str]]], func: OpFunc, doc: str = "", ) -> OperatorDef: """注册一个特征算子。 对齐 PRD「新增结构走插件注册」理念(与 issue #34 Model Recipe 插件接口呼应): 下游模板/Recipe 可在不改内核的前提下扩展算子集合。重复注册同名算子覆盖 旧定义(便于测试期间替换实现)。 """ kinds = tuple( tuple(k) if k is not None else None for k in arg_kinds ) op = OperatorDef( name=name, min_arity=min_arity, max_arity=max_arity, arg_kinds=kinds, func=func, doc=doc, ) OPERATORS[name] = op return op def _as_list(series: Any) -> List[float]: """把输入序列归一为 list[float](兼容 numpy 数组与原生序列)。""" if _HAS_NUMPY and isinstance(series, _np.ndarray): return [float(x) for x in series.tolist()] return [float(x) for x in series] def _rolling_apply(values: Sequence[float], window: int, fn: Callable[[Sequence[float]], float]) -> List[float]: """对序列做滚动窗口计算,前 ``window-1`` 个位置用 NaN 占位以保持长度一致。 返回长度恒等于输入长度,便于多特征对齐拼接(对齐训练/推理流水线诉求)。 """ out: List[float] = [] n = len(values) for i in range(n): if i + 1 < window: out.append(float("nan")) else: out.append(fn(values[i + 1 - window : i + 1])) return out def _window_to_steps(arg: Any, *, default: Optional[int] = None) -> int: """把窗口/数值参数折算为整数步长(向上去整,至少 1)。""" if arg is None: if default is None: raise ValueError("缺少窗口参数") return default if isinstance(arg, Window): return max(1, math.ceil(arg.steps)) if isinstance(arg, Number): v = arg.value if v <= 0: raise ValueError(f"窗口/步长必须为正数,实际为 {v}") return max(1, math.ceil(v)) raise ValueError(f"窗口参数类型非法:{type(arg).__name__}") # ---- 内置算子实现 --------------------------------------------------------- def _op_ema(series_map, args): tag = args[0] if not isinstance(tag, TagRef): raise TypeError("EMA 第一个参数必须是点位") values = _as_list(series_map[tag.name]) window = _window_to_steps(args[1]) if len(args) > 1 else None if window is None: raise TypeError("EMA 需要窗口参数") alpha = 2.0 / (window + 1.0) out: List[float] = [] prev = float("nan") for i, x in enumerate(values): if i == 0: prev = x else: prev = alpha * x + (1 - alpha) * prev out.append(prev) return out def _op_sma(series_map, args): tag = args[0] values = _as_list(series_map[tag.name]) window = _window_to_steps(args[1]) return _rolling_apply(values, window, lambda w: sum(w) / len(w)) def _op_rolling_std(series_map, args): tag = args[0] values = _as_list(series_map[tag.name]) window = _window_to_steps(args[1]) def _std(w: Sequence[float]) -> float: m = sum(w) / len(w) var = sum((x - m) ** 2 for x in w) / max(1, len(w) - 1) return math.sqrt(var) return _rolling_apply(values, window, _std) def _op_rolling_max(series_map, args): tag = args[0] values = _as_list(series_map[tag.name]) window = _window_to_steps(args[1]) return _rolling_apply(values, window, max) def _op_rolling_min(series_map, args): tag = args[0] values = _as_list(series_map[tag.name]) window = _window_to_steps(args[1]) return _rolling_apply(values, window, min) def _op_rate_of_change(series_map, args): tag = args[0] values = _as_list(series_map[tag.name]) window = _window_to_steps(args[1], default=1) if len(args) > 1 else 1 out: List[float] = [] for i in range(len(values)): if i < window: out.append(float("nan")) else: denom = abs(values[i - window]) out.append((values[i] - values[i - window]) / denom if denom else float("nan")) return out def _op_diff(series_map, args): tag = args[0] values = _as_list(series_map[tag.name]) window = _window_to_steps(args[1], default=1) if len(args) > 1 else 1 out: List[float] = [] for i in range(len(values)): if i < window: out.append(float("nan")) else: out.append(values[i] - values[i - window]) return out def _op_lag(series_map, args): tag = args[0] values = _as_list(series_map[tag.name]) window = _window_to_steps(args[1], default=1) if len(args) > 1 else 1 out: List[float] = [] for i in range(len(values)): out.append(values[i - window] if i - window >= 0 else float("nan")) return out def _op_log(series_map, args): tag = args[0] values = _as_list(series_map[tag.name]) return [math.log(x) if x > 0 else float("nan") for x in values] def _op_scale(series_map, args): tag = args[0] if not isinstance(args[1], Number): raise TypeError("Scale 第二个参数必须是数值系数") coef = args[1].value values = _as_list(series_map[tag.name]) return [x * coef for x in values] def _op_clip(series_map, args): tag = args[0] if not isinstance(args[1], Number) or not isinstance(args[2], Number): raise TypeError("Clip 参数 min/max 必须是数值") lo, hi = args[1].value, args[2].value values = _as_list(series_map[tag.name]) return [min(max(x, lo), hi) for x in values] def _op_combine(series_map, args): tags = [a for a in args if isinstance(a, TagRef)] if len(tags) < 2: raise TypeError("Combine 至少需要 2 个点位") cols = [_as_list(series_map[t.name]) for t in tags] length = min(len(c) for c in cols) return [sum(c[i] for c in cols) for i in range(length)] # ---- 注册内置算子 --------------------------------------------------------- # 参数 kind 枚举:tag / number / window / op _K_TAG = ("tag",) _K_NUM = ("number",) _K_WIN = ("window",) _K_TAG_OR_OP = ("tag", "op") _K_WIN_OR_NUM = ("window", "number") register_operator( "EMA", min_arity=2, max_arity=2, arg_kinds=(_K_TAG, _K_WIN_OR_NUM), func=_op_ema, doc="指数移动平均,参数:点位、窗口(数值步长或时长窗口)。", ) register_operator( "SMA", min_arity=2, max_arity=2, arg_kinds=(_K_TAG, _K_WIN_OR_NUM), func=_op_sma, doc="简单移动平均,参数:点位、窗口。", ) register_operator( "RollingStd", min_arity=2, max_arity=2, arg_kinds=(_K_TAG, _K_WIN_OR_NUM), func=_op_rolling_std, doc="滚动标准差(无偏估计),参数:点位、窗口。", ) register_operator( "RollingMax", min_arity=2, max_arity=2, arg_kinds=(_K_TAG, _K_WIN_OR_NUM), func=_op_rolling_max, doc="滚动最大值,参数:点位、窗口。", ) register_operator( "RollingMin", min_arity=2, max_arity=2, arg_kinds=(_K_TAG, _K_WIN_OR_NUM), func=_op_rolling_min, doc="滚动最小值,参数:点位、窗口。", ) register_operator( "RateOfChange", min_arity=1, max_arity=2, arg_kinds=(_K_TAG, _K_WIN_OR_NUM), func=_op_rate_of_change, doc="变化率 (x[t]-x[t-w])/|x[t-w]|,参数:点位、可选窗口(缺省 1)。", ) register_operator( "Diff", min_arity=1, max_arity=2, arg_kinds=(_K_TAG, _K_WIN_OR_NUM), func=_op_diff, doc="一阶差分 x[t]-x[t-w],参数:点位、可选窗口(缺省 1)。", ) register_operator( "Lag", min_arity=1, max_arity=2, arg_kinds=(_K_TAG, _K_WIN_OR_NUM), func=_op_lag, doc="滞后 x[t-w],参数:点位、可选步长(缺省 1)。", ) register_operator( "Log", min_arity=1, max_arity=1, arg_kinds=(_K_TAG,), func=_op_log, doc="自然对数,参数:点位(非正值返回 NaN)。", ) register_operator( "Scale", min_arity=2, max_arity=2, arg_kinds=(_K_TAG, _K_NUM), func=_op_scale, doc="线性缩放 x*coef,参数:点位、系数。", ) register_operator( "Clip", min_arity=3, max_arity=3, arg_kinds=(_K_TAG, _K_NUM, _K_NUM), func=_op_clip, doc="截断到 [min, max],参数:点位、min、max。", ) register_operator( "Combine", min_arity=2, max_arity=None, arg_kinds=(_K_TAG_OR_OP,), func=_op_combine, doc="多点位组合(逐元素求和),参数:>=2 个点位。", ) # --------------------------------------------------------------------------- # 语义校验 # --------------------------------------------------------------------------- def _validate_node(node: FeatureAST, issues: List[SpecIssue]) -> None: """递归校验 AST:算子存在性、arity、参数 kind。""" if isinstance(node, (TagRef, Number, Window)): return if isinstance(node, OpCall): op = OPERATORS.get(node.name) if op is None: issues.append( SpecIssue( code="unknown_operator", message=f"未知算子 '{node.name}';已知算子:{', '.join(sorted(OPERATORS))}", context=repr(node), ) ) # 仍递归校验子节点(便于一次性暴露全部问题) for a in node.args: _validate_node(a, issues) return if not op.arity_ok(len(node.args)): issues.append( SpecIssue( code="arity", message=( f"算子 '{node.name}' 参数个数 {len(node.args)} 不合法" f"(期望 {_arity_text(op)})" ), context=repr(node), ) ) # 参数 kind 校验。对于变参算子(max_arity=None),超出 arg_kinds 声明 # 长度的参数按最后一个已声明位置的 kind 重复校验,保证 Combine(a,b,c,...) # 的每个 tag 都被校验。 for idx in range(len(node.args)): if idx < len(op.arg_kinds): kind = op.arg_kinds[idx] elif op.max_arity is None and op.arg_kinds: kind = op.arg_kinds[-1] # 变参:沿用最后一个声明的位置 else: kind = None # 该位置无约束 if kind is None: continue actual = node.args[idx].to_dict().get("kind") if actual not in kind: allowed = "/".join(kind) issues.append( SpecIssue( code="bad_arg", message=( f"算子 '{node.name}' 第 {idx + 1} 个参数应为 {allowed}," f"实际为 {actual}" ), context=repr(node.args[idx]), ) ) for a in node.args: _validate_node(a, issues) return # 理论不可达 issues.append(SpecIssue(code="bad_ast", message=f"未知 AST 节点:{node!r}")) def _arity_text(op: OperatorDef) -> str: lo = op.min_arity if op.min_arity is not None else 0 if op.max_arity is None: return f"≥{lo}" if op.max_arity == lo: return f"{lo}" return f"{lo}~{op.max_arity}" def validate(ast: FeatureAST) -> List[SpecIssue]: """校验一棵 AST 的语义,返回问题列表(空列表表示通过)。 不抛异常:配置台(issue #62~#67)据此一次性聚合展示所有特征的语义错误。 """ issues: List[SpecIssue] = [] _validate_node(ast, issues) return issues # --------------------------------------------------------------------------- # 依赖分析 # --------------------------------------------------------------------------- def resolve_inputs(ast: FeatureAST) -> List[str]: """递归收集 AST 引用的全部原始点位名(去重、稳定顺序)。 训练/推理流水线(issue #40)据此决定要拉取哪些 tag 的时序数据。 """ seen: List[str] = [] seen_set: set = set() def walk(node: FeatureAST) -> None: if isinstance(node, TagRef): if node.name not in seen_set: seen_set.add(node.name) seen.append(node.name) elif isinstance(node, OpCall): for a in node.args: walk(a) # Number/Window 无依赖 walk(ast) return seen # --------------------------------------------------------------------------- # 执行(materialize) # --------------------------------------------------------------------------- def materialize(ast: FeatureAST, series_map: Dict[str, Sequence[float]]) -> Any: """在给定数据上执行 FeatureSpec,返回计算结果(通常为 list[float])。 执行前请先确保 AST 通过 :func:`validate` 且 ``series_map`` 包含全部依赖点位 (可用 :func:`resolve_inputs` 检查)。缺失点位或语义错误会抛 ``ValueError``/ ``KeyError``/``TypeError``,供流水线 fail-fast。 纯 tag 节点直接返回其序列;number/window 节点返回其标量。 """ if isinstance(ast, TagRef): if ast.name not in series_map: raise KeyError(f"缺少依赖点位数据:{ast.name}") return series_map[ast.name] if isinstance(ast, Number): return ast.value if isinstance(ast, Window): return ast.steps if isinstance(ast, OpCall): op = OPERATORS.get(ast.name) if op is None: raise ValueError(f"未知算子 '{ast.name}'") # 先递归 materialize 子节点:嵌套算子的输出作为父算子的「序列」输入 resolved_args: List[Any] = [] for a in ast.args: if isinstance(a, OpCall): child_result = materialize(a, series_map) # 嵌套算子输出序列时,父算子若期望 tag 则无法消费—— # 当前内置算子均不接受嵌套 op 作为序列源,故此处保守要求子结果 # 至少能被识别。保留 resolved_args 原样(OpCall 节点),由算子 # 内部按需处理;此处不强制类型。 resolved_args.append(a) # 维持 AST 形态,算子按签名判定 else: resolved_args.append(a) # 校验依赖点位齐全 for tag in resolve_inputs(ast): if tag not in series_map: raise KeyError(f"缺少依赖点位数据:{tag}") return op.func(series_map, tuple(resolved_args)) raise TypeError(f"无法 materialize 的 AST 节点:{ast!r}") # --------------------------------------------------------------------------- # 人类可读描述 # --------------------------------------------------------------------------- def describe(ast: FeatureAST) -> str: """返回 FeatureSpec 的结构化文本描述(用于配置台展示与文档)。 例:: >>> describe(parse("EMA(CLF-01.TEMP, 5m)")) 'EMA(指数移动平均) ← CLF-01.TEMP,窗口 5m(300s);依赖点位: CLF-01.TEMP' """ inputs = resolve_inputs(ast) head = repr(ast) op = OPERATORS.get(ast.name) if isinstance(ast, OpCall) else None if op is not None: parts = [f"{ast.name}({op.doc.split(',')[0] if op.doc else '算子'})"] parts.append("← " + ",".join(repr(a) for a in ast.args)) else: parts = [head] if inputs: parts.append("依赖点位: " + ", ".join(inputs)) return " | ".join(parts)