kalfa 3.0.0.dev0__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.
Files changed (148) hide show
  1. kalfa/__init__.py +19 -0
  2. kalfa/api.py +284 -0
  3. kalfa/board/__init__.py +1 -0
  4. kalfa/board/answers.py +419 -0
  5. kalfa/board/server.py +536 -0
  6. kalfa/board/static/api.js +176 -0
  7. kalfa/board/static/app.js +122 -0
  8. kalfa/board/static/board.css +228 -0
  9. kalfa/board/static/charts.css +46 -0
  10. kalfa/board/static/components/badge.js +14 -0
  11. kalfa/board/static/components/chart.js +204 -0
  12. kalfa/board/static/components/diagram.js +857 -0
  13. kalfa/board/static/components/error.js +17 -0
  14. kalfa/board/static/components/loading.js +7 -0
  15. kalfa/board/static/components/progress.js +25 -0
  16. kalfa/board/static/components/settings.js +418 -0
  17. kalfa/board/static/components/spark.js +43 -0
  18. kalfa/board/static/components/table.js +156 -0
  19. kalfa/board/static/diagram.css +76 -0
  20. kalfa/board/static/format.js +138 -0
  21. kalfa/board/static/index.html +34 -0
  22. kalfa/board/static/plot.js +594 -0
  23. kalfa/board/static/predictions.css +20 -0
  24. kalfa/board/static/store.js +215 -0
  25. kalfa/board/static/views/compare.js +185 -0
  26. kalfa/board/static/views/home.js +90 -0
  27. kalfa/board/static/views/record/config.js +81 -0
  28. kalfa/board/static/views/record/curves.js +137 -0
  29. kalfa/board/static/views/record/data.js +378 -0
  30. kalfa/board/static/views/record/files.js +128 -0
  31. kalfa/board/static/views/record/log.js +164 -0
  32. kalfa/board/static/views/record/model.js +286 -0
  33. kalfa/board/static/views/record/overview.js +216 -0
  34. kalfa/board/static/views/record/plots.js +103 -0
  35. kalfa/board/static/views/record/predictions.js +834 -0
  36. kalfa/board/static/views/record.js +179 -0
  37. kalfa/board/static/views/runs.js +124 -0
  38. kalfa/board/static/views/sweep.js +282 -0
  39. kalfa/board/watch.py +87 -0
  40. kalfa/cli.py +371 -0
  41. kalfa/commands/__init__.py +0 -0
  42. kalfa/commands/common.py +8 -0
  43. kalfa/commands/export.py +117 -0
  44. kalfa/commands/generate.py +31 -0
  45. kalfa/commands/guide/__init__.py +101 -0
  46. kalfa/commands/guide/about.py +93 -0
  47. kalfa/commands/guide/calls.py +102 -0
  48. kalfa/commands/guide/sections.py +186 -0
  49. kalfa/commands/guide/seen.py +307 -0
  50. kalfa/commands/guide/show.py +106 -0
  51. kalfa/commands/guide/training.py +333 -0
  52. kalfa/commands/guide/words.py +97 -0
  53. kalfa/commands/legos.py +508 -0
  54. kalfa/commands/plots.py +27 -0
  55. kalfa/commands/report/__init__.py +64 -0
  56. kalfa/commands/report/page.py +112 -0
  57. kalfa/commands/report/predictions.py +216 -0
  58. kalfa/commands/report/sections.py +356 -0
  59. kalfa/commands/stop.py +60 -0
  60. kalfa/commands/sweep/__init__.py +202 -0
  61. kalfa/commands/sweep/checks.py +98 -0
  62. kalfa/commands/sweep/plan.py +183 -0
  63. kalfa/commands/sweep/points.py +123 -0
  64. kalfa/commands/sweep/runner.py +100 -0
  65. kalfa/commands/sweep/space.py +67 -0
  66. kalfa/commands/sweep/strategies.py +228 -0
  67. kalfa/core/__init__.py +0 -0
  68. kalfa/core/aliases.py +229 -0
  69. kalfa/core/build.py +727 -0
  70. kalfa/core/check.py +550 -0
  71. kalfa/core/coerce.py +36 -0
  72. kalfa/core/context.py +149 -0
  73. kalfa/core/device.py +63 -0
  74. kalfa/core/errors.py +45 -0
  75. kalfa/core/graph.py +304 -0
  76. kalfa/core/history.py +70 -0
  77. kalfa/core/loop.py +710 -0
  78. kalfa/core/make.py +121 -0
  79. kalfa/core/plan.py +156 -0
  80. kalfa/core/predict.py +290 -0
  81. kalfa/core/protocols.py +122 -0
  82. kalfa/core/record.py +347 -0
  83. kalfa/core/refs.py +88 -0
  84. kalfa/core/rng.py +100 -0
  85. kalfa/core/rules.py +513 -0
  86. kalfa/core/run.py +568 -0
  87. kalfa/core/spec.py +413 -0
  88. kalfa/core/state.py +100 -0
  89. kalfa/core/wrap.py +671 -0
  90. kalfa/records/__init__.py +24 -0
  91. kalfa/records/compare.py +86 -0
  92. kalfa/records/reader.py +561 -0
  93. kalfa/records/stats/__init__.py +36 -0
  94. kalfa/records/stats/choices.py +282 -0
  95. kalfa/records/stats/classes.py +236 -0
  96. kalfa/records/stats/measures.py +144 -0
  97. kalfa/records/stats/pairs.py +225 -0
  98. kalfa/records/stats/tables.py +153 -0
  99. kalfa/records/sweep.py +244 -0
  100. kalfa/std/__init__.py +17 -0
  101. kalfa/std/actions.py +35 -0
  102. kalfa/std/common.py +56 -0
  103. kalfa/std/data/__init__.py +1 -0
  104. kalfa/std/data/base.py +45 -0
  105. kalfa/std/data/columns.py +190 -0
  106. kalfa/std/data/flow.py +317 -0
  107. kalfa/std/data/images.py +443 -0
  108. kalfa/std/data/options.py +147 -0
  109. kalfa/std/data/pre.py +84 -0
  110. kalfa/std/data/splits.py +140 -0
  111. kalfa/std/data/stream.py +294 -0
  112. kalfa/std/data/table.py +642 -0
  113. kalfa/std/data/text.py +436 -0
  114. kalfa/std/data/transforms.py +327 -0
  115. kalfa/std/data/weights.py +113 -0
  116. kalfa/std/data/window.py +135 -0
  117. kalfa/std/figure.py +37 -0
  118. kalfa/std/init.py +72 -0
  119. kalfa/std/loggers.py +105 -0
  120. kalfa/std/losses.py +161 -0
  121. kalfa/std/metrics.py +463 -0
  122. kalfa/std/models.py +122 -0
  123. kalfa/std/notes.py +1553 -0
  124. kalfa/std/objectives.py +360 -0
  125. kalfa/std/plots/__init__.py +75 -0
  126. kalfa/std/plots/classification.py +207 -0
  127. kalfa/std/plots/common.py +44 -0
  128. kalfa/std/plots/curves.py +56 -0
  129. kalfa/std/plots/images.py +188 -0
  130. kalfa/std/plots/pairs.py +66 -0
  131. kalfa/std/plots/predictions.py +154 -0
  132. kalfa/std/samplers.py +261 -0
  133. kalfa/std/samples.py +72 -0
  134. kalfa/std/schedules.py +74 -0
  135. kalfa/std/steps.py +133 -0
  136. kalfa/std/triggers.py +135 -0
  137. kalfa/summary.py +296 -0
  138. kalfa/tui/__init__.py +4 -0
  139. kalfa/tui/app.py +107 -0
  140. kalfa/tui/config.py +61 -0
  141. kalfa/tui/guide.py +100 -0
  142. kalfa/tui/jobs.py +97 -0
  143. kalfa/tui/legos.py +62 -0
  144. kalfa/tui/records.py +439 -0
  145. kalfa-3.0.0.dev0.dist-info/METADATA +261 -0
  146. kalfa-3.0.0.dev0.dist-info/RECORD +148 -0
  147. kalfa-3.0.0.dev0.dist-info/WHEEL +4 -0
  148. kalfa-3.0.0.dev0.dist-info/entry_points.txt +2 -0
