kirin-tor-cli 0.3.0rc3__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 (85) hide show
  1. kirin_tor/__init__.py +3 -0
  2. kirin_tor/application.py +680 -0
  3. kirin_tor/authoring.py +1129 -0
  4. kirin_tor/cli.py +1242 -0
  5. kirin_tor/community_discovery.py +376 -0
  6. kirin_tor/diagnostics.py +208 -0
  7. kirin_tor/engine.py +1460 -0
  8. kirin_tor/errors.py +137 -0
  9. kirin_tor/expression.py +1055 -0
  10. kirin_tor/kirin_syntax.py +406 -0
  11. kirin_tor/kirin_v2.py +1104 -0
  12. kirin_tor/limits.py +64 -0
  13. kirin_tor/operations.py +1057 -0
  14. kirin_tor/package_authoring.py +332 -0
  15. kirin_tor/package_manifest.py +667 -0
  16. kirin_tor/package_store.py +645 -0
  17. kirin_tor/plotting.py +246 -0
  18. kirin_tor/plugin_manifest.py +763 -0
  19. kirin_tor/plugin_store.py +506 -0
  20. kirin_tor/process_analysis.py +1321 -0
  21. kirin_tor/process_ast.py +225 -0
  22. kirin_tor/process_chart.py +422 -0
  23. kirin_tor/process_crossing.py +151 -0
  24. kirin_tor/process_expression.py +962 -0
  25. kirin_tor/process_ir.py +369 -0
  26. kirin_tor/process_lowering.py +778 -0
  27. kirin_tor/process_measure.py +232 -0
  28. kirin_tor/process_model.py +53 -0
  29. kirin_tor/process_parser.py +846 -0
  30. kirin_tor/process_renderer.py +163 -0
  31. kirin_tor/process_runtime.py +1643 -0
  32. kirin_tor/process_validation.py +131 -0
  33. kirin_tor/records.py +456 -0
  34. kirin_tor/relationship_graph.py +310 -0
  35. kirin_tor/scenario_ast.py +257 -0
  36. kirin_tor/scenario_ir.py +295 -0
  37. kirin_tor/scenario_lowering.py +1484 -0
  38. kirin_tor/scenario_measure_syntax.py +95 -0
  39. kirin_tor/scenario_parser.py +892 -0
  40. kirin_tor/scenario_renderer.py +226 -0
  41. kirin_tor/scenario_validation.py +269 -0
  42. kirin_tor/schema.py +1424 -0
  43. kirin_tor/templates.py +222 -0
  44. kirin_tor/timeout.py +82 -0
  45. kirin_tor/tutorial_sources/basic_model.kirin +19 -0
  46. kirin_tor/tutorial_sources/preset_comparison.kirin +34 -0
  47. kirin_tor/tutorial_sources/scan_chart.kirin +28 -0
  48. kirin_tor/tutorials.py +89 -0
  49. kirin_tor/units.py +215 -0
  50. kirin_tor/web.py +542 -0
  51. kirin_tor/web_assets/ChangeReview-bfGYV7JJ.js +2 -0
  52. kirin_tor/web_assets/Checkbox-p0fHt3Tq.js +1 -0
  53. kirin_tor/web_assets/Code-B0qtSTIf.js +1 -0
  54. kirin_tor/web_assets/CommunityDiscoveryPanel-DNYQeDTd.js +1 -0
  55. kirin_tor/web_assets/DocumentsView-BR16v_N4.js +17 -0
  56. kirin_tor/web_assets/FocusTrap-D5hdmc8y.js +1 -0
  57. kirin_tor/web_assets/GraphView-BrgNWVgM.js +1 -0
  58. kirin_tor/web_assets/InputBase-DLbSOsHj.js +1 -0
  59. kirin_tor/web_assets/PackagesView-DeKVuxuw.js +1 -0
  60. kirin_tor/web_assets/PluginsView-C2D0mtVq.js +1 -0
  61. kirin_tor/web_assets/RelationshipGraphCanvas-odileUI0.js +18 -0
  62. kirin_tor/web_assets/RunsView-Db77dlcf.js +1 -0
  63. kirin_tor/web_assets/Select-Qg63t2-G.js +1 -0
  64. kirin_tor/web_assets/SyntaxReference-CP9-3BYR.js +2 -0
  65. kirin_tor/web_assets/Tabs-Bn4SJfHG.js +1 -0
  66. kirin_tor/web_assets/TextInput-DfkY1-5d.js +1 -0
  67. kirin_tor/web_assets/WorkspaceSearch-BXtNlP2d.js +1 -0
  68. kirin_tor/web_assets/copy-m46mNftu.js +1 -0
  69. kirin_tor/web_assets/createLucideIcon-fs27rAxj.js +1 -0
  70. kirin_tor/web_assets/external-link-CWgLapm7.js +1 -0
  71. kirin_tor/web_assets/folder-input-DKWTrP27.js +1 -0
  72. kirin_tor/web_assets/get-auto-contrast-value-BTPwFOS2.js +1 -0
  73. kirin_tor/web_assets/index-D447QmAj.css +1 -0
  74. kirin_tor/web_assets/index-DBRHnxi_.js +50 -0
  75. kirin_tor/web_assets/index.html +16 -0
  76. kirin_tor/web_assets/refresh-cw-BQiGGhOd.js +1 -0
  77. kirin_tor/web_assets/trash-2-Cb5gNQOG.js +1 -0
  78. kirin_tor/workbench.py +1470 -0
  79. kirin_tor/workspace.py +971 -0
  80. kirin_tor_cli-0.3.0rc3.dist-info/METADATA +320 -0
  81. kirin_tor_cli-0.3.0rc3.dist-info/RECORD +85 -0
  82. kirin_tor_cli-0.3.0rc3.dist-info/WHEEL +5 -0
  83. kirin_tor_cli-0.3.0rc3.dist-info/entry_points.txt +2 -0
  84. kirin_tor_cli-0.3.0rc3.dist-info/licenses/LICENSE +21 -0
  85. kirin_tor_cli-0.3.0rc3.dist-info/top_level.txt +1 -0
