File size: 8,811 Bytes
9c84f9d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
"""νŒŒμ΄ν”„λΌμΈ 쑰립 β€” `extends` 상속 + dot-path override.

μ™œ: ν•œ μΆ•λ§Œ λ°”κΎΌ μ‹€ν—˜μ΄ νŒŒμ΄ν”„λΌμΈ 파일 **전체 사본**으둜 μŒ“μ—¬ μ™”λ‹€.
`pipeline_dev_v24a/b/c` λŠ” 109μ€„μ§œλ¦¬ v24 사본인데 μ‹€μ œ μ°¨μ΄λŠ”

    repeat_temperatures: [0.0, 0.2]  β†’  [0.0, 0.0] / [0.0, 0.3] / [0.0, 0.5]

ν•œ 쀄뿐이닀. μ‚¬λ³ΈμœΌλ‘œ 두면 (1) μ‹€ν—˜μ˜ μ‹€μ œ λ³€μˆ˜κ°€ diff λ₯Ό λ– μ•Όλ§Œ 보이고,
(2) base λ₯Ό κ³ μΉ  λ•Œ 사본듀이 쑰용히 κ°ˆλΌμ§„λ‹€. μ‹€ν—˜μ˜ μ •μ˜λŠ” 사본이 μ•„λ‹ˆλΌ
**diff** μ—¬μ•Ό ν•œλ‹€.

두 κ°€μ§€ ν‘œν˜„μ„ μ§€μ›ν•œλ‹€. λ‘˜ λ‹€ 같은 dot-path 문법을 μ“΄λ‹€:

    # 파일둜 λ‚¨κΈ°λŠ” μ‹€ν—˜ (μž¬μ‚¬μš©Β·μΈμš© λŒ€μƒ)
    name: pipeline_dev_v24a
    extends: pipeline_dev_v24
    overrides:
      steps.basic_correction.repeat_temperatures: [0.0, 0.0]

    # 파일 없이 ν•œ 번 λŒλ €λ³΄λŠ” μ‹€ν—˜
    solar-eval runs start ... -P pipeline_dev_v24 \\
      --set steps.basic_correction.repeat_temperatures='[0.0, 0.5]'

쑰립은 **λ‘œλ“œ μ‹œμ μ— λλ‚œλ‹€** β€” μ‹€ν–‰ μ—”μ§„(`STEP_REGISTRY`)은 `extends` λ₯Ό
μ•„μ˜ˆ λͺ¨λ₯΄κ³ , μ™„μ„±λœ steps λͺ©λ‘λ§Œ λ°›λŠ”λ‹€. κ·Έλž˜μ„œ 이 κΈ°λŠ₯은 μ—”μ§„μ˜ μΌλ°˜μ„±μ—
영ν–₯을 μ£Όμ§€ μ•ŠλŠ”λ‹€(λ²ˆμ—­Β·μš”μ•½ λ“± λ‹€λ₯Έ ν”„λ‘œμ νŠΈλ„ κ·ΈλŒ€λ‘œ μ“Έ 수 μžˆλ‹€).

상속은 **1λ‹¨κ³„λ‘œ μ œν•œ**ν•œλ‹€. base κ°€ 또 base λ₯Ό κ°–λŠ” 체인은 hydra κ³„μ—΄μ—μ„œ
반볡적으둜 λ¬Έμ œκ°€ 된 지점이고("이 값이 μ–΄λ””μ„œ μ™”λŠ”κ°€"λ₯Ό 좔적 λΆˆκ°€), PoC 규λͺ¨μ—μ„œ
μ–»λŠ” 게 μ—†λ‹€.
"""

import copy
import logging
from collections.abc import Callable, Mapping
from typing import Any

import yaml

logger = logging.getLogger(__name__)

#: dot-path μ—μ„œ μŠ€ν… λͺ©λ‘μ„ κ°€λ¦¬ν‚€λŠ” μ΅œμƒμœ„ ν‚€.
STEPS_KEY = "steps"


class PipelineCompositionError(ValueError):
    """`extends` ν•΄μ„μ΄λ‚˜ override 적용이 μ‹€νŒ¨ν–ˆμ„ λ•Œ.

    쑰용히 λ„˜μ–΄κ°€μ§€ μ•ŠλŠ”λ‹€ β€” μ˜€νƒ€ λ‚œ override λŠ” "아무 효과 μ—†λŠ” μ‹€ν—˜"을 λ§Œλ“€κ³ ,
    그건 잘λͺ»λœ 결둠으둜 λ°”λ‘œ 이어진닀 (이 엔진이 κ³Όκ±° μ‘°μš©ν•œ no-op μŠ€ν…μœΌλ‘œ
    같은 λ°©μ‹μœΌλ‘œ λ‹€μ³€λ‹€: `pipelines/errors.py` μ°Έκ³ ).
    """