kalfa/core/rules.py ADDED
@@ -0,0 +1,513 @@
1
+ import logging
2
+ from dataclasses import dataclass, field
3
+
4
+ from .context import TrainSettings
5
+ from .errors import RunError, error
6
+ from .refs import SINGULAR, TABLES, jsonable, join_tokens, ref_json, ref_parts, tokenize
7
+ from .spec import is_call
8
+ from .spec import kind as type_name
9
+
10
+
11
+ RULE_KEYS = ("when", "after", "once", "set", "stop", "run")
12
+ ROOTS = TABLES + ("train", "rules")
13
+ INNER = ("module", "metric", "function")
14
+ OPERATIONS = ("times", "plus")
15
+
16
+
17
+ @dataclass
18
+ class RuleSpec:
19
+ name: str
20
+ when: object = None
21
+ after: str | None = None
22
+ once: bool = True
23
+ set: dict = field(default_factory=dict)
24
+ stop: bool = False
25
+ run: object = None
26
+
27
+
28
+ @dataclass
29
+ class Patch:
30
+ path: str
31
+ value: object
32
+ asked: object
33
+ by: str
34
+ turn: int
35
+
36
+
37
+ def rules_by_name(ctx):
38
+ table = getattr(ctx, "rules_by_name", None)
39
+ if not table:
40
+ table = {rule.name: rule for rule in (getattr(ctx, "rules", None) or [])}
41
+ ctx.rules_by_name = table
42
+ return table
43
+
44
+
45
+ def describe(trigger):
46
+ if trigger is None:
47
+ return "rule"
48
+ method = getattr(trigger, "describe", None)
49
+ return method() if callable(method) else type(trigger).__name__
50
+
51
+
52
+ def logger_of(ctx):
53
+ return getattr(ctx, "logger", None) or logging.getLogger("kalfa.run")
54
+
55
+
56
+ class Rule:
57
+ def __init__(self, spec, when=None, run=None):
58
+ self.spec = spec
59
+ self.name = spec.name
60
+ self.when = when
61
+ self.run = run
62
+ self.after = spec.after
63
+ self.once = spec.once
64
+ self.fired_turn = None
65
+ self.done = False
66
+
67
+ def ready(self, ctx):
68
+ if self.after is None:
69
+ return True
70
+ table = rules_by_name(ctx)
71
+ other = table.get(self.after)
72
+ if other is None:
73
+ raise RunError(f"rule {self.name!r} waits for {self.after!r}, which is no rule; "
74
+ f"the rules are {sorted(table)}")
75
+ return other.fired_turn is not None and other.fired_turn < ctx.turn
76
+
77
+ def fire(self, ctx):
78
+ if self.done and self.once:
79
+ return False
80
+ if not self.ready(ctx):
81
+ return False
82
+ if self.when is not None and not self.when.fires(ctx):
83
+ return False
84
+ for path, value in self.spec.set.items():
85
+ apply_patch(ctx, path, value, by=self.name, turn=ctx.turn)
86
+ if self.spec.stop:
87
+ ctx.stop(f"{self.name}: {describe(self.when)}")
88
+ ctx.stopped_by = self.name
89
+ if self.run is not None:
90
+ self.run(ctx)
91
+ self.fired_turn = ctx.turn
92
+ self.done = self.once
93
+ ctx.fired.append(self.name)
94
+ logger_of(ctx).info("rule %s fired at turn %s%s", self.name, ctx.turn, self.effects())
95
+ if not self.once and self.when is not None:
96
+ reset = getattr(self.when, "reset", None)
97
+ if callable(reset):
98
+ reset()
99
+ return True
100
+
101
+ def effects(self):
102
+ parts = [f"{path}={value}" for path, value in self.spec.set.items()]
103
+ if self.spec.stop:
104
+ parts.append("stop")
105
+ if self.run is not None:
106
+ parts.append(f"run {type(self.run).__name__}")
107
+ return ": " + ", ".join(parts) if parts else ""
108
+
109
+ def state_dict(self):
110
+ state = {"fired_turn": self.fired_turn, "done": self.done}
111
+ if self.when is not None:
112
+ state["when"] = self.when.state_dict()
113
+ return state
114
+
115
+ def load_state_dict(self, state):
116
+ self.fired_turn = state.get("fired_turn")
117
+ self.done = bool(state.get("done", False))
118
+ if self.when is not None and "when" in state:
119
+ self.when.load_state_dict(state["when"])
120
+
121
+
122
+ def patch_value_problem(value):
123
+ if not isinstance(value, dict):
124
+ return None
125
+ if is_call(value):
126
+ return "a call cannot be a patch value; set a scalar, {times: x}, {plus: x} or an @reference"
127
+ if len(value) == 1 and next(iter(value)) in OPERATIONS:
128
+ amount = next(iter(value.values()))
129
+ if isinstance(amount, (int, float)) and not isinstance(amount, bool):
130
+ return None
131
+ return f"{next(iter(value))} takes a number, got {amount!r}"
132
+ if set(value) in ({"ref"}, {"ref", "attr"}):
133
+ return None
134
+ return "a patch value is a scalar, {times: x}, {plus: x} or an @reference"
135
+
136
+
137
+ def parse_rules(raw, problems):
138
+ if raw is None:
139
+ return []
140
+ if not isinstance(raw, dict):
141
+ problems.append(error("rules_type", f"rules is a mapping of rule name to rule, got {type_name(raw)}", "rules"))
142
+ return []
143
+ keys_hint = "the rule keys are " + ", ".join(RULE_KEYS)
144
+ specs = []
145
+ for raw_name, body in raw.items():
146
+ name = str(raw_name)
147
+ path = f"rules.{name}"
148
+ if not isinstance(body, dict):
149
+ problems.append(error("rule_type", f"a rule is a mapping, got {type_name(body)}", path, hint=keys_hint))
150
+ continue
151
+ spec = RuleSpec(name)
152
+ for key in body:
153
+ if key not in RULE_KEYS:
154
+ problems.append(error("rule_key", f"unknown rule key {key!r}", f"{path}.{key}", hint=keys_hint))
155
+ when = body.get("when")
156
+ if when is not None and not is_call(when) and not isinstance(when, str):
157
+ problems.append(error("rule_value", f"when is a trigger call, got {type_name(when)}", f"{path}.when",
158
+ hint="for example when: {use: plateau, watch: val/rmse, patience: 6}"))
159
+ else:
160
+ spec.when = when
161
+ after = body.get("after")
162
+ if after is not None:
163
+ if not isinstance(after, str) or after not in raw:
164
+ problems.append(error("rule_after", f"after names {after!r}, which is no rule", f"{path}.after",
165
+ hint="the rules are " + ", ".join(str(key) for key in raw)))
166
+ elif after == name:
167
+ problems.append(error("rule_after", "a rule cannot wait for itself", f"{path}.after"))
168
+ else:
169
+ spec.after = after
170
+ once = body.get("once", True)
171
+ if isinstance(once, bool):
172
+ spec.once = once
173
+ else:
174
+ problems.append(error("rule_value", f"once is true or false, got {once!r}", f"{path}.once"))
175
+ settings = body.get("set")
176
+ rejected = False
177
+ if "set" in body and not isinstance(settings, dict):
178
+ problems.append(error("rule_set", f"set is a mapping of plan path to value, got {type_name(settings)}",
179
+ f"{path}.set", hint="for example set: {optimizers.main.lr: {times: 0.5}}"))
180
+ elif settings:
181
+ for key, value in settings.items():
182
+ try:
183
+ tokenize(key)
184
+ except RunError as failure:
185
+ problems.append(error("rule_path", str(failure), f"{path}.set"))
186
+ rejected = True
187
+ continue
188
+ message = patch_value_problem(value)
189
+ if message is not None:
190
+ problems.append(error("rule_value", f"{key}: {message}", f"{path}.set"))
191
+ rejected = True
192
+ continue
193
+ spec.set[str(key)] = value
194
+ stop = body.get("stop", False)
195
+ if isinstance(stop, bool):
196
+ spec.stop = stop
197
+ else:
198
+ problems.append(error("rule_value", f"stop is true or false, got {stop!r}", f"{path}.stop"))
199
+ run = body.get("run")
200
+ if run is not None and not is_call(run) and not isinstance(run, str):
201
+ problems.append(error("rule_value", f"run is an action call, got {type_name(run)}", f"{path}.run"))
202
+ else:
203
+ spec.run = run
204
+ malformed_set = rejected or ("set" in body and not isinstance(settings, dict))
205
+ if not spec.set and not spec.stop and spec.run is None and not malformed_set:
206
+ problems.append(error("rule_empty", "a rule needs set, stop or run to do anything", path))
207
+ specs.append(spec)
208
+ return specs
209
+
210
+
211
+ def holder_with(holder, key):
212
+ if hasattr(holder, key):
213
+ return holder
214
+ for inner in INNER:
215
+ wrapped = getattr(holder, inner, None)
216
+ if wrapped is not None and hasattr(wrapped, key):
217
+ return wrapped
218
+ return None
219
+
220
+
221
+ def read(holder, token, path):
222
+ if isinstance(holder, dict):
223
+ if token not in holder:
224
+ raise RunError(f"{path}: {token!r} is not a key of the mapping; its keys are {sorted(map(str, holder))}")
225
+ return holder[token]
226
+ if isinstance(holder, (list, tuple)):
227
+ if not isinstance(token, int) or not 0 <= token < len(holder):
228
+ raise RunError(f"{path}: {token!r} is no index of a list of {len(holder)}")
229
+ return holder[token]
230
+ if isinstance(token, int):
231
+ raise RunError(f"{path}: {type_name(holder)} takes no index")
232
+ owner = holder_with(holder, token)
233
+ if owner is not None:
234
+ return getattr(owner, token)
235
+ params = getattr(holder, "params", None)
236
+ if isinstance(params, dict) and token in params:
237
+ return params[token]
238
+ raise RunError(f"{path}: {type_name(holder)} has no attribute {token!r}")
239
+
240
+
241
+ def walk(holder, rest, path):
242
+ if not rest:
243
+ raise RunError(f"{path}: the path names an object, not one of its params")
244
+ for token in rest[:-1]:
245
+ holder = read(holder, token, path)
246
+ kind = "train" if isinstance(holder, TrainSettings) else "generic"
247
+ return holder, rest[-1], kind
248
+
249
+
250
+ def root_of(ctx, tokens, path):
251
+ root = tokens[0]
252
+ if root in TABLES:
253
+ if len(tokens) < 2:
254
+ raise RunError(f"{path}: a path names an entry like {root}.<name>")
255
+ table = getattr(ctx, root, None) or {}
256
+ name = tokens[1]
257
+ if name not in table:
258
+ raise RunError(f"{path}: no {SINGULAR[root]} named {name!r}; the {root} are {sorted(table)}")
259
+ return table[name], tokens[2:]
260
+ if root == "train":
261
+ if len(tokens) > 1 and tokens[1] == "step":
262
+ if ctx.step is None:
263
+ raise RunError(f"{path}: there is no step object yet")
264
+ return ctx.step, tokens[2:]
265
+ if ctx.train is None:
266
+ raise RunError(f"{path}: the train settings are not built yet")
267
+ return ctx.train, tokens[1:]
268
+ if root == "rules":
269
+ if len(tokens) < 4 or tokens[2] not in ("when", "run"):
270
+ raise RunError(f"{path}: a rule path is rules.<name>.when.<param> or rules.<name>.run.<param>")
271
+ table = rules_by_name(ctx)
272
+ rule = table.get(tokens[1])
273
+ if rule is None:
274
+ raise RunError(f"{path}: no rule named {tokens[1]!r}; the rules are {sorted(table)}")
275
+ part = getattr(rule, tokens[2], None)
276
+ if part is None:
277
+ raise RunError(f"{path}: rule {tokens[1]!r} has no {tokens[2]}")
278
+ return part, tokens[3:]
279
+ raise RunError(f"{path}: unknown path root {root!r}; the roots are {', '.join(ROOTS)}")
280
+
281
+
282
+ def read_path(ctx, path):
283
+ tokens = tokenize(path)
284
+ holder, rest = root_of(ctx, tokens, path)
285
+ for token in rest:
286
+ holder = read(holder, token, path)
287
+ return holder
288
+
289
+
290
+ def locate(ctx, tokens, path):
291
+ root = tokens[0]
292
+ holder, rest = root_of(ctx, tokens, path)
293
+ if root in TABLES:
294
+ if not rest:
295
+ raise RunError(f"{path}: a patch names an entry and one of its params, like {root}.<name>.<param>")
296
+ if root == "optimizers" and len(rest) == 1:
297
+ return holder, rest[0], "optimizer"
298
+ if root == "models" and rest == ["freeze"]:
299
+ return holder, "freeze", "model"
300
+ return walk(holder, rest, path)
301
+
302
+
303
+ def current_of(holder, key, kind):
304
+ if kind == "optimizer":
305
+ if key == "minimizes":
306
+ return getattr(holder, "minimizes", None)
307
+ params = getattr(holder, "params", None)
308
+ if isinstance(params, dict) and key in params:
309
+ return params[key]
310
+ groups = getattr(getattr(holder, "real", None), "param_groups", None)
311
+ if groups:
312
+ return groups[0].get(key)
313
+ return None
314
+ if kind == "model":
315
+ return getattr(holder, "freeze", None)
316
+ try:
317
+ return read(holder, key, "")
318
+ except RunError:
319
+ return None
320
+
321
+
322
+ def as_int(value, path):
323
+ try:
324
+ return int(value)
325
+ except (TypeError, ValueError):
326
+ raise RunError(f"{path}: expected an integer, got {value!r}") from None
327
+
328
+
329
+ def write(holder, key, value, kind, path):
330
+ if kind == "optimizer":
331
+ if key == "minimizes":
332
+ holder.minimizes = value
333
+ return
334
+ if hasattr(holder, "set_param"):
335
+ holder.set_param(key, value)
336
+ return
337
+ elif kind == "model":
338
+ if hasattr(holder, "set_freeze"):
339
+ holder.set_freeze(value)
340
+ else:
341
+ holder.freeze = value
342
+ return
343
+ elif kind == "train":
344
+ if key == "epochs":
345
+ holder.epochs = as_int(value, path)
346
+ return
347
+ if key == "updates" and isinstance(holder.updates, dict) and not isinstance(value, dict):
348
+ holder.updates["total"] = as_int(value, path)
349
+ return
350
+ if isinstance(holder, dict):
351
+ holder[key] = value
352
+ return
353
+ if isinstance(holder, tuple):
354
+ raise RunError(f"{path}: a tuple cannot be patched in place")
355
+ if isinstance(holder, list):
356
+ if not isinstance(key, int) or not 0 <= key < len(holder):
357
+ raise RunError(f"{path}: {key!r} is no index of a list of {len(holder)}")
358
+ holder[key] = value
359
+ return
360
+ if isinstance(key, int):
361
+ raise RunError(f"{path}: {type_name(holder)} takes no index")
362
+ owner = holder_with(holder, key)
363
+ if owner is None:
364
+ raise RunError(f"{path}: {type_name(holder)} has no attribute {key!r}")
365
+ setattr(owner, key, value)
366
+
367
+
368
+ def resolve_ref(ctx, text):
369
+ label = f"@{text}"
370
+ tokens = tokenize(text)
371
+ root, rest = tokens[0], tokens[1:]
372
+ if root in TABLES:
373
+ if not rest:
374
+ raise RunError(f"{label} names a section; a reference names an entry like @{root}.<name>")
375
+ table = getattr(ctx, root, None) or {}
376
+ name = rest[0]
377
+ if name not in table:
378
+ raise RunError(f"{label}: no {SINGULAR[root]} named {name!r}; the {root} are {sorted(table)}")
379
+ obj = table[name]
380
+ rest = rest[1:]
381
+ if root == "models":
382
+ if rest and rest[0] == "ema":
383
+ obj = getattr(obj, "ema", None)
384
+ if obj is None:
385
+ raise RunError(f"{label}: model {name!r} has no ema copy")
386
+ rest = rest[1:]
387
+ else:
388
+ obj = getattr(obj, "module", obj)
389
+ elif root in ("data", "generate"):
390
+ obj = getattr(ctx, root, None)
391
+ if obj is None:
392
+ raise RunError(f"{label}: there is no {root} object")
393
+ else:
394
+ raise RunError(f"{label}: unknown reference root {root!r}; a reference starts with "
395
+ + ", ".join(TABLES + ("data", "generate")))
396
+ for token in rest:
397
+ obj = read(obj, token, label)
398
+ return obj
399
+
400
+
401
+ def resolve_value(ctx, current, asked):
402
+ text = ref_parts(asked)
403
+ if text is not None:
404
+ return resolve_ref(ctx, text)
405
+ if isinstance(asked, dict) and len(asked) == 1:
406
+ operation, amount = next(iter(asked.items()))
407
+ if operation in OPERATIONS:
408
+ if current is None:
409
+ raise RunError(f"{operation} needs a current value to work from, and there is none")
410
+ return current * amount if operation == "times" else current + amount
411
+ return asked
412
+
413
+
414
+ def node_prefix(nodes, tokens):
415
+ for count in range(len(tokens) - 1, 0, -1):
416
+ text = join_tokens(tokens[:count])
417
+ if text in nodes:
418
+ return text, tokens[count:]
419
+ return None, tokens
420
+
421
+
422
+ def assign_nested(container, tokens, value):
423
+ for token in tokens[:-1]:
424
+ if isinstance(container, dict) and token in container:
425
+ container = container[token]
426
+ elif isinstance(container, list) and isinstance(token, int) and 0 <= token < len(container):
427
+ container = container[token]
428
+ else:
429
+ return
430
+ last = tokens[-1]
431
+ if isinstance(container, dict) and not isinstance(last, int):
432
+ container[last] = value
433
+ elif isinstance(container, list) and isinstance(last, int) and 0 <= last < len(container):
434
+ container[last] = value
435
+
436
+
437
+ def bucket_of(node, key):
438
+ keys = node.get("keys")
439
+ params = node.get("params")
440
+ if isinstance(keys, dict) and key in keys:
441
+ return keys
442
+ if isinstance(params, dict) and key in params:
443
+ return params
444
+ from .build import SLOT_KEYS
445
+
446
+ if key in SLOT_KEYS.get(node.get("slot"), ()):
447
+ return node.setdefault("keys", {})
448
+ return node.setdefault("params", {})
449
+
450
+
451
+ def plan_write(plan, tokens, value):
452
+ if not isinstance(plan, dict):
453
+ return
454
+ if tokens[0] == "train" and (len(tokens) < 2 or tokens[1] != "step"):
455
+ section = plan.get("train")
456
+ if isinstance(section, dict) and len(tokens) > 1:
457
+ assign_nested(section, tokens[1:], value)
458
+ return
459
+ nodes = plan.get("nodes")
460
+ if not isinstance(nodes, dict):
461
+ return
462
+ node_path, rest = node_prefix(nodes, tokens)
463
+ if node_path is None or not rest or not isinstance(nodes[node_path], dict):
464
+ return
465
+ assign_nested(bucket_of(nodes[node_path], rest[0]), rest, value)
466
+
467
+
468
+ def apply_patch(ctx, path, asked, by="", turn=None):
469
+ turn = ctx.turn if turn is None else turn
470
+ tokens = tokenize(path)
471
+ holder, key, kind = locate(ctx, tokens, path)
472
+ current = current_of(holder, key, kind)
473
+ value = resolve_value(ctx, current, asked)
474
+ write(holder, key, value, kind, path)
475
+ value_json = ref_json(asked) if ref_parts(asked) is not None else jsonable(value, ctx)
476
+ plan_write(getattr(ctx, "plan", None), tokens, value_json)
477
+ record = getattr(ctx, "record", None)
478
+ if record is not None:
479
+ record.event("patch", turn=turn, by=by, path=path, value=value_json, asked=jsonable(asked, ctx))
480
+ write_plan(ctx, record)
481
+ patch = Patch(path, value, asked, by, turn)
482
+ patches = getattr(ctx, "patches", None)
483
+ if isinstance(patches, list):
484
+ patches.append(patch)
485
+ return patch
486
+
487
+
488
+ def write_plan(ctx, record):
489
+ plan = getattr(ctx, "plan", None)
490
+ writer = getattr(record, "write_json", None)
491
+ if isinstance(plan, dict) and callable(writer):
492
+ writer("plan.json", plan)
493
+
494
+
495
+ def replay(ctx, events, upto_turn):
496
+ record = ctx.record
497
+ ctx.record = None
498
+ count = 0
499
+ try:
500
+ for event in events:
501
+ if not isinstance(event, dict) or event.get("kind") != "patch":
502
+ continue
503
+ turn = event.get("turn") or 0
504
+ if turn > upto_turn:
505
+ continue
506
+ by = f"{event.get('by') or 'patch'} (replay)"
507
+ apply_patch(ctx, event["path"], event["value"], by=by, turn=turn)
508
+ count += 1
509
+ finally:
510
+ ctx.record = record
511
+ if count and record is not None:
512
+ write_plan(ctx, record)
513
+ return count