kirin_tor/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Kirin Tor structured mathematics toolkit."""
2
+
3
+ __version__ = "0.3.0rc3"
@@ -0,0 +1,680 @@
1
+ """Shared player-facing application services for the CLI and browser adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import uuid
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Callable, Dict, Iterable, Mapping, Optional, Sequence, Tuple, Union
10
+
11
+ import sympy as sp
12
+
13
+ from .engine import Engine
14
+ from .errors import KTError, ParameterError, WorkspaceError
15
+ from .expression import parse_exact_number
16
+ from .operations import evaluate, exact_text, scan_values
17
+ from .records import run_record_path, save_run
18
+ from .schema import require_parameter_name
19
+ from .workspace import Workspace
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class TargetOption:
24
+ value: str
25
+ label: str
26
+ unit: str
27
+ is_boolean: bool
28
+ inputs: Tuple[str, ...] = ()
29
+ group: Optional[str] = None
30
+ group_label: Optional[str] = None
31
+ display: str = "number"
32
+ digits: Optional[int] = None
33
+ package_name: Optional[str] = None
34
+ package_version: Optional[str] = None
35
+ package_source: Optional[str] = None
36
+ line: Optional[int] = None
37
+ column: Optional[int] = None
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class InputOption:
42
+ value: str
43
+ label: str
44
+ unit: str
45
+ value_type: str
46
+ default: Optional[object]
47
+ minimum: Optional[str]
48
+ maximum: Optional[str]
49
+ allowed_values: Tuple[object, ...]
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class NamedOption:
54
+ value: str
55
+ label: str
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class ChartOption:
60
+ value: str
61
+ label: str
62
+ line: Optional[int] = None
63
+ column: Optional[int] = None
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class WorkspaceIndex:
68
+ targets: Tuple[TargetOption, ...]
69
+ inputs: Tuple[InputOption, ...]
70
+ presets: Tuple[NamedOption, ...]
71
+ charts: Tuple[ChartOption, ...]
72
+ analyses: Tuple[ChartOption, ...]
73
+ document_ids: Tuple[str, ...]
74
+
75
+ @dataclass(frozen=True)
76
+ class ComparisonVariant:
77
+ name: str
78
+ preset: Optional[str] = None
79
+ overrides: Mapping[str, str] = None
80
+
81
+ def normalized_overrides(self) -> Mapping[str, str]:
82
+ return dict(self.overrides or {})
83
+
84
+ def parse_override_assignments(values: Iterable[str]) -> dict[str, str]:
85
+ """Parse repeatable NAME=VALUE input with stable duplicate handling."""
86
+ result: dict[str, str] = {}
87
+ for value in values:
88
+ if value.count("=") != 1:
89
+ raise ParameterError("temporary input must use NAME=VALUE")
90
+ name, number = (part.strip() for part in value.split("=", 1))
91
+ require_parameter_name(name, "parameter name", None)
92
+ if not number:
93
+ raise ParameterError(f"temporary input {name!r} has no value")
94
+ if name in result:
95
+ raise ParameterError(f"parameter {name!r} was overridden more than once")
96
+ result[name] = number
97
+ return result
98
+
99
+
100
+ def parse_override_text(text: str) -> dict[str, str]:
101
+ """Parse a player-facing comma/newline separated override field."""
102
+ values = [item.strip() for line in text.splitlines() for item in line.split(",") if item.strip()]
103
+ return parse_override_assignments(values)
104
+
105
+
106
+ def _normalize_player_value(value: str) -> str:
107
+ value = value.strip()
108
+ if value.endswith("%"):
109
+ number = parse_exact_number(value[:-1].strip())
110
+ return exact_text(sp.simplify(number / 100))
111
+ return value
112
+
113
+
114
+ def parse_player_override_text(
115
+ text: str, inputs: Sequence[InputOption]
116
+ ) -> dict[str, str]:
117
+ """Resolve player-facing names, separators, and exact percentages."""
118
+ normalized = text.translate(str.maketrans({",": ",", ";": ",", ";": ","}))
119
+ assignments = [
120
+ item.strip()
121
+ for line in normalized.splitlines()
122
+ for item in line.split(",")
123
+ if item.strip()
124
+ ]
125
+ result: dict[str, str] = {}
126
+ for assignment in assignments:
127
+ if assignment.count("=") != 1:
128
+ raise ParameterError("temporary input must use NAME=VALUE")
129
+ supplied_name, supplied_value = (part.strip() for part in assignment.split("=", 1))
130
+ if not supplied_name or not supplied_value:
131
+ raise ParameterError("temporary input requires both a name and a value")
132
+ matches = [
133
+ item
134
+ for item in inputs
135
+ if supplied_name
136
+ in {
137
+ item.value,
138
+ item.value.rsplit(".", 1)[-1],
139
+ item.label,
140
+ }
141
+ ]
142
+ if len(matches) > 1:
143
+ raise ParameterError(
144
+ f"temporary input {supplied_name!r} is ambiguous; use its full entry.input name"
145
+ )
146
+ if matches:
147
+ canonical = matches[0].value
148
+ else:
149
+ require_parameter_name(supplied_name, "parameter name", None)
150
+ canonical = supplied_name
151
+ if canonical in result:
152
+ raise ParameterError(f"parameter {canonical!r} was overridden more than once")
153
+ result[canonical] = _normalize_player_value(supplied_value)
154
+ return result
155
+
156
+
157
+ def build_workspace_index(workspace: Workspace) -> WorkspaceIndex:
158
+ """Build immutable selectors from one already validated workspace revision."""
159
+ engine = Engine(workspace)
160
+ targets = []
161
+ inputs = []
162
+ for entry in sorted(workspace.entries.values(), key=lambda item: item.id):
163
+ for name in sorted(entry.inputs):
164
+ spec = entry.inputs[name]
165
+ qualified = f"{entry.id}.{name}"
166
+ inputs.append(
167
+ InputOption(
168
+ qualified,
169
+ spec.label or qualified,
170
+ spec.unit_name,
171
+ spec.value_type,
172
+ spec.default,
173
+ spec.minimum,
174
+ spec.maximum,
175
+ tuple(spec.allowed_values),
176
+ )
177
+ )
178
+ group_by_output = {
179
+ output: group
180
+ for group in entry.groups.values()
181
+ for output in group.outputs
182
+ }
183
+ grouped_order = [
184
+ output
185
+ for group in entry.groups.values()
186
+ for output in group.outputs
187
+ ]
188
+ output_order = grouped_order + [
189
+ name for name in sorted(entry.outputs) if name not in group_by_output
190
+ ]
191
+ for name in output_order:
192
+ qualified = f"{entry.id}.{name}"
193
+ resolved = engine.resolve_target(qualified)
194
+ group = group_by_output.get(name)
195
+ position = entry.positions.get(f"outputs.{name}") or entry.positions.get(
196
+ f"outputs.{name}.expression"
197
+ )
198
+ targets.append(
199
+ TargetOption(
200
+ qualified,
201
+ engine.display_label(qualified) or qualified,
202
+ workspace.units.render(resolved.dimension),
203
+ resolved.is_boolean,
204
+ tuple(sorted(resolved.inputs)),
205
+ group.qualified_id if group else None,
206
+ group.label if group else None,
207
+ entry.outputs[name].get("display", "number"),
208
+ entry.outputs[name].get("digits"),
209
+ entry.package_origin.name if entry.package_origin else None,
210
+ entry.package_origin.version if entry.package_origin else None,
211
+ entry.package_origin.source if entry.package_origin else None,
212
+ position[0] if position else None,
213
+ position[1] if position else None,
214
+ )
215
+ )
216
+ presets = tuple(
217
+ NamedOption(reference, preset.label)
218
+ for reference, preset in sorted(workspace.presets.items())
219
+ )
220
+ charts = []
221
+ for document in sorted(workspace.charts.values(), key=lambda item: item.id):
222
+ position = document.positions.get("x") or document.positions.get("display")
223
+ charts.append(
224
+ ChartOption(
225
+ document.id,
226
+ document.name,
227
+ position[0] if position else None,
228
+ position[1] if position else None,
229
+ )
230
+ )
231
+ analyses = []
232
+ for analysis in sorted(workspace.analyses.values(), key=lambda item: item.qualified_id):
233
+ analyses.append(
234
+ ChartOption(
235
+ analysis.qualified_id,
236
+ analysis.label or analysis.id,
237
+ analysis.location.line if analysis.location else None,
238
+ analysis.location.column if analysis.location else None,
239
+ )
240
+ )
241
+ return WorkspaceIndex(
242
+ tuple(targets),
243
+ tuple(inputs),
244
+ presets,
245
+ tuple(charts),
246
+ tuple(analyses),
247
+ tuple(sorted(workspace.documents)),
248
+ )
249
+
250
+
251
+ def _render_number(expr: sp.Expr, precision: int, display_digits: int) -> str:
252
+ return sp.sstr(sp.N(expr, precision).evalf(display_digits))
253
+
254
+
255
+ def compare_variants(
256
+ workspace: Workspace,
257
+ target: str,
258
+ variants: Sequence[ComparisonVariant],
259
+ *,
260
+ precision: int = 30,
261
+ display_digits: int = 12,
262
+ timeout_seconds: float = 10.0,
263
+ ) -> dict:
264
+ """Evaluate named variants against one workspace revision and one target."""
265
+ if not variants:
266
+ raise ParameterError("comparison requires at least one variant")
267
+ if len(variants) > 8:
268
+ raise ParameterError("comparison supports at most 8 variants")
269
+ normalized_names = [variant.name.strip() for variant in variants]
270
+ if any(not name for name in normalized_names):
271
+ raise ParameterError("every comparison variant requires a name")
272
+ if len(set(normalized_names)) != len(normalized_names):
273
+ raise ParameterError("comparison variant names must be unique")
274
+
275
+ index = build_workspace_index(workspace)
276
+ target_option = next((item for item in index.targets if item.value == target), None)
277
+ if target_option is None:
278
+ raise ParameterError(f"comparison target must be a declared output: {target}")
279
+
280
+ rows = []
281
+ for name, variant in zip(normalized_names, variants):
282
+ try:
283
+ result = evaluate(
284
+ Engine(workspace),
285
+ target,
286
+ preset=variant.preset,
287
+ overrides=variant.normalized_overrides(),
288
+ precision=precision,
289
+ display_digits=display_digits,
290
+ timeout_seconds=timeout_seconds,
291
+ )
292
+ rows.append(
293
+ {
294
+ "name": name,
295
+ "preset": variant.preset,
296
+ "overrides": dict(variant.normalized_overrides()),
297
+ "status": "ok",
298
+ "result": result,
299
+ "delta_exact": None,
300
+ "delta_approximate": None,
301
+ "delta_percent": None,
302
+ }
303
+ )
304
+ except KTError as exc:
305
+ rows.append(
306
+ {
307
+ "name": name,
308
+ "preset": variant.preset,
309
+ "overrides": dict(variant.normalized_overrides()),
310
+ "status": "error",
311
+ "error": exc.as_dict(),
312
+ "delta_exact": None,
313
+ "delta_approximate": None,
314
+ "delta_percent": None,
315
+ }
316
+ )
317
+
318
+ baseline = rows[0]
319
+ if baseline["status"] == "ok" and not target_option.is_boolean:
320
+ baseline_expr = sp.sympify(baseline["result"]["exact"])
321
+ for row in rows[1:]:
322
+ if row["status"] != "ok":
323
+ continue
324
+ current_expr = sp.sympify(row["result"]["exact"])
325
+ delta = sp.simplify(current_expr - baseline_expr)
326
+ row["delta_exact"] = exact_text(delta)
327
+ row["delta_approximate"] = _render_number(delta, precision, display_digits)
328
+ if baseline_expr != 0:
329
+ percentage = sp.simplify(delta * 100 / baseline_expr)
330
+ row["delta_percent"] = _render_number(percentage, precision, display_digits)
331
+
332
+ successful = [row for row in rows if row["status"] == "ok"]
333
+ dependency_ids = sorted(
334
+ {
335
+ dependency
336
+ for row in successful
337
+ for dependency in row["result"].get("dependency_ids", [])
338
+ }
339
+ )
340
+ return {
341
+ "status": "ok" if len(successful) == len(rows) else "partial",
342
+ "operation": "compare",
343
+ "target": target,
344
+ "label": target_option.label,
345
+ "unit": target_option.unit,
346
+ "is_boolean": target_option.is_boolean,
347
+ "precision": precision,
348
+ "display_digits": display_digits,
349
+ "display_format": target_option.display,
350
+ "display_digits_player": target_option.digits,
351
+ "package_origin": (
352
+ {
353
+ "name": target_option.package_name,
354
+ "version": target_option.package_version,
355
+ "source": target_option.package_source,
356
+ }
357
+ if target_option.package_source is not None
358
+ else None
359
+ ),
360
+ "variants": rows,
361
+ "dependency_ids": dependency_ids,
362
+ }
363
+
364
+
365
+ def save_comparison_run(
366
+ workspace: Workspace,
367
+ run_id: str,
368
+ target: str,
369
+ variants: Sequence[ComparisonVariant],
370
+ *,
371
+ precision: int = 30,
372
+ display_digits: int = 12,
373
+ timeout_seconds: float = 10.0,
374
+ ) -> dict:
375
+ """Recompute and save one comparison against saved, validated source authority."""
376
+ request = {
377
+ "target": target,
378
+ "variants": [
379
+ {
380
+ "name": variant.name,
381
+ "preset": variant.preset,
382
+ "overrides": dict(variant.normalized_overrides()),
383
+ }
384
+ for variant in variants
385
+ ],
386
+ "precision": precision,
387
+ "display_digits": display_digits,
388
+ "timeout_seconds": timeout_seconds,
389
+ }
390
+ preset_document_ids = {
391
+ workspace.get_preset(variant.preset).owner_id
392
+ for variant in variants
393
+ if variant.preset is not None
394
+ }
395
+ return record_operation(
396
+ workspace,
397
+ run_id,
398
+ "compare",
399
+ request,
400
+ lambda: compare_variants(
401
+ workspace,
402
+ target,
403
+ variants,
404
+ precision=precision,
405
+ display_digits=display_digits,
406
+ timeout_seconds=timeout_seconds,
407
+ ),
408
+ preset_document_ids,
409
+ )
410
+
411
+
412
+ def scan_variant_comparison(
413
+ workspace: Workspace,
414
+ x: str,
415
+ range_text: str,
416
+ points: int,
417
+ target: str,
418
+ variants: Sequence[ComparisonVariant],
419
+ *,
420
+ precision: int = 30,
421
+ display_digits: int = 12,
422
+ timeout_seconds: float = 10.0,
423
+ ) -> dict:
424
+ """Scan one output for several player variants on one shared axis."""
425
+ if not variants:
426
+ raise ParameterError("chart comparison requires at least one variant")
427
+ if len(variants) > 8:
428
+ raise ParameterError("chart comparison supports at most 8 variants")
429
+ names = [variant.name.strip() for variant in variants]
430
+ if any(not name for name in names) or len(set(names)) != len(names):
431
+ raise ParameterError("chart comparison variants require unique non-empty names")
432
+
433
+ scans = []
434
+ scan_errors = []
435
+ for variant in variants:
436
+ try:
437
+ scans.append(
438
+ scan_values(
439
+ Engine(workspace),
440
+ x,
441
+ range_text,
442
+ points,
443
+ [target],
444
+ variant.preset,
445
+ variant.normalized_overrides(),
446
+ precision,
447
+ display_digits,
448
+ timeout_seconds,
449
+ )
450
+ )
451
+ scan_errors.append(None)
452
+ except KTError as exc:
453
+ scans.append(None)
454
+ scan_errors.append(str(exc))
455
+ first = next((scan for scan in scans if scan is not None), None)
456
+ if first is None:
457
+ raise ParameterError(f"all chart variants failed; first error: {scan_errors[0]}")
458
+ if any(
459
+ scan is not None
460
+ and (scan["x"] != first["x"] or scan["range"] != first["range"])
461
+ for scan in scans
462
+ ):
463
+ raise ParameterError("chart variants did not resolve to one shared input axis")
464
+ variant_targets = [f"variant_{index + 1}" for index in range(len(variants))]
465
+ rows = []
466
+ for row_index, first_row in enumerate(first["rows"]):
467
+ row = {
468
+ "x": first_row["x"],
469
+ "x_approximate": first_row["x_approximate"],
470
+ "values": {},
471
+ }
472
+ for key, scan, scan_error in zip(variant_targets, scans, scan_errors):
473
+ row["values"][key] = (
474
+ dict(scan["rows"][row_index]["values"][target])
475
+ if scan is not None
476
+ else {"exact": None, "approximate": None, "error": scan_error}
477
+ )
478
+ rows.append(row)
479
+ unit = first["units"][target]
480
+ return {
481
+ "status": "partial" if any(scan_errors) else "ok",
482
+ "operation": "scan_compare",
483
+ "x": first["x"],
484
+ "x_display_label": first.get("x_display_label"),
485
+ "x_unit": first["x_unit"],
486
+ "x_domain": first.get("x_domain"),
487
+ "range": first["range"],
488
+ "points": points,
489
+ "targets": variant_targets,
490
+ "labels": dict(zip(variant_targets, names)),
491
+ "units": {key: unit for key in variant_targets},
492
+ "parameters": {
493
+ key: (
494
+ scan.get("parameters", {})
495
+ if scan is not None
496
+ else dict(variant.normalized_overrides())
497
+ )
498
+ for key, scan, variant in zip(variant_targets, scans, variants)
499
+ },
500
+ "valid_points": {
501
+ key: scan["valid_points"][target] if scan is not None else 0
502
+ for key, scan in zip(variant_targets, scans)
503
+ },
504
+ "warnings": sorted(
505
+ {
506
+ warning
507
+ for scan in scans
508
+ if scan is not None
509
+ for warning in scan.get("warnings", [])
510
+ }
511
+ | {
512
+ f"方案 {name} 无法计算:{error}"
513
+ for name, error in zip(names, scan_errors)
514
+ if error is not None
515
+ }
516
+ ),
517
+ "precision": precision,
518
+ "display_digits": display_digits,
519
+ "rows": rows,
520
+ "variants": [
521
+ {
522
+ "key": key,
523
+ "name": name,
524
+ "preset": variant.preset,
525
+ "overrides": dict(variant.normalized_overrides()),
526
+ "status": "error" if error is not None else "ok",
527
+ "error": error,
528
+ }
529
+ for key, name, variant, error in zip(variant_targets, names, variants, scan_errors)
530
+ ],
531
+ "dependency_ids": sorted(
532
+ {
533
+ dependency
534
+ for scan in scans
535
+ if scan is not None
536
+ for dependency in scan.get("dependency_ids", [])
537
+ }
538
+ ),
539
+ }
540
+
541
+
542
+ def artifact_path(
543
+ workspace_or_root: Union[Workspace, Path], text: str, allow_outside: bool = False
544
+ ) -> Path:
545
+ """Resolve an artifact with the same default workspace boundary for every adapter."""
546
+ root = (
547
+ workspace_or_root.root
548
+ if isinstance(workspace_or_root, Workspace)
549
+ else Path(workspace_or_root).resolve()
550
+ )
551
+ path = Path(text)
552
+ resolved = (path if path.is_absolute() else root / path).resolve()
553
+ if not allow_outside:
554
+ try:
555
+ resolved.relative_to(root)
556
+ except ValueError as exc:
557
+ raise ParameterError(
558
+ f"output path leaves the workspace; pass --allow-outside-workspace explicitly: {resolved}"
559
+ ) from exc
560
+ return resolved
561
+
562
+
563
+ def preflight_artifacts(paths: Sequence[Path], force: bool) -> None:
564
+ if len(set(paths)) != len(paths):
565
+ raise ParameterError("plot and data outputs must use different paths")
566
+ if not force:
567
+ existing = [path for path in paths if path.exists()]
568
+ if existing:
569
+ raise ParameterError(
570
+ "output file already exists; use --force to replace it: "
571
+ + ", ".join(map(str, existing))
572
+ )
573
+
574
+
575
+ def atomic_write_sources(buffers: Mapping[Path, str]) -> None:
576
+ """Stage a validated set and roll back if any individual replace fails."""
577
+ staged: Dict[Path, Path] = {}
578
+ previous: Dict[Path, Optional[bytes]] = {}
579
+ replaced = []
580
+ try:
581
+ for raw_path, source_text in buffers.items():
582
+ path = Path(raw_path).resolve()
583
+ path.parent.mkdir(parents=True, exist_ok=True)
584
+ previous[path] = path.read_bytes() if path.exists() else None
585
+ temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
586
+ with temporary.open("x", encoding="utf-8") as handle:
587
+ handle.write(source_text)
588
+ handle.flush()
589
+ os.fsync(handle.fileno())
590
+ staged[path] = temporary
591
+ for path, temporary in staged.items():
592
+ try:
593
+ os.replace(temporary, path)
594
+ replaced.append(path)
595
+ except Exception:
596
+ for replaced_path in reversed(replaced):
597
+ old_content = previous[replaced_path]
598
+ if old_content is None:
599
+ replaced_path.unlink(missing_ok=True)
600
+ continue
601
+ restore = replaced_path.with_name(
602
+ f".{replaced_path.name}.{uuid.uuid4().hex}.restore"
603
+ )
604
+ with restore.open("xb") as handle:
605
+ handle.write(old_content)
606
+ handle.flush()
607
+ os.fsync(handle.fileno())
608
+ os.replace(restore, replaced_path)
609
+ raise
610
+ finally:
611
+ for temporary in staged.values():
612
+ temporary.unlink(missing_ok=True)
613
+
614
+
615
+ def record_operation(
616
+ workspace: Workspace,
617
+ save_run_id: Optional[str],
618
+ operation: str,
619
+ request: dict,
620
+ compute: Callable[[], dict],
621
+ extra_document_ids: Iterable[str] = (),
622
+ ) -> dict:
623
+ """Execute and optionally record one operation, including stable failures."""
624
+ if save_run_id:
625
+ candidate = run_record_path(workspace, save_run_id)
626
+ if candidate.exists():
627
+ raise WorkspaceError(
628
+ f"run record already exists and will not be overwritten: {candidate}"
629
+ )
630
+ try:
631
+ result = compute()
632
+ except KTError as exc:
633
+ if save_run_id:
634
+ save_run(
635
+ workspace,
636
+ save_run_id,
637
+ operation,
638
+ request,
639
+ exc.as_dict(),
640
+ workspace.documents.keys(),
641
+ )
642
+ raise
643
+ except Exception as exc:
644
+ if save_run_id:
645
+ failure = {
646
+ "status": "error",
647
+ "code": "internal_operation_error",
648
+ "message": str(exc),
649
+ }
650
+ save_run(
651
+ workspace,
652
+ save_run_id,
653
+ operation,
654
+ request,
655
+ failure,
656
+ workspace.documents.keys(),
657
+ )
658
+ raise
659
+ if save_run_id:
660
+ final_request = dict(request)
661
+ final_request["effective_parameters"] = result.get("parameters", {})
662
+ normalized_extra_ids = set()
663
+ for reference in extra_document_ids:
664
+ if reference in workspace.documents:
665
+ normalized_extra_ids.add(reference)
666
+ continue
667
+ preset = workspace.get_preset(reference)
668
+ if preset is not None:
669
+ normalized_extra_ids.add(preset.owner_id)
670
+ document_ids = set(result.get("dependency_ids", [])) | normalized_extra_ids
671
+ path = save_run(
672
+ workspace,
673
+ save_run_id,
674
+ operation,
675
+ final_request,
676
+ result,
677
+ document_ids,
678
+ )
679
+ result["run_record"] = str(path)
680
+ return result