From 90e594087e85e67052803f07b7fe22b1dfa6bf2c Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Sat, 18 Jul 2026 12:23:35 +0800 Subject: [PATCH 1/2] feat(map): add PineMap runtime semantics --- docs/coverage.md | 37 ++- docs/pine_v6_coverage_detail.md | 11 +- include/pineforge/map.hpp | 398 +++++++++++++++++++++++++++ tests/CMakeLists.txt | 1 + tests/test_map.cpp | 459 ++++++++++++++++++++++++++++++++ 5 files changed, 891 insertions(+), 15 deletions(-) create mode 100644 include/pineforge/map.hpp create mode 100644 tests/test_map.cpp diff --git a/docs/coverage.md b/docs/coverage.md index 67ce080..fc59268 100644 --- a/docs/coverage.md +++ b/docs/coverage.md @@ -16,13 +16,13 @@ > **Two layers of "supported".** PineForge as a whole = (a) this runtime > > - (b) PineForge's separate, source-available PineScript-to-C++ transpiler. Some Pine -> surface (arrays, maps, UDTs, most scalar `math.`* calls) has no -> dedicated runtime class because the transpiler emits the implementation -> inline using the C++ standard library or generated structs. Those are -> still fully supported in PineForge end-to-end; they just don't appear -> as a runtime module here. Where this distinction matters, the -> "no runtime module — Pine surface still supported" bucket below calls -> it out. +> surface (arrays, UDTs, most scalar `math.`* calls) has no dedicated +> runtime class because the transpiler emits the implementation inline using +> the C++ standard library or generated structs. Maps are in transition: +> `map.hpp` now provides the standalone `PineMap` runtime foundation, but +> the current transpiler still emits `std::unordered_map` and does not yet +> route generated strategies through it. Where this distinction matters, the +> buckets below call it out explicitly. > > **Out of scope today.** Visual / charting / alert APIs are not implemented > by this runtime regardless of consumer. The runtime now accepts continuous @@ -49,9 +49,10 @@ | Typed matrices | Supported | `PineGenericMatrix` (header-only template) for `int / bool / string / color / UDT` element types — structural ops only (numeric methods stay on `PineMatrix`). | | Series history | Supported | `Series` ring buffer with Pine `[k]` semantics. | | Color | Supported | `pine_color` constants plus `new_color`, `r`, `g`, `b`, `t` helpers. | -| `na` / `is_na` | Supported | Generic `na()` and `is_na(...)` for double / integer / bool. | +| `na` / `is_na` | Supported | Generic `na()` and `is_na(...)` for double / integer / bool, plus null-ID detection for `PineMap`. | | Logging / runtime errors | Supported | `pine_log_info / warning / error`, `pine_runtime_error` (throws). | -| Arrays / maps / UDTs | **No runtime module** (Pine surface still supported via consumer compiler) | The runtime ships no `array.hpp` / `map.hpp` / UDT module — its generic value containers are `Series` (history), `PineMatrix` (numeric matrices), and `PineGenericMatrix` (typed matrices). Pine arrays / maps / UDTs themselves work in PineForge: PineForge's transpiler emits them as `std::vector`, `std::unordered_map`, and generated C++ structs against this runtime's primitives. | +| Maps | Runtime foundation; transpiler migration pending | `map.hpp` provides ordered `PineMap` handles, Pine alias/copy/null semantics, typed missing values, the 50,000-pair limit, and primitive-only rollback snapshots. Current generated strategies still use the transpiler's legacy `std::unordered_map` path, so these runtime semantics are not yet end-to-end. | +| Arrays / UDTs | **No runtime module** (Pine surface still supported via consumer compiler) | Pine arrays and UDTs work through transpiler-emitted `std::vector` and generated C++ structs. Recursive snapshotting for UDTs or collections containing map/reference handles remains a codegen/type-system responsibility. | | Drawing / plotting / alerts | **No runtime module** | No charting / drawing / alert types exist in the runtime. PineForge's transpiler parses-and-skips these so the strategy still compiles and runs, but no visual side-effects are emitted. | @@ -129,8 +130,9 @@ single `.hpp`): | Bar magnifier | `magnifier.hpp` | `magnifier.cpp` | OHLC price-path sampling with six distribution modes; optional volume-weighted sample density. | | Matrices | `matrix.hpp` | `matrix.cpp` | Eigen-backed `PineMatrix`. | | Generic matrices | `generic_matrix.hpp` | header-only | Template `PineGenericMatrix` over `std::vector>` (T=bool specialized to `vector>`) for non-double element types. | +| Maps | `map.hpp` | header-only | `PineMap` handle runtime with insertion ordering, Pine-aware primitive keys, null IDs, 50,000-pair cap, explicit container copy, and primitive-value snapshot/restore. Current codegen integration is pending. | | Series history | `series.hpp` | header-only | Generic `Series` deque with `push` / `update` / `[k]` indexing. | -| `na` | `na.hpp` | header-only | `na()` generators and `is_na(...)` checks. | +| `na` | `na.hpp`, `map.hpp` | header-only | `na()` generators and `is_na(...)` checks, including the null-ID overload for `PineMap`. | | Bar struct | `bar.hpp` | header-only | `struct Bar { double open, high, low, close, volume; int64_t timestamp; };` (Unix milliseconds). | | Color | `color.hpp` | header-only | 17 named ARGB constants plus `new_color`, `r`, `g`, `b`, `t`. | | Logging | `log.hpp` | header-only | `pine_log_info / warning / error` (stderr) and `pine_runtime_error` (throws `std::runtime_error`). | @@ -740,15 +742,24 @@ The two lists below distinguish between *PineForge does not support this at all* and *the runtime has no module for this, but PineForge supports it via the consumer compiler's emitted code*. +### Runtime foundation present — consumer integration pending + +- Pine `map` now has a dedicated header-only `PineMap` foundation in + `map.hpp`. It implements map-ID aliasing, `map.copy()` container separation, + insertion-ordered keys/values, typed missing results, null IDs, Pine-aware + float keys, and the 50,000-pair limit. Public rollback snapshots are + deliberately available only for primitive values. The transpiler still + emits `std::unordered_map`, so map calls do **not** use this runtime + foundation end-to-end yet; nested UDT/array/reference-handle rollback also + remains pending in codegen. + ### No runtime module — Pine surface still supported via consumer compiler These features work in PineForge code today; the runtime simply does not own a dedicated class or function for them. PineForge's transpiler -emits inline C++ against ``, ``, ``, and -generated structs. +emits inline C++ against ``, ``, and generated structs. - Pine `array` — emitted as `std::vector` by PineForge's transpiler. -- Pine `map` — emitted as `std::unordered_map`. - User-defined types (UDTs) — emitted as plain C++ structs; nested fields and `array` are also handled there. - Currency conversion (`strategy.convert_to_`*) — no runtime feed; PineForge's transpiler treats this as identity (no FX adjustment). - Most scalar `math.`* functions (`abs`, `sqrt`, `min`, `max`, trig, `round`, etc.) — PineForge emits these against `` / inline expressions. diff --git a/docs/pine_v6_coverage_detail.md b/docs/pine_v6_coverage_detail.md index ffe04f7..67a1a3f 100644 --- a/docs/pine_v6_coverage_detail.md +++ b/docs/pine_v6_coverage_detail.md @@ -64,7 +64,7 @@ | `label` | type | ⏭️ Parse-and-skip | Drawing object; no runtime | | | `line` | type | ⏭️ Parse-and-skip | Drawing object; no runtime | | | `linefill` | type | ⏭️ Parse-and-skip | Drawing object; no runtime | | -| `map` | type | 🔧 Transpiler | `std::unordered_map` emitted by transpiler | | +| `map` | type | 🔧 Transpiler | `PineMap` runtime foundation exists in `map.hpp`; current transpiler still emits `std::unordered_map` | Runtime migration and recursive reference-graph rollback are pending; not yet end-to-end runtime-backed | | `matrix` | type | ✅ Runtime | `matrix.hpp` / `generic_matrix.hpp` | | | `polyline` | type | ⏭️ Parse-and-skip | Drawing object; no runtime | | | `series` | type/qualifier | ✅ Runtime | `series.hpp` — `Series` ring buffer | | @@ -586,7 +586,14 @@ All **⏭️ Parse-and-skip** — drawing object methods; no runtime backing. ### Functions — map.* (11 entries) -All **🔧 Transpiler** — emitted against `std::unordered_map`. No runtime module. +End-to-end status remains **🔧 Transpiler**: current generated strategies emit +against `std::unordered_map`. A dedicated `PineMap` foundation now +exists in `map.hpp` with null map IDs, alias-preserving assignment, explicit +container copy, insertion ordering, Pine-aware float/`na` keys, typed missing +results, `put_all`, the 50,000-pair limit, and primitive-value snapshot/restore. +Codegen has not yet switched these 11 calls to that API, and recursive +checkpointing for maps nested in UDTs/collections is still pending. Therefore +these rows must not be read as ✅ Runtime or as completed end-to-end parity. ### Functions — math.* diff --git a/include/pineforge/map.hpp b/include/pineforge/map.hpp new file mode 100644 index 0000000..5dd59cb --- /dev/null +++ b/include/pineforge/map.hpp @@ -0,0 +1,398 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace pineforge { + +namespace detail { + +template +using PineMapBare = std::remove_cv_t>; + +template +inline constexpr bool is_pine_map_key_v = + std::is_integral_v> || + std::is_floating_point_v> || + std::is_enum_v> || + std::is_same_v, std::string>; + +template +inline constexpr bool is_pine_map_snapshot_value_v = + !std::is_reference_v && + (std::is_arithmetic_v> || + std::is_enum_v> || + std::is_same_v, std::string>); + +template >> +struct PineMapKeyEqual { + bool operator()(const Key& lhs, const Key& rhs) const + noexcept(noexcept(lhs == rhs)) { + return lhs == rhs; + } +}; + +template +struct PineMapKeyEqual { + bool operator()(Key lhs, Key rhs) const noexcept { + return (std::isnan(lhs) && std::isnan(rhs)) || lhs == rhs; + } +}; + +template >> +struct PineMapKeyHash { + std::size_t operator()(const Key& key) const + noexcept(noexcept(std::hash{}(key))) { + return std::hash{}(key); + } +}; + +template +struct PineMapKeyHash { + std::size_t operator()(Key key) const noexcept { + if (std::isnan(key)) { + // All Pine na float keys compare as one key, irrespective of the + // NaN payload supplied by a consumer. + return static_cast(UINT64_C(0x9e3779b97f4a7c15)); + } + // KeyEqual considers -0.0 and +0.0 equal. Hash the canonical form so + // the unordered-map equality/hash contract remains explicit. + if (key == static_cast(0)) key = static_cast(0); + return std::hash{}(key); + } +}; + +} // namespace detail + +template +inline constexpr bool is_pine_map_snapshot_value_v = + detail::is_pine_map_snapshot_value_v; + +// PineMap models a Pine map ID, not a value-owned C++ associative container. +// Copying the handle therefore aliases the same backing store. Pine's +// map.copy() operation is represented by copy(), which allocates a distinct +// store and copies the entries into it. +// +// A list holds the authoritative entry sequence so overwrites never move an +// existing key and removals do not disturb the relative order of survivors. +// The hash index keeps get/put/remove/contains constant-time on average. +template +class PineMap { + static_assert(detail::is_pine_map_key_v, + "PineMap key must be a Pine fundamental or enum type"); + + using Entry = std::pair; + using EntryList = std::list; + using EntryIterator = typename EntryList::iterator; + using Index = std::unordered_map, + detail::PineMapKeyEqual>; + + static_assert(noexcept(std::declval().swap( + std::declval())), + "PineMap ordered storage must support no-throw swap"); + static_assert(noexcept(std::declval().swap( + std::declval())), + "PineMap index must support no-throw swap"); + + static constexpr const char* kNaIdError = + "map operation on na ID"; + static constexpr const char* kInvalidSnapshotError = + "map restore from invalid snapshot"; + static constexpr const char* kCapacityError = + "map cannot contain more than 50000 key-value pairs"; + static constexpr const char* kIndexInvariantError = + "map key index invariant violation"; + + struct Storage { + EntryList entries; + Index index; + + Storage() = default; + + Storage(const Storage& other) + : entries(other.entries), + index(0, other.index.hash_function(), other.index.key_eq()) { + rebuild_index(); + } + + Storage& operator=(const Storage& other) { + if (this == &other) return *this; + Storage replacement(other); + swap(replacement); + return *this; + } + + Storage(Storage&&) = default; + Storage& operator=(Storage&&) = default; + + void rebuild_index() { + Index replacement; + replacement.reserve(entries.size()); + for (auto it = entries.begin(); it != entries.end(); ++it) { + const auto inserted = replacement.emplace(it->first, it).second; + if (!inserted) { + throw std::runtime_error(kIndexInvariantError); + } + } + index.swap(replacement); + } + + void swap(Storage& other) noexcept { + // Swapping both containers preserves the relationship between + // each index iterator and its corresponding list. list::swap + // does not invalidate iterators. + entries.swap(other.entries); + index.swap(other.index); + } + }; + +public: + static constexpr int max_pairs = 50'000; + static constexpr bool snapshot_supported = + detail::is_pine_map_snapshot_value_v; + + // Snapshot is deliberately opaque. Besides a value copy of the entries, + // it retains the original backing-store identity. restore() mutates that + // store in place before reattaching the receiving handle, which both rolls + // back existing aliases and handles a map variable that was rebound after + // the snapshot, including a variable rebound to Pine na. restore() is an + // internal generated-checkpoint hook rather than a Pine map method, so a + // valid snapshot may reattach a null receiver. + // + // Value is copied according to its normal C++ copy semantics. This is a + // complete deep checkpoint for primitive Pine key/value instantiations. + // Generated UDTs or collections containing other handle types require a + // recursive, alias-aware checkpoint supplied by the code generator. + class Snapshot { + static_assert(snapshot_supported, + "PineMap::Snapshot supports primitive Pine values only; " + "UDT and collection handles require recursive codegen checkpointing"); + + std::shared_ptr identity_; + Storage state_; + + Snapshot(std::shared_ptr identity, const Storage& state) + : identity_(std::move(identity)), state_(state) {} + + friend class PineMap; + + public: + Snapshot(const Snapshot&) = default; + Snapshot& operator=(const Snapshot&) = default; + Snapshot(Snapshot&&) = default; + Snapshot& operator=(Snapshot&&) = default; + }; + + // The default value represents Pine na. map.new() is the operation + // that allocates an actual map ID. + PineMap() noexcept = default; + + PineMap(const PineMap&) noexcept = default; + PineMap& operator=(const PineMap&) noexcept = default; + + // A Pine map is an ID. Treat C++ moves like ordinary Pine assignment so + // compiler-generated std::move paths cannot invalidate the source handle. + PineMap(PineMap&& other) noexcept : storage_(other.storage_) {} + + PineMap& operator=(PineMap&& other) noexcept { + if (this != &other) storage_ = other.storage_; + return *this; + } + + [[nodiscard]] static PineMap new_() { + return PineMap(std::make_shared()); + } + + [[nodiscard]] bool is_na() const noexcept { return !storage_; } + + // Returns the previous value, or the value type's Pine na sentinel when + // the key is inserted for the first time. + Value put(Key key, Value value) { + Storage& storage = require_storage(); + auto found = storage.index.find(key); + if (found != storage.index.end()) { + Value previous = found->second->second; + found->second->second = std::move(value); + return previous; + } + + if (storage.entries.size() >= static_cast(max_pairs)) { + throw std::runtime_error(kCapacityError); + } + + storage.entries.emplace_back(std::move(key), std::move(value)); + auto entry = std::prev(storage.entries.end()); + try { + const auto inserted = storage.index.emplace(entry->first, entry).second; + if (!inserted) { + throw std::runtime_error(kIndexInvariantError); + } + } catch (...) { + // Both a false emplace result and hashing/allocation exceptions + // leave the index without the new key. + storage.entries.pop_back(); + throw; + } + return pineforge::na(); + } + + [[nodiscard]] Value get(const Key& key) const { + const Storage& storage = require_storage(); + auto found = storage.index.find(key); + if (found == storage.index.end()) return pineforge::na(); + return found->second->second; + } + + // Returns the removed value, or the value type's Pine na sentinel when no + // such key exists. + Value remove(const Key& key) { + Storage& storage = require_storage(); + auto found = storage.index.find(key); + if (found == storage.index.end()) return pineforge::na(); + + Value previous = found->second->second; + storage.entries.erase(found->second); + storage.index.erase(found); + return previous; + } + + [[nodiscard]] bool contains(const Key& key) const { + const Storage& storage = require_storage(); + return storage.index.find(key) != storage.index.end(); + } + + [[nodiscard]] int size() const { + const Storage& storage = require_storage(); + if (storage.entries.size() > + static_cast(std::numeric_limits::max())) { + throw std::overflow_error("map.size: result exceeds int range"); + } + return static_cast(storage.entries.size()); + } + + void clear() { + Storage& storage = require_storage(); + storage.index.clear(); + storage.entries.clear(); + } + + // Existing target keys retain their positions. New keys are appended in + // source insertion order. Iterating a map into itself is a no-op. + void put_all(const PineMap& source) { + Storage& target_storage = require_storage(); + const Storage& source_storage = source.require_storage(); + if (storage_ == source.storage_) return; + + std::size_t new_keys = 0; + for (const auto& entry : source_storage.entries) { + if (target_storage.index.find(entry.first) == + target_storage.index.end()) { + ++new_keys; + } + } + const auto available = static_cast(max_pairs) - + target_storage.entries.size(); + if (new_keys > available) { + // Preflight keeps put_all transactional with respect to the + // capacity check: existing values are not overwritten before a + // later new key discovers that the map is full. + throw std::runtime_error(kCapacityError); + } + + for (const auto& entry : source_storage.entries) { + (void)put(entry.first, entry.second); + } + } + + [[nodiscard]] std::vector keys() const { + const Storage& storage = require_storage(); + std::vector result; + result.reserve(storage.entries.size()); + for (const auto& entry : storage.entries) { + result.push_back(entry.first); + } + return result; + } + + [[nodiscard]] std::vector values() const { + const Storage& storage = require_storage(); + std::vector result; + result.reserve(storage.entries.size()); + for (const auto& entry : storage.entries) { + result.push_back(entry.second); + } + return result; + } + + [[nodiscard]] PineMap copy() const { + // The outer map ID is independent. Values use their regular copy + // semantics: primitive values are independent, while a handle-valued + // entry continues to alias that nested handle (Pine's shallow object + // copy behavior). Recursive checkpoint cloning belongs to generated + // UDT/collection state, not map.copy(). + return PineMap(std::make_shared(require_storage())); + } + + template && + std::is_same_v, int> = 0> + [[nodiscard]] Snapshot snapshot() const { + const Storage& storage = require_storage(); + return Snapshot(storage_, storage); + } + + template && + std::is_same_v, int> = 0> + void restore(const Snapshot& snapshot) { + if (!snapshot.identity_) { + throw std::runtime_error(kInvalidSnapshotError); + } + // Build the replacement completely before touching live state. The + // subsequent swaps preserve snapshot.identity_ itself, so all handles + // that already alias that ID observe the restored contents. + Storage replacement(snapshot.state_); + snapshot.identity_->swap(replacement); + storage_ = snapshot.identity_; + } + +private: + [[nodiscard]] Storage& require_storage() { + if (!storage_) throw std::runtime_error(kNaIdError); + return *storage_; + } + + [[nodiscard]] const Storage& require_storage() const { + if (!storage_) throw std::runtime_error(kNaIdError); + return *storage_; + } + + explicit PineMap(std::shared_ptr storage) + : storage_(std::move(storage)) {} + + std::shared_ptr storage_; +}; + +template +inline bool is_na(const PineMap& map) noexcept { + return map.is_na(); +} + +} // namespace pineforge diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6da3d24..eec8eaf 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -45,6 +45,7 @@ set(TEST_SOURCES test_generic_matrix_bool_proxy test_generic_matrix_series test_generic_matrix_bool_dispatch + test_map test_session_predicates test_vwap_bands test_get_input_int64 diff --git a/tests/test_map.cpp b/tests/test_map.cpp new file mode 100644 index 0000000..198f728 --- /dev/null +++ b/tests/test_map.cpp @@ -0,0 +1,459 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using pineforge::PineMap; + +template +static void expect_runtime_error(Fn&& fn, const char* message) { + bool threw = false; + try { + fn(); + } catch (const std::runtime_error& error) { + threw = true; + assert(std::string(error.what()) == message); + } + assert(threw); +} + +template +struct has_public_snapshot : std::false_type {}; + +template +struct has_public_snapshot< + Map, std::void_t().snapshot())>> + : std::true_type {}; + +struct SnapshotUnsafeUdt { + int value = 0; +}; + +static void test_na_id_and_stable_errors() { + using Map = PineMap; + constexpr const char* na_error = "map operation on na ID"; + + Map null_map; + auto typed_na = pineforge::na(); + assert(pineforge::is_na(null_map)); + assert(pineforge::is_na(typed_na)); + + auto allocated = Map::new_(); + assert(!pineforge::is_na(allocated)); + auto snapshot = allocated.snapshot(); + + expect_runtime_error([&] { (void)null_map.put("x", 1); }, na_error); + expect_runtime_error([&] { (void)null_map.get("x"); }, na_error); + expect_runtime_error([&] { (void)null_map.remove("x"); }, na_error); + expect_runtime_error([&] { (void)null_map.contains("x"); }, na_error); + expect_runtime_error([&] { (void)null_map.size(); }, na_error); + expect_runtime_error([&] { null_map.clear(); }, na_error); + expect_runtime_error([&] { (void)null_map.keys(); }, na_error); + expect_runtime_error([&] { (void)null_map.values(); }, na_error); + expect_runtime_error([&] { (void)null_map.copy(); }, na_error); + expect_runtime_error([&] { (void)null_map.snapshot(); }, na_error); + expect_runtime_error([&] { null_map.put_all(allocated); }, na_error); + expect_runtime_error([&] { allocated.put_all(null_map); }, na_error); + auto active_snapshot = std::move(snapshot); + expect_runtime_error( + [&] { null_map.restore(snapshot); }, + "map restore from invalid snapshot"); + null_map.restore(active_snapshot); + assert(!pineforge::is_na(null_map)); +} + +static void test_copy_aliases_and_explicit_copy_is_independent() { + auto map = PineMap::new_(); + assert(pineforge::is_na(map.put("alpha", 1.0))); + + auto alias = map; + assert(alias.get("alpha") == 1.0); + assert(alias.put("alpha", 2.0) == 1.0); + assert(map.get("alpha") == 2.0); + + auto independent = map.copy(); + assert(independent.put("alpha", 3.0) == 2.0); + assert(pineforge::is_na(independent.put("beta", 4.0))); + assert(map.get("alpha") == 2.0); + assert(!map.contains("beta")); + + auto assigned = PineMap::new_(); + assigned = map; + assigned.put("gamma", 5.0); + assert(map.get("gamma") == 5.0); +} + +static void test_move_preserves_source_handle_alias() { + auto source = PineMap::new_(); + source.put("a", 1); + + PineMap constructed(std::move(source)); + constructed.put("b", 2); + assert(source.get("b") == 2); + + auto assigned = PineMap::new_(); + assigned.put("discarded", 99); + assigned = std::move(source); + assigned.put("c", 3); + + assert(source.get("c") == 3); + assert(constructed.get("c") == 3); + assert(!assigned.contains("discarded")); + +} + +static void test_put_get_and_stable_insertion_order() { + auto map = PineMap::new_(); + assert(pineforge::is_na(map.put("second", 2))); + assert(pineforge::is_na(map.put("first", 1))); + assert(pineforge::is_na(map.put("third", 3))); + + assert(map.put("first", 10) == 1); + assert((map.keys() == std::vector{"second", "first", "third"})); + assert((map.values() == std::vector{2, 10, 3})); + assert(map.size() == 3); + assert(map.contains("first")); + assert(!map.contains("missing")); + assert(map.get("first") == 10); + assert(pineforge::is_na(map.get("missing"))); +} + +static void test_float_keys_use_pine_na_and_zero_equality() { + auto map = PineMap::new_(); + const double na_one = std::numeric_limits::quiet_NaN(); + const double na_two = std::nan("42"); + + assert(pineforge::is_na(map.put(na_one, 1))); + assert(map.contains(na_two)); + assert(map.get(na_two) == 1); + assert(map.put(na_two, 2) == 1); + assert(map.size() == 1); + assert(map.keys().size() == 1); + assert(std::isnan(map.keys().front())); + + assert(pineforge::is_na(map.put(-0.0, 3))); + assert(map.contains(+0.0)); + assert(map.get(+0.0) == 3); + assert(map.put(+0.0, 4) == 3); + assert(map.size() == 2); + + assert(map.remove(na_two) == 2); + assert(!map.contains(na_one)); + assert((map.values() == std::vector{4})); +} + +static void test_remove_updates_order_and_reinsert_appends() { + auto map = PineMap::new_(); + map.put(1, "one"); + map.put(2, "two"); + map.put(3, "three"); + map.put(4, "four"); + + assert(map.remove(2) == "two"); + assert((map.keys() == std::vector{1, 3, 4})); + assert((map.values() == std::vector{"one", "three", "four"})); + assert(map.remove(99).empty()); + assert((map.keys() == std::vector{1, 3, 4})); + + assert(map.put(2, "two-again").empty()); + assert((map.keys() == std::vector{1, 3, 4, 2})); +} + +static void test_put_all_overwrites_in_source_order() { + auto target = PineMap::new_(); + target.put("a", 1); + target.put("b", 2); + target.put("c", 3); + + auto source = PineMap::new_(); + source.put("b", 20); + source.put("d", 40); + source.put("a", 10); + source.put("e", 50); + + target.put_all(source); + assert((target.keys() == std::vector{"a", "b", "c", "d", "e"})); + assert((target.values() == std::vector{10, 20, 3, 40, 50})); + + // Aliases identify the same map ID; put_all on itself must remain safe and + // must not duplicate or reorder entries. + auto alias = target; + target.put_all(alias); + assert((target.keys() == std::vector{"a", "b", "c", "d", "e"})); + assert((target.values() == std::vector{10, 20, 3, 40, 50})); +} + +static void test_50000_pair_capacity_and_put_all_preflight() { + using Map = PineMap; + constexpr const char* capacity_error = + "map cannot contain more than 50000 key-value pairs"; + + auto map = Map::new_(); + for (int key = 0; key < Map::max_pairs; ++key) { + assert(pineforge::is_na(map.put(key, key))); + } + assert(map.size() == Map::max_pairs); + + // Overwriting at the limit is permitted and does not move or grow a key. + assert(map.put(0, -1) == 0); + assert(map.size() == Map::max_pairs); + expect_runtime_error( + [&] { (void)map.put(Map::max_pairs, 1); }, capacity_error); + assert(map.size() == Map::max_pairs); + + assert(map.remove(2) == 2); + assert(pineforge::is_na(map.put(Map::max_pairs, 50000))); + assert(map.size() == Map::max_pairs); + + auto overwrite_only = Map::new_(); + overwrite_only.put(0, 100); + overwrite_only.put(1, 101); + map.put_all(overwrite_only); + assert(map.get(0) == 100); + assert(map.get(1) == 101); + + auto mixed = Map::new_(); + mixed.put(0, 200); + mixed.put(Map::max_pairs + 1, 50001); + expect_runtime_error([&] { map.put_all(mixed); }, capacity_error); + + // Capacity is preflighted: the overwrite before the new source key was + // not partially applied. + assert(map.get(0) == 100); + assert(!map.contains(Map::max_pairs + 1)); + assert(map.size() == Map::max_pairs); +} + +static void test_clear_mutates_alias_only() { + auto map = PineMap::new_(); + map.put(1, 10); + map.put(2, 20); + auto alias = map; + auto independent = map.copy(); + + alias.clear(); + assert(map.size() == 0); + assert(alias.size() == 0); + assert(independent.size() == 2); + assert(independent.get(1) == 10); +} + +static void test_typed_missing_values_for_primitive_templates() { + auto doubles = PineMap::new_(); + assert(pineforge::is_na(doubles.get(1))); + assert(pineforge::is_na(doubles.remove(1))); + + auto integers = PineMap::new_(); + assert(pineforge::is_na(integers.get("missing"))); + assert(pineforge::is_na(integers.put("answer", int64_t{42}))); + assert(integers.get("answer") == 42); + + auto strings = PineMap::new_(); + assert(strings.get(true).empty()); + assert(strings.remove(false).empty()); + + auto booleans = PineMap::new_(); + assert(!booleans.get(1.5)); + assert(!booleans.remove(1.5)); + assert(!booleans.put(1.5, true)); + assert(booleans.get(1.5)); +} + +static void test_copy_of_nested_handle_is_outer_deep_inner_shallow() { + using Inner = PineMap; + using Outer = PineMap; + + auto inner = Inner::new_(); + inner.put(1, 10); + auto outer = Outer::new_(); + assert(pineforge::is_na(outer.put(7, inner))); + + auto copied = outer.copy(); + copied.get(7).put(2, 20); + assert(inner.get(2) == 20); + assert(outer.get(7).get(2) == 20); + + copied.remove(7); + assert(outer.contains(7)); + assert(!copied.contains(7)); +} + +static void test_snapshot_surface_is_primitive_only() { + using Primitive = PineMap; + using UdtValue = PineMap; + using CollectionValue = PineMap>; + using HandleValue = PineMap>; + + static_assert(Primitive::snapshot_supported); + static_assert(has_public_snapshot::value); + static_assert(!UdtValue::snapshot_supported); + static_assert(!CollectionValue::snapshot_supported); + static_assert(!HandleValue::snapshot_supported); + static_assert(!has_public_snapshot::value); + static_assert(!has_public_snapshot::value); + static_assert(!has_public_snapshot::value); + static_assert(!pineforge::is_pine_map_snapshot_value_v); + + // Non-primitive values remain usable for ordinary Pine map operations; + // only the unsafe public rollback surface is unavailable. + auto udts = UdtValue::new_(); + assert(udts.put(1, SnapshotUnsafeUdt{7}).value == 0); + assert(udts.get(1).value == 7); +} + +static void test_snapshot_restores_contents_identity_and_rebinding() { + auto live = PineMap::new_(); + live.put("a", 1); + live.put("b", 2); + auto alias = live; + auto snapshot = live.snapshot(); + + alias.put("a", 10); + alias.remove("b"); + alias.put("c", 3); + + auto replacement = PineMap::new_(); + replacement.put("replacement", 99); + auto replacement_alias = replacement; + live = replacement; + assert(live.contains("replacement")); + + live.restore(snapshot); + assert((live.keys() == std::vector{"a", "b"})); + assert((live.values() == std::vector{1, 2})); + + // The pre-snapshot alias sees the in-place rollback, and the restored + // variable has reattached to that same original map ID. + assert((alias.keys() == std::vector{"a", "b"})); + live.put("d", 4); + assert(alias.get("d") == 4); + + // Reattaching live must not mutate the temporary replacement identity. + assert(replacement_alias.size() == 1); + assert(replacement_alias.get("replacement") == 99); + assert(!replacement_alias.contains("d")); + + // Snapshots are reusable and remain detached from later mutations. + alias.put("a", 100); + alias.remove("b"); + alias.remove("d"); + live.restore(snapshot); + assert((live.keys() == std::vector{"a", "b"})); + assert((live.values() == std::vector{1, 2})); +} + +static void test_snapshot_restores_after_rebind_to_na() { + using Map = PineMap; + auto live = Map::new_(); + live.put("a", 1); + live.put("b", 2); + auto alias = live; + auto snapshot = live.snapshot(); + + live = Map{}; + assert(pineforge::is_na(live)); + alias.put("a", 10); + alias.remove("b"); + alias.put("c", 3); + + live.restore(snapshot); + assert(!pineforge::is_na(live)); + assert((live.keys() == std::vector{"a", "b"})); + assert((alias.values() == std::vector{1, 2})); + live.put("d", 4); + assert(alias.get("d") == 4); + + // A snapshot is reusable even after another live -> na transition. + live = Map{}; + assert(pineforge::is_na(live)); + alias.put("a", 100); + alias.remove("b"); + alias.remove("d"); + live.restore(snapshot); + assert((live.keys() == std::vector{"a", "b"})); + assert((live.values() == std::vector{1, 2})); + live.put("e", 5); + assert(alias.get("e") == 5); +} + +static void test_two_alias_members_restore_to_one_identity() { + auto first = PineMap::new_(); + first.put(1, 10); + first.put(2, 20); + auto second = first; + + auto first_snapshot = first.snapshot(); + auto second_snapshot = second.snapshot(); + + first.put(1, 100); + second.put(3, 30); + first = PineMap::new_(); + second = PineMap::new_(); + + first.restore(first_snapshot); + second.restore(second_snapshot); + assert((first.keys() == std::vector{1, 2})); + assert((second.keys() == std::vector{1, 2})); + + second.put(4, 40); + assert(first.get(4) == 40); + first.remove(1); + assert(!second.contains(1)); +} + +static void test_snapshot_keeps_original_identity_alive() { + using Map = PineMap; + auto snapshot = [] { + auto ephemeral = Map::new_(); + ephemeral.put(7, 70); + return ephemeral.snapshot(); + }(); + + auto restored = Map::new_(); + restored.put(8, 80); + restored.restore(snapshot); + assert(restored.size() == 1); + assert(restored.get(7) == 70); + assert(!restored.contains(8)); +} + +int main() { + static_assert(std::is_copy_constructible_v>); + static_assert(std::is_copy_assignable_v>); + static_assert(std::is_nothrow_default_constructible_v>); + static_assert(std::is_copy_constructible_v::Snapshot>); + static_assert(std::is_copy_assignable_v::Snapshot>); + static_assert(std::is_same_v< + decltype(std::declval&>().put(1, 2.0)), + double>); + + test_na_id_and_stable_errors(); + test_copy_aliases_and_explicit_copy_is_independent(); + test_move_preserves_source_handle_alias(); + test_put_get_and_stable_insertion_order(); + test_float_keys_use_pine_na_and_zero_equality(); + test_remove_updates_order_and_reinsert_appends(); + test_put_all_overwrites_in_source_order(); + test_50000_pair_capacity_and_put_all_preflight(); + test_clear_mutates_alias_only(); + test_typed_missing_values_for_primitive_templates(); + test_copy_of_nested_handle_is_outer_deep_inner_shallow(); + test_snapshot_surface_is_primitive_only(); + test_snapshot_restores_contents_identity_and_rebinding(); + test_snapshot_restores_after_rebind_to_na(); + test_two_alias_members_restore_to_one_identity(); + test_snapshot_keeps_original_identity_alive(); + + std::printf("All test_map tests passed.\n"); + return 0; +} From 9a075320c561d94abd7402a9c09d85370cad8d4d Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Sat, 18 Jul 2026 12:30:53 +0800 Subject: [PATCH 2/2] test(map): preserve mutations in Release builds --- tests/test_map.cpp | 83 ++++++++++++++++++++++++++++++---------------- 1 file changed, 55 insertions(+), 28 deletions(-) diff --git a/tests/test_map.cpp b/tests/test_map.cpp index 198f728..cc0366f 100644 --- a/tests/test_map.cpp +++ b/tests/test_map.cpp @@ -74,16 +74,20 @@ static void test_na_id_and_stable_errors() { static void test_copy_aliases_and_explicit_copy_is_independent() { auto map = PineMap::new_(); - assert(pineforge::is_na(map.put("alpha", 1.0))); + const auto initial_previous = map.put("alpha", 1.0); + assert(pineforge::is_na(initial_previous)); auto alias = map; assert(alias.get("alpha") == 1.0); - assert(alias.put("alpha", 2.0) == 1.0); + const auto alias_previous = alias.put("alpha", 2.0); + assert(alias_previous == 1.0); assert(map.get("alpha") == 2.0); auto independent = map.copy(); - assert(independent.put("alpha", 3.0) == 2.0); - assert(pineforge::is_na(independent.put("beta", 4.0))); + const auto independent_previous = independent.put("alpha", 3.0); + const auto independent_missing = independent.put("beta", 4.0); + assert(independent_previous == 2.0); + assert(pineforge::is_na(independent_missing)); assert(map.get("alpha") == 2.0); assert(!map.contains("beta")); @@ -114,11 +118,15 @@ static void test_move_preserves_source_handle_alias() { static void test_put_get_and_stable_insertion_order() { auto map = PineMap::new_(); - assert(pineforge::is_na(map.put("second", 2))); - assert(pineforge::is_na(map.put("first", 1))); - assert(pineforge::is_na(map.put("third", 3))); - - assert(map.put("first", 10) == 1); + const auto second_previous = map.put("second", 2); + const auto first_previous = map.put("first", 1); + const auto third_previous = map.put("third", 3); + assert(pineforge::is_na(second_previous)); + assert(pineforge::is_na(first_previous)); + assert(pineforge::is_na(third_previous)); + + const auto overwrite_previous = map.put("first", 10); + assert(overwrite_previous == 1); assert((map.keys() == std::vector{"second", "first", "third"})); assert((map.values() == std::vector{2, 10, 3})); assert(map.size() == 3); @@ -133,21 +141,26 @@ static void test_float_keys_use_pine_na_and_zero_equality() { const double na_one = std::numeric_limits::quiet_NaN(); const double na_two = std::nan("42"); - assert(pineforge::is_na(map.put(na_one, 1))); + const auto na_previous = map.put(na_one, 1); + assert(pineforge::is_na(na_previous)); assert(map.contains(na_two)); assert(map.get(na_two) == 1); - assert(map.put(na_two, 2) == 1); + const auto na_overwrite_previous = map.put(na_two, 2); + assert(na_overwrite_previous == 1); assert(map.size() == 1); assert(map.keys().size() == 1); assert(std::isnan(map.keys().front())); - assert(pineforge::is_na(map.put(-0.0, 3))); + const auto zero_previous = map.put(-0.0, 3); + assert(pineforge::is_na(zero_previous)); assert(map.contains(+0.0)); assert(map.get(+0.0) == 3); - assert(map.put(+0.0, 4) == 3); + const auto zero_overwrite_previous = map.put(+0.0, 4); + assert(zero_overwrite_previous == 3); assert(map.size() == 2); - assert(map.remove(na_two) == 2); + const auto removed_na = map.remove(na_two); + assert(removed_na == 2); assert(!map.contains(na_one)); assert((map.values() == std::vector{4})); } @@ -159,13 +172,16 @@ static void test_remove_updates_order_and_reinsert_appends() { map.put(3, "three"); map.put(4, "four"); - assert(map.remove(2) == "two"); + const auto removed_two = map.remove(2); + assert(removed_two == "two"); assert((map.keys() == std::vector{1, 3, 4})); assert((map.values() == std::vector{"one", "three", "four"})); - assert(map.remove(99).empty()); + const auto removed_missing = map.remove(99); + assert(removed_missing.empty()); assert((map.keys() == std::vector{1, 3, 4})); - assert(map.put(2, "two-again").empty()); + const auto reinsert_previous = map.put(2, "two-again"); + assert(reinsert_previous.empty()); assert((map.keys() == std::vector{1, 3, 4, 2})); } @@ -200,19 +216,23 @@ static void test_50000_pair_capacity_and_put_all_preflight() { auto map = Map::new_(); for (int key = 0; key < Map::max_pairs; ++key) { - assert(pineforge::is_na(map.put(key, key))); + const auto previous = map.put(key, key); + assert(pineforge::is_na(previous)); } assert(map.size() == Map::max_pairs); // Overwriting at the limit is permitted and does not move or grow a key. - assert(map.put(0, -1) == 0); + const auto limit_overwrite_previous = map.put(0, -1); + assert(limit_overwrite_previous == 0); assert(map.size() == Map::max_pairs); expect_runtime_error( [&] { (void)map.put(Map::max_pairs, 1); }, capacity_error); assert(map.size() == Map::max_pairs); - assert(map.remove(2) == 2); - assert(pineforge::is_na(map.put(Map::max_pairs, 50000))); + const auto removed_for_capacity = map.remove(2); + const auto replacement_previous = map.put(Map::max_pairs, 50000); + assert(removed_for_capacity == 2); + assert(pineforge::is_na(replacement_previous)); assert(map.size() == Map::max_pairs); auto overwrite_only = Map::new_(); @@ -251,21 +271,26 @@ static void test_clear_mutates_alias_only() { static void test_typed_missing_values_for_primitive_templates() { auto doubles = PineMap::new_(); assert(pineforge::is_na(doubles.get(1))); - assert(pineforge::is_na(doubles.remove(1))); + const auto removed_double = doubles.remove(1); + assert(pineforge::is_na(removed_double)); auto integers = PineMap::new_(); assert(pineforge::is_na(integers.get("missing"))); - assert(pineforge::is_na(integers.put("answer", int64_t{42}))); + const auto integer_previous = integers.put("answer", int64_t{42}); + assert(pineforge::is_na(integer_previous)); assert(integers.get("answer") == 42); auto strings = PineMap::new_(); assert(strings.get(true).empty()); - assert(strings.remove(false).empty()); + const auto removed_string = strings.remove(false); + assert(removed_string.empty()); auto booleans = PineMap::new_(); assert(!booleans.get(1.5)); - assert(!booleans.remove(1.5)); - assert(!booleans.put(1.5, true)); + const auto removed_bool = booleans.remove(1.5); + const auto bool_previous = booleans.put(1.5, true); + assert(!removed_bool); + assert(!bool_previous); assert(booleans.get(1.5)); } @@ -276,7 +301,8 @@ static void test_copy_of_nested_handle_is_outer_deep_inner_shallow() { auto inner = Inner::new_(); inner.put(1, 10); auto outer = Outer::new_(); - assert(pineforge::is_na(outer.put(7, inner))); + const auto outer_previous = outer.put(7, inner); + assert(pineforge::is_na(outer_previous)); auto copied = outer.copy(); copied.get(7).put(2, 20); @@ -307,7 +333,8 @@ static void test_snapshot_surface_is_primitive_only() { // Non-primitive values remain usable for ordinary Pine map operations; // only the unsafe public rollback surface is unavailable. auto udts = UdtValue::new_(); - assert(udts.put(1, SnapshotUnsafeUdt{7}).value == 0); + const auto udt_previous = udts.put(1, SnapshotUnsafeUdt{7}); + assert(udt_previous.value == 0); assert(udts.get(1).value == 7); }