dynamic-systems-testing-framework 1.0.1__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 (185) hide show
  1. dstf/__init__.py +41 -0
  2. dstf/__main__.py +10 -0
  3. dstf/authoring/__init__.py +13 -0
  4. dstf/authoring/builtin/__init__.py +32 -0
  5. dstf/authoring/builtin/accept.py +205 -0
  6. dstf/authoring/builtin/edit_criteria.py +479 -0
  7. dstf/authoring/builtin/edit_family.py +228 -0
  8. dstf/authoring/builtin/edit_on_plot.py +1354 -0
  9. dstf/authoring/builtin/edit_tree.py +1399 -0
  10. dstf/authoring/builtin/enqueue.py +144 -0
  11. dstf/authoring/capabilities.py +463 -0
  12. dstf/authoring/errors.py +49 -0
  13. dstf/authoring/family_rows.py +526 -0
  14. dstf/authoring/forms.py +631 -0
  15. dstf/authoring/layout.py +104 -0
  16. dstf/authoring/live_rows.py +337 -0
  17. dstf/authoring/live_runs.py +385 -0
  18. dstf/authoring/needle.py +127 -0
  19. dstf/authoring/picker_rows.py +92 -0
  20. dstf/authoring/queue_rows.py +354 -0
  21. dstf/authoring/session.py +3016 -0
  22. dstf/authoring/shell/__init__.py +9 -0
  23. dstf/authoring/shell/actions.py +702 -0
  24. dstf/authoring/shell/app.py +53 -0
  25. dstf/authoring/shell/drawer_controls.py +190 -0
  26. dstf/authoring/shell/family_fold.py +468 -0
  27. dstf/authoring/shell/figure_hook.py +249 -0
  28. dstf/authoring/shell/grips.js +1000 -0
  29. dstf/authoring/shell/header.py +783 -0
  30. dstf/authoring/shell/live_view.py +93 -0
  31. dstf/authoring/shell/picker.py +267 -0
  32. dstf/authoring/shell/placing.py +122 -0
  33. dstf/authoring/shell/plot_card.py +1171 -0
  34. dstf/authoring/shell/queue_panel.py +433 -0
  35. dstf/authoring/shell/rail.py +593 -0
  36. dstf/authoring/shell/shell.py +528 -0
  37. dstf/authoring/shell/stats_card.py +501 -0
  38. dstf/authoring/shell/strip.py +204 -0
  39. dstf/authoring/shell/style.py +721 -0
  40. dstf/authoring/shell/suite.py +277 -0
  41. dstf/authoring/shell/suite_grid.js +414 -0
  42. dstf/authoring/shell/suite_grid.py +373 -0
  43. dstf/authoring/shell/tab_theme.py +110 -0
  44. dstf/authoring/shell/tabstate.py +146 -0
  45. dstf/authoring/shell/test_list.py +179 -0
  46. dstf/authoring/shell/test_page.py +1404 -0
  47. dstf/authoring/shell/tree_card.py +1171 -0
  48. dstf/authoring/shell/variables_drawer.py +589 -0
  49. dstf/authoring/shell/widgets.py +188 -0
  50. dstf/authoring/situation.py +287 -0
  51. dstf/authoring/spec_document.py +215 -0
  52. dstf/authoring/spec_preview.py +83 -0
  53. dstf/authoring/suite_rows.py +99 -0
  54. dstf/authoring/tree_move.py +516 -0
  55. dstf/authoring/tree_pointer.py +33 -0
  56. dstf/authoring/tree_text.py +61 -0
  57. dstf/authoring/typed_number.js +64 -0
  58. dstf/authoring/typed_number.py +76 -0
  59. dstf/authoring/unscorable.py +141 -0
  60. dstf/authoring/variable_rows.py +216 -0
  61. dstf/backends/__init__.py +283 -0
  62. dstf/backends/base.py +1117 -0
  63. dstf/backends/common/__init__.py +1 -0
  64. dstf/backends/common/json_driver.py +484 -0
  65. dstf/backends/common/live_mat.py +369 -0
  66. dstf/backends/common/mat_reader.py +632 -0
  67. dstf/backends/common/proc_kill.py +238 -0
  68. dstf/backends/common/proc_output.py +20 -0
  69. dstf/backends/common/result_files.py +109 -0
  70. dstf/backends/common/result_variables.py +329 -0
  71. dstf/backends/common/statistics.py +39 -0
  72. dstf/backends/cross_backend.py +193 -0
  73. dstf/backends/dymola/__init__.py +5 -0
  74. dstf/backends/dymola/backend.py +776 -0
  75. dstf/backends/dymola/interface_loader.py +241 -0
  76. dstf/backends/dymola/log_parser.py +396 -0
  77. dstf/backends/dymola/persistent_backend.py +949 -0
  78. dstf/backends/fmpy/__init__.py +11 -0
  79. dstf/backends/fmpy/backend.py +443 -0
  80. dstf/backends/julia/__init__.py +27 -0
  81. dstf/backends/julia/backend.py +156 -0
  82. dstf/backends/julia/persistent_backend.py +541 -0
  83. dstf/backends/julia/run_persistent.jl +167 -0
  84. dstf/backends/julia/run_test.jl +146 -0
  85. dstf/backends/openmodelica/__init__.py +21 -0
  86. dstf/backends/openmodelica/backend.py +368 -0
  87. dstf/backends/openmodelica/init_xml.py +76 -0
  88. dstf/backends/openmodelica/log_parser.py +119 -0
  89. dstf/backends/openmodelica/mos_generator.py +228 -0
  90. dstf/backends/openmodelica/persistent_backend.py +1045 -0
  91. dstf/backends/openmodelica/session_loader.py +63 -0
  92. dstf/backends/persistent.py +1811 -0
  93. dstf/backends/pool.py +602 -0
  94. dstf/backends/progress.py +395 -0
  95. dstf/backends/python/__init__.py +11 -0
  96. dstf/backends/python/backend.py +141 -0
  97. dstf/backends/python/run_test.py +141 -0
  98. dstf/backends/run_metadata.py +103 -0
  99. dstf/backends/types.py +310 -0
  100. dstf/cli/__init__.py +12 -0
  101. dstf/cli/author_cmd.py +61 -0
  102. dstf/cli/baseline_cmds.py +341 -0
  103. dstf/cli/common.py +231 -0
  104. dstf/cli/diag_cmds.py +350 -0
  105. dstf/cli/main.py +561 -0
  106. dstf/cli/review.py +398 -0
  107. dstf/cli/run_cmds.py +589 -0
  108. dstf/cli/spec_cmds.py +123 -0
  109. dstf/comparison/__init__.py +13 -0
  110. dstf/comparison/algorithms.py +2127 -0
  111. dstf/comparison/builtins.py +291 -0
  112. dstf/comparison/comparator.py +1189 -0
  113. dstf/comparison/geometry.py +164 -0
  114. dstf/comparison/handles.py +1194 -0
  115. dstf/comparison/metric_tree.py +332 -0
  116. dstf/comparison/mode_schema.py +176 -0
  117. dstf/comparison/modes.py +788 -0
  118. dstf/comparison/registry.py +230 -0
  119. dstf/comparison/seeds.py +281 -0
  120. dstf/comparison/sides.py +158 -0
  121. dstf/comparison/snap.py +62 -0
  122. dstf/comparison/strict_keys.py +246 -0
  123. dstf/comparison/summaries.py +354 -0
  124. dstf/comparison/tree_eval.py +868 -0
  125. dstf/comparison/tree_spec.py +678 -0
  126. dstf/comparison/types.py +194 -0
  127. dstf/comparison/validator.py +232 -0
  128. dstf/config.py +815 -0
  129. dstf/discovery/__init__.py +0 -0
  130. dstf/discovery/json_recognizer.py +702 -0
  131. dstf/discovery/library_state.py +248 -0
  132. dstf/discovery/mo_parser.py +794 -0
  133. dstf/discovery/patch_apply.py +308 -0
  134. dstf/discovery/patch_envelope.py +63 -0
  135. dstf/discovery/plots.py +318 -0
  136. dstf/discovery/recognizer.py +103 -0
  137. dstf/discovery/scan_cache.py +154 -0
  138. dstf/discovery/spec_parser.py +538 -0
  139. dstf/discovery/spec_shape.py +122 -0
  140. dstf/discovery/spec_update.py +493 -0
  141. dstf/discovery/test_registry.py +488 -0
  142. dstf/discovery/unit_tests.py +95 -0
  143. dstf/figures/__init__.py +14 -0
  144. dstf/figures/assemble.py +48 -0
  145. dstf/figures/decimate.py +150 -0
  146. dstf/figures/families.py +289 -0
  147. dstf/figures/palette.py +170 -0
  148. dstf/figures/plotly.py +1413 -0
  149. dstf/figures/view.py +233 -0
  150. dstf/pipeline.py +435 -0
  151. dstf/plugins.py +104 -0
  152. dstf/reporting/__init__.py +0 -0
  153. dstf/reporting/console_report.py +187 -0
  154. dstf/reporting/context_builder.py +979 -0
  155. dstf/reporting/dashboard_render.py +534 -0
  156. dstf/reporting/junit_report.py +192 -0
  157. dstf/reporting/overlay_loader.py +708 -0
  158. dstf/reporting/report_suite.py +1059 -0
  159. dstf/reporting/rows.py +458 -0
  160. dstf/reporting/schema_export.py +413 -0
  161. dstf/reporting/stats_rows.py +321 -0
  162. dstf/reporting/status.py +388 -0
  163. dstf/reporting/templates/axes.js +61 -0
  164. dstf/reporting/templates/dashboard.html +284 -0
  165. dstf/reporting/templates/dashboard.js +604 -0
  166. dstf/reporting/templates/filters.js +100 -0
  167. dstf/reporting/templates/interactive.html +420 -0
  168. dstf/reporting/templates/report.js +116 -0
  169. dstf/reporting/templates/vendor/plotly.min.js +8 -0
  170. dstf/reporting/theme.py +324 -0
  171. dstf/reporting/tree_rows.py +73 -0
  172. dstf/reporting/verdicts.py +124 -0
  173. dstf/storage/__init__.py +0 -0
  174. dstf/storage/accept.py +216 -0
  175. dstf/storage/baseline.py +43 -0
  176. dstf/storage/batch_manifest.py +204 -0
  177. dstf/storage/ref_index.py +295 -0
  178. dstf/storage/reference_store.py +661 -0
  179. dstf/storage/roles.py +576 -0
  180. dstf/storage/run_stats.py +85 -0
  181. dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
  182. dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
  183. dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
  184. dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
  185. dynamic_systems_testing_framework-1.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,83 @@
