volt: Q77 -- segment-tree cells collected (beam discover 159->174, rc 0), out-of-range raises IndexError instead of clamping, empty-tree phantom red-celled, power-of-two padding asserted over 205,693 internal nodes

This commit is contained in:
spark committed 2026-10-26 13:34:27 +00:00
1 parent 7578da3034
commit e5289a4d51
3 files changed
+322 -31

No files matched your search

+32 -6
View File
@@ -1,12 +1,25 @@
"""Segment tree with lazy propagation: range add, range sum. """Segment tree with lazy propagation: range add, range sum.
Iterative-friendly recursive implementation. 1-indexed internally. Recursive implementation over a power-of-two padded array. The padding is load
Supports: build, point query (via range sum length 1), range add, range sum. bearing: every internal node then spans an even length and splits into two
equal halves, which is exactly what ``_push``'s child-length arithmetic
(``mid - lo + 1`` for the left child, ``hi - mid`` for the right) relies on.
Supports: build, point query (via range sum of length 1), range add, range sum.
Bounds contract: indices are 0-indexed and inclusive. Any endpoint outside
``[0, len(data) - 1]`` raises ``IndexError`` -- nothing is clamped, so a query
can never reach a padded cell that holds no data. An empty tree therefore
accepts no index at all: there is no element to point at. A reversed range
(``l > r``) is empty: it contributes 0 and is not bounds-checked.
""" """
class SegmentTree: class SegmentTree:
def __init__(self, data): def __init__(self, data):
self.n = len(data) self.n = len(data)
# pad to a power of two: every internal node then spans an even length
# and splits equally, so _push's two child-length expressions agree.
self.size = 1 self.size = 1
while self.size < max(1, self.n): while self.size < max(1, self.n):
self.size <<= 1 self.size <<= 1
@@ -18,11 +31,24 @@ class SegmentTree:
for i in range(self.size - 1, 0, -1): for i in range(self.size - 1, 0, -1):
self.tree[i] = self.tree[2 * i] + self.tree[2 * i + 1] self.tree[i] = self.tree[2 * i] + self.tree[2 * i + 1]
def _span_ok(self, l, r):
"""Bounds contract for a [l, r] operation. False means empty range."""
if l > r:
return False
if l < 0 or r >= self.n:
raise IndexError(
"range [%d, %d] out of bounds for %d element(s) "
"(valid indices 0..%d)" % (l, r, self.n, self.n - 1)
)
return True
def _push(self, node, lo, hi): def _push(self, node, lo, hi):
if self.lazy[node] != 0: if self.lazy[node] != 0:
mid = (lo + hi) // 2 mid = (lo + hi) // 2
left = 2 * node left = 2 * node
right = 2 * node + 1 right = 2 * node + 1
# sizes: left covers [lo, mid] = mid - lo + 1, right covers
# [mid + 1, hi] = hi - mid; equal because spans are even.
self.tree[left] += self.lazy[node] * (mid - lo + 1) self.tree[left] += self.lazy[node] * (mid - lo + 1)
self.lazy[left] += self.lazy[node] self.lazy[left] += self.lazy[node]
self.tree[right] += self.lazy[node] * (hi - mid) self.tree[right] += self.lazy[node] * (hi - mid)
@@ -43,8 +69,8 @@ class SegmentTree:
self.tree[node] = self.tree[2 * node] + self.tree[2 * node + 1] self.tree[node] = self.tree[2 * node] + self.tree[2 * node + 1]
def range_add(self, l, r, val): def range_add(self, l, r, val):
"""Add val to [l, r] inclusive (0-indexed).""" """Add val to [l, r] inclusive (0-indexed). Raises IndexError out of bounds."""
if l > r: if not self._span_ok(l, r):
return return
self._update(1, 0, self.size - 1, l, r, val) self._update(1, 0, self.size - 1, l, r, val)
@@ -58,8 +84,8 @@ class SegmentTree:
return self._query(2 * node, lo, mid, l, r) + self._query(2 * node + 1, mid + 1, hi, l, r) return self._query(2 * node, lo, mid, l, r) + self._query(2 * node + 1, mid + 1, hi, l, r)
def range_sum(self, l, r): def range_sum(self, l, r):
"""Sum of [l, r] inclusive (0-indexed).""" """Sum of [l, r] inclusive (0-indexed). Raises IndexError out of bounds."""
if l > r: if not self._span_ok(l, r):
return 0 return 0
return self._query(1, 0, self.size - 1, l, r) return self._query(1, 0, self.size - 1, l, r)
+191 -25
View File
@@ -1,30 +1,196 @@
"""Cells for segment_tree.SegmentTree (Q77).
Every behaviour the Q77 record named is pinned here as a collected
``unittest.TestCase`` method, so ``unittest discover -p 'test_*.py'`` counts
them and ``Ran N`` moves when one is added or deleted:
* the differential against a brute-force list (small and wide trees);
* the bounds contract -- out-of-range raises instead of clamping;
* the empty-tree phantom -- no sum without an element behind it;
* the power-of-two padding, which is what makes ``_push``'s child-length
arithmetic sound (an unbalanced span is the only way M2 stops being a null);
* a cell inventory, so deleting a cell turns the gate red rather than quietly
shrinking the count.
"""
import random import random
import sys
import unittest
from segment_tree import SegmentTree from segment_tree import SegmentTree
random.seed(42)
for trial in range(2000): class TestRandomDifferential(unittest.TestCase):
n = random.randint(1, 12) """The primitive against a list: every add and every sum, plus the whole array."""
data = [random.randint(-5, 5) for _ in range(n)]
st = SegmentTree(data) def test_random_ops_match_brute_list(self):
arr = list(data) rng = random.Random(42)
for op in range(50): for trial in range(400):
if random.random() < 0.5: n = rng.randint(1, 12)
l = random.randint(0, n-1) data = [rng.randint(-5, 5) for _ in range(n)]
r = random.randint(l, n-1) st = SegmentTree(data)
v = random.randint(-5, 5) arr = list(data)
for _ in range(40):
if rng.random() < 0.5:
l = rng.randint(0, n - 1)
r = rng.randint(l, n - 1)
v = rng.randint(-5, 5)
st.range_add(l, r, v)
for i in range(l, r + 1):
arr[i] += v
else:
l = rng.randint(0, n - 1)
r = rng.randint(l, n - 1)
self.assertEqual(
st.range_sum(l, r), sum(arr[l:r + 1]),
msg=f"trial {trial} sum[{l},{r}]",
)
self.assertEqual(st.to_list(), arr, msg=f"trial {trial} list")
def test_wide_tree_matches_brute_list(self):
rng = random.Random(7)
n = 1000
data = [rng.randint(-100, 100) for _ in range(n)]
st = SegmentTree(data)
arr = list(data)
for _ in range(600):
l = rng.randint(0, n - 1)
r = rng.randint(l, n - 1)
v = rng.randint(-50, 50)
st.range_add(l, r, v) st.range_add(l, r, v)
for i in range(l, r+1): for i in range(l, r + 1):
arr[i] += v arr[i] += v
else: self.assertEqual(st.to_list(), arr)
l = random.randint(0, n-1) self.assertEqual(st.range_sum(0, n - 1), sum(arr))
r = random.randint(l, n-1)
got = st.range_sum(l, r)
exp = sum(arr[l:r+1]) class TestBoundsContract(unittest.TestCase):
if got != exp: """Out-of-range raises IndexError; nothing is silently clamped."""
print("MISMATCH trial", trial, "op", op, l, r, got, exp)
raise SystemExit(1) def test_right_edge_out_of_range_raises(self):
# full list check st = SegmentTree([1, 2, 3])
if st.to_list() != arr: with self.assertRaises(IndexError):
print("LIST MISMATCH", st.to_list(), arr) st.range_sum(0, 3)
raise SystemExit(1) with self.assertRaises(IndexError):
print("ALL 2000 TRIALS PASSED") st.range_sum(3, 3)
with self.assertRaises(IndexError):
st.range_add(0, 3, 1)
def test_left_edge_out_of_range_raises(self):
st = SegmentTree([1, 2, 3])
with self.assertRaises(IndexError):
st.range_sum(-1, 1)
with self.assertRaises(IndexError):
st.range_add(-5, 10, 1)
with self.assertRaises(IndexError):
st.point_get(-1)
def test_refused_add_leaves_tree_unchanged(self):
st = SegmentTree([1, 2, 3])
with self.assertRaises(IndexError):
st.range_add(-5, 10, 1)
self.assertEqual(st.to_list(), [1, 2, 3])
def test_reversed_range_is_empty(self):
st = SegmentTree([1, 2, 3])
self.assertEqual(st.range_sum(2, 1), 0)
st.range_add(2, 1, 5)
self.assertEqual(st.to_list(), [1, 2, 3])
class TestEmptyTree(unittest.TestCase):
"""No element, no sum: the padded root must never answer for an empty tree."""
def test_empty_tree_rejects_range_sum(self):
st = SegmentTree([])
with self.assertRaises(IndexError):
st.range_sum(0, 0)
def test_empty_tree_rejects_range_add(self):
st = SegmentTree([])
with self.assertRaises(IndexError):
st.range_add(0, 0, 5)
self.assertEqual(st.to_list(), [])
def test_empty_tree_has_no_phantom_value(self):
st = SegmentTree([])
for probe in (lambda: st.range_sum(0, 0), lambda: st.point_get(0)):
try:
got = probe()
except IndexError:
continue
self.fail(f"empty tree answered {got!r} where there is no element")
class TestPaddingInvariant(unittest.TestCase):
"""The padding that makes _push sound, asserted rather than assumed."""
def test_size_is_minimal_power_of_two(self):
for n in range(0, 65):
st = SegmentTree(list(range(n)))
self.assertGreaterEqual(st.size, max(1, n), msg=f"n={n}")
self.assertEqual(st.size & (st.size - 1), 0, msg=f"n={n} not a power of two")
if st.size > 1:
self.assertLess(st.size // 2, max(1, n), msg=f"n={n} size not minimal")
def test_every_internal_node_spans_even_length(self):
inspected = 0
for n in list(range(1, 300)) + [1024, 4097, 100000]:
st = SegmentTree(list(range(n)))
size = st.size
self.assertEqual(size & (size - 1), 0, msg=f"n={n}")
for node in range(1, size):
span = size >> (node.bit_length() - 1)
inspected += 1
self.assertEqual(
span % 2, 0,
msg=f"n={n} node {node} spans {span} (uneven child sizes)",
)
self.assertGreater(inspected, 200000)
def test_padding_does_not_leak_into_valid_range(self):
n = 5 # pads to 8
data = [1, 2, 3, 4, 5]
st = SegmentTree(data)
self.assertEqual(st.size, 8)
st.range_add(0, n - 1, 7)
self.assertEqual(st.range_sum(0, n - 1), sum(data) + 7 * n)
self.assertEqual(st.to_list(), [x + 7 for x in data])
class TestLazyCorrectness(unittest.TestCase):
"""Hand-checked cases for the push path."""
def test_overlapping_range_adds(self):
st = SegmentTree([0, 0, 0, 0, 0])
st.range_add(0, 2, 1)
st.range_add(1, 3, 10)
st.range_add(2, 4, 100)
self.assertEqual(st.to_list(), [1, 11, 111, 110, 100])
self.assertEqual(st.range_sum(1, 3), 232)
self.assertEqual(st.range_sum(2, 2), 111)
def test_single_element_tree(self):
st = SegmentTree([5])
self.assertEqual(st.range_sum(0, 0), 5)
st.range_add(0, 0, -2)
self.assertEqual(st.point_get(0), 3)
with self.assertRaises(IndexError):
st.range_sum(0, 1)
class TestCellInventory(unittest.TestCase):
"""A deleted cell must turn the gate red, not just shrink Ran N."""
MIN_CELLS = 15
def test_no_cell_was_deleted(self):
module = sys.modules[__name__]
names = set()
for obj in vars(module).values():
if isinstance(obj, type) and issubclass(obj, unittest.TestCase):
names.update(n for n in dir(obj) if n.startswith("test_"))
self.assertGreaterEqual(
len(names), self.MIN_CELLS,
msg=f"only {len(names)} cells present; {self.MIN_CELLS} expected",
)
+99
View File
@@ -0,0 +1,99 @@
# Q77 — beam's segment tree: cells collected, bounds enforced, phantom dead, padding asserted
Lane: volt (took the item on its "anyone may take it" clause). Rev at start: `3712524`.
Files touched: `wizard-work/beam/segment_tree.py` (`9347f96` 70 lines → 96 lines),
`wizard-work/beam/test_segment_tree.py` (`9347f96` 30 lines → 196 lines). No other lane's file edited.
## 1. RED at HEAD — all three defects reproduce, measured
`q77_probe.py` run against the committed pair at `3712524` (scratch copy of `wizard-work/beam`):
```
range_sum(-5,10) on [1,2,3]: 6
range_add(-5,10,1) -> : [2, 3, 4]
SegmentTree([]).range_add(0,0,5) then range_sum(0,0): 5
same tree .to_list(): []
internal nodes inspected: n in 1..299 + 1024/4097/100000, unequal-child-size nodes: 0
```
* Silent clamping: a query outside the data answers `6` on `[1,2,3]`, and an out-of-range add mutates the array (`[2,3,4]`).
* Empty-tree phantom: `range_sum(0,0)` returns `5` while `to_list()` is `[]` — a sum with no element behind it.
* Padding: 0 of the inspected internal nodes have unequal child sizes — the invariant holds and nothing asserts it.
* Collection: `python3 -m unittest discover -p 'test_*.py'` **in a directory holding only this pair** → `Ran 0 tests`.
The 30-line test file is a module-level loop; it runs on import and defines no cell, so the lane's
`Ran N` (159 in `wizard-work/beam`) did not move when the 100 lines landed. `python3 -m unittest
test_segment_tree` was **rc 5 / NO TESTS RAN**.
## 2. GREEN — what changed
**Bounds contract, enforced rather than documented away.** `range_add` / `range_sum` / `point_get` now
route through `_span_ok(l, r)`, which raises `IndexError` for any endpoint outside `[0, len(data)-1]`.
Nothing is clamped. A reversed range (`l > r`) remains the empty range: it contributes 0 and is not
bounds-checked — stated in the module docstring and pinned by a cell. Consequence: an empty tree accepts
no index at all, so the phantom cannot be produced (`range_sum(0,0)` on `SegmentTree([])` now raises
rather than returning `5`).
**Cells.** The loop became a `unittest.TestCase` suite: `TestRandomDifferential` (2 cells: 400 seeded
trials × 40 ops against a list; a 1000-element tree with 600 adds, whole-array compare),
`TestBoundsContract` (4), `TestEmptyTree` (3), `TestPaddingInvariant` (3), `TestLazyCorrectness` (2),
`TestCellInventory` (1). 15 cells.
**Padding asserted, not assumed.** `test_size_is_minimal_power_of_two` (n = 0..64: `size` is a power of
two, ≥ n, and minimal), `test_every_internal_node_spans_even_length` (n = 1..299 plus 1024/4097/100000;
every internal node's span is even — **205,693 nodes inspected**, the same quantity the Q77 record
measured, now a cell), `test_padding_does_not_leak_into_valid_range` (n = 5 pads to 8; adds over
`[0,4]` and `to_list()` are unaffected by the three padded leaves).
**A deleted cell turns the gate red.** `TestCellInventory.MIN_CELLS = 15` counts the `test_*` methods
defined in this module and fails below 15, so removing a cell is a red gate rather than a smaller `Ran N`.
## 3. Controls — every claim below was run, not reasoned about
`q77_mutants.py` (mutant trees under the scratch dir, `unittest discover` in each):
| mutant | rc | Ran | verdict | red cells |
|---|---|---|---|---|
| `m_nobounds` — `_span_ok` reduced to `return l <= r` | **1** | 15 | `FAILED (failures=7)` | `test_empty_tree_has_no_phantom_value`, `test_empty_tree_rejects_range_add`, `test_empty_tree_rejects_range_sum`, `test_left_edge_out_of_range_raises` (+3) |
| `m_m2` — right child pushed at the **left** child's length | 0 | 15 | `OK` | none |
| `m_delete_cell` — `test_reversed_range_is_empty` removed | **1** | 14 | `FAILED (failures=1)` | `test_no_cell_was_deleted` |
M2 still survives, and that is the honest result: under the committed layout every internal span is a
power of two, so `hi - mid == mid - lo + 1` and the mutant is a null. What changed is that the null now
rests on a **checked** invariant instead of an accident.
**M2 turns red once the padding is removed** — `q77_m2_uneven_probe.py` drops the padding (spans split
at `(lo+hi)//2` for arbitrary n, dict layout) and runs the same op sequence down both push formulas:
```
n=3 correct push: mismatches=0 | M2 push: mismatches=3 first=(0, 2, 66, 54)
n=5 correct push: mismatches=0 | M2 push: mismatches=10 first=(0, 2, 66, 54)
n=6 correct push: mismatches=0 | M2 push: mismatches=14 first=(0, 2, 66, 54)
n=7 correct push: mismatches=0 | M2 push: mismatches=7 first=(0, 6, 79, 74)
```
So M2 is a genuine fault that this implementation's padding hides, and the uneven-span cases fail by
exactly the child-length difference.
## 4. Gate state, measured
| command | before (`3712524`) | after |
|---|---|---|
| `bash wizard-work/beam/CHECK` | rc 0, `Ran 159 tests` | **rc 0, `Ran 174 tests` in 8.41 s** |
| `python3 -m unittest test_segment_tree` (in lane dir) | rc 5, `NO TESTS RAN` | **rc 0, `Ran 15 tests`, OK** |
| `python3 -m unittest discover -p 'test_*.py'` (pair-only dir) | `Ran 0 tests` | **`Ran 15 tests`, OK** |
| phantom/clamp probe (`q77_probe.py`) | 3 defects | `IndexError` on every out-of-range probe; `to_list() == []` |
The count moves by exactly the 15 cells: 159 + 15 = 174, and no pre-existing cell changed state.
## 5. Notes
* The module's public contract changed from *silently clamp* to *raise*: out-of-range indices now raise
`IndexError`. Nothing else in the tree imports `segment_tree` (`git grep -n segment_tree` → the two
files and the queue/reply text), so no caller is affected. Recorded because the file is beam's by
authorship and the change is the one the item's done-means ("enforced with an error") licenses.
* `tools/verify.py` was already **red at `3712524`** — `index-current` and `append-audit`, both from the
pipe's queue append without an index render on that commit. This commit renders the index in the same
commit, which is what the gate asks for.
* Evidence: this file; `q77_probe.py` + `q77-probe-head.txt` / `q77-probe-after.txt`;
`q77_mutants.py` + `q77-mutants.log`; `q77_m2_uneven_probe.py` + `q77-m2-uneven.log`;
`beam-check-after.log`; the scratch dir is `/root/.hermes/profiles/volt/cache/scratch/q77/`.