mirror of https://github.com/razor-ai/soup.git
240 lines
7.8 KiB
Python
240 lines
7.8 KiB
Python
"""v0.45.0 Part E — Data Recipe DAG validators (schema-only).
|
|
|
|
Parses a YAML recipe describing a Seed -> LLM Text -> Code -> Judge ->
|
|
Validators -> Sampler graph and validates the topology. The live runner
|
|
(execution against a local model) is deferred to v0.45.1.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import stat
|
|
from collections import deque
|
|
from dataclasses import dataclass
|
|
from types import MappingProxyType
|
|
from typing import Any, Dict, List, Mapping, Tuple
|
|
|
|
# Closed allowlist of node kinds. Mirrors the unsloth Studio schema.
|
|
_NODE_KINDS = (
|
|
"seed",
|
|
"llm_text",
|
|
"code",
|
|
"judge",
|
|
"validator",
|
|
"sampler",
|
|
)
|
|
NODE_KINDS: frozenset = frozenset(_NODE_KINDS)
|
|
|
|
# Per-recipe caps — defence-in-depth against pathological YAML.
|
|
_MAX_NODES = 256
|
|
_MAX_EDGES = 1024
|
|
_MAX_NAME_LEN = 64
|
|
_MAX_FILE_BYTES = 1_048_576 # 1 MiB
|
|
|
|
_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_\-]{0,63}$")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RecipeNode:
|
|
"""One node in the data-recipe DAG."""
|
|
|
|
name: str
|
|
kind: str
|
|
config: Mapping[str, Any]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RecipeDAG:
|
|
"""Validated recipe topology."""
|
|
|
|
nodes: Tuple[RecipeNode, ...]
|
|
edges: Tuple[Tuple[str, str], ...]
|
|
topo_order: Tuple[str, ...]
|
|
|
|
|
|
def _check_name(name: str) -> str:
|
|
if not isinstance(name, str):
|
|
raise TypeError("node name must be a string")
|
|
if "\x00" in name:
|
|
raise ValueError("node name must not contain null bytes")
|
|
if not _NAME_RE.match(name):
|
|
raise ValueError(
|
|
f"node name must match [a-z0-9][a-z0-9_-]{{0,{_MAX_NAME_LEN - 1}}}"
|
|
)
|
|
return name
|
|
|
|
|
|
def _check_kind(kind: str) -> str:
|
|
if not isinstance(kind, str):
|
|
raise TypeError("node kind must be a string")
|
|
canonical = kind.strip().lower()
|
|
if canonical not in NODE_KINDS:
|
|
raise ValueError(
|
|
f"unknown node kind: {kind!r}. supported: {sorted(NODE_KINDS)}"
|
|
)
|
|
return canonical
|
|
|
|
|
|
def _topological_sort(
|
|
names: List[str], edges: List[Tuple[str, str]]
|
|
) -> List[str]:
|
|
"""Kahn's algorithm. Raises ``ValueError`` on cycle.
|
|
|
|
Uses ``collections.deque`` so each pop is O(1); successors that become
|
|
zero in-degree on the same step are appended in sorted order so the
|
|
output is deterministic without re-sorting the queue every iteration.
|
|
"""
|
|
in_degree: Dict[str, int] = {name: 0 for name in names}
|
|
successors: Dict[str, List[str]] = {name: [] for name in names}
|
|
for source, target in edges:
|
|
in_degree[target] += 1
|
|
successors[source].append(target)
|
|
queue: deque = deque(
|
|
sorted(name for name, deg in in_degree.items() if deg == 0)
|
|
)
|
|
order: List[str] = []
|
|
while queue:
|
|
current = queue.popleft()
|
|
order.append(current)
|
|
ready: List[str] = []
|
|
for successor in successors[current]:
|
|
in_degree[successor] -= 1
|
|
if in_degree[successor] == 0:
|
|
ready.append(successor)
|
|
ready.sort()
|
|
queue.extend(ready)
|
|
if len(order) != len(names):
|
|
raise ValueError("recipe DAG contains a cycle")
|
|
return order
|
|
|
|
|
|
def parse_recipe(raw: Any) -> RecipeDAG:
|
|
"""Validate and topologically sort a recipe dict.
|
|
|
|
Required shape::
|
|
|
|
{
|
|
"nodes": [{"name": "...", "kind": "...", "config": {...}}, ...],
|
|
"edges": [["from_name", "to_name"], ...]
|
|
}
|
|
"""
|
|
if not isinstance(raw, dict):
|
|
raise TypeError("recipe must be a dict")
|
|
raw_nodes = raw.get("nodes")
|
|
if not isinstance(raw_nodes, list) or not raw_nodes:
|
|
raise ValueError("recipe.nodes must be a non-empty list")
|
|
if len(raw_nodes) > _MAX_NODES:
|
|
raise ValueError(f"recipe.nodes exceeds {_MAX_NODES} entries")
|
|
|
|
nodes: List[RecipeNode] = []
|
|
seen_names: set[str] = set()
|
|
for index, raw_node in enumerate(raw_nodes):
|
|
if not isinstance(raw_node, dict):
|
|
raise TypeError(f"recipe.nodes[{index}] must be a dict")
|
|
name = _check_name(raw_node.get("name", ""))
|
|
if name in seen_names:
|
|
raise ValueError(f"duplicate node name: {name!r}")
|
|
seen_names.add(name)
|
|
kind = _check_kind(raw_node.get("kind", ""))
|
|
config = raw_node.get("config", {})
|
|
if not isinstance(config, dict):
|
|
raise TypeError(f"recipe.nodes[{index}].config must be a dict")
|
|
nodes.append(
|
|
RecipeNode(name=name, kind=kind, config=MappingProxyType(dict(config)))
|
|
)
|
|
|
|
raw_edges = raw.get("edges", [])
|
|
if not isinstance(raw_edges, list):
|
|
raise TypeError("recipe.edges must be a list")
|
|
if len(raw_edges) > _MAX_EDGES:
|
|
raise ValueError(f"recipe.edges exceeds {_MAX_EDGES} entries")
|
|
edges: List[Tuple[str, str]] = []
|
|
edge_seen: set[Tuple[str, str]] = set()
|
|
name_set = {node.name for node in nodes}
|
|
for index, raw_edge in enumerate(raw_edges):
|
|
if not isinstance(raw_edge, (list, tuple)) or len(raw_edge) != 2:
|
|
raise ValueError(
|
|
f"recipe.edges[{index}] must be a 2-element [from, to] list"
|
|
)
|
|
source = _check_name(raw_edge[0])
|
|
target = _check_name(raw_edge[1])
|
|
if source == target:
|
|
raise ValueError(f"self-loop edge rejected: {source!r}")
|
|
if source not in name_set:
|
|
raise ValueError(f"edge source {source!r} not in nodes")
|
|
if target not in name_set:
|
|
raise ValueError(f"edge target {target!r} not in nodes")
|
|
key = (source, target)
|
|
if key in edge_seen:
|
|
raise ValueError(f"duplicate edge: {source!r} -> {target!r}")
|
|
edge_seen.add(key)
|
|
edges.append(key)
|
|
|
|
topo = _topological_sort([n.name for n in nodes], edges)
|
|
return RecipeDAG(
|
|
nodes=tuple(nodes),
|
|
edges=tuple(edges),
|
|
topo_order=tuple(topo),
|
|
)
|
|
|
|
|
|
def parse_recipe_yaml(text: str) -> RecipeDAG:
|
|
"""Parse a YAML string into a validated ``RecipeDAG``."""
|
|
if not isinstance(text, str):
|
|
raise TypeError("text must be a string")
|
|
if "\x00" in text:
|
|
raise ValueError("recipe text must not contain null bytes")
|
|
if len(text.encode("utf-8")) > _MAX_FILE_BYTES:
|
|
raise ValueError(f"recipe text exceeds {_MAX_FILE_BYTES} bytes")
|
|
import yaml # lazy import — keep CLI startup fast
|
|
|
|
try:
|
|
data = yaml.safe_load(text)
|
|
except yaml.YAMLError as exc:
|
|
raise ValueError(f"invalid YAML: {exc}") from exc
|
|
return parse_recipe(data)
|
|
|
|
|
|
def load_recipe_yaml(path: str) -> RecipeDAG:
|
|
"""Load a recipe YAML from a path under cwd."""
|
|
from soup_cli.utils.paths import is_under_cwd
|
|
|
|
if not isinstance(path, str) or not path:
|
|
raise ValueError("path must be a non-empty string")
|
|
if "\x00" in path:
|
|
raise ValueError("path must not contain null bytes")
|
|
# Symlink rejection on the *original* path (TOCTOU policy mirroring
|
|
# v0.33.0 #22 / v0.43.0 Part C / v0.44.0 Part B). ``realpath`` below
|
|
# would already follow symlinks, but we want to reject them as a
|
|
# defence layer rather than silently follow.
|
|
try:
|
|
lstat_result = os.lstat(path)
|
|
except FileNotFoundError as exc:
|
|
raise FileNotFoundError(path) from exc
|
|
if stat.S_ISLNK(lstat_result.st_mode):
|
|
raise ValueError(
|
|
f"recipe path must not be a symlink: {os.path.basename(path)}"
|
|
)
|
|
real = os.path.realpath(path)
|
|
if not is_under_cwd(real):
|
|
raise ValueError(f"recipe path must stay under cwd: {os.path.basename(real)}")
|
|
if not os.path.isfile(real):
|
|
raise FileNotFoundError(real)
|
|
size = os.path.getsize(real)
|
|
if size > _MAX_FILE_BYTES:
|
|
raise ValueError(f"recipe file exceeds {_MAX_FILE_BYTES} bytes")
|
|
with open(real, "r", encoding="utf-8") as handle:
|
|
text = handle.read()
|
|
return parse_recipe_yaml(text)
|
|
|
|
|
|
__all__ = [
|
|
"NODE_KINDS",
|
|
"RecipeDAG",
|
|
"RecipeNode",
|
|
"parse_recipe",
|
|
"parse_recipe_yaml",
|
|
"load_recipe_yaml",
|
|
]
|