1
+ """The pending drawer's preview (the owner's ruling Q6 A): a test's entry
2
+ before and after its staged edits, as ``test_spec.json`` will hold it —
3
+ ``json.dumps(entry, indent=2)``, the indentation Save writes — diffed line
4
+ by line with ``difflib``, and each line's variable names read by their
5
+ label with the result-file name muted beside it. Pure: stdlib only."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import difflib
10
+ import json
11
+ import re
12
+ from collections.abc import Mapping
13
+ from dataclasses import dataclass
14
+ from typing import TYPE_CHECKING, Literal
15
+
16
+ if TYPE_CHECKING:
17
+ from ..backends.types import VariableText
18
+
19
+ Kind = Literal["same", "add", "del", "gap"]
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Line:
24
+ kind: Kind
25
+ text: str
26
+
27
+
28
+ #: What stands between two groups of changes.
29
+ GAP = Line("gap", "…")
30
+
31
+
32
+ def _dumped(entry: dict | None) -> list[str]:
33
+ return json.dumps(entry, indent=2).splitlines() if entry is not None else []
34
+
35
+
36
+ def entry_lines(
37
+ before: dict | None, after: dict | None, context: int = 3
38
+ ) -> list[Line]:
39
+ """The changed lines of ``before`` → ``after`` (None: no entry) with
40
+ ``context`` equal lines around each group of changes, a :data:`GAP`
41
+ between two groups, a replaced run as its removed lines then its added
42
+ ones; ``[]`` when the two are equal. ``autojunk`` is off: in a long
43
+ tree ``}`` and ``"metric": "nrmse",`` are each more than 1% of the
44
+ lines, and junking them turned two changed tolerances into some two
45
+ hundred replaced lines."""
46
+ a, b = _dumped(before), _dumped(after)
47
+ matcher = difflib.SequenceMatcher(a=a, b=b, autojunk=False)
48
+ out: list[Line] = []
49
+ for n, group in enumerate(matcher.get_grouped_opcodes(context)):
50
+ if n:
51
+ out.append(GAP)
52
+ for tag, i1, i2, j1, j2 in group:
53
+ if tag == "equal":
54
+ out += [Line("same", t) for t in a[i1:i2]]
55
+ continue
56
+ out += [Line("del", t) for t in a[i1:i2]]
57
+ out += [Line("add", t) for t in b[j1:j2]]
58
+ return out
59
+
60
+
61
+ #: A JSON string token, escapes included.
62
+ _STRING = re.compile(r'"(?:[^"\\]|\\.)*"')
63
+
64
+
65
+ def spans(text: str, texts: Mapping[str, VariableText]) -> list[tuple[str, bool]]:
66
+ """``text`` as ``(fragment, muted)`` pieces: each JSON string that names
67
+ a variable of ``texts`` whose label is not its name reads as the label,
68
+ then the name muted, its quotes dropped; everything else is kept
69
+ verbatim, so a line with no such name joins back to itself."""
70
+ out: list[tuple[str, bool]] = []
71
+ pos = 0
72
+ for m in _STRING.finditer(text):
73
+ name = json.loads(m.group())
74
+ vt = texts.get(name)
75
+ if vt is None or vt.label == name:
76
+ continue
77
+ if m.start() > pos:
78
+ out.append((text[pos : m.start()], False))
79
+ out += [(vt.label, False), (" " + name, True)]
80
+ pos = m.end()
81
+ if pos < len(text):
82
+ out.append((text[pos:], False))
83
+ return out
@@ -0,0 +1,99 @@
1
+ """A Suite row from the Session: the row model's ``TestRow``
2
+ (``reporting.rows``) built from what the Session holds — its comparison
3
+ through ``summary_fields`` (the numbers the report's sidecar carries,
4
+ computed the same way), the result's timing, the test's provenance, its
5
+ staged edits and its place in the simulation queue. In process: a sidecar is the
6
+ full-resolution context, one JSON parse per test. No UI framework here
7
+ (the Session tier)."""
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Collection, Iterable, Mapping
12
+ from dataclasses import replace
13
+ from typing import TYPE_CHECKING
14
+
15
+ from ..backends.pool import ItemState
16
+ from ..reporting.rows import TestRow, summary_fields
17
+ from ..reporting.status import (
18
+ QUEUED,
19
+ RUNNING,
20
+ SCORING_FAILED,
21
+ SIM_FAIL,
22
+ scored_status,
23
+ )
24
+ from .spec_document import edit_counts
25
+
26
+ if TYPE_CHECKING:
27
+ from .session import Session
28
+
29
+
30
+ def _edits(session: Session) -> Mapping[str, int]:
31
+ return edit_counts(session.spec.pending)
32
+
33
+
34
+ def suite_row(
35
+ session: Session,
36
+ model_id: str,
37
+ *,
38
+ failed: Collection[str] = (),
39
+ edits: Mapping[str, int] | None = None,
40
+ ) -> TestRow:
41
+ """One Suite row. A test the queue holds reads QUEUED or RUNNING,
42
+ whatever else is true of it — QUEUED from the click on, while the
43
+ click's read of the library is still out (`Session.checking`); then the
44
+ comparison — CANCELLED where it
45
+ has no result because its run in the queue was stopped (until that
46
+ row is cleared); then a scoring failure the open announced (``failed`` —
47
+ the shell's set, kept until the next open, so a model a later rescore
48
+ scored reads its verdict, not the failure); else the row waits for its
49
+ verdict. ``edits`` is the pending-edit count per model; ``suite_rows``
50
+ counts it once for every row."""
51
+ counts = _edits(session) if edits is None else edits
52
+ project = session.project
53
+ test = project.test(model_id) if project is not None else None
54
+ row = TestRow(
55
+ model_id=model_id,
56
+ field_sources=dict(test.field_sources) if test is not None else {},
57
+ edits=counts.get(model_id, 0),
58
+ )
59
+ state = session.queue_state(model_id)
60
+ comp = session.comparisons.get(model_id)
61
+ if state is ItemState.WAITING or model_id in session.checking:
62
+ return replace(row, status=QUEUED)
63
+ if state is ItemState.RUNNING:
64
+ return replace(row, status=RUNNING)
65
+ if comp is not None:
66
+ result = project.results.get(model_id) if project is not None else None
67
+ s = summary_fields(
68
+ comp,
69
+ stats=result.statistics if result is not None else None,
70
+ ref_id=f"ref_{comp.test_id}" if comp.test_id else None,
71
+ field_sources=row.field_sources,
72
+ )
73
+ return replace(
74
+ row,
75
+ status=scored_status(s["status"], cancelled=state is ItemState.CANCELLED),
76
+ ref_id=s["ref_id"],
77
+ worst_score=s["worst_nrmse"],
78
+ n_unscored=s["n_unscored"],
79
+ n_vars=s["n_vars"],
80
+ n_vars_passed=s["n_vars_passed"],
81
+ n_warnings=s["n_warnings"],
82
+ translation_wall=s["translation_wall"],
83
+ sim_wall=s["sim_wall"],
84
+ sim_cpu=s["sim_cpu"],
85
+ total_wall=s["total_wall"],
86
+ message=comp.error_message,
87
+ )
88
+ if model_id in failed:
89
+ return replace(row, status=SIM_FAIL, message=f"{SCORING_FAILED} (see the log)")
90
+ return replace(row, waiting=True)
91
+
92
+
93
+ def suite_rows(
94
+ session: Session, model_ids: Iterable[str], *, failed: Collection[str] = ()
95
+ ) -> list[TestRow]:
96
+ """The rows of ``model_ids``, in that order; the edits counted once."""
97
+ counts = _edits(session)
98
+ failed_set = set(failed)
99
+ return [suite_row(session, m, failed=failed_set, edits=counts) for m in model_ids]
@@ -0,0 +1,516 @@
1
+ """Moving a subtree of a raw metrics tree (U4): the whole new tree, or the
2
+ one sentence saying why not. UI-free and pure: the Edit tree capability
3
+ stages the tree `plan_move` returns as ONE ``add /metrics`` op, and lists
4
+ every place a node can go from `move_destinations`.
5
+
6
+ The owner's rulings it implements: a move that empties a combinator
7
+ removes it, up the tree (D2); a ``k-of-n`` whose ``k`` exceeds its
8
+ criteria lowers ``k``, a weight leaves a ``weighted`` with its child and a
9
+ node moved into one gets weight 1 — each said in the move's notes (D3); a
10
+ move that puts under an ``or`` or a ``k-of-n`` a child that can never fail
11
+ is refused (D4, by D142's reason: it would pass its parent with nothing
12
+ else passing). Only a pairing the move MAKES is refused — a tree as it is
13
+ stays as it is.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import copy
19
+ import re
20
+ from collections.abc import Callable, Iterator, Mapping, Sequence
21
+ from dataclasses import dataclass
22
+ from typing import TYPE_CHECKING, Literal
23
+
24
+ from ..comparison.validator import BaselineRoleLookup
25
+ from .tree_pointer import node_or_none
26
+
27
+ if TYPE_CHECKING:
28
+ from .spec_document import PatchOp
29
+
30
+ ROOT = "/metrics"
31
+
32
+ # V5's sentences, in the order they are checked (a move that changes
33
+ # nothing is None between INTO_ITSELF and INTO_WARN), the validator's last.
34
+ ROOT_REFUSAL = "The root holds the whole tree: move what is in it instead."
35
+ INTO_ITSELF = "A criterion group can't move into itself."
36
+ INTO_WARN = (
37
+ "A warning holds exactly one criterion or group: drop onto it to wrap "
38
+ "both in `and`."
39
+ )
40
+ WARN_UNDER = (
41
+ "A warning passes whatever it scores: under or / k-of-n it would pass its "
42
+ "parent with nothing else passing."
43
+ )
44
+ #: Not the tree's refusal but the drag's: one that started on rows rebuilt
45
+ #: before its drop (`TreeCard.dropped`), whose paths name another tree.
46
+ TREE_CHANGED = "The tree changed while you dragged: try again."
47
+
48
+ #: The place a node already is, as `move_destinations` lists it.
49
+ WHERE_IT_IS = "where it is"
50
+ #: A place before a combinator's child, as `move_destinations` says it:
51
+ #: this, a space, and that child's text (the menu cuts that in its middle).
52
+ BEFORE = "before"
53
+
54
+ #: The combinators a child that can never fail may not newly sit under.
55
+ _NO_FREE_PASS_UNDER = ("or", "k-of-n")
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class Dest:
60
+ """Where a node goes. ``at``: into the combinator at ``path``, before
61
+ the child at ``index`` as the tree is NOW, before the move (``None``:
62
+ at its end). ``onto``: onto the node at ``path`` — a criterion is
63
+ wrapped with the moved node in an ``and`` (it first), a combinator
64
+ takes it at its end."""
65
+
66
+ kind: Literal["at", "onto"]
67
+ path: str
68
+ index: int | None = None
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class Moved:
73
+ tree: dict # the whole new tree: what `add /metrics` stages
74
+ path: str # where the moved node is in it
75
+ notes: tuple[str, ...] # what else changed: "k 3 → 2", ...
76
+ #: Every node the move kept: its path before -> its path in ``tree``.
77
+ #: A move shifts its old and new siblings too; what is keyed by a
78
+ #: node's path (the tree card's folded groups, U5 R1) follows this.
79
+ paths: dict[str, str]
80
+
81
+
82
+ class MoveRefused(ValueError):
83
+ """``str()`` is the one sentence the row shows (V5)."""
84
+
85
+
86
+ def validator_for(role_of: BaselineRoleLookup) -> Callable[[dict], None]:
87
+ """What a move's new tree must pass: the parser, then the D66 roles —
88
+ raising `MoveRefused` with the text `Session.stage`'s refusal shows (the
89
+ parser's message without its path; the validator's joined)."""
90
+ from ..comparison.tree_spec import MetricSpecError, parse_metric_tree
91
+ from ..comparison.validator import validate_tree
92
+
93
+ def validate(raw: dict) -> None:
94
+ try:
95
+ spec = parse_metric_tree(raw)
96
+ except MetricSpecError as e:
97
+ raise MoveRefused(e.message) from e
98
+ errors = validate_tree(spec, role_of)
99
+ if errors:
100
+ raise MoveRefused(" ".join(e.message for e in errors))
101
+
102
+ return validate
103
+
104
+
105
+ def _parent(path: str) -> tuple[str, int]:
106
+ parent, index = path.rsplit("/children/", 1)
107
+ return parent, int(index)
108
+
109
+
110
+ def _inside(path: str, src: str) -> bool:
111
+ return path == src or path.startswith(src + "/")
112
+
113
+
114
+ def _index_of(kids: list, node: dict) -> int:
115
+ return next(i for i, k in enumerate(kids) if k is node)
116
+
117
+
118
+ def _nodes(tree: dict) -> Iterator[dict]:
119
+ yield tree
120
+ for child in tree.get("children") or []:
121
+ yield from _nodes(child)
122
+
123
+
124
+ def _paths(tree: dict, path: str = ROOT) -> Iterator[tuple[int, str]]:
125
+ """``(id(node), path)`` for every node of ``tree``."""
126
+ yield id(tree), path
127
+ for i, child in enumerate(tree.get("children") or []):
128
+ yield from _paths(child, f"{path}/children/{i}")
129
+
130
+
131
+ def _path_of(tree: dict, node: dict, path: str = ROOT) -> str | None:
132
+ if tree is node:
133
+ return path
134
+ for i, child in enumerate(tree.get("children") or []):
135
+ found = _path_of(child, node, f"{path}/children/{i}")
136
+ if found is not None:
137
+ return found
138
+ return None
139
+
140
+
141
+ def always_passes(node: dict) -> bool:
142
+ """Can this node never fail, whatever its criteria score? A ``warn``
143
+ cannot; a combinator can by its children (an ``and`` all, an ``or``
144
+ any, a ``k-of-n`` at least k of them); a ``weighted`` sum and a
145
+ criterion can always fail."""
146
+ kind = node.get("combinator")
147
+ kids = node.get("children") or []
148
+ if kind == "warn":
149
+ return True
150
+ if kind == "and":
151
+ return bool(kids) and all(always_passes(c) for c in kids)
152
+ if kind == "or":
153
+ return any(always_passes(c) for c in kids)
154
+ if kind == "k-of-n":
155
+ k = node.get("k")
156
+ return isinstance(k, int) and sum(always_passes(c) for c in kids) >= k
157
+ return False
158
+
159
+
160
+ def _free_passes(tree: dict) -> Iterator[tuple[dict, dict]]:
161
+ """Every ``(parent, child)`` of ``tree`` with an ``or`` / ``k-of-n``
162
+ parent and a child that can never fail."""
163
+ for node in _nodes(tree):
164
+ if node.get("combinator") in _NO_FREE_PASS_UNDER:
165
+ for child in node.get("children") or []:
166
+ if always_passes(child):
167
+ yield node, child
168
+
169
+
170
+ def _same_place(tree: dict, src: str, dest: Dest) -> bool:
171
+ """Is ``dest`` where the node at ``src`` already is — before itself,
172
+ before the child after it, or the end when it is last?"""
173
+ parent, i = _parent(src)
174
+ if dest.path != parent:
175
+ return False
176
+ holder = node_or_none(tree, parent)
177
+ assert holder is not None # the parent of a node that exists
178
+ n = len(holder["children"])
179
+ index = dest.index if dest.kind == "at" else None
180
+ if index is None or index >= n:
181
+ return i == n - 1
182
+ return index in (i, i + 1)
183
+
184
+
185
+ def plan_move(
186
+ tree: dict,
187
+ src: str,
188
+ dest: Dest,
189
+ *,
190
+ validate: Callable[[dict], None] | None = None,
191
+ ) -> Moved | None:
192
+ """The whole new tree with the node at ``src`` moved to ``dest``, or
193
+ None when it would stay where it is. `MoveRefused` says why a move
194
+ cannot be made; ``validate`` (`validator_for`) is checked last."""
195
+ if src == ROOT:
196
+ raise MoveRefused(ROOT_REFUSAL)
197
+ if _inside(dest.path, src):
198
+ raise MoveRefused(INTO_ITSELF)
199
+ if node_or_none(tree, src) is None:
200
+ raise ValueError(f"{src} names no node")
201
+ target = node_or_none(tree, dest.path)
202
+ if target is None:
203
+ raise ValueError(f"{dest.path} names no node")
204
+ wrap = dest.kind == "onto" and "combinator" not in target
205
+ if not wrap and "combinator" not in target:
206
+ raise ValueError(f"{dest.path} is a criterion: nothing goes in it")
207
+ if not wrap and _same_place(tree, src, dest):
208
+ return None
209
+ if not wrap and target["combinator"] == "warn":
210
+ raise MoveRefused(INTO_WARN)
211
+
212
+ new = copy.deepcopy(tree)
213
+ # Each new node beside the one it was copied from, so a pairing D4
214
+ # finds is compared with the old tree by reference. The new node is
215
+ # held in the value too: a pruned dict freed mid-move would free its
216
+ # id() for the wrap's new `and`.
217
+ origin = {id(n): (n, o) for o, n in zip(_nodes(tree), _nodes(new))}
218
+ notes: list[str] = []
219
+ # Everything the move names, resolved BEFORE anything shifts: the dicts
220
+ # themselves are the identities (a removal renumbers a list, never
221
+ # replaces the dicts in it).
222
+ moved = node_or_none(new, src)
223
+ old_parent_path, old_index = _parent(src)
224
+ old_parent = node_or_none(new, old_parent_path)
225
+ into = node_or_none(new, dest.path)
226
+ assert moved is not None and old_parent is not None and into is not None
227
+ chain = [old_parent]
228
+ path = old_parent_path
229
+ while path != ROOT:
230
+ path = _parent(path)[0]
231
+ up = node_or_none(new, path)
232
+ assert up is not None
233
+ chain.append(up)
234
+ holder = node_or_none(new, _parent(dest.path)[0]) if wrap else None
235
+ if wrap:
236
+ assert holder is not None # a criterion at the root holds no src
237
+ anchor = None
238
+ else:
239
+ kids = into["children"]
240
+ index = dest.index if dest.kind == "at" else None
241
+ anchor = kids[index] if index is not None and index < len(kids) else None
242
+
243
+ # Out: the node leaves its parent; its weight goes with it — said,
244
+ # unless it only moves among the same weighted's children.
245
+ del old_parent["children"][old_index]
246
+ weight = _drop_weight(old_parent, old_index)
247
+ if weight is not None and into is not old_parent:
248
+ notes.append(_went(weight))
249
+ # In, BEFORE any repair: the destination then holds the node, so the
250
+ # prune below never removes it nor anything holding it, and a place
251
+ # before a combinator the prune removes is where that combinator was.
252
+ if holder is not None:
253
+ at = _index_of(holder["children"], into)
254
+ holder["children"][at] = {"combinator": "and", "children": [into, moved]}
255
+ else:
256
+ kids = into["children"]
257
+ at = len(kids) if anchor is None else _index_of(kids, anchor)
258
+ kids.insert(at, moved)
259
+ if into.get("combinator") == "weighted" and isinstance(
260
+ into.get("weights"), list
261
+ ):
262
+ if into is old_parent and weight is not None:
263
+ # A reorder among one weighted's children: the weight
264
+ # travels with its child, and nothing else changed.
265
+ into["weights"].insert(at, weight)
266
+ else:
267
+ into["weights"].insert(at, 1)
268
+ notes.append("weight 1 given")
269
+ # The source's side, repaired upward: an emptied combinator goes (D2)
270
+ # — the root never empties, it holds the destination — then every
271
+ # k-of-n that lost a child lowers k to its criteria (D3).
272
+ for node, up in zip(chain, chain[1:]):
273
+ if node["children"]:
274
+ break
275
+ i = _index_of(up["children"], node)
276
+ del up["children"][i]
277
+ notes.append(f"the empty {node['combinator']} was removed")
278
+ pruned = _drop_weight(up, i)
279
+ if pruned is not None:
280
+ notes.append(_went(pruned))
281
+ for node in chain:
282
+ k, n = node.get("k"), len(node["children"])
283
+ if node.get("combinator") == "k-of-n" and n and isinstance(k, int) and k > n:
284
+ node["k"] = n
285
+ notes.append(f"k {k} → {n}")
286
+
287
+ if not wrap and new == tree:
288
+ # Two equal siblings trading places: the tree is as it was, and a
289
+ # pending edit that changes nothing is no edit.
290
+ return None
291
+
292
+ # D4, by D142's reason: a child that can never fail under an `or` or
293
+ # a `k-of-n` is refused where the move made that pairing; one the old
294
+ # tree already had is left as it is.
295
+ for parent, child in _free_passes(new):
296
+ old_parent_of = origin.get(id(parent))
297
+ old_child_of = origin.get(id(child))
298
+ was = (
299
+ old_parent_of is not None
300
+ and old_child_of is not None
301
+ and old_parent_of[0] is parent
302
+ and old_child_of[0] is child
303
+ and any(c is old_child_of[1] for c in old_parent_of[1]["children"])
304
+ and always_passes(old_child_of[1])
305
+ )
306
+ if not was:
307
+ raise MoveRefused(WARN_UNDER)
308
+ if validate is not None:
309
+ validate(new)
310
+ where = _path_of(new, moved)
311
+ assert where is not None
312
+ # Each kept node by its copy (`origin`): a node the move removed (D2)
313
+ # is in no path of ``new``, the wrap's new `and` in no path of ``tree``.
314
+ before, after = dict(_paths(tree)), dict(_paths(new))
315
+ paths = {
316
+ before[id(old)]: after[key] for key, (_, old) in origin.items() if key in after
317
+ }
318
+ return Moved(tree=new, path=where, notes=tuple(notes), paths=paths)
319
+
320
+
321
+ #: A JSON pointer that names a node of the tree, not one of its fields: the
322
+ #: root, or a child at an index or at the end (``-``).
323
+ _NODE_PATH = re.compile(rf"{ROOT}(/children/(\d+|-))*")
324
+
325
+
326
+ def path_map(tree: dict, ops: Sequence[PatchOp]) -> dict[str, str]:
327
+ """Every node of ``tree`` that ``ops`` keep: its path before -> after
328
+ (U5 R1) — what is keyed by a node's path follows it. Not by comparing
329
+ the trees on both sides of a stage: the Session stages a copy, so no
330
+ node is the same object on both. By the ops' own semantics, over
331
+ ``tree`` as the edit read it: an add at an index shifts the siblings
332
+ after it, a remove drops its subtree and shifts them back, a replace (an
333
+ add at the root) puts its value where the node was — and a node of
334
+ ``tree`` that an op's value CARRIES (a wrap's child, an unwrap's
335
+ children, the root leaf an add pairs with the new one) is that node,
336
+ found by identity: `EditTree.act` builds its values from the very tree
337
+ it read. A copy is a new node — a move's whole new tree is one, which is
338
+ why a move reports `Moved.paths`. An op on a field (``/k``,
339
+ ``/combinator``) moves no node."""
340
+ was = {key: path for key, path in _paths(tree)}
341
+
342
+ def shadow(node: dict) -> dict:
343
+ return {
344
+ "was": was.get(id(node)),
345
+ "children": [shadow(c) for c in node.get("children") or []],
346
+ }
347
+
348
+ root = shadow(tree)
349
+ for op in ops:
350
+ if not _NODE_PATH.fullmatch(op.path):
351
+ continue
352
+ if op.path == ROOT:
353
+ if op.op != "remove":
354
+ root = shadow(op.value)
355
+ continue
356
+ parent_path, index = op.path.rsplit("/children/", 1)
357
+ parent = root
358
+ for i in parent_path[len(ROOT) :].split("/children/")[1:]:
359
+ parent = parent["children"][int(i)]
360
+ kids = parent["children"]
361
+ at = len(kids) if index == "-" else int(index)
362
+ if op.op == "remove":
363
+ del kids[at]
364
+ elif op.op == "add":
365
+ kids.insert(at, shadow(op.value))
366
+ elif op.op == "replace":
367
+ kids[at] = shadow(op.value)
368
+ else:
369
+ raise ValueError(f"{op.op!r} is no op an edit of the tree stages")
370
+ out: dict[str, str] = {}
371
+
372
+ def walk(node: dict, path: str) -> None:
373
+ if node["was"] is not None:
374
+ out[node["was"]] = path
375
+ for i, child in enumerate(node["children"]):
376
+ walk(child, f"{path}/children/{i}")
377
+
378
+ walk(root, ROOT)
379
+ return out
380
+
381
+
382
+ def _drop_weight(node: dict, index: int) -> float | None:
383
+ """A ``weighted`` node's weight at ``index``, taken out with its child."""
384
+ if node.get("combinator") != "weighted":
385
+ return None
386
+ weights = node.get("weights")
387
+ if not isinstance(weights, list) or index >= len(weights):
388
+ return None
389
+ return weights.pop(index)
390
+
391
+
392
+ def _went(weight: object) -> str:
393
+ """The note for a weight that left its ``weighted`` with its child."""
394
+ text = format(weight, "g") if isinstance(weight, (int, float)) else str(weight)
395
+ return f"weight {text} went with it"
396
+
397
+
398
+ def _view(node: dict, names: Mapping[str, str]) -> dict:
399
+ """The raw node as `tree_text` reads a `tree_view` node: a criterion's
400
+ label is what the page reads for its variable."""
401
+ if "combinator" not in node:
402
+ return {"kind": "leaf", "label": names.get(node.get("variable", "")), **node}
403
+ return {
404
+ **node,
405
+ "kind": "combinator",
406
+ "children": [_view(c, names) for c in node.get("children") or []],
407
+ }
408
+
409
+
410
+ def move_destinations(
411
+ tree: dict,
412
+ src: str,
413
+ *,
414
+ validate: Callable[[dict], None] | None = None,
415
+ names: Mapping[str, str] | None = None,
416
+ ) -> list[tuple[str, str, Dest, str | None]]:
417
+ """Every place the node at ``src`` can go, in tree order, as ``(text,
418
+ where, dest, reason)``: a combinator's (``text``) positions between its
419
+ children's rows — "before" the row there, the drag's own word, and "at
420
+ the end" — and a criterion's "wrap both in and". ``names`` is each
421
+ variable's text as the page reads it (`SessionView.labels`). Where two
422
+ siblings read the same, "before" either would not say which: their
423
+ places are numbered instead ("at 2").
424
+
425
+ The places beside the node read as ONE row, "where it is" (reason None;
426
+ the caller greys it: `plan_move` gives None). The node's own row is
427
+ not listed; the places in its own subtree are, with their refusal."""
428
+ from .tree_text import node_text
429
+
430
+ names = names or {}
431
+ rows: list[tuple[str, str, Dest, str | None]] = []
432
+ here = False
433
+
434
+ def one(text: str, where: str, dest: Dest) -> None:
435
+ nonlocal here
436
+ try:
437
+ planned = plan_move(tree, src, dest, validate=validate)
438
+ except MoveRefused as e:
439
+ rows.append((text, where, dest, str(e)))
440
+ return
441
+ if planned is None:
442
+ if not here:
443
+ here = True
444
+ rows.append((text, WHERE_IT_IS, dest, None))
445
+ return
446
+ rows.append((text, where, dest, None))
447
+
448
+ def walk(node: dict, path: str) -> None:
449
+ text = node_text(_view(node, names))
450
+ if path == ROOT:
451
+ text += " (root)"
452
+ if "combinator" not in node:
453
+ if path != src:
454
+ one(text, "wrap both in and", Dest("onto", path))
455
+ return
456
+ kids = node.get("children") or []
457
+ befores = [node_text(_view(child, names)) for child in kids]
458
+ for i, child in enumerate(kids):
459
+ before = befores[i]
460
+ unique = befores.count(before) == 1
461
+ where = f"{BEFORE} {before}" if unique else f"at {i + 1}"
462
+ one(text, where, Dest("at", path, i))
463
+ walk(child, f"{path}/children/{i}")
464
+ one(text, "at the end", Dest("at", path, None))
465
+
466
+ walk(tree, ROOT)
467
+ return rows
468
+
469
+
470
+ #: A drag's zones on a row (D1 as amended by the owner): its top half is
471
+ #: before it, its bottom half onto it — no "after": every pixel line means
472
+ #: one place. To put a node last at an outer level, drop onto that outer
473
+ #: combinator. The root has only "onto": nothing sits beside it.
474
+ ZONES = ("before", "onto")
475
+
476
+
477
+ def zone_dest(path: str, zone: str) -> Dest | None:
478
+ """The destination a drop in ``zone`` of ``path``'s row means: before
479
+ it among its siblings, or onto it (a criterion: wrap both in ``and``;
480
+ a combinator: its end). None for a zone the row has not got."""
481
+ if zone == "onto":
482
+ return Dest("onto", path)
483
+ if zone == "before" and path != ROOT:
484
+ parent, index = _parent(path)
485
+ return Dest("at", parent, index)
486
+ return None
487
+
488
+
489
+ def zone_map(
490
+ tree: dict, src: str, *, validate: Callable[[dict], None] | None = None
491
+ ) -> dict[str, dict[str, str | None]]:
492
+ """``{row path: {zone: reason}}`` over every row of ``tree`` for a drag
493
+ of the node at ``src``: ``None`` allowed, ``""`` where it already is,
494
+ else the sentence that refuses it. The page asks once per drag and
495
+ draws every zone from the answer, with no round trip while dragging:
496
+ it grows with the square of the tree (134 nodes ~0.2 s)."""
497
+ out: dict[str, dict[str, str | None]] = {}
498
+
499
+ def walk(node: dict, path: str) -> None:
500
+ zones: dict[str, str | None] = {}
501
+ for zone in ZONES:
502
+ dest = zone_dest(path, zone)
503
+ if dest is None:
504
+ continue
505
+ try:
506
+ planned = plan_move(tree, src, dest, validate=validate)
507
+ except MoveRefused as e:
508
+ zones[zone] = str(e)
509
+ continue
510
+ zones[zone] = "" if planned is None else None
511
+ out[path] = zones
512
+ for i, child in enumerate(node.get("children") or []):
513
+ walk(child, f"{path}/children/{i}")
514
+
515
+ walk(tree, ROOT)
516
+ return out