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.
- kirin_tor/__init__.py +3 -0
- kirin_tor/application.py +680 -0
- kirin_tor/authoring.py +1129 -0
- kirin_tor/cli.py +1242 -0
- kirin_tor/community_discovery.py +376 -0
- kirin_tor/diagnostics.py +208 -0
- kirin_tor/engine.py +1460 -0
- kirin_tor/errors.py +137 -0
- kirin_tor/expression.py +1055 -0
- kirin_tor/kirin_syntax.py +406 -0
- kirin_tor/kirin_v2.py +1104 -0
- kirin_tor/limits.py +64 -0
- kirin_tor/operations.py +1057 -0
- kirin_tor/package_authoring.py +332 -0
- kirin_tor/package_manifest.py +667 -0
- kirin_tor/package_store.py +645 -0
- kirin_tor/plotting.py +246 -0
- kirin_tor/plugin_manifest.py +763 -0
- kirin_tor/plugin_store.py +506 -0
- kirin_tor/process_analysis.py +1321 -0
- kirin_tor/process_ast.py +225 -0
- kirin_tor/process_chart.py +422 -0
- kirin_tor/process_crossing.py +151 -0
- kirin_tor/process_expression.py +962 -0
- kirin_tor/process_ir.py +369 -0
- kirin_tor/process_lowering.py +778 -0
- kirin_tor/process_measure.py +232 -0
- kirin_tor/process_model.py +53 -0
- kirin_tor/process_parser.py +846 -0
- kirin_tor/process_renderer.py +163 -0
- kirin_tor/process_runtime.py +1643 -0
- kirin_tor/process_validation.py +131 -0
- kirin_tor/records.py +456 -0
- kirin_tor/relationship_graph.py +310 -0
- kirin_tor/scenario_ast.py +257 -0
- kirin_tor/scenario_ir.py +295 -0
- kirin_tor/scenario_lowering.py +1484 -0
- kirin_tor/scenario_measure_syntax.py +95 -0
- kirin_tor/scenario_parser.py +892 -0
- kirin_tor/scenario_renderer.py +226 -0
- kirin_tor/scenario_validation.py +269 -0
- kirin_tor/schema.py +1424 -0
- kirin_tor/templates.py +222 -0
- kirin_tor/timeout.py +82 -0
- kirin_tor/tutorial_sources/basic_model.kirin +19 -0
- kirin_tor/tutorial_sources/preset_comparison.kirin +34 -0
- kirin_tor/tutorial_sources/scan_chart.kirin +28 -0
- kirin_tor/tutorials.py +89 -0
- kirin_tor/units.py +215 -0
- kirin_tor/web.py +542 -0
- kirin_tor/web_assets/ChangeReview-bfGYV7JJ.js +2 -0
- kirin_tor/web_assets/Checkbox-p0fHt3Tq.js +1 -0
- kirin_tor/web_assets/Code-B0qtSTIf.js +1 -0
- kirin_tor/web_assets/CommunityDiscoveryPanel-DNYQeDTd.js +1 -0
- kirin_tor/web_assets/DocumentsView-BR16v_N4.js +17 -0
- kirin_tor/web_assets/FocusTrap-D5hdmc8y.js +1 -0
- kirin_tor/web_assets/GraphView-BrgNWVgM.js +1 -0
- kirin_tor/web_assets/InputBase-DLbSOsHj.js +1 -0
- kirin_tor/web_assets/PackagesView-DeKVuxuw.js +1 -0
- kirin_tor/web_assets/PluginsView-C2D0mtVq.js +1 -0
- kirin_tor/web_assets/RelationshipGraphCanvas-odileUI0.js +18 -0
- kirin_tor/web_assets/RunsView-Db77dlcf.js +1 -0
- kirin_tor/web_assets/Select-Qg63t2-G.js +1 -0
- kirin_tor/web_assets/SyntaxReference-CP9-3BYR.js +2 -0
- kirin_tor/web_assets/Tabs-Bn4SJfHG.js +1 -0
- kirin_tor/web_assets/TextInput-DfkY1-5d.js +1 -0
- kirin_tor/web_assets/WorkspaceSearch-BXtNlP2d.js +1 -0
- kirin_tor/web_assets/copy-m46mNftu.js +1 -0
- kirin_tor/web_assets/createLucideIcon-fs27rAxj.js +1 -0
- kirin_tor/web_assets/external-link-CWgLapm7.js +1 -0
- kirin_tor/web_assets/folder-input-DKWTrP27.js +1 -0
- kirin_tor/web_assets/get-auto-contrast-value-BTPwFOS2.js +1 -0
- kirin_tor/web_assets/index-D447QmAj.css +1 -0
- kirin_tor/web_assets/index-DBRHnxi_.js +50 -0
- kirin_tor/web_assets/index.html +16 -0
- kirin_tor/web_assets/refresh-cw-BQiGGhOd.js +1 -0
- kirin_tor/web_assets/trash-2-Cb5gNQOG.js +1 -0
- kirin_tor/workbench.py +1470 -0
- kirin_tor/workspace.py +971 -0
- kirin_tor_cli-0.3.0rc3.dist-info/METADATA +320 -0
- kirin_tor_cli-0.3.0rc3.dist-info/RECORD +85 -0
- kirin_tor_cli-0.3.0rc3.dist-info/WHEEL +5 -0
- kirin_tor_cli-0.3.0rc3.dist-info/entry_points.txt +2 -0
- kirin_tor_cli-0.3.0rc3.dist-info/licenses/LICENSE +21 -0
- kirin_tor_cli-0.3.0rc3.dist-info/top_level.txt +1 -0
kirin_tor/__init__.py
ADDED
kirin_tor/application.py
ADDED
|
@@ -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
|