dtree-encoding 1.6.0__tar.gz → 1.8.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dtree-encoding
3
- Version: 1.6.0
3
+ Version: 1.8.0
4
4
  Summary: DTree: incremental encoding of the topology of directed trees by two projections (Q1, Q2): O(1) reachability, and/or queries on the topology of the tree, dynamic (incremental) updates
5
5
  Author-email: Enis Olgac <enis@digraphs.info>
6
6
  License: MIT
@@ -59,7 +59,9 @@ t = DTree.from_file("Test.csx") # e.g. examples/Test.csx from the repository
59
59
  | C# (docs) | Python |
60
60
  |---|---|
61
61
  | `DTree(id)` | `DTree(tree_id="P1")` |
62
- | `Insert(parent, child)` | `insert(parent, child)` |
62
+ | `Insert(parent, child)` | `insert(parent, child)` (last child) |
63
+ | `InsertAfter`, `InsertBefore` | `insert_after(sibling, child)`, `insert_before(sibling, child)` |
64
+ | `AddLine` | `add_line(line)` (`p: c`, `s > c`, `s < c`; also in `from_file`) |
63
65
  | `Contains(node)` | `contains(node)` / `node in t` |
64
66
  | `GetParent(node)` | `get_parent(node)` (`None` for the root) |
65
67
  | `CutNPaste(u, p)` | `cut_n_paste(subtree_root, new_parent)` |
@@ -75,9 +77,12 @@ t = DTree.from_file("Test.csx") # e.g. examples/Test.csx from the repository
75
77
  | `PathToRoot`, `RootToPath` | `path_to_root`, `root_to_path` (plus `path(u, v)`) |
76
78
  | `GetNode`, `TryGetNode`, `DNode` | `get_node`, `try_get_node`, `DNode` (node handles; `insert` returns one) |
77
79
  | `SubtreeLast` | `subtree_last` (last node of a subtree in Q1) |
80
+ | `NextInQ1` | `next_in_q1` (next node in Q1, the display order; `None` at the end) |
78
81
  | `VMin`, `VMax`, `UMin`, `UMax` (of nodes) | `vmin`, `vmax`, `umin`, `umax` |
79
82
  | `ReachesAll`, `MayReachSome`, `ReachesSome` | `reaches_all`, `may_reach_some`, `reaches_some` |
80
83
  | `ReachedByAll`, `MayBeReachedBySome`, `ReachedBySome` | `reached_by_all`, `may_be_reached_by_some`, `reached_by_some` |
84
+ | `Add`, `Discard`, `Contains` (of a constructed node) | `add`, `discard`, `node in s` |
85
+ | `UMin.Covering` | `covering` (topmost member that reaches a node; `None` if none) |
81
86
 
82
87
  ## Semantics
83
88
 
@@ -88,7 +93,7 @@ t = DTree.from_file("Test.csx") # e.g. examples/Test.csx from the repository
88
93
  - `delete(v)` re-attaches v's children to v's parent, in v's place among its siblings.
89
94
  - `delete_subtree(v)` removes v with all its descendants and returns them in Q1 order.
90
95
  - `cut_n_paste(u, p)` moves the subtree rooted at u under p as p's last child; p must not lie inside u's subtree.
