Files
iAOP/templates/ti-cl4/recipe-optim/problem.py
T

785 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""Ti-2 配方动态优化 · 优化问题建模(约束/目标定义)(Issue #78 / PRD 5.3 ②)。
承接 PRD 5.3「② 配方动态优化」与超参包示例(``objective`` / ``features`` /
``target``):把"给定质量目标 + 工艺约束,求最优配方/参数"这条链路**模板化、
可配置、可测试**,且与 #79 求解器、#80 跨工序寻优、#81 可解释建议解耦。
PRD 设计口径
------------
- 架构表(PRD §5.3):``工艺优化/配方推荐 | 优化/推荐 | 入:质量目标+约束;
出:参数/配方建议 | ② 配方动态优化 | 高(需闭环反馈)``。
- 模板化技术路径:超参包驱动——``objective``、输入特征清单、``target`` 等可变量
外置为 JSON 超参包,切换模板仅改此包;跨行业差异落资产,不落代码。
- 风险表:二期交付(一期数据门槛不足),故本期**先把问题建模沉淀为可校验的声明
式规格**,为 #79 求解器、#80 跨工序寻优、#81 可解释建议提供**统一的问题描述
契约**;先有"能跑通、可测试"的模型,数据就绪后接求解器(#79/#80)。
本模块交付
----------
1. **决策变量 ``DecisionVariable``**:配方/工艺可调参数的声明式规格——变量名、
单位、取值域(``Bounds`` 连续区间 / ``Choices`` 离散枚举)、初值、是否整型、
工艺含义(``meaning``,供 #81 可解释建议引用)。
2. **目标函数规格 ``ObjectiveSpec``**:``Sense``(minimize/maximize)+ 目标项
(``ObjectiveTerm``:系数 × 变量,线性目标)+ 目标 ``target``(PRD 超参包字段)。
3. **约束规格 ``ConstraintSpec``**:``ConstraintKind``(box / linear / ratio /
forbidden)统一描述工艺约束(温度上下限、配方配比、禁止组合等)。
4. **问题模型 ``OptimizationProblem``**:聚合变量 + 目标 + 约束,提供校验
(``validate``,聚合并列出全部错误,便于配置台一次性反馈)、声明式加载
(零第三方依赖 YAML 子集解析,与 data-bus/rag-kb/impurity-forecast 同款)。
设计要点
--------
- **零运行时依赖**(纯标准库):与内核既有模块一致,便于离线/隔离网部署。
- **求解器无关**:本模块只描述"问题",``solve`` 留给 #79 注入;便于换行业复用、
单测无需真实求解器。
- **可解释前置**:变量 ``meaning`` + 约束 ``reason`` 字段,为 #81 优化建议"可溯源"
预留引用依据(对齐 PRD"要求结果可解释、可溯源,要引用依据")。
"""
from __future__ import annotations
import math
import os
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
# 缺失值统一用 float('nan'),与 impurity-forecast 一致,便于上层判空屏蔽。
NAN = float("nan")
class ProblemError(ValueError):
"""配方优化问题建模错误(未知变量 / 越界 / 约束矛盾 / 重复定义等)。"""
# ---------------------------------------------------------------------------
# 决策变量
# ---------------------------------------------------------------------------
class DomainKind(str, Enum):
"""决策变量取值域类型。"""
BOUNDS = "bounds" # 连续区间 [low, high](如温度 800~900℃)
CHOICES = "choices" # 离散枚举(如催化剂型号 A/B/C)
@dataclass
class DecisionVariable:
"""一个可调配方/工艺参数的声明式规格。
``bounds`` 与 ``choices`` 二选一(由 ``kind`` 决定):
- ``bounds``:``[low, high]``,``integer=True`` 时取整;
- ``choices``:离散可选值列表(任意可比较的标量,多为 float/str)。
"""
name: str
kind: DomainKind
meaning: str = "" # 工艺含义,供 #81 可解释建议引用
unit: str = "" # 单位(℃、m³/h、kg、…)
bounds: Optional[Tuple[float, float]] = None
choices: Optional[List[Any]] = None
initial: Optional[float] = None # 当前工况/配方初值
integer: bool = False # 仅 bounds 连续域生效
def __post_init__(self) -> None:
if not self.name or not str(self.name).strip():
raise ProblemError("DecisionVariable.name 不能为空")
if self.kind == DomainKind.BOUNDS:
if self.bounds is None:
raise ProblemError(f"变量 {self.name!r} kind=bounds 但未提供 bounds")
low, high = self.bounds
if _is_num(low) and _is_num(high) and low > high:
raise ProblemError(
f"变量 {self.name!r} bounds 下界 {low} 大于上界 {high}")
if self.integer and self.bounds is not None:
low, high = self.bounds
if _is_num(low) and float(low).is_integer() is False:
raise ProblemError(
f"变量 {self.name!r} integer=True 但下界 {low} 非整")
if _is_num(high) and float(high).is_integer() is False:
raise ProblemError(
f"变量 {self.name!r} integer=True 但上界 {high} 非整")
elif self.kind == DomainKind.CHOICES:
if not self.choices:
raise ProblemError(f"变量 {self.name!r} kind=choices 但 choices 为空")
else: # pragma: no cover - 枚举穷尽
raise ProblemError(f"变量 {self.name!r} 未知 kind={self.kind!r}")
def contains(self, value: Any) -> bool:
"""取值是否落在该变量合法域内。"""
if self.kind == DomainKind.BOUNDS and self.bounds is not None:
if not _is_num(value):
return False
low, high = self.bounds
if self.integer and float(value).is_integer() is False:
return False
return low <= value <= high
# choices
return value in (self.choices or [])
def clamp(self, value: Any) -> Any:
"""把越界的连续域取值夹回合法区间(离散域不夹,原值返回)。"""
if self.kind == DomainKind.BOUNDS and self.bounds is not None and _is_num(value):
low, high = self.bounds
value = max(low, min(high, value))
if self.integer:
value = float(round(value))
return value
return value
def to_dict(self) -> Dict[str, Any]:
d: Dict[str, Any] = {
"name": self.name,
"kind": self.kind.value,
"meaning": self.meaning,
"unit": self.unit,
"integer": self.integer,
}
if self.kind == DomainKind.BOUNDS:
d["bounds"] = list(self.bounds) if self.bounds else None
else:
d["choices"] = list(self.choices) if self.choices else None
if self.initial is not None:
d["initial"] = self.initial
return d
@classmethod
def from_dict(cls, d: Dict[str, Any]) -> "DecisionVariable":
name = d.get("name")
if not isinstance(name, str):
raise ProblemError("DecisionVariable 缺少 name 字段")
kind_raw = d.get("kind", "bounds")
try:
kind = DomainKind(str(kind_raw))
except ValueError as e:
raise ProblemError(f"变量 {name!r} 未知 kind={kind_raw!r}") from e
bounds = d.get("bounds")
choices = d.get("choices")
if kind == DomainKind.BOUNDS and bounds is not None:
if (not isinstance(bounds, (list, tuple))) or len(bounds) != 2:
raise ProblemError(f"变量 {name!r} bounds 必须是 [low, high]")
bounds = (float(bounds[0]), float(bounds[1]))
if kind == DomainKind.CHOICES and choices is not None:
choices = list(choices)
return cls(
name=name,
kind=kind,
meaning=str(d.get("meaning", "")),
unit=str(d.get("unit", "")),
bounds=bounds,
choices=choices,
initial=d.get("initial"),
integer=bool(d.get("integer", False)),
)
# ---------------------------------------------------------------------------
# 目标函数
# ---------------------------------------------------------------------------
class Sense(str, Enum):
"""优化方向。"""
MINIMIZE = "minimize"
MAXIMIZE = "maximize"
@property
def label(self) -> str:
return {Sense.MINIMIZE: "最小化", Sense.MAXIMIZE: "最大化"}[self]
@dataclass
class ObjectiveTerm:
"""线性目标项:``coefficient * variable``(变量名引用 ``DecisionVariable.name``)。"""
variable: str
coefficient: float = 1.0
def to_dict(self) -> Dict[str, Any]:
return {"variable": self.variable, "coefficient": self.coefficient}
@classmethod
def from_dict(cls, d: Dict[str, Any]) -> "ObjectiveTerm":
if "variable" not in d:
raise ProblemError("ObjectiveTerm 缺少 variable 字段")
return cls(variable=str(d["variable"]), coefficient=float(d.get("coefficient", 1.0)))
@dataclass
class ObjectiveSpec:
"""目标函数声明式规格(线性加权,对齐 PRD 超参包 ``objective`` 字段)。
形如 ``sense(coef1*var1 + coef2*var2 + ...)``,目标质量 ``target`` 为达标量
(如 ``Ti_purity ≥ 99.5%`` 中的 99.5),仅记录、不参与求解,供 #81 可解释。
"""
sense: Sense = Sense.MAXIMIZE
terms: List[ObjectiveTerm] = field(default_factory=list)
target: Optional[str] = None # PRD 超参包 ``target``:如 "Ti_purity"
target_value: Optional[float] = None # 达标量(可选)
description: str = ""
def evaluate(self, assignment: Dict[str, float]) -> float:
"""给定一组变量取值,计算目标函数值(未知变量按 0 计)。"""
total = 0.0
for t in self.terms:
v = assignment.get(t.variable)
if _is_num(v):
total += t.coefficient * v
return total
def to_dict(self) -> Dict[str, Any]:
d: Dict[str, Any] = {
"sense": self.sense.value,
"terms": [t.to_dict() for t in self.terms],
}
if self.target is not None:
d["target"] = self.target
if self.target_value is not None:
d["target_value"] = self.target_value
if self.description:
d["description"] = self.description
return d
@classmethod
def from_dict(cls, d: Dict[str, Any]) -> "ObjectiveSpec":
sense_raw = d.get("sense", "maximize")
try:
sense = Sense(str(sense_raw))
except ValueError as e:
raise ProblemError(f"未知 sense={sense_raw!r}") from e
terms = [ObjectiveTerm.from_dict(t) for t in d.get("terms", [])]
tv = d.get("target_value")
return cls(
sense=sense,
terms=terms,
target=d.get("target"),
target_value=float(tv) if _is_num(tv) else None,
description=str(d.get("description", "")),
)
# ---------------------------------------------------------------------------
# 约束
# ---------------------------------------------------------------------------
class ConstraintKind(str, Enum):
"""约束类型(统一描述常见工艺约束)。"""
BOX = "box" # 变量上下界(冗余于 DecisionVariable.bounds,供"运行期收紧")
LINEAR = "linear" # 线性不等式 Σ a_i*x_i (</<=/>/>=) b
RATIO = "ratio" # 配比约束:x_a / x_b (op) value
FORBIDDEN = "forbidden" # 禁止组合:若干变量取值组合不允许
_LINEAR_OPS = {"<", "<=", ">", ">=", "==", "!="}
@dataclass
class ConstraintSpec:
"""约束声明式规格。
每条约束带 ``reason``(工艺依据,供 #81 可解释建议"可溯源、引用依据")。
"""
kind: ConstraintKind
reason: str = ""
# box
variable: Optional[str] = None
bounds: Optional[Tuple[float, float]] = None
# linear
coefficients: Optional[Dict[str, float]] = None
op: str = "<="
rhs: float = 0.0
# ratio
numerator: Optional[str] = None
denominator: Optional[str] = None
value: float = 0.0
# forbidden
combination: Optional[Dict[str, Any]] = None
def __post_init__(self) -> None:
if self.kind == ConstraintKind.LINEAR and self.op not in _LINEAR_OPS:
raise ProblemError(f"线性约束非法 op={self.op!r}")
if self.kind == ConstraintKind.LINEAR and not self.coefficients:
raise ProblemError("线性约束 coefficients 不能为空")
# ---- 校验(返回错误信息列表,不抛异常,便于聚合) ------------------
def errors_against(self, variables: Dict[str, DecisionVariable]) -> List[str]:
"""对该约束引用的变量是否存在做静态校验,返回错误信息列表。"""
errs: List[str] = []
if self.kind == ConstraintKind.BOX:
if not self.variable:
errs.append("box 约束缺少 variable")
elif self.variable not in variables:
errs.append(f"box 约束引用未知变量 {self.variable!r}")
elif self.kind == ConstraintKind.LINEAR:
for vname in (self.coefficients or {}):
if vname not in variables:
errs.append(f"线性约束引用未知变量 {vname!r}")
elif self.kind == ConstraintKind.RATIO:
for fld, vname in (("numerator", self.numerator),
("denominator", self.denominator)):
if not vname:
errs.append(f"ratio 约束缺少 {fld}")
elif vname not in variables:
errs.append(f"ratio 约束引用未知变量 {vname!r}")
elif self.kind == ConstraintKind.FORBIDDEN:
for vname in (self.combination or {}):
if vname not in variables:
errs.append(f"forbidden 约束引用未知变量 {vname!r}")
return errs
# ---- 可行性判定(给定取值,判断该约束是否满足) --------------------
def satisfied_by(self, assignment: Dict[str, Any]) -> bool:
"""给定一组变量取值,判断该约束是否被满足(未知变量视为未约束)。"""
if self.kind == ConstraintKind.BOX and self.bounds is not None and self.variable:
v = assignment.get(self.variable)
if not _is_num(v):
return True # 未知取值不判
low, high = self.bounds
return low <= v <= high
if self.kind == ConstraintKind.LINEAR and self.coefficients:
total = 0.0
unknown = False
for vname, coef in self.coefficients.items():
v = assignment.get(vname)
if not _is_num(v):
unknown = True
break
total += coef * v
if unknown:
return True
return _apply_op(total, self.op, self.rhs)
if self.kind == ConstraintKind.RATIO and self.numerator and self.denominator:
a = assignment.get(self.numerator)
b = assignment.get(self.denominator)
if not _is_num(a) or not _is_num(b) or b == 0:
return True
return _apply_op(a / b, self.op, self.value)
if self.kind == ConstraintKind.FORBIDDEN and self.combination:
# 组合中每个键值都命中才算"禁止组合"被触发
for vname, want in self.combination.items():
if assignment.get(vname) != want:
return True
return False
return True
def to_dict(self) -> Dict[str, Any]:
d: Dict[str, Any] = {"kind": self.kind.value}
if self.reason:
d["reason"] = self.reason
if self.kind == ConstraintKind.BOX:
d["variable"] = self.variable
d["bounds"] = list(self.bounds) if self.bounds else None
elif self.kind == ConstraintKind.LINEAR:
d["coefficients"] = dict(self.coefficients or {})
d["op"] = self.op
d["rhs"] = self.rhs
elif self.kind == ConstraintKind.RATIO:
d["numerator"] = self.numerator
d["denominator"] = self.denominator
d["op"] = self.op
d["value"] = self.value
elif self.kind == ConstraintKind.FORBIDDEN:
d["combination"] = dict(self.combination or {})
return d
@classmethod
def from_dict(cls, d: Dict[str, Any]) -> "ConstraintSpec":
kind_raw = d.get("kind")
try:
kind = ConstraintKind(str(kind_raw))
except ValueError as e:
raise ProblemError(f"未知约束 kind={kind_raw!r}") from e
bounds = d.get("bounds")
if kind == ConstraintKind.BOX and bounds is not None:
bounds = (float(bounds[0]), float(bounds[1]))
coefs = d.get("coefficients")
if coefs is not None:
coefs = {k: float(v) for k, v in coefs.items()}
return cls(
kind=kind,
reason=str(d.get("reason", "")),
variable=d.get("variable"),
bounds=bounds,
coefficients=coefs,
op=str(d.get("op", "<=")),
rhs=float(d.get("rhs", 0.0)),
numerator=d.get("numerator"),
denominator=d.get("denominator"),
value=float(d.get("value", 0.0)),
combination=d.get("combination"),
)
# ---------------------------------------------------------------------------
# 优化问题
# ---------------------------------------------------------------------------
def objective_factory() -> ObjectiveSpec:
"""dataclass 默认值工厂:空目标(最大化、无项)。"""
return ObjectiveSpec(sense=Sense.MAXIMIZE)
@dataclass
class OptimizationProblem:
"""配方优化问题模型(变量 + 目标 + 约束),求解器无关。
设计为「先建模、后求解」:``validate`` 做静态一致性校验(变量引用、域完整性),
``is_feasible`` 做取值可行性判定(运行期收紧约束 / 禁止组合),``solve`` 留给
#79 注入求解器,本模块不绑任何优化库。
"""
variables: List[DecisionVariable] = field(default_factory=list)
objective: ObjectiveSpec = field(default_factory=objective_factory)
constraints: List[ConstraintSpec] = field(default_factory=list)
problem_id: str = ""
template: str = "" # 如 "iAOP-Template-Ti"
description: str = ""
# ---- 变量索引 ----------------------------------------------------
@property
def variable_map(self) -> Dict[str, DecisionVariable]:
return {v.name: v for v in self.variables}
# ---- 校验 --------------------------------------------------------
def validate(self) -> List[str]:
"""聚合所有静态错误,返回错误信息列表(空列表表示通过)。"""
errs: List[str] = []
seen: set = set()
for v in self.variables:
if v.name in seen:
errs.append(f"重复定义变量 {v.name!r}")
seen.add(v.name)
vmap = self.variable_map
for t in self.objective.terms:
if t.variable not in vmap:
errs.append(f"目标项引用未知变量 {t.variable!r}")
for i, c in enumerate(self.constraints):
for e in c.errors_against(vmap):
errs.append(f"约束 #{i} ({c.kind.value}): {e}")
if self.objective.terms and not any(
t.variable in vmap for t in self.objective.terms
):
errs.append("目标函数所有项均引用未知变量")
return errs
# ---- 可行性判定 --------------------------------------------------
def is_feasible(self, assignment: Dict[str, Any]) -> bool:
"""给定一组变量取值,判断是否满足全部约束与变量域。"""
vmap = self.variable_map
for name, val in assignment.items():
v = vmap.get(name)
if v is not None and not v.contains(val):
return False
return all(c.satisfied_by(assignment) for c in self.constraints)
def violated_constraints(self, assignment: Dict[str, Any]) -> List[ConstraintSpec]:
"""返回被该取值违反的约束列表(供 #81 可解释建议引用依据)。"""
return [c for c in self.constraints if not c.satisfied_by(assignment)]
# ---- 序列化 ------------------------------------------------------
def to_dict(self) -> Dict[str, Any]:
d: Dict[str, Any] = {
"variables": [v.to_dict() for v in self.variables],
"objective": self.objective.to_dict(),
"constraints": [c.to_dict() for c in self.constraints],
}
if self.problem_id:
d["problem_id"] = self.problem_id
if self.template:
d["template"] = self.template
if self.description:
d["description"] = self.description
return d
@classmethod
def from_dict(cls, d: Dict[str, Any]) -> "OptimizationProblem":
return cls(
variables=[DecisionVariable.from_dict(v) for v in d.get("variables", [])],
objective=ObjectiveSpec.from_dict(d.get("objective", {})),
constraints=[ConstraintSpec.from_dict(c) for c in d.get("constraints", [])],
problem_id=str(d.get("problem_id", "")),
template=str(d.get("template", "")),
description=str(d.get("description", "")),
)
# ---------------------------------------------------------------------------
# 辅助函数
# ---------------------------------------------------------------------------
def _is_num(x: object) -> bool:
return isinstance(x, (int, float)) and not (isinstance(x, float) and math.isnan(x))
def _apply_op(left: float, op: str, right: float) -> bool:
"""应用比较算子(线性/配比约束共用)。"""
if op == "<":
return left < right
if op == "<=":
return left <= right
if op == ">":
return left > right
if op == ">=":
return left >= right
if op == "==":
return abs(left - right) < 1e-12
if op == "!=":
return abs(left - right) >= 1e-12
return False # pragma: no cover
# ---------------------------------------------------------------------------
# 零依赖 YAML 子集加载(与 data-bus / rag-kb / impurity-forecast 同款)
# ---------------------------------------------------------------------------
def load_problem(path: str) -> OptimizationProblem:
"""从声明式模板资产(YAML 子集)加载优化问题。
解析支持:缩进块、``key: value``、``- item``、行内 ``# 注释``、字符串/数字/
布尔、内联 ``[a, b]`` 列表与 ``{a: 1}`` 映射。足以覆盖本模板资产格式;
不引入第三方依赖,与内核既有模块一致。
"""
with open(path, "r", encoding="utf-8") as fh:
text = fh.read()
data = _parse_yaml_subset(text)
if not isinstance(data, dict):
raise ProblemError(f"模板 {path} 顶层应为映射")
return OptimizationProblem.from_dict(data)
def _parse_yaml_subset(text: str) -> Any:
"""极简 YAML 子集解析器(仅供模板资产,非通用 YAML)。"""
# 去注释 + 去尾部空白,保留缩进
lines: List[str] = []
for raw in text.splitlines():
# 行内注释:仅在 "# " 前不是值的一部分时剥离;这里取保守策略——行首/值后
# 的 " #" 视为注释。冒号/方括号内的 # 不处理。
stripped = raw.rstrip()
if not stripped.strip():
continue
# 简单注释行
if stripped.lstrip().startswith("#"):
continue
# 去行尾注释(" #" 形式)
hash_idx = _find_inline_comment(stripped)
if hash_idx is not None:
stripped = stripped[:hash_idx].rstrip()
if stripped:
lines.append(stripped)
parser = _YamlParser(lines)
return parser.parse_block(0)[0] if lines else {}
def _find_inline_comment(line: str) -> Optional[int]:
"""返回行内注释 ``#`` 的索引(无则 None),跳过 ``[...]``/``{...}`` 内的 #。"""
depth = 0
in_str = False
for i, ch in enumerate(line):
if ch == '"':
in_str = not in_str
elif not in_str:
if ch in "[{":
depth += 1
elif ch in "]}":
depth = max(0, depth - 1)
elif ch == "#" and depth == 0 and i > 0 and line[i - 1] in (" ", "\t"):
return i
elif ch == "#" and depth == 0 and i == 0:
return i
return None
class _YamlParser:
"""递归下降的 YAML 子集解析器(按缩进分层)。"""
def __init__(self, lines: List[str]) -> None:
self.lines = lines
self.i = 0
def _indent(self, line: str) -> int:
return len(line) - len(line.lstrip(" "))
def parse_block(self, indent: int) -> Tuple[Any, bool]:
"""解析当前缩进层级的一个块,返回 (value, is_list_marker)。"""
if self.i >= len(self.lines):
return {}, False
line = self.lines[self.i]
cur_indent = self._indent(line)
if cur_indent < indent:
return {}, False
stripped = line.strip()
if stripped.startswith("- ") or stripped == "-":
return self._parse_list(cur_indent), True
return self._parse_mapping(cur_indent), False
def _parse_mapping(self, indent: int) -> Dict[str, Any]:
result: Dict[str, Any] = {}
# 实际子键缩进可能 > indent(如 "- key: v" 后 4 空格键、项缩进 2)。
# 用首行真实缩进对齐,避免误把合法子键当"孤立缩进"跳过。
effective = indent
if self.i < len(self.lines):
first = self._indent(self.lines[self.i])
if first > indent:
effective = first
while self.i < len(self.lines):
line = self.lines[self.i]
cur = self._indent(line)
if cur < effective:
break
if cur > effective:
# 跳过孤立缩进(不应出现,保守跳过)
self.i += 1
continue
stripped = line.strip()
if stripped.startswith("- "):
break
key, sep, rest = stripped.partition(":")
if not sep:
self.i += 1
continue
key = key.strip()
rest = rest.strip()
self.i += 1
if rest:
result[key] = _parse_scalar(rest)
else:
# 子块:用首行真实缩进解析(列表或映射),兼容 4 空格子键等
if self.i < len(self.lines) and self._indent(self.lines[self.i]) > effective:
child_indent = self._indent(self.lines[self.i])
val, _ = self.parse_block(child_indent)
result[key] = val
else:
result[key] = None
return result
def _parse_list(self, indent: int) -> List[Any]:
result: List[Any] = []
while self.i < len(self.lines):
line = self.lines[self.i]
cur = self._indent(line)
if cur < indent:
break
if cur > indent:
self.i += 1
continue
stripped = line.strip()
if not stripped.startswith("-"):
break
item_text = stripped[1:].strip()
self.i += 1
# 后续更深缩进的行是否归属本项
if self.i < len(self.lines):
child_indent = self._indent(self.lines[self.i])
else:
child_indent = cur
has_deeper = child_indent > cur
if item_text:
# 可能是 "- key: value"(映射项)或 "- 标量"
if ":" in item_text and not item_text.startswith("["):
# 单行映射项的首键
key, sep, rest = item_text.partition(":")
kval = _parse_scalar(rest.strip()) if rest.strip() else None
if has_deeper:
# 把首键与后续子块合并:先解析子块,再把首键塞入
sub, _ = self.parse_block(child_indent)
item: Dict[str, Any] = sub if isinstance(sub, dict) else {}
item[key.strip()] = kval
else:
item = {key.strip(): kval}
result.append(item)
else:
if has_deeper:
# 标量头 + 子块(本模板未使用,保守取子块)
sub, _ = self.parse_block(child_indent)
result.append(sub)
else:
result.append(_parse_scalar(item_text))
else:
# "- " 后跟子块
if has_deeper:
sub, _ = self.parse_block(child_indent)
result.append(sub)
return result
def _parse_scalar(text: str) -> Any:
"""解析标量:数字/布尔/字符串/内联列表/内联映射。"""
text = text.strip()
if not text:
return ""
# 内联列表
if text.startswith("[") and text.endswith("]"):
inner = text[1:-1].strip()
if not inner:
return []
return [_parse_scalar(part.strip()) for part in _split_top(inner, ",")]
# 内联映射
if text.startswith("{") and text.endswith("}"):
inner = text[1:-1].strip()
if not inner:
return {}
out: Dict[str, Any] = {}
for part in _split_top(inner, ","):
k, sep, v = part.partition(":")
if sep:
out[k.strip()] = _parse_scalar(v.strip())
return out
low = text.lower()
if low == "true":
return True
if low == "false":
return False
if low in ("null", "none", "~"):
return None
# 数字
try:
if "." in text or "e" in low:
return float(text)
return int(text)
except ValueError:
# 去引号
if len(text) >= 2 and text[0] in "\"'" and text[-1] == text[0]:
return text[1:-1]
return text
def _split_top(text: str, sep: str) -> List[str]:
"""按分隔符切分顶层(跳过 []/{} 内的)。"""
parts: List[str] = []
depth = 0
cur: List[str] = []
in_str = False
for ch in text:
if ch == '"':
in_str = not in_str
cur.append(ch)
elif not in_str and ch in "[{":
depth += 1
cur.append(ch)
elif not in_str and ch in "]}":
depth = max(0, depth - 1)
cur.append(ch)
elif ch == sep and depth == 0:
parts.append("".join(cur))
cur = []
else:
cur.append(ch)
if cur:
parts.append("".join(cur))
return parts