PineForge unreleased
Deterministic PineScript v6 backtest runtime — C ABI reference
Loading...
Searching...
No Matches
id_pair_ordered_fold.hpp
Go to the documentation of this file.
1#pragma once
2
5
6#include <algorithm>
7#include <array>
8#include <cstddef>
9#include <cstdint>
10#include <cstring>
11#include <limits>
12#include <string_view>
13#include <type_traits>
14#include <utility>
15
16namespace pineforge::detail {
17
18// Exact action of a byte sequence on ANY incoming BrokerStateHashSink::h.
19// XOR changes only the low byte. Induction on bytes gives
20// F(256*q+r) = F(r) + q*(F(256)-F(0)), modulo 2^64.
21// Store F(0)..F(256); concatenation X,Y is Y(X(r)) at all 257 seeds.
23public:
24 BrokerStateByteFold() noexcept { reset(); }
25 void reset() noexcept {
26 for (std::size_t r = 0; r < basis_.size(); ++r) basis_[r] = r;
27 }
28 std::uint64_t apply(std::uint64_t seed) const noexcept {
29 return basis_[seed & 255] + (seed >> 8) * (basis_[256] - basis_[0]);
30 }
31 void bytes(const void* data, std::size_t length) {
32 for (auto& seed : basis_) {
34 sink.h = seed;
35 sink.bytes(data, length);
36 seed = sink.h;
37 }
38 }
39 // May replace the left operand in place; after must be a different map.
40 void then(const BrokerStateByteFold& after) noexcept {
41 for (auto& seed : basis_) seed = after.apply(seed);
42 }
43
44private:
45 std::array<std::uint64_t, 257> basis_{};
46};
47static_assert(sizeof(BrokerStateByteFold) == 2056, "exact fold has 257 words");
48
49// A map file must not impose a lower key-count ceiling than the existing
50// hash index. Its largest power-of-two slot array has 2^(B-6) slots, where B
51// is the number of value bits of off_t, at half load: at most (OFF_MAX/64)+1
52// distinct rows. Segment large
53// records across a FIXED descriptor array covering that entire population.
54// Normal runs open just one segment of each file. No resident directory
55// grows with keys; no fold-specific capacity/exhaustion error is introduced.
56// MaxOffset is a compile-time seam for exercising segment boundaries in tests.
57template<class Record, std::uint64_t MaxOffset =
58 static_cast<std::uint64_t>(std::numeric_limits<off_t>::max())>
60 static constexpr std::uint64_t kRowsPerSegment = MaxOffset / sizeof(Record);
61 static constexpr std::uint64_t kMaximumRows = MaxOffset / 64 + 1;
62 static_assert(kRowsPerSegment != 0, "a fold record fits in an off_t file");
63 static constexpr std::size_t kSegments = static_cast<std::size_t>(
64 (kMaximumRows + kRowsPerSegment - 1) / kRowsPerSegment);
65 std::array<IdPairScratchFile, kSegments> files_;
66public:
68 // These regions are the consumed-cycle auxiliary index, including
69 // its undo path and every lazy segment. Reuse the closed identity
70 // vocabulary; a file prefix is never a diagnostic store identity.
71 for (auto& file : files_) file.set_store(ScratchStore::partial_cycles_idx);
72 }
73 bool opened() const noexcept {
74 for (const auto& file : files_) if (file.opened()) return true;
75 return false;
76 }
77 void clear() noexcept { for (auto& file : files_) file.reset(); }
79 for (auto& file : files_) file.fixture_count_io(io);
80 }
81 void read(std::uint64_t row, void* bytes, std::size_t size = sizeof(Record),
82 std::size_t within = 0) const {
83 files_[static_cast<std::size_t>(row / kRowsPerSegment)].read(
84 (row % kRowsPerSegment) * sizeof(Record) + within, bytes, size);
85 }
86 void write(std::uint64_t row, const void* bytes, const std::string& directory) {
87 auto& file = files_[static_cast<std::size_t>(row / kRowsPerSegment)];
88 file.open(directory);
89 file.write((row % kRowsPerSegment) * sizeof(Record), bytes, sizeof(Record));
90 }
91 static std::uint64_t disk_bytes(std::uint64_t rows) noexcept {
92 constexpr auto maximum = std::numeric_limits<std::uint64_t>::max();
93 return rows > maximum / sizeof(Record) ? maximum : rows * sizeof(Record);
94 }
95};
96
97// Ordered auxiliary index ONLY; IdPairStore still owns lookup, first-seen
98// iteration, keys and values. End-of-key is symbol 0, byte b is symbol b+1.
99// MSB-first 9-bit symbols give exactly std::string's unsigned-byte order,
100// with every prefix before its extensions, including NUL / 0x1f / 0xff.
101// Compressed radix pivots strictly increase along a path: for maximum key
102// length L there are <=9*(L+1) branches, independently of key population.
103// Leaves have <=16 original row offsets and NO map. Only branches have maps.
104// No recursion, RAM path stack, key roster, capacity knob or per-key heap.
106public:
107 struct Position {
108 std::uint64_t byte = 0;
109 std::uint64_t mask = 0;
110 };
111 static bool before(Position a, Position b) noexcept {
112 return a.byte < b.byte || (a.byte == b.byte && a.mask > b.mask);
113 }
114 static unsigned symbol(std::string_view key, std::uint64_t at) noexcept {
115 return at < key.size() ? static_cast<unsigned char>(key[static_cast<std::size_t>(at)]) + 1U : 0U;
116 }
117 static Position differing_symbol(std::uint64_t at, unsigned a, unsigned b) noexcept {
118 unsigned mask = 256;
119 while ((mask & (a ^ b)) == 0) mask >>= 1;
120 return {at, mask}; // callers establish a != b
121 }
122
123 IdPairOrderedFold() = default;
126 IdPairOrderedFold(IdPairOrderedFold&& other) noexcept { swap(other); }
128 if (this != &other) {
129 IdPairOrderedFold moved(std::move(other));
130 swap(moved);
131 }
132 return *this;
133 }
134 void swap(IdPairOrderedFold& other) noexcept {
135 using std::swap;
136 swap(leaves_, other.leaves_); swap(branches_, other.branches_); swap(undo_, other.undo_);
137 swap(root_fold_, other.root_fold_); swap(root_, other.root_);
138 swap(leaf_count_, other.leaf_count_); swap(branch_count_, other.branch_count_);
139 swap(undo_rows_, other.undo_rows_); swap(undo_count_, other.undo_count_);
140 swap(recovering_, other.recovering_);
141 }
142 void clear() noexcept {
143 leaves_.clear(); branches_.clear(); undo_.clear();
144 root_fold_.reset();
145 root_ = leaf_count_ = branch_count_ = undo_rows_ = undo_count_ = 0;
146 recovering_ = false;
147 }
148 bool opened() const noexcept { return leaves_.opened() || branches_.opened() || undo_.opened(); }
149 std::uint64_t disk_bytes() const noexcept {
150 return saturated_add(saturated_add(decltype(leaves_)::disk_bytes(leaf_count_),
151 decltype(branches_)::disk_bytes(branch_count_)),
152 decltype(undo_)::disk_bytes(undo_rows_));
153 }
154 std::uint64_t apply(std::uint64_t seed) const noexcept { return root_fold_.apply(seed); }
156 leaves_.fixture_count_io(io); branches_.fixture_count_io(io); undo_.fixture_count_io(io);
157 }
158
159private:
160 static constexpr std::size_t kBucket = 16;
161 struct Leaf {
162 std::uint64_t count = 0;
163 std::array<std::uint64_t, kBucket> rows{};
164 };
165 struct Branch {
166 Position pivot;
167 std::array<std::uint64_t, 2> child{};
168 BrokerStateByteFold fold;
169 };
170 // Also the disk path stack. A complete before-image is written BEFORE
171 // touching any published node. Failed journal writes touch no live node.
172 struct Snapshot {
173 std::uint64_t ref = 0;
174 std::array<unsigned char, sizeof(Branch)> bytes{};
175 };
176 struct Workspace {
177 Snapshot snapshot;
178 Branch branch;
179 Leaf leaf, left, right;
180 std::array<std::uint64_t, kBucket + 1> rows{};
181 BrokerStateByteFold fold, other;
182 };
183 static_assert(sizeof(Leaf) == 136 && sizeof(Branch) == 2088 && sizeof(Snapshot) == 2096,
184 "ordered scratch layouts have no padding");
185 static_assert(std::is_trivially_copyable_v<Leaf> && std::is_trivially_copyable_v<Branch>,
186 "journalled nodes preserve their complete byte representations");
187
188 static bool is_leaf(std::uint64_t ref) noexcept { return (ref & 1) != 0; }
189 static std::uint64_t row_index(std::uint64_t ref) noexcept {
190 return (ref >> 1) - 1;
191 }
192 void read_node(std::uint64_t ref, void* bytes) const {
193 if (is_leaf(ref)) leaves_.read(row_index(ref), bytes);
194 else branches_.read(row_index(ref), bytes);
195 }
196 void write_node(std::uint64_t ref, const void* bytes, const std::string& directory) {
197 if (is_leaf(ref)) leaves_.write(row_index(ref), bytes, directory);
198 else branches_.write(row_index(ref), bytes, directory);
199 }
200 template<class Node>
201 std::uint64_t append_node(const Node& node, std::uint64_t& count, bool leaf,
202 const std::string& directory) {
203 const auto ref = ((count + 1) << 1) | (leaf ? 1U : 0U);
204 write_node(ref, &node, directory);
205 ++count;
206 return ref;
207 }
208 void read_snapshot(std::uint64_t at, Snapshot& snapshot) const {
209 undo_.read(at, &snapshot);
210 }
211 void recover(Snapshot& snapshot, const std::string& directory) {
212 if (!recovering_) return;
213 // Retryable even if restoration itself has a short write. Lookups
214 // use the original table; hashes use the still-committed RAM root.
215 for (std::uint64_t at = 0; at < undo_count_; ++at) {
216 read_snapshot(at, snapshot);
217 write_node(snapshot.ref, snapshot.bytes.data(), directory);
218 }
219 recovering_ = false;
220 }
221 template<class Reader>
222 static void leaf_fold(const Leaf& leaf, Reader& reader, BrokerStateByteFold& fold) {
223 fold.reset();
224 for (std::uint64_t i = 0; i < leaf.count; ++i) reader.append(leaf.rows[i], fold);
225 }
226 template<class Reader>
227 void load_fold(std::uint64_t ref, Reader& reader, BrokerStateByteFold& fold) const {
228 if (is_leaf(ref)) {
229 Leaf leaf;
230 read_node(ref, &leaf);
231 leaf_fold(leaf, reader, fold);
232 } else {
233 // Read just the map, without another map-sized stack temporary.
234 branches_.read(row_index(ref), &fold, sizeof(fold), offsetof(Branch, fold));
235 }
236 }
237
238public:
239 // Reader provides bounded-buffer comparisons, first differing symbols,
240 // and the exact prospective row bytes (s(key), i(value); dead = empty).
241 template<class Reader>
242 void update(std::string_view key, std::uint64_t row, const std::string& directory, Reader& reader) {
243 Workspace work;
244 recover(work.snapshot, directory);
245
246 std::uint64_t ref = root_, depth = 0;
247 while (ref != 0) {
248 work.snapshot.ref = ref;
249 read_node(ref, work.snapshot.bytes.data());
250 // Offsets are checked by IdPairScratchFile; no fixed depth cap.
251 undo_.write(depth, &work.snapshot, directory);
252 ++depth;
253 undo_rows_ = std::max(undo_rows_, depth);
254 if (is_leaf(ref)) {
255 std::memcpy(&work.leaf, work.snapshot.bytes.data(), sizeof(Leaf));
256 break;
257 }
258 std::memcpy(&work.branch, work.snapshot.bytes.data(), sizeof(Branch));
259 ref = work.branch.child[(symbol(key, work.branch.pivot.byte) & work.branch.pivot.mask) != 0];
260 }
261 const std::uint64_t ancestors = depth == 0 ? 0 : depth - 1;
262 std::uint64_t parents = ancestors;
263 bool present = false;
264 for (std::uint64_t i = 0; i < work.leaf.count; ++i)
265 present = present || work.leaf.rows[i] == row;
266
267 // A new key may differ in a compressed-away prefix ABOVE the leaf
268 // we reached. Insert there, preserving the entire existing subtree.
269 Position divergence;
270 if (!present && work.leaf.count != 0) {
271 divergence = reader.difference(work.leaf.rows[0], key);
272 for (std::uint64_t i = 0; i < ancestors; ++i) {
273 read_snapshot(i, work.snapshot);
274 std::memcpy(&work.branch, work.snapshot.bytes.data(), sizeof(Branch));
275 if (before(divergence, work.branch.pivot)) { parents = i; break; }
276 }
277 }
278
279 // From here any exception leaves the cached root and table unchanged.
280 // The complete journal is retained for the next mutation's recovery.
281 undo_count_ = depth;
282 recovering_ = true;
283 auto next_leaves = leaf_count_, next_branches = branch_count_;
284 if (parents < ancestors) {
285 read_snapshot(parents, work.snapshot);
286 const auto old_subtree = work.snapshot.ref;
287 work.left.count = 1;
288 work.left.rows[0] = row;
289 const auto new_leaf = append_node(work.left, next_leaves, true, directory);
290 leaf_fold(work.left, reader, work.fold);
291 load_fold(old_subtree, reader, work.other);
292 const unsigned side = (symbol(key, divergence.byte) & divergence.mask) != 0;
293 if (side == 0) work.fold.then(work.other);
294 else { work.other.then(work.fold); work.fold = work.other; }
295 work.branch.pivot = divergence;
296 work.branch.child[side] = new_leaf;
297 work.branch.child[1 - side] = old_subtree;
298 work.branch.fold = work.fold;
299 ref = append_node(work.branch, next_branches, false, directory);
300 } else {
301 std::size_t count = 0;
302 bool inserted = present;
303 for (std::uint64_t i = 0; i < work.leaf.count; ++i) {
304 if (!inserted && reader.compare(work.leaf.rows[i], key) > 0) {
305 work.rows[count++] = row;
306 inserted = true;
307 }
308 work.rows[count++] = work.leaf.rows[i];
309 }
310 if (!inserted) work.rows[count++] = row;
311 if (count <= kBucket) {
312 work.left.count = count;
313 std::copy_n(work.rows.begin(), count, work.left.rows.begin());
314 if (ref == 0) ref = append_node(work.left, next_leaves, true, directory);
315 else write_node(ref, &work.left, directory);
316 leaf_fold(work.left, reader, work.fold);
317 } else {
318 const auto pivot = reader.difference(work.rows[0], work.rows[count - 1]);
319 std::size_t split = 0;
320 while (split < count && (reader.symbol(work.rows[split], pivot.byte) & pivot.mask) == 0)
321 ++split;
322 work.left.count = split; work.right.count = count - split;
323 std::copy_n(work.rows.begin(), split, work.left.rows.begin());
324 std::copy_n(work.rows.begin() + split, count - split, work.right.rows.begin());
325 write_node(ref, &work.left, directory); // reuses the journalled old leaf
326 const auto right = append_node(work.right, next_leaves, true, directory);
327 leaf_fold(work.left, reader, work.fold);
328 leaf_fold(work.right, reader, work.other);
329 work.fold.then(work.other);
330 work.branch.pivot = pivot; work.branch.child = {ref, right};
331 work.branch.fold = work.fold;
332 ref = append_node(work.branch, next_branches, false, directory);
333 }
334 }
335 while (parents != 0) {
336 read_snapshot(--parents, work.snapshot);
337 std::memcpy(&work.branch, work.snapshot.bytes.data(), sizeof(Branch));
338 const unsigned side = (symbol(key, work.branch.pivot.byte) & work.branch.pivot.mask) != 0;
339 work.branch.child[side] = ref;
340 load_fold(work.branch.child[1 - side], reader, work.other);
341 if (side == 0) work.fold.then(work.other);
342 else { work.other.then(work.fold); work.fold = work.other; }
343 work.branch.fold = work.fold;
344 write_node(work.snapshot.ref, &work.branch, directory);
345 ref = work.snapshot.ref;
346 }
347 // No throwing operation between publishing this root and the caller
348 // publishing its pending table header/slot and live count.
349 root_ = ref; root_fold_ = work.fold;
350 leaf_count_ = next_leaves; branch_count_ = next_branches;
351 recovering_ = false;
352 }
353
354 // Resident root + update workspace + the reader's two 512-byte buffers,
355 // table rehash buffer, headers and scalar call frames fit under 32 KiB.
356 // All variable-depth storage is in undo_, never on the heap or stack.
357 static constexpr std::size_t workspace_bytes() noexcept { return sizeof(Workspace); }
358
359private:
360 static std::uint64_t saturated_add(std::uint64_t a, std::uint64_t b) noexcept {
361 constexpr auto maximum = std::numeric_limits<std::uint64_t>::max();
362 return b > maximum - a ? maximum : a + b;
363 }
364 IdPairFoldFile<Leaf> leaves_;
365 IdPairFoldFile<Branch> branches_;
366 IdPairFoldFile<Snapshot> undo_;
367 BrokerStateByteFold root_fold_;
368 std::uint64_t root_ = 0, leaf_count_ = 0, branch_count_ = 0;
369 std::uint64_t undo_rows_ = 0, undo_count_ = 0;
370 bool recovering_ = false;
371};
372static_assert(sizeof(IdPairOrderedFold) + IdPairOrderedFold::workspace_bytes() + 8192 <= 32768,
373 "ordered fold resident root, fixed buffers and call-frame allowance <=32 KiB");
374
375} // namespace pineforge::detail
void then(const BrokerStateByteFold &after) noexcept
std::uint64_t apply(std::uint64_t seed) const noexcept
void bytes(const void *data, std::size_t length)
void write(std::uint64_t row, const void *bytes, const std::string &directory)
static std::uint64_t disk_bytes(std::uint64_t rows) noexcept
void fixture_count_io(IdPairScratchFile::FixtureIO *io) noexcept
void read(std::uint64_t row, void *bytes, std::size_t size=sizeof(Record), std::size_t within=0) const
IdPairOrderedFold(const IdPairOrderedFold &)=delete
void fixture_count_io(IdPairScratchFile::FixtureIO *io) noexcept
IdPairOrderedFold(IdPairOrderedFold &&other) noexcept
static Position differing_symbol(std::uint64_t at, unsigned a, unsigned b) noexcept
static bool before(Position a, Position b) noexcept
static unsigned symbol(std::string_view key, std::uint64_t at) noexcept
void swap(IdPairOrderedFold &other) noexcept
std::uint64_t disk_bytes() const noexcept
static constexpr std::size_t workspace_bytes() noexcept
IdPairOrderedFold & operator=(IdPairOrderedFold &&other) noexcept
std::uint64_t apply(std::uint64_t seed) const noexcept
void update(std::string_view key, std::uint64_t row, const std::string &directory, Reader &reader)
IdPairOrderedFold & operator=(const IdPairOrderedFold &)=delete