91
- - And/or queries on the topology: the constructed nodes `vmin(S)`, `vmax(S)` (S as target) and `umin(S)`, `umax(S)` (S as source) take the minimum or maximum of each index over S, separately in Q1 and Q2. `reaches_all` and `reached_by_all` are exact O(1) tests; `may_reach_some` and `may_be_reached_by_some` are only filters, and `reaches_some` and `reached_by_some` are exact in O(log k). A constructed node stays valid under insertions and is recomputed automatically after `delete`, `delete_subtree` or `cut_n_paste`. See [and/or queries on the topology](https://github.com/EnisOlgac/DTree/blob/main/docs/and-or-queries.md).
96
+ - And/or queries on the topology: the constructed nodes `vmin(S)`, `vmax(S)` (S as target) and `umin(S)`, `umax(S)` (S as source) take the minimum or maximum of each index over S, separately in Q1 and Q2. `reaches_all` and `reached_by_all` are exact O(1) tests; `may_reach_some` and `may_be_reached_by_some` are only filters, and `reaches_some` and `reached_by_some` are exact in O(log k). Members are added with `add` and removed with `discard`. A constructed node stays valid under insertions and is recomputed automatically after `delete`, `delete_subtree` or `cut_n_paste`. See [and/or queries on the topology](https://github.com/EnisOlgac/DTree/blob/main/docs/and-or-queries.md).
92
97
  - Projection positions are fractions: a new node gets the mediant (a+c)/(b+d) of its neighbors, so normally no other node moves. Only rare alternating insertion patterns trigger a local renumbering. Tree depth and width are unbounded. `t.Q1.index(v)` returns a position as a `Fraction`.
93
98
 
94
99
  ## Tests
@@ -101,14 +106,21 @@ Every operation is checked against a brute-force parent/children model on random
101
106
 
102
107
  ## Benchmarks
103
108
 
109
+ The benchmarks come with the package, so you can measure on your own machine:
110
+
104
111
  ```
105
- python benchmarks/bench.py # trees of 10,000 and 100,000 nodes
106
- python benchmarks/bench.py --quick # a few seconds
107
- python benchmarks/bench.py --dynamic # only insertions and queries alternating
108
- python benchmarks/bench.py 1000000 # custom sizes
112
+ python -m dtree.bench # trees of 10,000 and 100,000 nodes
113
+ python -m dtree.bench --quick # a few seconds
114
+ python -m dtree.bench --dynamic # only the dynamic updates and the tree view
115
+ python -m dtree.bench --note "idle machine" # remark for the report
116
+ python -m dtree.bench 1000000 # custom sizes
109
117
  ```
110
118
 
111
- The script prints Markdown tables: build time and renumbering counts per tree shape (random, chain, star at the root, star at an inner node, alternating worst case), list growth at either end, and timings for `can_reach`, `depth`, `delete`, `cut_n_paste` and `delete_subtree`, and comparisons with a classical parent walk: reachability by tree depth, and/or queries with constructed nodes, and dynamic updates (insertions and queries alternating). Each `DList` keeps the counters `renumber_count` and `renumber_moved`, so the same figures are available in your own code via `t.Q1` and `t.Q2`.
119
+ In a clone of the repository, `python python/benchmarks/bench.py` runs the same code from the sources.
120
+
121
+ The script prints Markdown tables: build time and renumbering counts per tree shape (random, chain, star at the root, star at an inner node, alternating worst case), list growth at either end, and timings for `can_reach`, `depth`, `delete`, `cut_n_paste` and `delete_subtree`, and comparisons with a classical parent walk: reachability by tree depth, and/or queries with constructed nodes, dynamic updates (insertions with 1 or 10 queries each, and static pre/post numbers at depths 100 and 1,000), and a tree view with expand/collapse. Each dynamic measurement is the best of three runs. Each `DList` keeps the counters `renumber_count` and `renumber_moved`, so the same figures are available in your own code via `t.Q1` and `t.Q2`.
122
+
123
+ Every run also writes its tables as a Markdown report to `benchmarks/python/`—in the current directory, or at the repository root when run inside a clone— named after date and time (for example `2026-10-17_14-18.md`), with a header giving the DTree and Python versions, the operating system and the processor; `--no-save` skips it. The C# benchmarks (`dotnet run --project csharp/DTree.Tests -c Release -- bench`) write theirs to `benchmarks/csharp/`.
112
124
 
113
125
  ## Provenance
114
126
 
@@ -36,7 +36,9 @@ t = DTree.from_file("Test.csx") # e.g. examples/Test.csx from the repository
36
36
  | C# (docs) | Python |
37
37
  |---|---|
38
38
  | `DTree(id)` | `DTree(tree_id="P1")` |
39
- | `Insert(parent, child)` | `insert(parent, child)` |
39
+ | `Insert(parent, child)` | `insert(parent, child)` (last child) |
40
+ | `InsertAfter`, `InsertBefore` | `insert_after(sibling, child)`, `insert_before(sibling, child)` |
41
+ | `AddLine` | `add_line(line)` (`p: c`, `s > c`, `s < c`; also in `from_file`) |
40
42
  | `Contains(node)` | `contains(node)` / `node in t` |
41
43
  | `GetParent(node)` | `get_parent(node)` (`None` for the root) |
42
44
  | `CutNPaste(u, p)` | `cut_n_paste(subtree_root, new_parent)` |
@@ -52,9 +54,12 @@ t = DTree.from_file("Test.csx") # e.g. examples/Test.csx from the repository
52
54
  | `PathToRoot`, `RootToPath` | `path_to_root`, `root_to_path` (plus `path(u, v)`) |
53
55
  | `GetNode`, `TryGetNode`, `DNode` | `get_node`, `try_get_node`, `DNode` (node handles; `insert` returns one) |
54
56
  | `SubtreeLast` | `subtree_last` (last node of a subtree in Q1) |
57
+ | `NextInQ1` | `next_in_q1` (next node in Q1, the display order; `None` at the end) |
55
58
  | `VMin`, `VMax`, `UMin`, `UMax` (of nodes) | `vmin`, `vmax`, `umin`, `umax` |
56
59
  | `ReachesAll`, `MayReachSome`, `ReachesSome` | `reaches_all`, `may_reach_some`, `reaches_some` |
57
60
  | `ReachedByAll`, `MayBeReachedBySome`, `ReachedBySome` | `reached_by_all`, `may_be_reached_by_some`, `reached_by_some` |
61
+ | `Add`, `Discard`, `Contains` (of a constructed node) | `add`, `discard`, `node in s` |
62
+ | `UMin.Covering` | `covering` (topmost member that reaches a node; `None` if none) |
58
63
 
59
64
  ## Semantics
60
65
 
@@ -65,7 +70,7 @@ t = DTree.from_file("Test.csx") # e.g. examples/Test.csx from the repository
65
70
  - `delete(v)` re-attaches v's children to v's parent, in v's place among its siblings.
66
71
  - `delete_subtree(v)` removes v with all its descendants and returns them in Q1 order.
67
72
  - `cut_n_paste(u, p)` moves the subtree rooted at u under p as p's last child; p must not lie inside u's subtree.
68
- - And/or queries on the topology: the constructed nodes `vmin(S)`, `vmax(S)` (S as target) and `umin(S)`, `umax(S)` (S as source) take the minimum or maximum of each index over S, separately in Q1 and Q2. `reaches_all` and `reached_by_all` are exact O(1) tests; `may_reach_some` and `may_be_reached_by_some` are only filters, and `reaches_some` and `reached_by_some` are exact in O(log k). A constructed node stays valid under insertions and is recomputed automatically after `delete`, `delete_subtree` or `cut_n_paste`. See [and/or queries on the topology](https://github.com/EnisOlgac/DTree/blob/main/docs/and-or-queries.md).
73
+ - And/or queries on the topology: the constructed nodes `vmin(S)`, `vmax(S)` (S as target) and `umin(S)`, `umax(S)` (S as source) take the minimum or maximum of each index over S, separately in Q1 and Q2. `reaches_all` and `reached_by_all` are exact O(1) tests; `may_reach_some` and `may_be_reached_by_some` are only filters, and `reaches_some` and `reached_by_some` are exact in O(log k). Members are added with `add` and removed with `discard`. A constructed node stays valid under insertions and is recomputed automatically after `delete`, `delete_subtree` or `cut_n_paste`. See [and/or queries on the topology](https://github.com/EnisOlgac/DTree/blob/main/docs/and-or-queries.md).
69
74
  - Projection positions are fractions: a new node gets the mediant (a+c)/(b+d) of its neighbors, so normally no other node moves. Only rare alternating insertion patterns trigger a local renumbering. Tree depth and width are unbounded. `t.Q1.index(v)` returns a position as a `Fraction`.
70
75
 
71
76
  ## Tests
@@ -78,14 +83,21 @@ Every operation is checked against a brute-force parent/children model on random
78
83
 
79
84
  ## Benchmarks
80
85
 
86
+ The benchmarks come with the package, so you can measure on your own machine:
87
+
81
88
  ```
82
- python benchmarks/bench.py # trees of 10,000 and 100,000 nodes
83
- python benchmarks/bench.py --quick # a few seconds
84
- python benchmarks/bench.py --dynamic # only insertions and queries alternating
85
- python benchmarks/bench.py 1000000 # custom sizes
89
+ python -m dtree.bench # trees of 10,000 and 100,000 nodes
90
+ python -m dtree.bench --quick # a few seconds
91
+ python -m dtree.bench --dynamic # only the dynamic updates and the tree view
92
+ python -m dtree.bench --note "idle machine" # remark for the report
93
+ python -m dtree.bench 1000000 # custom sizes
86
94
  ```
87
95
 
88
- The script prints Markdown tables: build time and renumbering counts per tree shape (random, chain, star at the root, star at an inner node, alternating worst case), list growth at either end, and timings for `can_reach`, `depth`, `delete`, `cut_n_paste` and `delete_subtree`, and comparisons with a classical parent walk: reachability by tree depth, and/or queries with constructed nodes, and dynamic updates (insertions and queries alternating). Each `DList` keeps the counters `renumber_count` and `renumber_moved`, so the same figures are available in your own code via `t.Q1` and `t.Q2`.
96
+ In a clone of the repository, `python python/benchmarks/bench.py` runs the same code from the sources.
97
+
98
+ The script prints Markdown tables: build time and renumbering counts per tree shape (random, chain, star at the root, star at an inner node, alternating worst case), list growth at either end, and timings for `can_reach`, `depth`, `delete`, `cut_n_paste` and `delete_subtree`, and comparisons with a classical parent walk: reachability by tree depth, and/or queries with constructed nodes, dynamic updates (insertions with 1 or 10 queries each, and static pre/post numbers at depths 100 and 1,000), and a tree view with expand/collapse. Each dynamic measurement is the best of three runs. Each `DList` keeps the counters `renumber_count` and `renumber_moved`, so the same figures are available in your own code via `t.Q1` and `t.Q2`.
99
+
100
+ Every run also writes its tables as a Markdown report to `benchmarks/python/`—in the current directory, or at the repository root when run inside a clone— named after date and time (for example `2026-10-17_14-18.md`), with a header giving the DTree and Python versions, the operating system and the processor; `--no-save` skips it. The C# benchmarks (`dotnet run --project csharp/DTree.Tests -c Release -- bench`) write theirs to `benchmarks/csharp/`.
89
101
 
90
102
  ## Provenance
91
103
 
@@ -3,4 +3,4 @@
3
3
  from .dtree import SINK, SOURCE, DList, DNode, DTree, UMax, UMin, VMax, VMin
4
4
 
5
5
  __all__ = ["DTree", "DNode", "DList", "VMin", "VMax", "UMin", "UMax", "SOURCE", "SINK"]
6
- __version__ = "1.6.0"
6
+ __version__ = "1.8.0"
@@ -0,0 +1,580 @@
1
+ """Performance benchmarks for DTree.
2
+
3
+ Usage, after ``pip install dtree-encoding`` (or from the repository's python/ folder,
4
+ where ``python benchmarks/bench.py`` does the same):
5
+
6
+ python -m dtree.bench # sizes 10,000 and 100,000
7
+ python -m dtree.bench 1000 1000000 # custom sizes
8
+ python -m dtree.bench --quick # small sizes, a few seconds
9
+ python -m dtree.bench --dynamic # only the dynamic updates and the tree view
10
+ python -m dtree.bench --note "idle machine after restart" # remark for the report
11
+ python -m dtree.bench --no-save # do not write a report
12
+
13
+ Every run also writes its tables as a Markdown report, named after date and time
14
+ (e.g. 2026-10-17_14-18.md), with a header giving the DTree and Python versions, the
15
+ operating system and the processor: to benchmarks/python/ at the repository root when
16
+ run from a clone, otherwise to benchmarks/python/ in the current directory.
17
+
18
+ For each tree shape it reports the build time and how often positions in Q1
19
+ and Q2 had to be renumbered, then times the queries and structural changes.
20
+ Finally it compares reachability with the parent walk of classical doubly linked
21
+ trees, for single pairs and for and/or queries (VMin, VMax, UMin, UMax), over increasing depth,
22
+ and under dynamic updates, with insertions and queries alternating (including static
23
+ pre/post numbers recomputed after each insertion), and in a use case: a tree view whose
24
+ nodes are inserted, collapsed and expanded while rows are shown.
25
+ Timings depend on the machine; the renumbering counts do not.
26
+ """
27
+
28
+ import argparse
29
+ import datetime
30
+ import io
31
+ import os
32
+ import platform
33
+ import random
34
+ import sys
35
+ import time
36
+
37
+ import dtree
38
+ from dtree import SINK, SOURCE, DList, DTree
39
+
40
+
41
+ def _base_dir():
42
+ """The repository root when run from a clone, otherwise the current directory."""
43
+ root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
44
+ if os.path.isdir(os.path.join(root, "csharp")) and os.path.isdir(os.path.join(root, "python")):
45
+ return root
46
+ return os.getcwd()
47
+
48
+
49
+ REPO = _base_dir()
50
+ REPORTS = os.path.join(REPO, "benchmarks", "python")
51
+
52
+
53
+ class _Tee(io.TextIOBase):
54
+ """Writes to the console and keeps a copy for the report."""
55
+
56
+ def __init__(self, console):
57
+ self.console, self.copy = console, io.StringIO()
58
+
59
+ def write(self, text):
60
+ self.console.write(text)
61
+ self.console.flush()
62
+ self.copy.write(text)
63
+ return len(text)
64
+
65
+ def flush(self):
66
+ self.console.flush()
67
+
68
+
69
+ def _processor():
70
+ name = platform.processor() or platform.machine()
71
+ try: # Linux: the model name is more telling than the architecture
72
+ with open("/proc/cpuinfo", encoding="utf-8") as fh:
73
+ for line in fh:
74
+ if line.startswith("model name"):
75
+ return line.split(":", 1)[1].strip()
76
+ except OSError:
77
+ pass
78
+ return name
79
+
80
+
81
+ def write_report(body, started, args_text, note):
82
+ os.makedirs(REPORTS, exist_ok=True)
83
+ path = os.path.join(REPORTS, started.strftime("%Y-%m-%d_%H-%M") + ".md")
84
+ head = [
85
+ f"# DTree benchmarks, Python—{started.strftime('%Y-%m-%d %H:%M')}",
86
+ "",
87
+ f"- DTree {dtree.__version__}, Python {sys.version.split()[0]} ({platform.python_implementation()})",
88
+ f"- {platform.system()} {platform.release()} ({platform.platform()})",
89
+ f"- Processor: {_processor()}, {os.cpu_count()} logical cores",
90
+ f"- Command: `python -m dtree.bench{(' ' + args_text) if args_text else ''}`",
91
+ ]
92
+ if note:
93
+ head.append(f"- Note: {note}")
94
+ lines = body.splitlines()
95
+ if lines and lines[0].startswith("DTree benchmarks"):
96
+ lines = lines[1:] # the console title is replaced by the header above
97
+ with open(path, "w", encoding="utf-8") as fh:
98
+ fh.write("\n".join(head) + "\n" + "\n".join(lines).rstrip() + "\n")
99
+ return path
100
+
101
+ SHAPES = {
102
+ # name: function(i, names, rnd) -> parent of the new node n{i}
103
+ "random": lambda i, names, rnd: rnd.choice(names),
104
+ "chain": lambda i, names, rnd: names[-1],
105
+ "star (root)": lambda i, names, rnd: "n0",
106
+ "star (inner node)": lambda i, names, rnd: "n1",
107
+ # worst case for rational positions: always the newer half-gap, alternating sides
108
+ "alternating": lambda i, names, rnd: f"n{2 * ((i - 1) // 2)}",
109
+ # a chain of 3,000, then new leaves under the spine nodes from the top down, repeatedly:
110
+ # finding the end of a subtree by walking down the last children would be O(depth) here
111
+ "top-down comb": lambda i, names, rnd: names[-1] if i < 3000 else f"n{(i - 3000) % 3000}",
112
+ }
113
+
114
+
115
+ def build(shape, n, seed=1):
116
+ rnd = random.Random(seed)
117
+ t, names = DTree("B"), ["n0"]
118
+ pick = SHAPES[shape]
119
+ start = time.perf_counter()
120
+ for i in range(1, n):
121
+ if shape == "star (inner node)" and i <= 2:
122
+ parent = "n0" # n1 and n2 under the root, so n1 has a right neighbor
123
+ else:
124
+ parent = pick(i, names, rnd)
125
+ t.insert(parent, f"n{i}")
126
+ names.append(f"n{i}")
127
+ return t, names, time.perf_counter() - start
128
+
129
+
130
+ def stats(q):
131
+ return q.renumber_count, q.renumber_moved
132
+
133
+
134
+ def row(*cells):
135
+ print("| " + " | ".join(str(c) for c in cells) + " |")
136
+
137
+
138
+ def bench_build(sizes):
139
+ print("\n### Building trees\n")
140
+ row("shape", "nodes", "time [s]", "renumberings Q1 / Q2", "positions moved per insertion")
141
+ row("---", "---:", "---:", "---:", "---:")
142
+ for shape in SHAPES:
143
+ for n in sizes:
144
+ t, _, dt = build(shape, n)
145
+ c1, m1 = stats(t.Q1)
146
+ c2, m2 = stats(t.Q2)
147
+ row(shape, f"{n:,}", f"{dt:.2f}", f"{c1:,} / {c2:,}", f"{(m1 + m2) / (2 * n):.2f}")
148
+
149
+
150
+ def bench_lists(sizes):
151
+ print("\n### Growing a single list (DList)\n")
152
+ row("pattern", "nodes", "time [s]", "renumberings")
153
+ row("---", "---:", "---:", "---:")
154
+ for pattern in ("append", "prepend", "alternate"):
155
+ for n in sizes:
156
+ q = DList("Q")
157
+ start = time.perf_counter()
158
+ for i in range(n):
159
+ right = pattern == "append" or (pattern == "alternate" and i % 2)
160
+ if right:
161
+ q.insert_before(f"v{i}", SINK)
162
+ else:
163
+ q.insert_after(f"v{i}", SOURCE)
164
+ row(pattern, f"{n:,}", f"{time.perf_counter() - start:.2f}", f"{q.renumber_count:,}")
165
+
166
+
167
+ def bench_operations(n, queries=1_000_000):
168
+ print(f"\n### Queries and changes on a random tree of {n:,} nodes\n")
169
+ row("operation", "count", "time [s]", "per operation [µs]")
170
+ row("---", "---:", "---:", "---:")
171
+ rnd = random.Random(2)
172
+
173
+ def timed(name, count, fn):
174
+ start = time.perf_counter()
175
+ fn()
176
+ dt = time.perf_counter() - start
177
+ row(name, f"{count:,}", f"{dt:.2f}", f"{dt / count * 1e6:.1f}")
178
+
179
+ t, names, _ = build("random", n)
180
+ pairs = [(rnd.choice(names), rnd.choice(names)) for _ in range(queries)]
181
+ timed("can_reach (names)", queries, lambda: sum(t.can_reach(u, v) for u, v in pairs))
182
+ handles = [(t.get_node(u), t.get_node(v)) for u, v in pairs]
183
+ timed("can_reach (handles)", queries, lambda: sum(t.can_reach(u, v) for u, v in handles))
184
+ timed("depth", len(names), lambda: sum(t.depth(v) for v in names))
185
+
186
+ k = max(1, n // 10)
187
+ t, names, _ = build("random", n)
188
+ victims = rnd.sample(names[1:], k)
189
+ timed("delete", k, lambda: [t.delete(v) for v in victims])
190
+
191
+ t, names, _ = build("random", n)
192
+ moves = []
193
+ while len(moves) < max(1, n // 100):
194
+ u, p = rnd.choice(names[1:]), rnd.choice(names)
195
+ moves.append((u, p))
196
+
197
+ def do_moves():
198
+ for u, p in moves:
199
+ if t.contains(u) and t.contains(p) and u != p and not t.can_reach(u, p):
200
+ t.cut_n_paste(u, p)
201
+
202
+ timed("cut_n_paste", len(moves), do_moves)
203
+
204
+ t, names, _ = build("random", n)
205
+ leaves = [v for v in names if t.is_leaf(v)][: max(1, n // 10)]
206
+ parents = list({t.get_parent(v) for v in leaves if t.get_parent(v)})
207
+ timed("delete_subtree", len(parents), lambda: [t.delete_subtree(v) for v in parents if t.contains(v) and not t.is_root(v)])
208
+
209
+
210
+ def build_with_depth(n, depth, seed=1):
211
+ """A spine (chain) of `depth` nodes; all other nodes hang at random under spine nodes."""
212
+ rnd = random.Random(seed)
213
+ t, names = DTree("D"), ["n0"]
214
+ for i in range(1, n):
215
+ if i < depth:
216
+ parent = names[-1] # extend the spine
217
+ else:
218
+ parent = names[rnd.randrange(min(depth, len(names)))]
219
+ t.insert(parent, f"n{i}")
220
+ names.append(f"n{i}")
221
+ return t, names
222
+
223
+
224
+ def parent_walk(parent, u, v):
225
+ """What classical doubly linked trees do: climb from v towards the root, looking for u."""
226
+ w = parent.get(v)
227
+ while w is not None:
228
+ if w == u:
229
+ return True
230
+ w = parent.get(w)
231
+ return False
232
+
233
+
234
+ def ancestors_in(parent, v, members):
235
+ """Climb once from v towards the root and count the ancestors of v that lie in members."""
236
+ hits, w = 0, parent.get(v)
237
+ while w is not None:
238
+ if w in members:
239
+ hits += 1
240
+ w = parent.get(w)
241
+ return hits
242
+
243
+
244
+ def bench_depth(n, depths, queries):
245
+ print(f"\n### Reachability by depth: DTree vs parent walk ({n:,} nodes, {queries:,} random pairs)\n")
246
+ row("depth", "DTree [µs]", "parent walk [µs]", "factor")
247
+ row("---:", "---:", "---:", "---:")
248
+ for depth in depths:
249
+ t, names = build_with_depth(n, depth)
250
+ parent = t._parent
251
+ rnd = random.Random(2)
252
+ pairs = [(rnd.choice(names), rnd.choice(names)) for _ in range(queries)]
253
+ start = time.perf_counter()
254
+ a = [t.can_reach(u, v) for u, v in pairs]
255
+ d = time.perf_counter() - start
256
+ start = time.perf_counter()
257
+ b = [parent_walk(parent, u, v) for u, v in pairs]
258
+ w = time.perf_counter() - start
259
+ assert a == b
260
+ row(f"{depth:,}", f"{d / queries * 1e6:.2f}", f"{w / queries * 1e6:.2f}", factor(w, d))
261
+
262
+
263
+ def bench_sets(n, depths, k, queries):
264
+ print(f"\n### And/or queries: constructed nodes vs parent walk ({n:,} nodes, set of {k:,} nodes, {queries:,} query nodes)\n")
265
+ row("depth", "question", "DTree [µs]", "parent walk [µs]", "factor")
266
+ row("---:", "---", "---:", "---:", "---:")
267
+ for depth in depths:
268
+ t, names = build_with_depth(n, depth)
269
+ parent = t._parent
270
+ rnd = random.Random(4)
271
+ S = rnd.sample(names, k)
272
+ U = [rnd.choice(names) for _ in range(queries)]
273
+ start = time.perf_counter()
274
+ vmin, vmax, umin, umax = t.vmin(S), t.vmax(S), t.umin(S), t.umax(S)
275
+ build = time.perf_counter() - start
276
+ members = set(S)
277
+ # "reaches": climb from every member looking for u. "reached by": climb once from u,
278
+ # looking each ancestor up in a hash set of the members (the best a parent walk can do).
279
+ for name, v_q, walk_q in (
280
+ ("reaches all", vmin.reaches_all, lambda u: all(parent_walk(parent, u, v) for v in S)),
281
+ ("reaches some", vmax.reaches_some, lambda u: any(parent_walk(parent, u, v) for v in S)),
282
+ ("reached by all", umax.reached_by_all, lambda u: ancestors_in(parent, u, members) == len(members)),
283
+ ("reached by some", umin.reached_by_some, lambda u: ancestors_in(parent, u, members) > 0),
284
+ ):
285
+ start = time.perf_counter()
286
+ a = [v_q(u) for u in U]
287
+ d = time.perf_counter() - start
288
+ start = time.perf_counter()
289
+ b = [walk_q(u) for u in U]
290
+ w = time.perf_counter() - start
291
+ assert a == b, name
292
+ row(f"{depth:,}", name, f"{d / queries * 1e6:.2f}", f"{w / queries * 1e6:.1f}", factor(w, d))
293
+ print(f"\n(Building VMin, VMax, UMin and UMax for {k:,} nodes: {build * 1e3:.1f} ms, once per set.)")
294
+
295
+
296
+ def factor(slow, fast):
297
+ return f"{slow / fast:.1f}×" if slow < 10 * fast else f"{slow / fast:,.0f}×"
298
+
299
+
300
+ def dynamic_workload(n, depth, queries_per_insert, seed=5):
301
+ """The same sequence for every contender: insertions (a spine of `depth` nodes, the
302
+ others attached at random to spine nodes) with random queries among the nodes present."""
303
+ rnd = random.Random(seed)
304
+ steps = []
305
+ for i in range(1, n):
306
+ parent = i - 1 if i < depth else rnd.randrange(min(depth, i))
307
+ qs = [(rnd.randrange(i + 1), rnd.randrange(i + 1)) for _ in range(queries_per_insert)]
308
+ steps.append((parent, i, qs))
309
+ return steps
310
+
311
+
312
+ def run_dtree(steps):
313
+ """DTree with node handles (insert returns the new node's handle)."""
314
+ t, answers = DTree("D"), []
315
+ nodes = [None] * (len(steps) + 1)
316
+ for p, c, qs in steps:
317
+ if c == 1:
318
+ nodes[1] = t.insert("n0", "n1")
319
+ nodes[0] = t.get_node("n0")
320
+ else:
321
+ nodes[c] = t.insert(nodes[p], f"n{c}")
322
+ for u, v in qs:
323
+ answers.append(t.can_reach(nodes[u], nodes[v]))
324
+ return answers
325
+
326
+
327
+ def run_parent_walk(steps):
328
+ parent, answers = {}, []
329
+ for p, c, qs in steps:
330
+ parent[c] = p
331
+ for u, v in qs:
332
+ answers.append(parent_walk(parent, u, v))
333
+ return answers
334
+
335
+
336
+ def run_static_numbers(steps):
337
+ """Pre- and post-order numbers from a traversal, recomputed before a query whenever the
338
+ tree has changed: O(1) queries, but every insertion invalidates all numbers."""
339
+ children, answers = {}, []
340
+ pre = post = None
341
+ for p, c, qs in steps:
342
+ children.setdefault(p, []).append(c)
343
+ pre = None # the insertion invalidates the numbers
344
+ for u, v in qs:
345
+ if pre is None:
346
+ pre, post, clock = {}, {}, 0
347
+ stack = [(0, False)]
348
+ while stack:
349
+ x, done = stack.pop()
350
+ if done:
351
+ post[x] = clock
352
+ clock += 1
353
+ continue
354
+ pre[x] = clock
355
+ clock += 1
356
+ stack.append((x, True))
357
+ stack.extend((k, False) for k in reversed(children.get(x, ())))
358
+ answers.append(pre[u] < pre[v] and post[v] < post[u])
359
+ return answers
360
+
361
+
362
+ def timed(run, steps):
363
+ start = time.perf_counter()
364
+ answers = run(steps)
365
+ return time.perf_counter() - start, answers
366
+
367
+
368
+ def best(run, steps):
369
+ """Best of three runs, or a single run if it takes over two seconds (noise is small then)."""
370
+ sec, answers = timed(run, steps)
371
+ if sec < 2:
372
+ for _ in range(2):
373
+ sec = min(sec, timed(run, steps)[0])
374
+ return sec, answers
375
+
376
+
377
+ def bench_dynamic(n, depths, static_sizes, static_depths=(100, 1_000)):
378
+ timed(run_dtree, dynamic_workload(1_000, 10, 1)) # warm-up
379
+ timed(run_parent_walk, dynamic_workload(1_000, 10, 1))
380
+ print(f"\n### Dynamic updates: insertions and queries alternating ({n:,} nodes)\n")
381
+ print("A step is one insertion followed by 1 or 10 queries between random nodes present at "
382
+ "that moment; µs per step, best of three runs.\n")
383
+ row("depth", "DTree, 1 query", "parent walk, 1 query", "factor",
384
+ "DTree, 10 queries", "parent walk, 10 queries", "factor")
385
+ row("---:", "---:", "---:", "---:", "---:", "---:", "---:")
386
+ for depth in depths:
387
+ cells = [f"{depth:,}"]
388
+ for q in (1, 10):
389
+ steps = dynamic_workload(n, depth, q)
390
+ d, a = best(run_dtree, steps)
391
+ w, b = best(run_parent_walk, steps)
392
+ assert a == b
393
+ cells += [f"{d / len(steps) * 1e6:.2f}", f"{w / len(steps) * 1e6:.2f}", factor(w, d)]
394
+ row(*cells)
395
+ print("\n### Dynamic updates with static pre/post numbers, recomputed after each insertion "
396
+ "(1 query per insertion)\n")
397
+ print("The tree keeps the given depth as it grows: more nodes make it wider, not deeper. "
398
+ "Total time in ms.\n")
399
+ row("depth", "nodes", "DTree [ms]", "parent walk [ms]", "static numbers [ms]")
400
+ row("---:", "---:", "---:", "---:", "---:")
401
+ for depth in static_depths:
402
+ for m in static_sizes:
403
+ steps = dynamic_workload(m, depth, 1)
404
+ d, a = best(run_dtree, steps)
405
+ w, b = best(run_parent_walk, steps)
406
+ r, c = timed(run_static_numbers, steps)
407
+ assert a == b == c
408
+ row(f"{depth:,}", f"{m:,}", f"{d * 1e3:,.1f}", f"{w * 1e3:,.1f}", f"{r * 1e3:,.0f}")
409
+
410
+
411
+ # -- use case: a tree view with expand/collapse --------------------------------------------
412
+
413
+ def tree_view_workload(n, depth, steps, seed=6):
414
+ """Changes to a tree built by build_with_depth(n, depth): each step either inserts a leaf
415
+ under a random spine node or toggles (collapses or expands) a random spine node, and then
416
+ names a random node v whose row is to be shown."""
417
+ rnd = random.Random(seed)
418
+ ops, size = [], n
419
+ for _ in range(steps):
420
+ if rnd.random() < 0.5:
421
+ op = ("insert", f"n{rnd.randrange(depth)}", f"n{size}")
422
+ size += 1
423
+ else:
424
+ op = ("toggle", f"n{rnd.randrange(depth)}", None)
425
+ ops.append(op + (f"n{rnd.randrange(size)}",))
426
+ return ops
427
+
428
+
429
+ def tree_view_dtree(t, ops, rows):
430
+ """DTree: the collapsed nodes are one UMin. The visible row that contains v is
431
+ collapsed.covering(v) (v itself if None); the rows below it follow Q1, jumping over the
432
+ subtree of every collapsed node with subtree_last."""
433
+ collapsed, out = t.umin(), []
434
+ nxt, last = t.next_in_q1, t.subtree_last
435
+ for kind, a, b, v in ops:
436
+ if kind == "insert":
437
+ t.insert(a, b)
438
+ elif a in collapsed:
439
+ collapsed.discard(a)
440
+ else:
441
+ collapsed.add(a)
442
+ x = collapsed.covering(v) or v
443
+ shown = [x]
444
+ while len(shown) < rows:
445
+ if x in collapsed:
446
+ x = last(x)
447
+ x = nxt(x)
448
+ if x is None:
449
+ break
450
+ shown.append(x)
451
+ out.append(shown)
452
+ return out
453
+
454
+
455
+ def tree_view_walk(tree, ops, rows):
456
+ """Classical tree model: parent link, child list and next-sibling link per node, and a set of
457
+ collapsed nodes. The visible row that contains v is its topmost collapsed ancestor (one climb
458
+ to the root); the next row is the first child of an expanded node, otherwise the next sibling
459
+ of the node or of its nearest ancestor that has one."""
460
+ parent, kids, nsib = tree
461
+ collapsed, out = set(), []
462
+ for kind, a, b, v in ops:
463
+ if kind == "insert":
464
+ parent[b] = a
465
+ ks = kids.setdefault(a, [])
466
+ if ks:
467
+ nsib[ks[-1]] = b
468
+ ks.append(b)
469
+ elif a in collapsed:
470
+ collapsed.discard(a)
471
+ else:
472
+ collapsed.add(a)
473
+ top, w = None, parent[v]
474
+ while w is not None:
475
+ if w in collapsed:
476
+ top = w
477
+ w = parent[w]
478
+ x = top or v
479
+ shown = [x]
480
+ while len(shown) < rows:
481
+ ks = kids.get(x)
482
+ if ks and x not in collapsed:
483
+ x = ks[0]
484
+ else:
485
+ while x is not None and x not in nsib:
486
+ x = parent[x]
487
+ if x is None:
488
+ break
489
+ x = nsib[x]
490
+ shown.append(x)
491
+ out.append(shown)
492
+ return out
493
+
494
+
495
+ def classical_copy(t):
496
+ parent, kids, nsib = {}, {}, {}
497
+ for v in t.q1:
498
+ parent[v] = t.get_parent(v)
499
+ ks = t.children(v)
500
+ if ks:
501
+ kids[v] = ks
502
+ nsib.update(zip(ks, ks[1:]))
503
+ return parent, kids, nsib
504
+
505
+
506
+ def bench_tree_view(n, depths, steps, rows=20):
507
+ print(f"\n### Use case: tree view with expand/collapse ({n:,} nodes, {steps:,} steps)\n")
508
+ print("Each step changes the tree (inserts a leaf or collapses/expands a node, half and half), "
509
+ f"then shows a random node: it finds the visible row that contains it and lists the {rows} "
510
+ "visible rows from there. µs per step, best of three runs.\n")
511
+ row("depth", "DTree [µs per step]", "parent walk [µs per step]", "factor")
512
+ row("---:", "---:", "---:", "---:")
513
+ for depth in depths:
514
+ ops = tree_view_workload(n, depth, steps)
515
+ t, _ = build_with_depth(n, depth)
516
+ base = classical_copy(t)
517
+ d = w = float("inf")
518
+ for _ in range(3):
519
+ t, _ = build_with_depth(n, depth)
520
+ sec, a = timed(lambda o: tree_view_dtree(t, o, rows), ops)
521
+ d = min(d, sec)
522
+ tree = ({**base[0]}, {k: list(v) for k, v in base[1].items()}, {**base[2]})
523
+ sec, b = timed(lambda o: tree_view_walk(tree, o, rows), ops)
524
+ w = min(w, sec)
525
+ assert a == b
526
+ row(f"{depth:,}", f"{d / steps * 1e6:.1f}", f"{w / steps * 1e6:.1f}", factor(w, d))
527
+
528
+
529
+ def main():
530
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
531
+ ap.add_argument("sizes", nargs="*", type=int, help="tree sizes (default: 10000 100000)")
532
+ ap.add_argument("--quick", action="store_true", help="small sizes for a fast check")
533
+ ap.add_argument("--dynamic", action="store_true", help="only the dynamic updates and the tree view")
534
+ ap.add_argument("--note", default="", help="remark written into the report (e.g. machine state)")
535
+ ap.add_argument("--no-save", action="store_true", help="do not write a Markdown report")
536
+ args = ap.parse_args()
537
+ started = datetime.datetime.now()
538
+ tee = None
539
+ if not args.no_save:
540
+ tee = _Tee(sys.stdout)
541
+ sys.stdout = tee
542
+ try:
543
+ run(args)
544
+ finally:
545
+ if tee is not None:
546
+ sys.stdout = tee.console
547
+ shown = " ".join(f'"{a}"' if " " in a else a for a in sys.argv[1:])
548
+ path = write_report(tee.copy.getvalue(), started, shown, args.note)
549
+ print(f"\nReport written to {os.path.relpath(path, REPO)}")
550
+
551
+
552
+ def run(args):
553
+ if args.dynamic:
554
+ print(f"DTree benchmarks, Python {sys.version.split()[0]}")
555
+ if args.quick:
556
+ bench_dynamic(5_000, [10, 100, 1_000], [500, 1_000, 2_000])
557
+ bench_tree_view(5_000, [10, 100, 1_000], 1_000)
558
+ else:
559
+ bench_dynamic(20_000, [10, 100, 1_000, 10_000], [1_000, 2_000, 5_000])
560
+ bench_tree_view(20_000, [10, 100, 1_000, 10_000], 5_000)
561
+ return
562
+ sizes = [1_000, 10_000] if args.quick else (args.sizes or [10_000, 100_000])
563
+ print(f"DTree benchmarks, Python {sys.version.split()[0]}")
564
+ bench_build(sizes)
565
+ bench_lists(sizes)
566
+ bench_operations(max(sizes), queries=100_000 if args.quick else 1_000_000)
567
+ if args.quick:
568
+ bench_depth(10_000, [10, 100, 1_000, 10_000], 20_000)
569
+ bench_sets(10_000, [10, 100, 1_000], 100, 200)
570
+ bench_dynamic(5_000, [10, 100, 1_000], [500, 1_000, 2_000])
571
+ bench_tree_view(5_000, [10, 100, 1_000], 1_000)
572
+ else:
573
+ bench_depth(100_000, [10, 100, 1_000, 10_000, 100_000], 20_000)
574
+ bench_sets(100_000, [10, 100, 1_000, 10_000], 1_000, 200)
575
+ bench_dynamic(20_000, [10, 100, 1_000, 10_000], [1_000, 2_000, 5_000])
576
+ bench_tree_view(20_000, [10, 100, 1_000, 10_000], 5_000)
577
+
578
+
579
+ if __name__ == "__main__":
580
+ main()
@@ -351,7 +351,12 @@ class DTree:
351
351
  # -- construction ------------------------------------------------------
352
352
  @classmethod
353
353
  def from_file(cls, path: str, tree_id: Optional[str] = None) -> "DTree":
354
- """Read the ``parent: child1; child2`` format used by the .csx examples."""
354
+ """Read the format used by the .csx examples.
355
+
356
+ ``parent: child1; child2`` adds children as last children of ``parent``;
357
+ ``sibling > child1; child2`` inserts them as siblings directly after ``sibling``
358
+ (in that order), ``sibling < child1; child2`` directly before it.
359
+ """
355
360
  import os
356
361
 
357
362
  tree = cls(tree_id or os.path.splitext(os.path.basename(path))[0])
@@ -362,12 +367,27 @@ class DTree:
362
367
  continue
363
368
  if line.startswith("@@"):
364
369
  break
365
- parent, _, rest = line.partition(":")
366
- for child in (c.strip() for c in rest.split(";")):
367
- if child:
368
- tree.insert(parent.strip(), child)
370
+ tree.add_line(line)
369
371
  return tree
370
372
 
373
+ def add_line(self, line: str) -> List[DNode]:
374
+ """Apply one line of the description format (see from_file); returns the new handles."""
375
+ at = min((i for i in (line.find(":"), line.find(">"), line.find("<")) if i >= 0), default=-1)
376
+ if at < 0:
377
+ raise ValueError(f"no ':', '>' or '<' in {line!r}")
378
+ op, anchor = line[at], line[:at].strip()
379
+ children = [c.strip() for c in line[at + 1:].split(";") if c.strip()]
380
+ added = []
381
+ for child in children:
382
+ if op == ":":
383
+ added.append(self.insert(anchor, child))
384
+ elif op == ">":
385
+ added.append(self.insert_after(anchor, child))
386
+ anchor = child # the next one goes after this one
387
+ else:
388
+ added.append(self.insert_before(anchor, child))
389
+ return added
390
+
371
391
  def insert(self, parent, child: str) -> DNode:
372
392
  """Add the edge parent -> child and return the handle of ``child``.
373
393
 
@@ -387,6 +407,56 @@ class DTree:
387
407
  self.edge_log.append(f"({p.name}, {child})")
388
408
  return n
389
409
 
410
+ def insert_after(self, sibling, child: str) -> DNode:
411
+ """Insert ``child`` as a sibling directly after ``sibling`` (same parent) and return its
412
+ handle. In Q1 it goes after the end of the sibling's subtree, in Q2 directly before the
413
+ sibling; no other node moves. The root has no siblings."""
414
+ s = self._sibling(sibling, child)
415
+ p = s.up
416
+ i = p.kids.index(s)
417
+ w = self._subtree_last_node(s)
418
+ n = self._link_node(child, p, i + 1, w.c1, s.c2.prev)
419
+ if i + 1 == len(p.kids) - 1:
420
+ p.last = n # n is now the last node of p's subtree
421
+ self.edge_log.append(f"({s.name} > {child})")
422
+ return n
423
+
424
+ def insert_before(self, sibling, child: str) -> DNode:
425
+ """Insert ``child`` as a sibling directly before ``sibling`` (same parent) and return its
426
+ handle. In Q1 it goes directly before the sibling, in Q2 after the end of the sibling's
427
+ subtree there (directly before the previous sibling; for a first child, after the end
428
+ of the parent's subtree in Q2, found by following first children); no other node moves."""
429
+ s = self._sibling(sibling, child)
430
+ p = s.up
431
+ i = p.kids.index(s)
432
+ q2_after = p.kids[i - 1].c2.prev if i > 0 else self._subtree_first_last_q2(s).c2
433
+ n = self._link_node(child, p, i, s.c1.prev, q2_after)
434
+ self.edge_log.append(f"({s.name} < {child})")
435
+ return n
436
+
437
+ def _sibling(self, sibling, child: str) -> DNode:
438
+ if child in (SOURCE, SINK):
439
+ raise ValueError("SOURCE/SINK markers cannot be tree nodes")
440
+ s = self._node(sibling)
441
+ if s.up is None:
442
+ raise ValueError("the root has no siblings")
443
+ if child in self._nodes:
444
+ raise ValueError(f"child {child!r} is already in the tree")
445
+ return s
446
+
447
+ def _link_node(self, name: str, p: DNode, index: int, q1_after, q2_after) -> DNode:
448
+ """Create a child of p at position ``index`` among p's children, linked after the given cells."""
449
+ n = DNode(name, self)
450
+ c1, c2 = self._q1._new(name), self._q2._new(name)
451
+ self._q2._link_after(c2, q2_after)
452
+ self._q1._link_after(c1, q1_after)
453
+ n.up = p
454
+ p.kids.insert(index, n)
455
+ c1.node = c2.node = n
456
+ n.c1, n.c2 = c1, c2
457
+ self._nodes[name] = n
458
+ return n
459
+
390
460
  def _new_node(self, name: str, p: Optional[DNode]) -> DNode:
391
461
  n = DNode(name, self)
392
462
  q1, q2 = self._q1, self._q2
@@ -466,6 +536,12 @@ class DTree:
466
536
  w = w.kids[0]
467
537
  return w
468
538
 
539
+ def next_in_q1(self, node) -> Optional[str]:
540
+ """The node after ``node`` in Q1, as a name, or None after the last node. Q1 is the
541
+ display order of a tree view (parents before children, siblings in insertion order)."""
542
+ c = self._node(node).c1.next
543
+ return c.node.name if c is not None and c.node is not None else None
544
+
469
545
  def subtree_last(self, node):
470
546
  """Last node of the subtree of ``node`` in Q1, as a name (the subtree is the block from
471
547
  ``node`` to it). Usually O(1) when asked again for the same node."""
@@ -785,7 +861,8 @@ class _VNode:
785
861
  the minimal (or maximal) index there; the two may be different nodes, so the constructed
786
862
  node usually corresponds to no real node. Insertions and renumbering keep the order of
787
863
  existing nodes, so it stays valid while the tree grows. After a deletion or a cut_n_paste it is
788
- recomputed from the remaining members on its next use.
864
+ recomputed from the remaining members on its next use. Members are added with ``add`` and
865
+ removed with ``discard``.
789
866
  """
790
867
 
791
868
  _lowest = True # VMin keeps the leftmost member, VMax the rightmost
@@ -829,9 +906,49 @@ class _VNode:
829
906
  self._added(node)
830
907
  return self
831
908
 
909
+ def discard(self, node) -> "_VNode":
910
+ """Remove a node (name or handle) from the set; no error if it is not a member.
911
+
912
+ O(log k) to find it plus the list removal; if it held an index (the minimal or
913
+ maximal position in Q1 or Q2), the index nodes are recomputed in O(k). Returns the
914
+ constructed node itself. Typical use: expanding a node in a tree view, after it was
915
+ added to the set of collapsed nodes.
916
+ """
917
+ node = self._t._name(node)
918
+ self._check()
919
+ if node not in self._seen:
920
+ return self
921
+ self._seen.discard(node)
922
+ q1, m = self._t._q1, self._members
923
+ lo, hi = 0, len(m) # members are sorted by Q1 order
924
+ while lo < hi:
925
+ mid = (lo + hi) // 2
926
+ if q1.precedes(m[mid], node):
927
+ lo = mid + 1
928
+ else:
929
+ hi = mid
930
+ del m[lo]
931
+ if node == self._c1 or node == self._c2:
932
+ t = self._t
933
+ self._c1 = self._c2 = None
934
+ for v in self._members:
935
+ if self._better(t._q1, v, self._c1):
936
+ self._c1 = v
937
+ if self._better(t._q2, v, self._c2):
938
+ self._c2 = v
939
+ self._discarded(node)
940
+ return self
941
+
942
+ def __contains__(self, node) -> bool:
943
+ self._check()
944
+ return self._t._name(node) in self._seen
945
+
832
946
  def _added(self, node: str) -> None:
833
947
  """Hook for subclasses that keep more than the index nodes."""
834
948
 
949
+ def _discarded(self, node: str) -> None:
950
+ """Hook for subclasses that keep more than the index nodes."""
951
+
835
952
  def _rebuilt(self) -> None:
836
953
  """Hook for subclasses that keep more than the index nodes."""
837
954
 
@@ -989,6 +1106,10 @@ class UMin(_VNode):
989
1106
  del m[i] # node is above them: they are no longer topmost
990
1107
  m.insert(i, node)
991
1108
 
1109
+ def _discarded(self, node: str) -> None:
1110
+ if node in self._tops: # members it covered may become topmost
1111
+ self._rebuilt()
1112
+
992
1113
  def _rebuilt(self) -> None:
993
1114
  self._tops = []
994
1115
  t = self._t
@@ -1011,3 +1132,18 @@ class UMin(_VNode):
1011
1132
  if not self.may_be_reached_by_some(v):
1012
1133
  return False
1013
1134
  return self._covered(v)
1135
+
1136
+ def covering(self, v) -> Optional[str]:
1137
+ """The topmost member of the set that reaches v, or None if no member does (O(log k)).
1138
+
1139
+ With the collapsed nodes of a tree view as a UMin, this is the visible row that
1140
+ contains v: None means v itself is visible.
1141
+ """
1142
+ v = self._t._name(v)
1143
+ self._check()
1144
+ if not self._t.contains(v):
1145
+ return None
1146
+ i = self._last_top_before(v)
1147
+ if i > 0 and self._t.can_reach(self._tops[i - 1], v):
1148
+ return self._tops[i - 1]
1149
+ return None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dtree-encoding
3
- Version: 1.6.0
3
+ Version: 1.8.0
4
4
  Summary: DTree: incremental encoding of the topology of directed trees by two projections (Q1, Q2): O(1) reachability, and/or queries on the topology of the tree, dynamic (incremental) updates
5
5
  Author-email: Enis Olgac <enis@digraphs.info>
6
6
  License: MIT
@@ -59,7 +59,9 @@ t = DTree.from_file("Test.csx") # e.g. examples/Test.csx from the repository
59
59
  | C# (docs) | Python |
60
60
  |---|---|
61
61
  | `DTree(id)` | `DTree(tree_id="P1")` |
62
- | `Insert(parent, child)` | `insert(parent, child)` |
62
+ | `Insert(parent, child)` | `insert(parent, child)` (last child) |
63
+ | `InsertAfter`, `InsertBefore` | `insert_after(sibling, child)`, `insert_before(sibling, child)` |
64
+ | `AddLine` | `add_line(line)` (`p: c`, `s > c`, `s < c`; also in `from_file`) |
63
65
  | `Contains(node)` | `contains(node)` / `node in t` |
64
66
  | `GetParent(node)` | `get_parent(node)` (`None` for the root) |
65
67
  | `CutNPaste(u, p)` | `cut_n_paste(subtree_root, new_parent)` |
@@ -75,9 +77,12 @@ t = DTree.from_file("Test.csx") # e.g. examples/Test.csx from the repository
75
77
  | `PathToRoot`, `RootToPath` | `path_to_root`, `root_to_path` (plus `path(u, v)`) |
76
78
  | `GetNode`, `TryGetNode`, `DNode` | `get_node`, `try_get_node`, `DNode` (node handles; `insert` returns one) |
77
79
  | `SubtreeLast` | `subtree_last` (last node of a subtree in Q1) |
80
+ | `NextInQ1` | `next_in_q1` (next node in Q1, the display order; `None` at the end) |
78
81
  | `VMin`, `VMax`, `UMin`, `UMax` (of nodes) | `vmin`, `vmax`, `umin`, `umax` |
79
82
  | `ReachesAll`, `MayReachSome`, `ReachesSome` | `reaches_all`, `may_reach_some`, `reaches_some` |
80
83
  | `ReachedByAll`, `MayBeReachedBySome`, `ReachedBySome` | `reached_by_all`, `may_be_reached_by_some`, `reached_by_some` |
84
+ | `Add`, `Discard`, `Contains` (of a constructed node) | `add`, `discard`, `node in s` |
85
+ | `UMin.Covering` | `covering` (topmost member that reaches a node; `None` if none) |
81
86
 
82
87
  ## Semantics
83
88
 
@@ -88,7 +93,7 @@ t = DTree.from_file("Test.csx") # e.g. examples/Test.csx from the repository
88
93
  - `delete(v)` re-attaches v's children to v's parent, in v's place among its siblings.
89
94
  - `delete_subtree(v)` removes v with all its descendants and returns them in Q1 order.
90
95
  - `cut_n_paste(u, p)` moves the subtree rooted at u under p as p's last child; p must not lie inside u's subtree.
91
- - And/or queries on the topology: the constructed nodes `vmin(S)`, `vmax(S)` (S as target) and `umin(S)`, `umax(S)` (S as source) take the minimum or maximum of each index over S, separately in Q1 and Q2. `reaches_all` and `reached_by_all` are exact O(1) tests; `may_reach_some` and `may_be_reached_by_some` are only filters, and `reaches_some` and `reached_by_some` are exact in O(log k). A constructed node stays valid under insertions and is recomputed automatically after `delete`, `delete_subtree` or `cut_n_paste`. See [and/or queries on the topology](https://github.com/EnisOlgac/DTree/blob/main/docs/and-or-queries.md).
96
+ - And/or queries on the topology: the constructed nodes `vmin(S)`, `vmax(S)` (S as target) and `umin(S)`, `umax(S)` (S as source) take the minimum or maximum of each index over S, separately in Q1 and Q2. `reaches_all` and `reached_by_all` are exact O(1) tests; `may_reach_some` and `may_be_reached_by_some` are only filters, and `reaches_some` and `reached_by_some` are exact in O(log k). Members are added with `add` and removed with `discard`. A constructed node stays valid under insertions and is recomputed automatically after `delete`, `delete_subtree` or `cut_n_paste`. See [and/or queries on the topology](https://github.com/EnisOlgac/DTree/blob/main/docs/and-or-queries.md).
92
97
  - Projection positions are fractions: a new node gets the mediant (a+c)/(b+d) of its neighbors, so normally no other node moves. Only rare alternating insertion patterns trigger a local renumbering. Tree depth and width are unbounded. `t.Q1.index(v)` returns a position as a `Fraction`.
93
98
 
94
99
  ## Tests
@@ -101,14 +106,21 @@ Every operation is checked against a brute-force parent/children model on random
101
106
 
102
107
  ## Benchmarks
103
108
 
109
+ The benchmarks come with the package, so you can measure on your own machine:
110
+
104
111
  ```
105
- python benchmarks/bench.py # trees of 10,000 and 100,000 nodes
106
- python benchmarks/bench.py --quick # a few seconds
107
- python benchmarks/bench.py --dynamic # only insertions and queries alternating
108
- python benchmarks/bench.py 1000000 # custom sizes
112
+ python -m dtree.bench # trees of 10,000 and 100,000 nodes
113
+ python -m dtree.bench --quick # a few seconds
114
+ python -m dtree.bench --dynamic # only the dynamic updates and the tree view
115
+ python -m dtree.bench --note "idle machine" # remark for the report
116
+ python -m dtree.bench 1000000 # custom sizes
109
117
  ```
110
118
 
111
- The script prints Markdown tables: build time and renumbering counts per tree shape (random, chain, star at the root, star at an inner node, alternating worst case), list growth at either end, and timings for `can_reach`, `depth`, `delete`, `cut_n_paste` and `delete_subtree`, and comparisons with a classical parent walk: reachability by tree depth, and/or queries with constructed nodes, and dynamic updates (insertions and queries alternating). Each `DList` keeps the counters `renumber_count` and `renumber_moved`, so the same figures are available in your own code via `t.Q1` and `t.Q2`.
119
+ In a clone of the repository, `python python/benchmarks/bench.py` runs the same code from the sources.
120
+
121
+ The script prints Markdown tables: build time and renumbering counts per tree shape (random, chain, star at the root, star at an inner node, alternating worst case), list growth at either end, and timings for `can_reach`, `depth`, `delete`, `cut_n_paste` and `delete_subtree`, and comparisons with a classical parent walk: reachability by tree depth, and/or queries with constructed nodes, dynamic updates (insertions with 1 or 10 queries each, and static pre/post numbers at depths 100 and 1,000), and a tree view with expand/collapse. Each dynamic measurement is the best of three runs. Each `DList` keeps the counters `renumber_count` and `renumber_moved`, so the same figures are available in your own code via `t.Q1` and `t.Q2`.
122
+
123
+ Every run also writes its tables as a Markdown report to `benchmarks/python/`—in the current directory, or at the repository root when run inside a clone— named after date and time (for example `2026-10-17_14-18.md`), with a header giving the DTree and Python versions, the operating system and the processor; `--no-save` skips it. The C# benchmarks (`dotnet run --project csharp/DTree.Tests -c Release -- bench`) write theirs to `benchmarks/csharp/`.
112
124
 
113
125
  ## Provenance
114
126
 
@@ -1,6 +1,7 @@
1
1
  README.md
2
2
  pyproject.toml
3
3
  dtree/__init__.py
4
+ dtree/bench.py
4
5
  dtree/dtree.py
5
6
  dtree_encoding.egg-info/PKG-INFO
6
7
  dtree_encoding.egg-info/SOURCES.txt
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "dtree-encoding"
7
- version = "1.6.0"
7
+ version = "1.8.0"
8
8
  description = "DTree: incremental encoding of the topology of directed trees by two projections (Q1, Q2): O(1) reachability, and/or queries on the topology of the tree, dynamic (incremental) updates"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -68,6 +68,18 @@ class Reference:
68
68
  for n in gone:
69
69
  del self.parent[n]
70
70
 
71
+ def insert_after(self, s, v):
72
+ p = self.parent[s]
73
+ sib = self.children[p]
74
+ sib.insert(sib.index(s) + 1, v)
75
+ self.parent[v] = p
76
+
77
+ def insert_before(self, s, v):
78
+ p = self.parent[s]
79
+ sib = self.children[p]
80
+ sib.insert(sib.index(s), v)
81
+ self.parent[v] = p
82
+
71
83
  def move(self, u, p):
72
84
  old = self.parent[u]
73
85
  self.children[old].remove(u)
@@ -332,6 +344,58 @@ class TestVNodes(unittest.TestCase):
332
344
  self.check_vnodes(t, live, **v_s)
333
345
  self.assertEqual(sorted(v_s["umin"].members), sorted(live))
334
346
 
347
+ def test_discard(self):
348
+ # random adds and discards, with inserts, deletes and moves in between
349
+ for seed in range(15):
350
+ t, ref, rnd = random_tree(60, seed)
351
+ v_s = dict(vmin=t.vmin(), vmax=t.vmax(), umin=t.umin(), umax=t.umax())
352
+ S, k = set(), 60
353
+ for step in range(120):
354
+ nodes = t.nodes
355
+ r = rnd.random()
356
+ if r < 0.35:
357
+ v = rnd.choice(nodes)
358
+ for x in v_s.values():
359
+ x.add(v if rnd.random() < 0.5 else t.get_node(v))
360
+ S.add(v)
361
+ elif r < 0.65 and S:
362
+ v = rnd.choice(sorted(S))
363
+ for x in v_s.values():
364
+ x.discard(v)
365
+ S.discard(v)
366
+ elif r < 0.8:
367
+ t.insert(rnd.choice(nodes), f"x{k}")
368
+ k += 1
369
+ elif r < 0.9 and len(nodes) > 10:
370
+ v = rnd.choice(nodes[1:])
371
+ t.delete(v)
372
+ S.discard(v)
373
+ elif len(nodes) > 10:
374
+ u = rnd.choice(nodes[1:])
375
+ banned = set(t.subtree(u))
376
+ t.cut_n_paste(u, rnd.choice([w for w in nodes if w not in banned]))
377
+ live = sorted(S)
378
+ self.check_vnodes(t, live, **v_s)
379
+ self.assertEqual(sorted(v_s["umin"].members), live)
380
+ for v in live[:3]:
381
+ self.assertIn(v, v_s["vmax"])
382
+ for v in t.nodes: # topmost member that reaches v
383
+ above = [a for a in t.path_to_root(v)[1:] if a in S]
384
+ self.assertEqual(v_s["umin"].covering(v), above[-1] if above else None)
385
+ # discarding a non-member is a no-op
386
+ t = DTree()
387
+ t.insert("a", "b")
388
+ u = t.umin(["b"])
389
+ u.discard("a").discard("nobody")
390
+ self.assertEqual(u.members, ["b"])
391
+
392
+ def test_next_in_q1(self):
393
+ t, ref, rnd = random_tree(80, 7)
394
+ q1 = t.q1
395
+ for a, b in zip(q1, q1[1:] + [None]):
396
+ self.assertEqual(t.next_in_q1(a), b)
397
+ self.assertEqual(t.next_in_q1(t.get_node(a)), b)
398
+
335
399
  def test_incremental_add(self):
336
400
  t, ref, rnd = random_tree(100, 3)
337
401
  v_s = dict(vmin=t.vmin(), vmax=t.vmax(), umin=t.umin(), umax=t.umax())
@@ -507,6 +571,136 @@ class TestAgainstReference(unittest.TestCase):
507
571
  check(self, t, ref)
508
572
 
509
573
 
574
+ class TestSiblingInsert(unittest.TestCase):
575
+ def test_example(self):
576
+ t = DTree()
577
+ t.insert("A", "B")
578
+ t.insert("A", "D")
579
+ t.insert("B", "C")
580
+ t.insert_after("B", "X") # A: B, X, D
581
+ t.insert_before("B", "W") # A: W, B, X, D
582
+ t.insert_before("C", "C0") # B: C0, C
583
+ self.assertEqual(t.children("A"), ["W", "B", "X", "D"])
584
+ self.assertEqual(t.children("B"), ["C0", "C"])
585
+ self.assertEqual(t.q1, ["A", "W", "B", "C0", "C", "X", "D"])
586
+ self.assertEqual(t.q2, ["A", "D", "X", "B", "C", "C0", "W"])
587
+ self.assertTrue(t.can_reach("A", "X") and not t.can_reach("B", "X"))
588
+ self.assertEqual(t.subtree_last("A"), "D")
589
+
590
+ def test_rules(self):
591
+ t = DTree()
592
+ t.insert("A", "B")
593
+ with self.assertRaises(ValueError):
594
+ t.insert_after("A", "Z") # the root has no siblings
595
+ with self.assertRaises(ValueError):
596
+ t.insert_before("A", "Z")
597
+ with self.assertRaises(ValueError):
598
+ t.insert_after("B", "A") # already in the tree
599
+ with self.assertRaises(KeyError):
600
+ t.insert_after("nobody", "Z")
601
+ n = t.insert_after(t.get_node("B"), "C") # handles work too
602
+ self.assertIsInstance(n, DNode)
603
+ self.assertEqual(t.children("A"), ["B", "C"])
604
+
605
+ def test_example_file(self):
606
+ t = DTree.from_file(os.path.join(EXAMPLES, "siblings.csx"))
607
+ self.assertEqual(t.children("01"), ["02", "04", "06", "05"])
608
+ self.assertEqual(t.q1, ["01", "02", "03a", "03", "04", "06", "05"])
609
+ self.assertEqual(t.q2, ["01", "05", "06", "04", "02", "03", "03a"])
610
+
611
+ def test_description_lines(self):
612
+ t = DTree()
613
+ t.add_line("A: B; D")
614
+ t.add_line("B > X; Y") # X after B, Y after X
615
+ t.add_line("D < W1; W2") # W1 before D, W2 before D
616
+ t.add_line("B: C")
617
+ self.assertEqual(t.children("A"), ["B", "X", "Y", "W1", "W2", "D"])
618
+ self.assertEqual(t.children("B"), ["C"])
619
+
620
+ def test_random_mix(self):
621
+ # all three kinds of insertion, mixed with deletes and moves, against the reference
622
+ import dtree.dtree as impl
623
+ for budget in (impl._BUDGET, 0):
624
+ saved, impl._BUDGET = impl._BUDGET, budget
625
+ try:
626
+ for seed in range(12):
627
+ t, ref, rnd = random_tree(20, seed)
628
+ k = 0
629
+ for _ in range(100):
630
+ nodes = ref.nodes()
631
+ inner = [n for n in nodes if n != ref.root]
632
+ r = rnd.random()
633
+ name = f"x{k}"
634
+ if r < 0.25:
635
+ p = rnd.choice(nodes)
636
+ t.insert(p, name)
637
+ ref.insert(p, name)
638
+ k += 1
639
+ elif r < 0.5 and inner:
640
+ s = rnd.choice(inner)
641
+ t.insert_after(s, name)
642
+ ref.insert_after(s, name)
643
+ k += 1
644
+ elif r < 0.75 and inner:
645
+ s = rnd.choice(inner)
646
+ t.insert_before(s, name)
647
+ ref.insert_before(s, name)
648
+ k += 1
649
+ elif r < 0.85 and len(inner) > 10:
650
+ v = rnd.choice(inner)
651
+ t.delete(v)
652
+ ref.delete(v)
653
+ elif len(inner) > 10:
654
+ u = rnd.choice(inner)
655
+ banned = set(t.subtree(u))
656
+ p = rnd.choice([n for n in nodes if n not in banned])
657
+ t.cut_n_paste(u, p)
658
+ ref.move(u, p)
659
+ check(self, t, ref)
660
+ for v in rnd.sample(ref.nodes(), 3):
661
+ sub_v = [w for w in ref.nodes() if w == v or v == ref.root or v in ref_ancestors(ref.parent, w)]
662
+ self.assertEqual(t.subtree_last(v), [w for w in t.q1 if w in sub_v][-1])
663
+ finally:
664
+ impl._BUDGET = saved
665
+
666
+ def test_same_gap_many_times(self):
667
+ # repeated insertion into the same gap from both sides: positions stay strictly increasing
668
+ t, ref = DTree(), Reference()
669
+ t.insert("r", "a")
670
+ ref.insert("r", "a")
671
+ t.insert("r", "b")
672
+ ref.insert("r", "b")
673
+ left, right = "a", "b"
674
+ for i in range(400):
675
+ if i % 2:
676
+ t.insert_after(left, f"l{i}")
677
+ ref.insert_after(left, f"l{i}")
678
+ left = f"l{i}"
679
+ else:
680
+ t.insert_before(right, f"r{i}")
681
+ ref.insert_before(right, f"r{i}")
682
+ right = f"r{i}"
683
+ check(self, t, ref)
684
+
685
+ def test_constructed_nodes_stay_valid(self):
686
+ t, ref, rnd = random_tree(60, 3)
687
+ S = rnd.sample(ref.nodes(), 6)
688
+ um, vm = t.umin(S), t.vmax(S)
689
+ for i in range(60):
690
+ s = rnd.choice([n for n in ref.nodes() if n != ref.root])
691
+ if i % 2:
692
+ t.insert_after(s, f"y{i}")
693
+ ref.insert_after(s, f"y{i}")
694
+ else:
695
+ t.insert_before(s, f"y{i}")
696
+ ref.insert_before(s, f"y{i}")
697
+ for v in ref.nodes():
698
+ anc = set(ref_ancestors(ref.parent, v)[1:]) | ({ref.root} if v != ref.root else set())
699
+ self.assertEqual(um.reached_by_some(v), any(m in anc for m in S), v)
700
+ below = {w for w in ref.nodes() if w != v and (v == ref.root or v in ref_ancestors(ref.parent, w))}
701
+ self.assertEqual(vm.reaches_some(v), any(m in below for m in S), v)
702
+
703
+
510
704
  class TestNoSizeLimit(unittest.TestCase):
511
705
  def test_deep_chain(self):
512
706
  t = DTree()
File without changes