def compose_pipeline(
    raw: Mapping[str, Any],
    *,
    load_base: Callable[[str], Mapping[str, Any]] | None = None,
    overrides: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
    """νŒŒμ΄ν”„λΌμΈ YAML ν•œ μž₯을 μ‹€ν–‰ κ°€λŠ₯ν•œ μ„€μ •μœΌλ‘œ μ‘°λ¦½ν•œλ‹€.

    Args:
        raw: νŒŒμ΄ν”„λΌμΈ YAML 을 νŒŒμ‹±ν•œ dict.
        load_base: `extends` λŒ€μƒ 이름을 λ°›μ•„ κ·Έ YAML dict λ₯Ό λŒλ €μ£ΌλŠ” ν•¨μˆ˜.
            `extends` λ₯Ό μ“΄ νŒŒμ΄ν”„λΌμΈμ„ μ‘°λ¦½ν•˜λ €λ©΄ λ°˜λ“œμ‹œ ν•„μš”ν•˜λ‹€.
        overrides: 파일 λ°”κΉ₯μ—μ„œ λ“€μ–΄μ˜¨ override (CLI `--set`). YAML 의
            `overrides` 보닀 **λ‚˜μ€‘μ—** μ μš©λœλ‹€.

    Returns:
        `extends`/`overrides` ν‚€κ°€ 걷힌, 해석이 λλ‚œ μ„€μ • dict.

    Raises:
        PipelineCompositionError: base λ₯Ό λͺ» μ°Ύκ±°λ‚˜, 상속이 2단계 μ΄μƒμ΄κ±°λ‚˜,
            override κ²½λ‘œκ°€ μ‹€μž¬ν•˜μ§€ μ•ŠλŠ” μŠ€ν…μ„ 가리킬 λ•Œ.
    """
    base_name = raw.get("extends")
    if base_name is not None and not isinstance(base_name, str):
        raise PipelineCompositionError(f"`extends` must be a pipeline name, got {base_name!r}")

    if base_name:
        if load_base is None:
            raise PipelineCompositionError(
                f"Pipeline extends {base_name!r} but no loader was provided"
            )
        base = dict(load_base(base_name))
        if base.get("extends"):
            raise PipelineCompositionError(
                f"Multi-level inheritance is not supported: {base_name!r} itself extends "
                f"{base['extends']!r}. Flatten it or extend the root pipeline directly."
            )
        composed = _merge_base(base, raw)
    else:
        composed = copy.deepcopy({k: v for k, v in raw.items() if k != "extends"})

    file_overrides = composed.pop("overrides", None) or raw.get("overrides") or {}
    _apply_overrides(composed, file_overrides, origin="pipeline `overrides`")
    _apply_overrides(composed, overrides or {}, origin="--set")
    return composed


def _merge_base(base: dict[str, Any], child: Mapping[str, Any]) -> dict[str, Any]:
    """base μœ„μ— child 의 μ΅œμƒμœ„ ν‚€λ₯Ό μ–ΉλŠ”λ‹€.

    `steps` λŠ” child κ°€ ν†΅μ§Έλ‘œ μ„ μ–Έν–ˆμ„ λ•Œλ§Œ κ΅μ²΄ν•œλ‹€ β€” λΆ€λΆ„ 병합은 override 의
    일이고, μ—¬κΈ°μ„œκΉŒμ§€ ν•˜λ©΄ "steps κ°€ μ–΄λ””μ„œ μ™”λŠ”κ°€"κ°€ 흐렀진닀.

    **κΉŠμ€ 볡사가 ν•„μˆ˜λ‹€**: μ–•κ²Œ λ³΅μ‚¬ν•˜λ©΄ μžμ‹μ˜ override κ°€ base 의 step dict λ₯Ό
    μ œμžλ¦¬μ—μ„œ 고쳐, 같은 base λ₯Ό μ“°λŠ” λ‹€λ₯Έ μžμ‹κΉŒμ§€ μ˜€μ—Όλœλ‹€.
    """
    merged = copy.deepcopy(base)
    for key, value in child.items():
        if key in ("extends", "overrides"):
            continue
        merged[key] = copy.deepcopy(value)
    return merged


def _apply_overrides(config: dict[str, Any], overrides: Mapping[str, Any], *, origin: str) -> None:
    """dot-path override λ₯Ό μ œμžλ¦¬μ— μ μš©ν•œλ‹€."""
    if not isinstance(overrides, Mapping):
        raise PipelineCompositionError(
            f"{origin} must be a mapping of dot-path β†’ value, got {type(overrides).__name__}"
        )
    for path, value in overrides.items():
        try:
            _set_path(config, str(path), value)
        except PipelineCompositionError as e:
            raise PipelineCompositionError(f"{origin}: {e}") from None


def _set_path(config: dict[str, Any], path: str, value: Any) -> None:
    """`steps.<step>.<field>...` λ˜λŠ” `<field>...` κ²½λ‘œμ— 값을 μ“΄λ‹€."""
    parts = [p for p in path.split(".") if p]
    if not parts:
        raise PipelineCompositionError("empty override path")

    if parts[0] == STEPS_KEY:
        if len(parts) < 3:
            raise PipelineCompositionError(
                f"{path!r} β€” step override needs at least "
                f"'{STEPS_KEY}.<step_name>.<field>' (e.g. "
                f"'{STEPS_KEY}.basic_correction.reasoning_effort')"
            )
        target = _find_step(config, parts[1], path)
        _assign(target, parts[2:], value, path)
        return

    _assign(config, parts, value, path)


def _find_step(config: Mapping[str, Any], step_name: str, path: str) -> dict[str, Any]:
    """μ΄λ¦„μœΌλ‘œ μŠ€ν…μ„ μ°ΎλŠ”λ‹€. μ—†μœΌλ©΄ 후보λ₯Ό μ‹€μ–΄ raise (μ˜€νƒ€λ₯Ό 쑰용히 λ„˜κΈ°μ§€ μ•ŠλŠ”λ‹€)."""
    steps = config.get(STEPS_KEY)
    if not isinstance(steps, list):
        raise PipelineCompositionError(f"{path!r} β€” pipeline has no `{STEPS_KEY}` list")

    matches = [s for s in steps if isinstance(s, Mapping) and s.get("name") == step_name]
    if not matches:
        names = [str(s.get("name")) for s in steps if isinstance(s, Mapping)]
        raise PipelineCompositionError(
            f"{path!r} β€” no step named {step_name!r} (steps: {', '.join(names)})"
        )
    if len(matches) > 1:
        raise PipelineCompositionError(
            f"{path!r} β€” {step_name!r} matches {len(matches)} steps; step names must be unique "
            f"to be addressable by override"
        )
    return matches[0]  # type: ignore[return-value]


def _assign(target: dict[str, Any], parts: list[str], value: Any, path: str) -> None:
    """쀑첩 dict λ₯Ό 따라 λ‚΄λ €κ°€λ©° λ§ˆμ§€λ§‰ 킀에 값을 μ“΄λ‹€. 쀑간 dict λŠ” λ§Œλ“€μ–΄ μ€€λ‹€."""
    cursor: Any = target
    for key in parts[:-1]:
        nxt = cursor.get(key)
        if nxt is None:
            nxt = {}
            cursor[key] = nxt
        elif not isinstance(nxt, dict):
            raise PipelineCompositionError(
                f"{path!r} β€” {key!r} holds {type(nxt).__name__}, cannot descend into it"
            )
        cursor = nxt
    cursor[parts[-1]] = value


def parse_set_option(raw_values: tuple[str, ...] | list[str]) -> dict[str, Any]:
    """CLI `--set key=value` λͺ©λ‘μ„ override 맡으둜 λ°”κΎΌλ‹€.

    값은 YAML 둜 νŒŒμ‹±ν•΄ νƒ€μž…μ„ μ‚΄λ¦°λ‹€ β€” `high` λŠ” λ¬Έμžμ—΄, `2` λŠ” μ •μˆ˜,
    `[0.0, 0.5]` λŠ” λ¦¬μŠ€νŠΈκ°€ λœλ‹€. νŒŒμ‹±μ— μ‹€νŒ¨ν•˜λ©΄ λ¬Έμžμ—΄ κ·ΈλŒ€λ‘œ λ‘”λ‹€.

    Raises:
        PipelineCompositionError: `=` κ°€ μ—†λŠ” ν•­λͺ©μ΄ μžˆμ„ λ•Œ.
    """
    overrides: dict[str, Any] = {}
    for item in raw_values:
        if "=" not in item:
            raise PipelineCompositionError(
                f"--set expects 'path=value', got {item!r} "
                f"(e.g. --set steps.basic_correction.reasoning_effort=high)"
            )
        key, _, raw_value = item.partition("=")
        key = key.strip()
        if not key:
            raise PipelineCompositionError(f"--set has an empty path: {item!r}")
        try:
            overrides[key] = yaml.safe_load(raw_value)
        except yaml.YAMLError:
            overrides[key] = raw_value
    return overrides