opal-optimizer 0.3.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- opal/__init__.py +7 -0
- opal/_version.py +3 -0
- opal/agent.py +394 -0
- opal/backends/__init__.py +31 -0
- opal/backends/_codex_client.py +450 -0
- opal/backends/_privacy.py +116 -0
- opal/backends/base.py +248 -0
- opal/backends/claude.py +665 -0
- opal/backends/codex.py +356 -0
- opal/backends/mailbox.py +1034 -0
- opal/backends/unavailable.py +161 -0
- opal/cli.py +396 -0
- opal/context.py +111 -0
- opal/defaults.json +11 -0
- opal/defaults.py +54 -0
- opal/estimate.py +120 -0
- opal/hf.py +109 -0
- opal/misc/__init__.py +2 -0
- opal/misc/aggressive.py +157 -0
- opal/misc/audit.py +84 -0
- opal/misc/boundary.py +226 -0
- opal/misc/checkpoint.py +399 -0
- opal/misc/ddp.py +147 -0
- opal/misc/integrity.py +263 -0
- opal/misc/nested.py +67 -0
- opal/misc/neutral_analysis.py +166 -0
- opal/misc/precision.py +210 -0
- opal/misc/recovery.py +231 -0
- opal/misc/schedulers.py +214 -0
- opal/misc/setup.py +253 -0
- opal/misc/skip.py +165 -0
- opal/misc/taskfacts.py +293 -0
- opal/misc/waiting.py +58 -0
- opal/programs.py +482 -0
- opal/prompts/example_updater.py +252 -0
- opal/prompts/neutral.md +206 -0
- opal/report.py +1001 -0
- opal/report_page.py +175 -0
- opal/report_privacy.py +159 -0
- opal/results.py +80 -0
- opal/schedule.py +134 -0
- opal/summaries.py +224 -0
- opal/wrapper.py +1674 -0
- opal_optimizer-0.3.5.dist-info/METADATA +232 -0
- opal_optimizer-0.3.5.dist-info/RECORD +49 -0
- opal_optimizer-0.3.5.dist-info/WHEEL +5 -0
- opal_optimizer-0.3.5.dist-info/entry_points.txt +2 -0
- opal_optimizer-0.3.5.dist-info/licenses/LICENSE +21 -0
- opal_optimizer-0.3.5.dist-info/top_level.txt +1 -0
opal/misc/taskfacts.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""The TASK paragraph of the draft neutral prompt (prompts/neutral.md): the run's facts and when decisions happen, in
|
|
2
|
+
plain sentences. Facts come from wrap(epochs=, train_size=, batch_size=, same_order_each_epoch=); a clause whose value
|
|
3
|
+
was not given is left out. The schedule is described from the actual decision updates."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _v(x):
|
|
9
|
+
return f"{x:,}" if isinstance(x, int) and not isinstance(x, bool) else str(x)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run_facts(total, epochs=None, train_size=None, batch_size=None, same_order=None) -> str:
|
|
13
|
+
s = f"The run is planned for {total:,} training updates"
|
|
14
|
+
if epochs is not None and train_size is not None:
|
|
15
|
+
s += f", about {_v(epochs)} epochs over a training set of {_v(train_size)}"
|
|
16
|
+
elif epochs is not None:
|
|
17
|
+
s += f", about {_v(epochs)} epochs"
|
|
18
|
+
elif train_size is not None:
|
|
19
|
+
s += f", over a training set of {_v(train_size)}"
|
|
20
|
+
if batch_size is not None:
|
|
21
|
+
s += f", with a batch of {_v(batch_size)} per update"
|
|
22
|
+
if same_order is True:
|
|
23
|
+
s += "; each epoch passes over the same data again"
|
|
24
|
+
elif same_order is False:
|
|
25
|
+
s += "; each epoch passes over the data in a new order"
|
|
26
|
+
return s + "."
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _shown(updates, head=3):
|
|
30
|
+
if len(updates) <= head + 1:
|
|
31
|
+
return ", ".join(f"{u:,}" for u in updates)
|
|
32
|
+
return ", ".join(f"{u:,}" for u in updates[:head]) + f", ..., {updates[-1]:,}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _every(updates):
|
|
36
|
+
gaps = [b - a for a, b in zip(updates, updates[1:])]
|
|
37
|
+
if not gaps:
|
|
38
|
+
return None, True
|
|
39
|
+
return round(sum(gaps) / len(gaps)), len(set(gaps)) == 1
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _spacing(prev, updates):
|
|
43
|
+
"""Mean gap from `prev` through `updates`, and whether every gap is the same."""
|
|
44
|
+
gaps = [y - x for x, y in zip([prev] + updates, updates)]
|
|
45
|
+
return round(sum(gaps) / len(gaps)), len(set(gaps)) == 1
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _per_epoch(k, total, epochs):
|
|
49
|
+
if not isinstance(epochs, (int, float)) or isinstance(epochs, bool) or epochs <= 0:
|
|
50
|
+
return ""
|
|
51
|
+
return f" (about once every {k * epochs / total:.2g} epochs)"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _every_phrase(k, exact):
|
|
55
|
+
return f"{'every' if exact else 'about every'} {k:,} updates"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def schedule_sentence(updates, kind, total, epochs=None, segments=None) -> str:
|
|
59
|
+
"""When the agent is called, computed from the actual decision updates. kind is wrap(schedule=...)'s form
|
|
60
|
+
(opal/schedule.py): one phase for "uniform", an early phase (the first 5% of training) and a late one for the
|
|
61
|
+
front-loaded default, one phase per segment for "segments" (segments: the updates of each), the updates themselves
|
|
62
|
+
for "updates"."""
|
|
63
|
+
n = len(updates)
|
|
64
|
+
if n == 0:
|
|
65
|
+
return "No decisions are scheduled."
|
|
66
|
+
if n == 1:
|
|
67
|
+
return f"You are called once, at update {updates[0]:,}."
|
|
68
|
+
if kind == "updates":
|
|
69
|
+
return f"You are called at updates {_shown(updates)}; {n} scheduled decisions in all."
|
|
70
|
+
if kind == "segments":
|
|
71
|
+
return _segments_sentence([s for s in segments or [updates] if s], total, epochs) + (
|
|
72
|
+
f"; {n} scheduled decisions in all."
|
|
73
|
+
)
|
|
74
|
+
if kind == "uniform":
|
|
75
|
+
k, exact = _spacing(0, updates)
|
|
76
|
+
return (
|
|
77
|
+
f"You are called {_every_phrase(k, exact)}{_per_epoch(k, total, epochs)}: at updates {_shown(updates)}, "
|
|
78
|
+
f"{n} scheduled decisions in all."
|
|
79
|
+
)
|
|
80
|
+
early = [u for u in updates if u <= max(1, int(total * 0.05))] or updates[:1]
|
|
81
|
+
late = updates[len(early) :]
|
|
82
|
+
if len(early) == 1:
|
|
83
|
+
s = f"You are called at update {early[0]:,}"
|
|
84
|
+
else:
|
|
85
|
+
k1, ex1 = _spacing(0, early)
|
|
86
|
+
s = f"You are called {_every_phrase(k1, ex1)}{_per_epoch(k1, total, epochs)} for the first {early[-1]:,} "
|
|
87
|
+
s += f"updates ({len(early)} decisions)"
|
|
88
|
+
if len(late) == 1:
|
|
89
|
+
s += f", then at update {late[0]:,}"
|
|
90
|
+
elif late:
|
|
91
|
+
k2, ex2 = _every(late) # the late phase's own spacing, not the jump from the early phase
|
|
92
|
+
s += (
|
|
93
|
+
f", then {_every_phrase(k2, ex2)}{_per_epoch(k2, total, epochs)} from update {late[0]:,} to update "
|
|
94
|
+
f"{late[-1]:,} ({len(late)} decisions)"
|
|
95
|
+
)
|
|
96
|
+
return s + f"; {n} scheduled decisions in all."
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _segments_sentence(phases, total, epochs):
|
|
100
|
+
"""One clause per segment, in the front-loaded default's words: "every K updates for the first U updates (n
|
|
101
|
+
decisions)", then "every K updates from update A to update B (n decisions)"; a segment of one decision is "at
|
|
102
|
+
update A"."""
|
|
103
|
+
parts = []
|
|
104
|
+
for i, seg in enumerate(phases):
|
|
105
|
+
if len(seg) == 1:
|
|
106
|
+
parts.append(f"at update {seg[0]:,}")
|
|
107
|
+
continue
|
|
108
|
+
k, exact = _spacing(0, seg) if i == 0 else _every(seg)
|
|
109
|
+
where = f"for the first {seg[-1]:,} updates" if i == 0 else f"from update {seg[0]:,} to update {seg[-1]:,}"
|
|
110
|
+
parts.append(f"{_every_phrase(k, exact)}{_per_epoch(k, total, epochs)} {where} ({len(seg)} decisions)")
|
|
111
|
+
return "You are called " + ", then ".join(parts)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def mode_sentence(blocking, max_lag=None) -> str:
|
|
115
|
+
if max_lag is not None:
|
|
116
|
+
return (
|
|
117
|
+
f"Training waits for your answer when it would otherwise take effect more than {max_lag:g} updates late "
|
|
118
|
+
"(judged from the time per update and your recent answer times, and always at the first decision), and "
|
|
119
|
+
"keeps running otherwise."
|
|
120
|
+
)
|
|
121
|
+
if blocking:
|
|
122
|
+
return "Training pauses until you finish, and your edits take effect from that update."
|
|
123
|
+
return (
|
|
124
|
+
"Training keeps running while you work; your edits take effect at the first update boundary after you finish."
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def decisions(updates, kind, total, blocking, max_lag=None, show_next=False, epochs=None, segments=None) -> str:
|
|
129
|
+
"""kind, segments: as in schedule_sentence."""
|
|
130
|
+
s = schedule_sentence(updates, kind, total, epochs, segments)
|
|
131
|
+
s += ' You are also called when your analysis returns {"wake": reason}, and when the training loss becomes non-finite.'
|
|
132
|
+
s += " " + mode_sentence(blocking, max_lag)
|
|
133
|
+
if show_next and updates:
|
|
134
|
+
s += " Each message gives the update of the next scheduled decision."
|
|
135
|
+
return s + (
|
|
136
|
+
" Between decisions, your programs run by themselves at every update, so whatever should change between "
|
|
137
|
+
"decisions must be written into them."
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def protocol_note(every, max_decisions, blocking, sync_every, pause_on_nonfinite=True) -> str:
|
|
142
|
+
"""The technical details PROTOCOL keeps: the check cadence, the delay before an edit takes effect, one decision in
|
|
143
|
+
flight, merged reasons."""
|
|
144
|
+
s = (
|
|
145
|
+
f"The wrapper checks every {every} updates (and at your analysis steps), whatever EVERY you set. A decision "
|
|
146
|
+
"is asked only at a check: the first check at or after a scheduled update, or after your analysis asks; every "
|
|
147
|
+
f"check also looks for a non-finite loss. At most {max_decisions} decisions are asked in all, wake-ups included."
|
|
148
|
+
)
|
|
149
|
+
if not blocking:
|
|
150
|
+
s += (
|
|
151
|
+
" When training keeps running while you work, an edit takes effect within a few updates of your answer on "
|
|
152
|
+
f"one GPU and within {2 * sync_every} updates in data-parallel runs; each message tells you how late your "
|
|
153
|
+
"previous edit took effect."
|
|
154
|
+
) + (" A non-finite training loss pauses training until you answer." if pause_on_nonfinite else "")
|
|
155
|
+
return (
|
|
156
|
+
s + " Only one decision is in flight at a time; reasons that arise meanwhile are merged into the next request."
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ----- the starting optimizer in one line (the full dump goes to reference/starting_optimizer.txt) -------------------
|
|
161
|
+
|
|
162
|
+
SCHED_KEYS = (
|
|
163
|
+
"T_max",
|
|
164
|
+
"eta_min",
|
|
165
|
+
"total_iters",
|
|
166
|
+
"start_factor",
|
|
167
|
+
"end_factor",
|
|
168
|
+
"step_size",
|
|
169
|
+
"gamma",
|
|
170
|
+
"milestones",
|
|
171
|
+
"max_lr",
|
|
172
|
+
"total_steps",
|
|
173
|
+
"pct_start",
|
|
174
|
+
"T_0",
|
|
175
|
+
"T_mult",
|
|
176
|
+
"factor",
|
|
177
|
+
"power",
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _num(v):
|
|
182
|
+
if hasattr(v, "item") and getattr(v, "numel", lambda: 0)() == 1:
|
|
183
|
+
v = v.item()
|
|
184
|
+
return f"{v:g}" if isinstance(v, float) else str(v)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _same(groups, key):
|
|
188
|
+
vals = [repr(g.get(key)) for g in groups if key in g]
|
|
189
|
+
return len(set(vals)) == 1
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _group_value(groups, key, name):
|
|
193
|
+
present = [g for g in groups if key in g]
|
|
194
|
+
if not present:
|
|
195
|
+
return None
|
|
196
|
+
if _same(present, key):
|
|
197
|
+
v = present[0][key]
|
|
198
|
+
return f"{name} {tuple(_num(x) for x in v) if isinstance(v, (tuple, list)) else _num(v)}".replace("'", "")
|
|
199
|
+
return f"{name} per group " + ", ".join(
|
|
200
|
+
_num(g[key]) if not isinstance(g[key], (tuple, list)) else str(tuple(_num(x) for x in g[key])).replace("'", "")
|
|
201
|
+
for g in present
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _scheduler(s, shared=True):
|
|
206
|
+
cls = type(s).__name__
|
|
207
|
+
fns = getattr(s, "lr_lambdas", None)
|
|
208
|
+
if fns: # a function's shape cannot be read reliably
|
|
209
|
+
if not shared:
|
|
210
|
+
return f"a {cls} schedule (its function is not shared)"
|
|
211
|
+
return f"a {cls} schedule; its source is in reference/starting_optimizer.txt"
|
|
212
|
+
try:
|
|
213
|
+
st = s.state_dict()
|
|
214
|
+
except Exception:
|
|
215
|
+
st = {}
|
|
216
|
+
args = [f"{k} {_num(st[k])}" for k in SCHED_KEYS if k in st and isinstance(st[k], (int, float, list, tuple))]
|
|
217
|
+
return (
|
|
218
|
+
f"{cls} ({', '.join(args)})" if args else f"a {cls} schedule; its source is in reference/starting_optimizer.txt"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def starting_summary(optimizers, schedulers, clip=None, shared=True) -> str:
|
|
223
|
+
"""One line from the user's objects: class, lr, betas or momentum, eps, weight decay with tensors per group, and the
|
|
224
|
+
scheduler (its class and settings, or the lambda's name or first line when that is reliable)."""
|
|
225
|
+
parts = []
|
|
226
|
+
for o in optimizers:
|
|
227
|
+
groups = o.param_groups
|
|
228
|
+
bits = [type(o).__name__]
|
|
229
|
+
# a scheduler sets the current lr (0 at the first update of a warmup): name its base, initial_lr
|
|
230
|
+
base = all("initial_lr" in g for g in groups)
|
|
231
|
+
lr = ("initial_lr", "base lr") if base else ("lr", "lr")
|
|
232
|
+
for key, name in (lr, ("betas", "betas"), ("momentum", "momentum"), ("eps", "eps")):
|
|
233
|
+
v = _group_value(groups, key, name)
|
|
234
|
+
if v:
|
|
235
|
+
bits.append(v)
|
|
236
|
+
if any("weight_decay" in g for g in groups):
|
|
237
|
+
wd = ", ".join(f"{_num(g.get('weight_decay', 0))} on {len(g['params'])} tensors" for g in groups)
|
|
238
|
+
bits.append(f"weight decay {wd}")
|
|
239
|
+
parts.append(", ".join(bits))
|
|
240
|
+
if schedulers:
|
|
241
|
+
parts.append("scheduler " + "; ".join(_scheduler(s, shared) for s in schedulers))
|
|
242
|
+
else:
|
|
243
|
+
parts.append("no scheduler")
|
|
244
|
+
if clip is not None:
|
|
245
|
+
parts.append(f"gradients clipped to a global norm of {float(clip):g} before each update")
|
|
246
|
+
return "; ".join(parts)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def duration(seconds) -> str:
|
|
250
|
+
"""'an hour', '6 hours', '15 minutes', '90 seconds'."""
|
|
251
|
+
s = int(seconds)
|
|
252
|
+
if s % 3600 == 0:
|
|
253
|
+
return "an hour" if s == 3600 else f"{s // 3600} hours"
|
|
254
|
+
if s % 60 == 0:
|
|
255
|
+
return "a minute" if s == 60 else f"{s // 60} minutes"
|
|
256
|
+
return f"{s} seconds"
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def time_limit(seconds) -> str:
|
|
260
|
+
"""The decision time limit, from the backend's timeout (seconds)."""
|
|
261
|
+
if not seconds:
|
|
262
|
+
return ""
|
|
263
|
+
return f"Each decision may take up to {duration(seconds)}."
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# ----- OPAL 0.1.1-validation (wrap(validation=N)): what the neutral prompt says about the development set ---------
|
|
267
|
+
|
|
268
|
+
EVALUATION_OFF = (
|
|
269
|
+
"Evaluation data and results are not shown to\nyou unless the person running the training chose to show a value, "
|
|
270
|
+
"which then appears in obs.metrics and in the\ndecision message; do not look for them elsewhere."
|
|
271
|
+
) # the v0.1.1 sentence, line breaks included, so the prompt is unchanged with the option off
|
|
272
|
+
EVALUATION_ON = (
|
|
273
|
+
"Evaluation data are not shown to you, nor are\nthe test results. The development-set loss and accuracy, and any "
|
|
274
|
+
"other value the person running the training\nchose to show, appear in obs.metrics and in the decision message; "
|
|
275
|
+
"do not look for them elsewhere."
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def validation_note(every=None, size=None) -> str:
|
|
280
|
+
"""The TASK sentences (appended to the run's facts) when the training script reports a development set:
|
|
281
|
+
about every N updates, at times that are not announced."""
|
|
282
|
+
if not every:
|
|
283
|
+
return ""
|
|
284
|
+
of = f" of {_v(size)}" if size is not None else ""
|
|
285
|
+
return (
|
|
286
|
+
f" The training script evaluates the model on a development set{of} held out from training about every "
|
|
287
|
+
f"{every:,} updates, at times that are not announced, and shows you its loss, and its accuracy when the "
|
|
288
|
+
"script reports one. The performance in the goal is measured on a separate test set that you never see."
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def evaluation_note(every=None) -> str:
|
|
293
|
+
return EVALUATION_ON if every else EVALUATION_OFF
|
opal/misc/waiting.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""blocking="auto": training waits at a decision only when the agent's answer would otherwise take effect more than
|
|
2
|
+
max_lag_updates updates late. The estimate is the median of the agent's last few answer times divided by the time per
|
|
3
|
+
update: the median over the last few check intervals, with the pauses for the agent left out (a median, so an
|
|
4
|
+
interval with an evaluation or a checkpoint in it does not shrink the estimate). Before the first answer, or before the
|
|
5
|
+
time per update is measured, training waits. Rank 0 decides; the existing broadcast at each check tells the others.
|
|
6
|
+
|
|
7
|
+
Why: without waiting, an answer lands (answer time / time per update) updates after the state the agent saw, and no
|
|
8
|
+
new decision is asked while one is in flight, so on a fast run most scheduled decisions are never asked."""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import statistics
|
|
13
|
+
from collections import deque
|
|
14
|
+
|
|
15
|
+
WINDOW = 5 # answers, and check intervals, in the medians
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AutoWait:
|
|
19
|
+
def __init__(self, max_lag_updates):
|
|
20
|
+
if isinstance(max_lag_updates, bool) or not isinstance(max_lag_updates, (int, float)) or max_lag_updates < 0:
|
|
21
|
+
raise ValueError(f"opal: max_lag_updates must be a number >= 0, got {max_lag_updates!r}")
|
|
22
|
+
self.max_lag = max_lag_updates
|
|
23
|
+
self.answers = deque(maxlen=WINDOW)
|
|
24
|
+
self.ms_per_update = None
|
|
25
|
+
self._intervals = deque(maxlen=WINDOW)
|
|
26
|
+
self._t = self._u = None
|
|
27
|
+
self._paused = 0.0
|
|
28
|
+
|
|
29
|
+
def tick(self, u, now):
|
|
30
|
+
"""At each check step (rank 0): time per update since the previous check, without the pauses in between."""
|
|
31
|
+
if self._t is not None and u > self._u:
|
|
32
|
+
self._intervals.append(1000 * max(0.0, now - self._t - self._paused) / (u - self._u))
|
|
33
|
+
self.ms_per_update = statistics.median(self._intervals)
|
|
34
|
+
self._t, self._u, self._paused = now, u, 0.0
|
|
35
|
+
|
|
36
|
+
def paused(self, seconds):
|
|
37
|
+
self._paused += seconds
|
|
38
|
+
|
|
39
|
+
def answered(self, resp):
|
|
40
|
+
"""An answer's time, unless the backend failed or stopped waiting (those say nothing about the agent)."""
|
|
41
|
+
if resp and not (resp.get("backend_error") or resp.get("gave_up") or resp.get("late")):
|
|
42
|
+
if resp.get("seconds") is not None:
|
|
43
|
+
self.answers.append(float(resp["seconds"]))
|
|
44
|
+
|
|
45
|
+
def decide(self):
|
|
46
|
+
"""Whether training waits at the decision being asked, and the record of why."""
|
|
47
|
+
if not self.answers or not self.ms_per_update:
|
|
48
|
+
why = "no answer time measured yet" if not self.answers else "no time per update measured yet"
|
|
49
|
+
return dict(wait="waited", wait_reason=why, predicted_lag_updates=None)
|
|
50
|
+
s = statistics.median(self.answers)
|
|
51
|
+
lag = 1000 * s / self.ms_per_update
|
|
52
|
+
block = lag > self.max_lag
|
|
53
|
+
return dict(
|
|
54
|
+
wait="waited" if block else "did not wait",
|
|
55
|
+
wait_reason=f"predicted lag {lag:.0f} updates {'>' if block else '<='} {self.max_lag:g} "
|
|
56
|
+
f"(median answer {s:.1f} s, {self.ms_per_update:.1f} ms per update)",
|
|
57
|
+
predicted_lag_updates=round(lag, 1),
|
|
58
|
+
)
|