dtree-encoding 1.5.0__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.
dtree/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """DTree: directed tree library with dual projections Q1 and Q2."""
2
+
3
+ from .dtree import SINK, SOURCE, DList, DTree, UMax, UMin, VMax, VMin
4
+
5
+ __all__ = ["DTree", "DList", "VMin", "VMax", "UMin", "UMax", "SOURCE", "SINK"]
6
+ __version__ = "1.5.0"
dtree/dtree.py ADDED
@@ -0,0 +1,839 @@
1
+ """DTree: a single-rooted directed tree encoded by two linear projections.
2
+
3
+ Python implementation of the DTree design and API by Enis Olgac (original C#
4
+ prototype in releases up to 1.1.0). This port was written with Claude (Anthropic) and checked against a
5
+ brute-force reference in ``tests/``.
6
+
7
+ Projection model
8
+ ----------------
9
+ Every tree is encoded by two orderings of its nodes, both bracketed by the
10
+ symbolic markers SOURCE ("[") and SINK ("]"):
11
+
12
+ * Q1: pre-order, children left to right (in insertion order).
13
+ * Q2: pre-order, children right to left (sibling-reversed).
14
+
15
+ Node u reaches node v if and only if u strictly precedes v in both Q1 and Q2.
16
+ Reachability is irreflexive: no node reaches itself.
17
+
18
+ Update rules (taken from the C# original)
19
+ ----------------------------------------
20
+ * insert(u, v): v is placed immediately after u in Q2, and in Q1 immediately
21
+ before the first node after u that u cannot reach (the end of u's subtree).
22
+ Insertion costs O(depth of the subtree below u); positions of other nodes
23
+ normally stay unchanged.
24
+ * delete(v): v is removed from both projections; its children move up to v's
25
+ parent and keep their relative order (a cut-and-paste of v's children).
26
+ * delete_subtree(v): the contiguous block of v's subtree is removed from both
27
+ projections.
28
+ * cut_n_paste(u, p): the contiguous block of u's subtree is lifted out of both
29
+ projections and re-inserted under p using the same rule as insert.
30
+
31
+ Positions are fractions. A new node gets the mediant (a+c)/(b+d) of its two
32
+ neighbors a/b and c/d (the Stern-Brocot construction), which always lies
33
+ strictly between them, so normally no other node moves. Rational positions
34
+ were Enis Olgac's idea; repeated insertion into the same gap (a node with many
35
+ children, appending at either end) then keeps positions at O(log n) bits. Only
36
+ when a denominator would exceed 64 bits, which needs alternating insertions,
37
+ is a small surrounding region renumbered with simple fractions k/2**32.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ from fractions import Fraction
43
+ from typing import Dict, Iterable, Iterator, List, Optional
44
+
45
+ SOURCE = "["
46
+ SINK = "]"
47
+
48
+ _Q = 1 << 32 # denominator used when a crowded region is renumbered
49
+ _LIMIT = 64 # renumber when a denominator would exceed this many bits
50
+ _SLACK = 16 # after renumbering, neighbors are at least this many 1/_Q apart
51
+ _GROWTH = 2 # each doubling of the region raises the required spacing by this factor
52
+
53
+
54
+ class _Cell:
55
+ __slots__ = ("name", "prev", "next", "num", "den")
56
+
57
+ def __init__(self, name: str) -> None:
58
+ self.name = name
59
+ self.prev: Optional[_Cell] = None
60
+ self.next: Optional[_Cell] = None
61
+ self.num = 0
62
+ self.den = 1
63
+
64
+
65
+ class DList:
66
+ """Doubly linked list with rational positions (O(1) precedence test).
67
+
68
+ Positions are fractions num/den. A new node gets the mediant
69
+ (a+c)/(b+d) of its neighbors a/b and c/d, which always lies strictly
70
+ between them; SOURCE and SINK act as -1/0 and +1/0. Nothing else moves.
71
+ Only when a denominator would exceed _LIMIT bits is a small surrounding
72
+ region renumbered with simple fractions k/_Q.
73
+ """
74
+
75
+ def __init__(self, name: str) -> None:
76
+ self.name = name
77
+ self._cells: Dict[str, _Cell] = {}
78
+ self.first = self._new(SOURCE)
79
+ self.last = self._new(SINK)
80
+ self.first.next = self.last
81
+ self.last.prev = self.first
82
+ self.first.num, self.first.den = -1, 0 # -infinity
83
+ self.last.num, self.last.den = 1, 0 # +infinity
84
+ # Statistics: how often positions were renumbered and how many moved.
85
+ self.renumber_count = 0
86
+ self.renumber_moved = 0
87
+
88
+ # -- internals --------------------------------------------------------
89
+ def _new(self, name: str) -> _Cell:
90
+ cell = _Cell(name)
91
+ self._cells[name] = cell
92
+ return cell
93
+
94
+ @staticmethod
95
+ def _lt(x: _Cell, y: _Cell) -> bool:
96
+ """x lies strictly left of y, by comparing x.num/x.den with y.num/y.den."""
97
+ if x.den and y.den:
98
+ return x.num * y.den < y.num * x.den
99
+ if x.den == 0 and y.den == 0:
100
+ return x.num < y.num
101
+ return (x.num if x.den == 0 else 0) < (y.num if y.den == 0 else 0)
102
+
103
+ def _link_after(self, cell: _Cell, prev: _Cell) -> None:
104
+ nxt = prev.next
105
+ cell.prev, cell.next = prev, nxt
106
+ prev.next = cell
107
+ nxt.prev = cell
108
+ if prev is self.first and nxt is self.last:
109
+ cell.num, cell.den = 0, 1 # first node of an empty list
110
+ return
111
+ # Mediant of the neighbors. Next to SOURCE (-1/0) or SINK (+1/0) this
112
+ # steps by one whole unit, so a list can grow at either end forever
113
+ # without renumbering (positives to the right, negatives to the left).
114
+ cell.num = prev.num + nxt.num
115
+ cell.den = prev.den + nxt.den
116
+ if cell.den.bit_length() > _LIMIT:
117
+ self._rebalance(cell)
118
+
119
+ def _rebalance(self, cell: _Cell) -> None:
120
+ """Renumber the smallest region around ``cell`` that has room, using fractions k/_Q."""
121
+ q = _Q
122
+ lo = hi = cell
123
+ m, k, need = 1, 1, _SLACK
124
+ while True:
125
+ for _ in range(k):
126
+ if lo.prev is not self.first:
127
+ lo = lo.prev
128
+ m += 1
129
+ if hi.next is not self.last:
130
+ hi = hi.next
131
+ m += 1
132
+ left, right = lo.prev, hi.next
133
+ if left is self.first and right is self.last:
134
+ ks = [(j - m // 2) * q for j in range(m)]
135
+ break
136
+ if left is self.first:
137
+ top = -((-right.num * q) // right.den) # ceil(right * q)
138
+ ks = [top - (m - j) * q for j in range(m)]
139
+ break
140
+ if right is self.last:
141
+ bottom = (left.num * q) // left.den # floor(left * q)
142
+ ks = [bottom + (j + 1) * q for j in range(m)]
143
+ break
144
+ bottom = (left.num * q) // left.den
145
+ top = -((-right.num * q) // right.den)
146
+ room = top - bottom - 1 # integers k with left < k/q < right
147
+ if room >= m * need:
148
+ ks = [bottom + ((j + 1) * (room + 1)) // (m + 1) for j in range(m)]
149
+ break
150
+ # Larger regions must be sparser before they are accepted (as in
151
+ # Bender et al.), so a renumbered region keeps spare room and the
152
+ # next overflow nearby can again be handled locally.
153
+ k *= 2
154
+ need *= _GROWTH
155
+ c = lo
156
+ for kv in ks:
157
+ c.num, c.den = kv, q
158
+ c = c.next
159
+ self.renumber_count += 1
160
+ self.renumber_moved += m
161
+
162
+ def _unlink(self, cell: _Cell) -> None:
163
+ cell.prev.next = cell.next
164
+ cell.next.prev = cell.prev
165
+ cell.prev = cell.next = None
166
+
167
+ # -- public -----------------------------------------------------------
168
+ def __contains__(self, name: str) -> bool:
169
+ return name in self._cells
170
+
171
+ # Names follow List.cs: Source/Sink, First/Last, Prev/Next, Index,
172
+ # IsAllocated, IsLeftOf ... IsSelfOrRightOf, InsAfter/InsBefore, IsSameAs.
173
+
174
+ @property
175
+ def source(self) -> str:
176
+ return self.first.name
177
+
178
+ @property
179
+ def sink(self) -> str:
180
+ return self.last.name
181
+
182
+ def index(self, name: str):
183
+ """Position of ``name`` as a Fraction (SOURCE/SINK: -inf/+inf), or None if absent (List.cs: Index)."""
184
+ cell = self._cells.get(name)
185
+ if cell is None:
186
+ return None
187
+ if cell.den == 0:
188
+ return float("inf") if cell.num > 0 else float("-inf")
189
+ return Fraction(cell.num, cell.den)
190
+
191
+ label = index
192
+
193
+ def is_allocated(self, name: str) -> bool:
194
+ return name in self._cells
195
+
196
+ def is_not_allocated(self, name: str) -> bool:
197
+ return name not in self._cells
198
+
199
+ def next(self, name: str) -> Optional[str]:
200
+ nxt = self._cells[name].next
201
+ return nxt.name if nxt else None
202
+
203
+ def prev(self, name: str) -> Optional[str]:
204
+ prv = self._cells[name].prev
205
+ return prv.name if prv else None
206
+
207
+ def is_left_of(self, v: str, u: str) -> bool:
208
+ return self._lt(self._cells[v], self._cells[u])
209
+
210
+ def is_self_or_left_of(self, v: str, u: str) -> bool:
211
+ return v == u or self._lt(self._cells[v], self._cells[u])
212
+
213
+ def is_right_of(self, v: str, u: str) -> bool:
214
+ return self._lt(self._cells[u], self._cells[v])
215
+
216
+ def is_self_or_right_of(self, v: str, u: str) -> bool:
217
+ return v == u or self._lt(self._cells[u], self._cells[v])
218
+
219
+ def precedes(self, u: str, v: str) -> bool:
220
+ """Strict order: u lies left of v (same as is_left_of)."""
221
+ x, y = self._cells[u], self._cells[v]
222
+ if x.den and y.den:
223
+ return x.num * y.den < y.num * x.den
224
+ return self._lt(x, y)
225
+
226
+ def _cell_for(self, name: str) -> _Cell:
227
+ """Existing cell (unlinked first, so it is moved) or a new one."""
228
+ cell = self._cells.get(name)
229
+ if cell is None:
230
+ return self._new(name)
231
+ self._unlink(cell)
232
+ return cell
233
+
234
+ def insert_after(self, name: str, anchor: str) -> None:
235
+ """Place ``name`` directly after ``anchor``; moves it if already present (List.cs: InsAfter)."""
236
+ if name == anchor or name == SINK:
237
+ return
238
+ if anchor == SINK:
239
+ return self.insert_before(name, SINK)
240
+ a = self._cells[anchor]
241
+ if a.next is not None and a.next.name == name:
242
+ return
243
+ cell = self._cell_for(name)
244
+ self._link_after(cell, self._cells[anchor])
245
+
246
+ def insert_before(self, name: str, anchor: str) -> None:
247
+ """Place ``name`` directly before ``anchor``; moves it if already present (List.cs: InsBefore)."""
248
+ if name == anchor or name == SOURCE:
249
+ return
250
+ if anchor == SOURCE:
251
+ return self.insert_after(name, SOURCE)
252
+ a = self._cells[anchor]
253
+ if a.prev is not None and a.prev.name == name:
254
+ return
255
+ cell = self._cell_for(name)
256
+ self._link_after(cell, self._cells[anchor].prev)
257
+
258
+ def is_same_as(self, other: "DList") -> bool:
259
+ """True if both lists hold the same nodes in the same order (List.cs: IsSameAs)."""
260
+ return list(self) == list(other)
261
+
262
+ def remove(self, name: str) -> None:
263
+ self._unlink(self._cells.pop(name))
264
+
265
+ def cut_block(self, first: str, last: str) -> List[str]:
266
+ """Remove the contiguous block first..last and return its names in order."""
267
+ a, b = self._cells[first], self._cells[last]
268
+ names, cell = [], a
269
+ while True:
270
+ names.append(cell.name)
271
+ if cell is b:
272
+ break
273
+ cell = cell.next
274
+ a.prev.next = b.next
275
+ b.next.prev = a.prev
276
+ for n in names:
277
+ self._cells.pop(n)
278
+ return names
279
+
280
+ def paste_block_after(self, names: List[str], anchor: str) -> None:
281
+ for n in names:
282
+ self.insert_after(n, anchor)
283
+ anchor = n
284
+
285
+ def __iter__(self) -> Iterator[str]:
286
+ cell = self.first.next
287
+ while cell is not self.last:
288
+ yield cell.name
289
+ cell = cell.next
290
+
291
+ def to_list(self, with_markers: bool = False) -> List[str]:
292
+ body = list(self)
293
+ return [SOURCE, *body, SINK] if with_markers else body
294
+
295
+ def __repr__(self) -> str:
296
+ return f"{self.name}: [{', '.join(self)}]"
297
+
298
+
299
+ class DTree:
300
+ """Single-rooted directed tree with dual projections Q1 and Q2."""
301
+
302
+ def __init__(self, tree_id: str = "P1") -> None:
303
+ self.tree_id = tree_id
304
+ self._parent: Dict[str, str] = {} # child -> parent (root -> SOURCE)
305
+ self._children: Dict[str, List[str]] = {} # parent -> children, insertion order
306
+ self._q1 = DList(f"{tree_id}.Q1")
307
+ self._q2 = DList(f"{tree_id}.Q2")
308
+ self.edge_log: List[str] = []
309
+ self._reshapes = 0 # counts deletions and moves; insertions keep the order of existing nodes
310
+
311
+ # -- construction ------------------------------------------------------
312
+ @classmethod
313
+ def from_file(cls, path: str, tree_id: Optional[str] = None) -> "DTree":
314
+ """Read the ``parent: child1; child2`` format used by the .csx examples."""
315
+ import os
316
+
317
+ tree = cls(tree_id or os.path.splitext(os.path.basename(path))[0])
318
+ with open(path, encoding="utf-8-sig") as fh:
319
+ for line in fh:
320
+ line = line.strip()
321
+ if not line or line.startswith(("#", "--", "**")):
322
+ continue
323
+ if line.startswith("@@"):
324
+ break
325
+ parent, _, rest = line.partition(":")
326
+ for child in (c.strip() for c in rest.split(";")):
327
+ if child:
328
+ tree.insert(parent.strip(), child)
329
+ return tree
330
+
331
+ def insert(self, parent: str, child: str) -> None:
332
+ """Add the edge parent -> child.
333
+
334
+ The first call makes ``parent`` the root. Afterwards ``parent`` must
335
+ already exist and ``child`` must not.
336
+ """
337
+ if parent in (SOURCE, SINK) or child in (SOURCE, SINK):
338
+ raise ValueError("SOURCE/SINK markers cannot be tree nodes")
339
+ if not self._parent:
340
+ self._attach(SOURCE, parent)
341
+ elif parent not in self._parent:
342
+ raise KeyError(f"parent {parent!r} is not in the tree")
343
+ if child in self._parent:
344
+ raise ValueError(f"child {child!r} is already in the tree")
345
+ self._attach(parent, child)
346
+ self.edge_log.append(f"({parent}, {child})")
347
+
348
+ def _attach(self, parent: str, child: str) -> None:
349
+ end = self._subtree_end_q1(parent)
350
+ self._q2.insert_after(child, parent)
351
+ self._q1.insert_before(child, end)
352
+ self._parent[child] = parent
353
+ self._children.setdefault(parent, []).append(child)
354
+
355
+ def _subtree_end_q1(self, u: str) -> str:
356
+ """First node after u in Q1 that u cannot reach (may be SINK)."""
357
+ return self._q1.next(self._subtree_last(u, self._q1))
358
+
359
+ def _subtree_last(self, u: str, q: DList) -> str:
360
+ """Last node of u's subtree in projection q, found in O(depth).
361
+
362
+ Q1 lists children in insertion order, so its last node is reached by
363
+ following last children; Q2 reverses siblings, so by following first
364
+ children.
365
+ """
366
+ pick = -1 if q is self._q1 else 0
367
+ w = u
368
+ while w in self._children:
369
+ w = self._children[w][pick]
370
+ return w
371
+
372
+ # -- structural reconfiguration ---------------------------------------
373
+ def delete(self, node: str) -> None:
374
+ """Remove ``node``; its children are re-attached to its parent.
375
+
376
+ Deleting the root is disallowed.
377
+ """
378
+ self._require(node)
379
+ if self.is_root(node):
380
+ raise ValueError("the root cannot be deleted")
381
+ parent = self._parent.pop(node)
382
+ kids = self._children.pop(node, [])
383
+ siblings = self._children[parent]
384
+ i = siblings.index(node)
385
+ siblings[i : i + 1] = kids
386
+ for k in kids:
387
+ self._parent[k] = parent
388
+ if not siblings:
389
+ del self._children[parent]
390
+ self._q1.remove(node)
391
+ self._q2.remove(node)
392
+ self._reshapes += 1
393
+
394
+ def delete_subtree(self, node: str) -> List[str]:
395
+ """Remove ``node`` together with all its descendants.
396
+
397
+ Returns the removed nodes in Q1 order. Deleting the root's subtree
398
+ (the whole tree) is disallowed, as for delete.
399
+ """
400
+ self._require(node)
401
+ if self.is_root(node):
402
+ raise ValueError("the root cannot be deleted")
403
+ last1 = self._subtree_last(node, self._q1)
404
+ last2 = self._subtree_last(node, self._q2)
405
+ removed = self._q1.cut_block(node, last1)
406
+ self._q2.cut_block(node, last2)
407
+ parent = self._parent[node]
408
+ self._children[parent].remove(node)
409
+ if not self._children[parent]:
410
+ del self._children[parent]
411
+ for v in removed:
412
+ del self._parent[v]
413
+ self._children.pop(v, None)
414
+ self._reshapes += 1
415
+ return removed
416
+
417
+ def cut_n_paste(self, subtree_root: str, new_parent: str) -> None:
418
+ """Detach the subtree rooted at ``subtree_root`` and attach it under ``new_parent``.
419
+
420
+ The subtree keeps its internal shape and becomes the last child of
421
+ ``new_parent``. ``new_parent`` must not lie inside the moved subtree,
422
+ and the root cannot be moved.
423
+ """
424
+ self._require(subtree_root)
425
+ self._require(new_parent)
426
+ if self.is_root(subtree_root):
427
+ raise ValueError("the root cannot be moved")
428
+ if new_parent == subtree_root or self.can_reach(subtree_root, new_parent):
429
+ raise ValueError("new_parent lies inside the subtree being moved")
430
+ u = subtree_root
431
+ last1 = self._subtree_last(u, self._q1)
432
+ last2 = self._subtree_last(u, self._q2)
433
+ block1 = self._q1.cut_block(u, last1)
434
+ block2 = self._q2.cut_block(u, last2)
435
+ old = self._parent[u]
436
+ self._children[old].remove(u)
437
+ if not self._children[old]:
438
+ del self._children[old]
439
+ # Same placement rule as insert: Q2 right after the parent,
440
+ # Q1 right after the current last node of the parent's subtree.
441
+ last = self._subtree_last(new_parent, self._q1)
442
+ self._q2.paste_block_after(block2, new_parent)
443
+ self._q1.paste_block_after(block1, last)
444
+ self._parent[u] = new_parent
445
+ self._reshapes += 1
446
+ self._children.setdefault(new_parent, []).append(u)
447
+
448
+ # -- accessors --------------------------------------------------------
449
+ @property
450
+ def root(self) -> Optional[str]:
451
+ kids = self._children.get(SOURCE)
452
+ return kids[0] if kids else None
453
+
454
+ @property
455
+ def source(self) -> str:
456
+ return SOURCE
457
+
458
+ @property
459
+ def sink(self) -> str:
460
+ return SINK
461
+
462
+ @property
463
+ def Q1(self) -> DList:
464
+ """The Q1 projection itself, for list-level checks (is_left_of, index, ...).
465
+
466
+ Live view: read it, but change the tree only through DTree methods.
467
+ """
468
+ return self._q1
469
+
470
+ @property
471
+ def Q2(self) -> DList:
472
+ """The Q2 projection itself, for list-level checks. Live view, as Q1."""
473
+ return self._q2
474
+
475
+ @property
476
+ def q1(self) -> List[str]:
477
+ """Q1 as a plain list of node names (a copy, without the markers)."""
478
+ return self._q1.to_list()
479
+
480
+ @property
481
+ def q2(self) -> List[str]:
482
+ return self._q2.to_list()
483
+
484
+ @property
485
+ def size(self) -> int:
486
+ return len(self._parent)
487
+
488
+ @property
489
+ def nodes(self) -> List[str]:
490
+ return self.q1
491
+
492
+ def __len__(self) -> int:
493
+ return self.size
494
+
495
+ def __contains__(self, node: str) -> bool:
496
+ return self.contains(node)
497
+
498
+ def __repr__(self) -> str:
499
+ return f"DTree({self.tree_id!r}, size={self.size})\n {self._q1!r}\n {self._q2!r}"
500
+
501
+ # -- queries ----------------------------------------------------------
502
+ def contains(self, node: str) -> bool:
503
+ return node in self._parent
504
+
505
+ def get_parent(self, node: str) -> Optional[str]:
506
+ """Parent of ``node``; ``None`` for the root. Raises KeyError if absent."""
507
+ self._require(node)
508
+ p = self._parent[node]
509
+ return None if p == SOURCE else p
510
+
511
+ def children(self, node: str) -> List[str]:
512
+ self._require(node)
513
+ return list(self._children.get(node, []))
514
+
515
+ def can_reach(self, u: str, v: str) -> bool:
516
+ """True if there is a directed path of length >= 1 from u to v.
517
+
518
+ Irreflexive: can_reach(u, u) is False.
519
+ """
520
+ if u not in self._q1 or v not in self._q1:
521
+ return False
522
+ return self._q1.precedes(u, v) and self._q2.precedes(u, v)
523
+
524
+ def can_not_reach(self, u: str, v: str) -> bool:
525
+ return not self.can_reach(u, v)
526
+
527
+ def is_root(self, node: str) -> bool:
528
+ return node == self.root
529
+
530
+ def is_leaf(self, node: str) -> bool:
531
+ self._require(node)
532
+ return node not in self._children
533
+
534
+ def is_ancestor(self, u: str, v: str) -> bool:
535
+ """True if u is a proper ancestor of v (u lies above v on v's root path)."""
536
+ return self.contains(u) and self.contains(v) and self.can_reach(u, v)
537
+
538
+ def is_descendant(self, u: str, v: str) -> bool:
539
+ """True if u lies below v. Equivalent to is_ancestor(v, u)."""
540
+ return self.is_ancestor(v, u)
541
+
542
+ def same_subtree(self, u: str, v: str) -> bool:
543
+ """True if both nodes are in this tree (and so share its root)."""
544
+ return self.contains(u) and self.contains(v)
545
+
546
+ def subtree(self, node: str) -> List[str]:
547
+ """Nodes of the subtree rooted at ``node``, in Q1 order."""
548
+ self._require(node)
549
+ out, v = [node], self._q1.next(node)
550
+ while v != SINK and self.can_reach(node, v):
551
+ out.append(v)
552
+ v = self._q1.next(v)
553
+ return out
554
+
555
+ def subtree_size(self, node: str) -> int:
556
+ return len(self.subtree(node))
557
+
558
+ def path_to_root(self, node: str) -> List[str]:
559
+ """[node, parent, ..., root]."""
560
+ self._require(node)
561
+ out, v = [], node
562
+ while v != SOURCE:
563
+ out.append(v)
564
+ v = self._parent[v]
565
+ return out
566
+
567
+ def root_to_path(self, node: str) -> List[str]:
568
+ """[root, ..., parent, node]."""
569
+ return self.path_to_root(node)[::-1]
570
+
571
+ def path(self, u: str, v: str) -> List[str]:
572
+ """Nodes from u down to v, both included; [u] when u == v.
573
+
574
+ Raises if u != v and u cannot reach v.
575
+ """
576
+ if u != v and not self.can_reach(u, v):
577
+ raise ValueError(f"{u!r} cannot reach {v!r}")
578
+ out, w = [v], v
579
+ while w != u:
580
+ w = self._parent[w]
581
+ out.append(w)
582
+ return out[::-1]
583
+
584
+ def depth(self, node: str) -> int:
585
+ """Distance from the root; the root has depth 0."""
586
+ return len(self.path_to_root(node)) - 1
587
+
588
+ # -- helpers ----------------------------------------------------------
589
+ # -- set queries ------------------------------------------------------
590
+ def vmin(self, nodes: Iterable[str] = ()) -> "VMin":
591
+ """Constructed node VMin: the minimum of each index over the nodes (see VMin)."""
592
+ return VMin(self, nodes)
593
+
594
+ def vmax(self, nodes: Iterable[str] = ()) -> "VMax":
595
+ """Constructed node VMax: the maximum of each index over the nodes (see VMax)."""
596
+ return VMax(self, nodes)
597
+
598
+ def umin(self, nodes: Iterable[str] = ()) -> "UMin":
599
+ """Constructed node UMin: the minimum of each index over the nodes (see UMin)."""
600
+ return UMin(self, nodes)
601
+
602
+ def umax(self, nodes: Iterable[str] = ()) -> "UMax":
603
+ """Constructed node UMax: the maximum of each index over the nodes (see UMax)."""
604
+ return UMax(self, nodes)
605
+
606
+ def _require(self, node: str) -> None:
607
+ if node not in self._parent:
608
+ raise KeyError(f"node {node!r} is not in the tree")
609
+
610
+
611
+ class _VNode:
612
+ """A constructed node for a set S of tree nodes (E. Olgac, "Topology and Structure of
613
+ Directed Graphs - Inducing a Subgraph", 2021): the minimum (VMin, UMin) or maximum
614
+ (VMax, UMax) of each index over S. V means S takes the target role, can_reach(u, S),
615
+ and U the source role, can_reach(S, v); UMin and VMin hold the same numbers.
616
+
617
+ It stores no positions, only the members and, for each of Q1 and Q2, the member holding
618
+ the minimal (or maximal) index there; the two may be different nodes, so the constructed
619
+ node usually corresponds to no real node. Insertions and renumbering keep the order of
620
+ existing nodes, so it stays valid while the tree grows. After a deletion or a cut_n_paste it is
621
+ recomputed from the remaining members on its next use.
622
+ """
623
+
624
+ _lowest = True # VMin keeps the leftmost member, VMax the rightmost
625
+
626
+ def __init__(self, tree: "DTree", nodes: Iterable[str] = ()) -> None:
627
+ self._t = tree
628
+ self._members: List[str] = [] # in Q1 order
629
+ self._seen: set = set()
630
+ self._c1: Optional[str] = None
631
+ self._c2: Optional[str] = None
632
+ self._reshapes = tree._reshapes
633
+ for v in nodes:
634
+ self.add(v)
635
+
636
+ def _better(self, q: DList, v: str, c: Optional[str]) -> bool:
637
+ if c is None:
638
+ return True
639
+ return q.precedes(v, c) if self._lowest else q.precedes(c, v)
640
+
641
+ def add(self, node: str) -> "_VNode":
642
+ """Add a node of the tree to the set; returns the constructed node itself."""
643
+ self._t._require(node)
644
+ if node in self._seen:
645
+ return self
646
+ self._check()
647
+ self._seen.add(node)
648
+ q1 = self._t._q1
649
+ lo, hi = 0, len(self._members) # keep members sorted by Q1 order
650
+ while lo < hi:
651
+ mid = (lo + hi) // 2
652
+ if q1.precedes(node, self._members[mid]):
653
+ hi = mid
654
+ else:
655
+ lo = mid + 1
656
+ self._members.insert(lo, node)
657
+ if self._better(self._t._q1, node, self._c1):
658
+ self._c1 = node
659
+ if self._better(self._t._q2, node, self._c2):
660
+ self._c2 = node
661
+ self._added(node)
662
+ return self
663
+
664
+ def _added(self, node: str) -> None:
665
+ """Hook for subclasses that keep more than the index nodes."""
666
+
667
+ def _rebuilt(self) -> None:
668
+ """Hook for subclasses that keep more than the index nodes."""
669
+
670
+ def refresh(self) -> None:
671
+ """Drop deleted members and recompute the index nodes."""
672
+ t = self._t
673
+ q1 = t._q1
674
+ self._members = sorted((v for v in self._members if t.contains(v)),
675
+ key=lambda v: q1.index(v))
676
+ self._seen = set(self._members)
677
+ self._c1 = self._c2 = None
678
+ for v in self._members:
679
+ if self._better(t._q1, v, self._c1):
680
+ self._c1 = v
681
+ if self._better(t._q2, v, self._c2):
682
+ self._c2 = v
683
+ self._reshapes = t._reshapes
684
+ self._rebuilt()
685
+
686
+ def _check(self) -> None:
687
+ t = self._t
688
+ if self._reshapes != t._reshapes:
689
+ self.refresh()
690
+
691
+ @property
692
+ def members(self) -> List[str]:
693
+ self._check()
694
+ return list(self._members)
695
+
696
+ @property
697
+ def index_nodes(self) -> tuple:
698
+ """The members holding the minimal (VMin, UMin) or maximal (VMax, UMax) index in Q1 and in Q2 (possibly two different nodes)."""
699
+ self._check()
700
+ return self._c1, self._c2
701
+
702
+ def __len__(self) -> int:
703
+ self._check()
704
+ return len(self._members)
705
+
706
+
707
+ class VMin(_VNode):
708
+ """S in the target role, can_reach(u, S). Minimum of each index over a node set: u reaches all of the set exactly when u lies
709
+ strictly left of VMin in both Q1 and Q2."""
710
+
711
+ _lowest = True
712
+
713
+ def reaches_all(self, u: str) -> bool:
714
+ """True if u reaches every node of the set (vacuously True for an empty set)."""
715
+ self._check()
716
+ t = self._t
717
+ if not t.contains(u):
718
+ return False
719
+ if self._c1 is None:
720
+ return True
721
+ return t._q1.precedes(u, self._c1) and t._q2.precedes(u, self._c2)
722
+
723
+
724
+ class VMax(_VNode):
725
+ """S in the target role, can_reach(u, S). Maximum of each index over a node set: lying
726
+ strictly left of VMax in both Q1 and Q2 is necessary for u to reach some of the set, but
727
+ not sufficient, so may_reach_some is only a filter; reaches_some is exact."""
728
+
729
+ _lowest = False
730
+
731
+ def may_reach_some(self, u: str) -> bool:
732
+ """Fast rejection: False means u reaches no node of the set. True is not a confirmation."""
733
+ self._check()
734
+ t = self._t
735
+ if self._c1 is None or not t.contains(u):
736
+ return False
737
+ return t._q1.precedes(u, self._c1) and t._q2.precedes(u, self._c2)
738
+
739
+ def reaches_some(self, u: str) -> bool:
740
+ """True if u reaches at least one node of the set (exact).
741
+
742
+ The subtree of u is the block directly after u in Q1, so u reaches some member
743
+ exactly when the first member after u in Q1 lies in it: a binary search over the
744
+ members (kept in Q1 order) and one reachability test.
745
+ """
746
+ if not self.may_reach_some(u):
747
+ return False
748
+ t, m = self._t, self._members
749
+ q1 = t._q1
750
+ lo, hi = 0, len(m)
751
+ while lo < hi:
752
+ mid = (lo + hi) // 2
753
+ if q1.precedes(u, m[mid]):
754
+ hi = mid
755
+ else:
756
+ lo = mid + 1
757
+ return lo < len(m) and t.can_reach(u, m[lo])
758
+
759
+
760
+ class UMax(_VNode):
761
+ """S in the source role, can_reach(S, v). Maximum of each index over a node set: v is
762
+ reached by all of the set exactly when v lies strictly right of UMax in both Q1 and Q2."""
763
+
764
+ _lowest = False
765
+
766
+ def reached_by_all(self, v: str) -> bool:
767
+ """True if every node of the set reaches v (vacuously True for an empty set)."""
768
+ self._check()
769
+ t = self._t
770
+ if not t.contains(v):
771
+ return False
772
+ if self._c1 is None:
773
+ return True
774
+ return t._q1.precedes(self._c1, v) and t._q2.precedes(self._c2, v)
775
+
776
+
777
+ class UMin(_VNode):
778
+ """S in the source role, can_reach(S, v). Minimum of each index over a node set: lying
779
+ strictly right of UMin in both Q1 and Q2 is necessary for v to be reached by some of the
780
+ set, but not sufficient, so may_be_reached_by_some is only a filter; reached_by_some is
781
+ exact.
782
+
783
+ The exact test keeps the topmost members (those with no ancestor in the set) in Q1
784
+ order. Their subtrees are disjoint, so if one of them is an ancestor of v, no other
785
+ topmost member lies between it and v in Q1: the last topmost member before v in Q1 is
786
+ the only one to test.
787
+ """
788
+
789
+ _lowest = True
790
+
791
+ def __init__(self, tree: "DTree", nodes: Iterable[str] = ()) -> None:
792
+ self._tops: List[str] = [] # topmost members, in Q1 order
793
+ super().__init__(tree, nodes)
794
+
795
+ def _last_top_before(self, v: str) -> int:
796
+ """Number of topmost members strictly left of v in Q1."""
797
+ q1, m = self._t._q1, self._tops
798
+ lo, hi = 0, len(m)
799
+ while lo < hi:
800
+ mid = (lo + hi) // 2
801
+ if q1.precedes(m[mid], v):
802
+ lo = mid + 1
803
+ else:
804
+ hi = mid
805
+ return lo
806
+
807
+ def _covered(self, v: str) -> bool:
808
+ i = self._last_top_before(v)
809
+ return i > 0 and self._t.can_reach(self._tops[i - 1], v)
810
+
811
+ def _added(self, node: str) -> None:
812
+ if self._covered(node):
813
+ return # an ancestor of node is already in the set
814
+ i = self._last_top_before(node)
815
+ t, m = self._t, self._tops
816
+ while i < len(m) and t.can_reach(node, m[i]):
817
+ del m[i] # node is above them: they are no longer topmost
818
+ m.insert(i, node)
819
+
820
+ def _rebuilt(self) -> None:
821
+ self._tops = []
822
+ t = self._t
823
+ for v in self._members: # Q1 order: ancestors come first
824
+ if not (self._tops and t.can_reach(self._tops[-1], v)):
825
+ self._tops.append(v)
826
+
827
+ def may_be_reached_by_some(self, v: str) -> bool:
828
+ """Fast rejection: False means no node of the set reaches v; True is not a confirmation."""
829
+ self._check()
830
+ t = self._t
831
+ if self._c1 is None or not t.contains(v):
832
+ return False
833
+ return t._q1.precedes(self._c1, v) and t._q2.precedes(self._c2, v)
834
+
835
+ def reached_by_some(self, v: str) -> bool:
836
+ """True if at least one node of the set reaches v (exact, O(log k))."""
837
+ if not self.may_be_reached_by_some(v):
838
+ return False
839
+ return self._covered(v)
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.4
2
+ Name: dtree-encoding
3
+ Version: 1.5.0
4
+ Summary: DTree: incremental encoding of directed trees by two projections (Q1, Q2) with O(1) reachability and set queries
5
+ Author-email: Enis Olgac <enis@digraphs.info>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/EnisOlgac/DTree
8
+ Project-URL: Documentation, https://github.com/EnisOlgac/DTree/blob/main/docs/API.md
9
+ Project-URL: Changelog, https://github.com/EnisOlgac/DTree/blob/main/CHANGELOG.md
10
+ Project-URL: Paper, https://doi.org/10.5772/intechopen.1013408
11
+ Project-URL: Archive, https://doi.org/10.5281/zenodo.23395710
12
+ Keywords: directed tree,reachability,incremental encoding,dynamic tree,graph theory,data structure
13
+ Classifier: Development Status :: 5 - Production/Stable
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.8
22
+ Description-Content-Type: text/markdown
23
+
24
+ # DTree for Python
25
+
26
+ Python implementation of `DTree`, the incremental encoding of directed trees by two projections Q1 and Q2 (see the [API reference](https://github.com/EnisOlgac/DTree/blob/main/docs/API.md) and the [paper](https://doi.org/10.5772/intechopen.1013408)). No dependencies; Python 3.8+.
27
+
28
+ ```
29
+ pip install dtree-encoding
30
+ ```
31
+
32
+ The package is imported as `dtree`.
33
+
34
+ ```python
35
+ from dtree import DTree
36
+
37
+ t = DTree("P1")
38
+ t.insert("A", "B")
39
+ t.insert("B", "C")
40
+
41
+ t.can_reach("A", "C") # True
42
+ t.path_to_root("C") # ['C', 'B', 'A']
43
+ t.q1, t.q2 # (['A', 'B', 'C'], ['A', 'B', 'C'])
44
+
45
+ t.insert("A", "D")
46
+ t.cut_n_paste("C", "D") # move subtree C under D
47
+ t.delete("B") # B's children (none left) move up to A
48
+ t.delete_subtree("D") # removes D and C
49
+ ```
50
+
51
+ Load one of the example files (`parent: child1; child2` per line):
52
+
53
+ ```python
54
+ t = DTree.from_file("Test.csx") # e.g. examples/Test.csx from the repository
55
+ ```
56
+
57
+ ## API (C# name → Python name)
58
+
59
+ | C# (docs) | Python |
60
+ |---|---|
61
+ | `DTree(id)` | `DTree(tree_id="P1")` |
62
+ | `Insert(parent, child)` | `insert(parent, child)` |
63
+ | `Contains(node)` | `contains(node)` / `node in t` |
64
+ | `GetParent(node)` | `get_parent(node)` (`None` for the root) |
65
+ | `CutNPaste(u, p)` | `cut_n_paste(subtree_root, new_parent)` |
66
+ | `Delete(node)` | `delete(node)`—children move up to the parent |
67
+ | — | `delete_subtree(node)`—removes the node and all descendants |
68
+ | `Q1`, `Q2` | `Q1`, `Q2` (the `DList` projections, for list-level checks); `q1`, `q2` (plain lists, without the `[` `]` markers) |
69
+ | `Source`, `Sink` | `source`, `sink` |
70
+ | `CanReach`, `CanNotReach` | `can_reach`, `can_not_reach` |
71
+ | `IsRoot`, `IsLeaf` | `is_root`, `is_leaf` |
72
+ | `IsAncestor`, `IsDescendant` | `is_ancestor`, `is_descendant` |
73
+ | `SameSubtree`, `SubtreeSize` | `same_subtree`, `subtree_size` (plus `subtree`) |
74
+ | `Depth` | `depth` (root = 0) |
75
+ | `PathToRoot`, `RootToPath` | `path_to_root`, `root_to_path` (plus `path(u, v)`) |
76
+ | `VMin`, `VMax`, `UMin`, `UMax` (of nodes) | `vmin`, `vmax`, `umin`, `umax` |
77
+ | `ReachesAll`, `MayReachSome`, `ReachesSome` | `reaches_all`, `may_reach_some`, `reaches_some` |
78
+ | `ReachedByAll`, `MayBeReachedBySome`, `ReachedBySome` | `reached_by_all`, `may_be_reached_by_some`, `reached_by_some` |
79
+
80
+ ## Semantics
81
+
82
+ - `can_reach(u, v)` is irreflexive: no node reaches itself, so `can_reach(u, u)` is `False`. (The C# prototype is reflexive.)
83
+ - `is_ancestor(u, v)` equals `can_reach(u, v)` and is likewise proper.
84
+ - `delete(v)` re-attaches v's children to v's parent, in v's place among its siblings.
85
+ - `delete_subtree(v)` removes v with all its descendants and returns them in Q1 order.
86
+ - `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.
87
+ - 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 [set queries](https://github.com/EnisOlgac/DTree/blob/main/docs/set-queries.md).
88
+ - 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`.
89
+
90
+ ## Tests
91
+
92
+ ```
93
+ python -m unittest discover -s tests
94
+ ```
95
+
96
+ Every operation is checked against a brute-force parent/children model on random trees, including random sequences of deletes and moves.
97
+
98
+ ## Benchmarks
99
+
100
+ ```
101
+ python benchmarks/bench.py # trees of 10,000 and 100,000 nodes
102
+ python benchmarks/bench.py --quick # a few seconds
103
+ python benchmarks/bench.py --dynamic # only insertions and queries alternating
104
+ python benchmarks/bench.py 1000000 # custom sizes
105
+ ```
106
+
107
+ 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, set 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`.
108
+
109
+ ## Provenance
110
+
111
+ Design and API: Enis Olgac. This Python port was written with Claude (Anthropic) from the C# original and the API reference.
@@ -0,0 +1,6 @@
1
+ dtree/__init__.py,sha256=T_N2PGTrhcITiUjo3wTqqbvDBNBwUcGx1DL4otbse74,241
2
+ dtree/dtree.py,sha256=RD3R2DaA_aVQrOnXSpYtPl3ibpNJb9GVDJ2_KRquClc,31814
3
+ dtree_encoding-1.5.0.dist-info/METADATA,sha256=EQYpNNa7r8WIc1zAZI9S9VIQOdkLG10sgiTZT0-3eUc,6028
4
+ dtree_encoding-1.5.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
5
+ dtree_encoding-1.5.0.dist-info/top_level.txt,sha256=4QtyU5yebXuqrNE0olcPNOJmZ8woJFEiJczb-jmar2o,6
6
+ dtree_encoding-1.5.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ dtree