diff --git a/CMakeLists.txt b/CMakeLists.txt index 8884af82..12c3b74f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -133,6 +133,7 @@ set(PINEFORGE_KERNEL_SOURCES src/engine_consumer.cpp src/market_driver.cpp src/native_execution_consumer.cpp + src/native_calc_plan.cpp src/magnifier.cpp src/math.cpp src/matrix.cpp @@ -151,7 +152,6 @@ set(PINEFORGE_KERNEL_SOURCES src/selected_report_view.cpp src/selected_window_plan.cpp ) - # The kernel-only archive. Same objects, same flags as the kernel half of # libpineforge.a — a consumer that never speaks Pine links this one. add_library(pineforge_kernel STATIC ${PINEFORGE_KERNEL_SOURCES}) diff --git a/include/pineforge/native_calc_point.hpp b/include/pineforge/native_calc_point.hpp new file mode 100644 index 00000000..5cca583d --- /dev/null +++ b/include/pineforge/native_calc_point.hpp @@ -0,0 +1,108 @@ +#pragma once + +#include + +// The calculation points of a driving-event schedule: what the kernel derives, +// for an instance that computes on a declared cadence, from the events it is +// given and its own bar state (src/native_calc_plan.hpp holds the derivation). +// Plain data only, so the boundary that exposes a plan and the code that checks +// one read the same vocabulary. + +namespace pineforge::native_calc { + +/// The driving events of a schedule. The numbers are the event-kind registry of +/// the named-stream boundary; zero, and every value above AdvanceTime, name no +/// event: the derivation refuses them rather than plan nothing for them. +enum class EventKind : std::uint32_t { + WarmupBar = 1, + Bar = 2, + Tick = 3, + AdvanceTime = 4, +}; + +/// Whether `kind` is in the registry: the one range check a raw number passes +/// before the derivation treats it as an EventKind. +constexpr bool is_known(EventKind kind) noexcept { + const auto raw = static_cast(kind); + return raw >= static_cast(EventKind::WarmupBar) + && raw <= static_cast(EventKind::AdvanceTime); +} + +/// One driving event: its kind and its time. `time_ms` is a tick's time, a +/// bar's open time, or the instant a time advance moves the clock to. +struct Event { + EventKind kind = EventKind::Tick; + std::int64_t time_ms = 0; +}; + +/// Which driving events make an instance calculate, as a bit set. A cadence +/// always contains kCadenceBarClose; the other two bits are optional. +inline constexpr std::uint32_t kCadenceAdvanceTime = 1u << 0; +inline constexpr std::uint32_t kCadenceBarClose = 1u << 1; +inline constexpr std::uint32_t kCadenceTick = 1u << 2; +inline constexpr std::uint32_t kCadenceMask = + kCadenceAdvanceTime | kCadenceBarClose | kCadenceTick; + +/// Whether a calculation ran before the payload of its driving event was +/// applied (a bar closed only because time reached its end) or after it. +enum class Phase : std::uint32_t { + Pre = 0, + Post = 1, +}; + +/// Provisional for an intrabar calculation, Confirmed for the calculation that +/// closes a bar. +enum class Finality : std::uint32_t { + Provisional = 0, + Confirmed = 1, +}; + +/// One planned calculation. +struct Point { + /// Zero-based index of the driving event among the events of the schedule. + std::uint64_t event_index = 0; + /// Trace ordinal of the driving event, one-based and independent of how the + /// trace is cut into schedules. + std::uint64_t event = 0; + /// Ordinal of the bar lifecycle the calculation belongs to, assigned by the + /// kernel: zero for the first lifecycle opened, one more for each next. + std::uint64_t bar_key = 0; + /// Ordinal of the calculation within its bar lifecycle. + std::uint64_t calc_ordinal = 0; + /// Ordinal of the calculation within its driving event. + std::uint64_t calc = 0; + /// Event-time position: the bar's exclusive end (never later than the event + /// time) for a Pre calculation, the event time itself for a Post one. + std::int64_t at_ms = 0; + Phase phase = Phase::Pre; + Finality finality = Finality::Provisional; +}; + +/// How a derivation ended. Ok with a count of zero is a schedule that plans no +/// calculation; every other status is a refusal and plans nothing. +enum class Status : std::uint32_t { + Ok = 0, + /// The consumer's schedule group was not enabled for the current run. + NotEngaged, + /// A null event array with a nonzero count. + BadArguments, + /// A cadence without the bar-close bit, or with a bit outside the set. + BadCadence, + /// An event kind outside the registry. + UnknownEventKind, + /// A bar lifecycle had to open while the last ordinal assigned was already + /// the largest representable one: the boundary reports capacity exhausted. + BarKeyExhausted, + /// The trace ordinal of the next event is the largest representable one. + TraceExhausted, + /// The timebase has no bar interval at the time of an event that opens one. + NoBarSlot, + /// The interval the timebase holds for an event ends no later than the + /// event: its end cannot be the exclusive end of a bar that event opens. + BarEndNotAfterEvent, + /// A position the kernel planned itself lies outside the window of its + /// driving event: an internal invariant failure, not an admission error. + PositionOutsideWindow, +}; + +} // namespace pineforge::native_calc diff --git a/src/native_calc_plan.cpp b/src/native_calc_plan.cpp new file mode 100644 index 00000000..952ce478 --- /dev/null +++ b/src/native_calc_plan.cpp @@ -0,0 +1,289 @@ +#include "native_calc_plan.hpp" +#include "native_execution_consumer.hpp" + +#include + +#include +#include + +namespace pineforge::native_calc { +namespace { + +constexpr std::uint64_t kMaxOrdinal = std::numeric_limits::max(); + +// FNV-1a's offset and prime, one word at a time: a bijection of the +// accumulator for a fixed word and of the word for a fixed accumulator, so two +// folds that differ in a single word never share a result. +constexpr std::uint64_t kFoldSeed = 14695981039346656037ULL; +constexpr std::uint64_t kFoldPrime = 1099511628211ULL; +constexpr std::uint64_t kGroupTag = 0x43414c43504c414eULL; + +constexpr std::uint64_t fold_word(std::uint64_t accumulator, std::uint64_t word) noexcept { + accumulator ^= word; + accumulator *= kFoldPrime; + accumulator ^= accumulator >> 29; + return accumulator; +} + +} // namespace + +Plan plan_schedule(const State& before, std::uint32_t cadence, const Event* events, + std::size_t count, const BarClock& clock, bool want_points) { + Plan plan; + plan.after = before; + const auto refused = [&](Status why) { + plan.status = why; + plan.count = 0; + plan.after = before; + plan.points.clear(); + return plan; + }; + if ((cadence & ~kCadenceMask) != 0 || (cadence & kCadenceBarClose) == 0) { + return refused(Status::BadCadence); + } + if (count != 0 && events == nullptr) return refused(Status::BadArguments); + + State s = before; + std::uint64_t planned = 0; + std::vector rows; + + // Whether the timebase holds time boundaries at an instant. A clock with no + // slot there says nothing against it, so such an instant keeps the bounded + // rules; only a slot that says it is unbounded lifts them. + const auto has_boundaries = [&clock](std::int64_t instant) { + Slot slot; + return !clock(instant, slot) || slot.bounded; + }; + + for (std::size_t index = 0; index < count; ++index) { + const Event& event = events[index]; + if (!is_known(event.kind)) return refused(Status::UnknownEventKind); + if (s.trace_first == kMaxOrdinal) return refused(Status::TraceExhausted); + const std::int64_t at = event.time_ms; + const std::uint64_t ordinal = s.trace_first; + std::uint64_t calc = 0; + Status failure = Status::Ok; + + // One calculation of this event, checked against the window of its + // driving event: a Pre position lies between the previous event's time + // (unbounded below for the first event) and this one's, a Post position + // is this event's time. + const auto emit = [&](Phase phase, std::int64_t position_ms, std::uint64_t key, + std::uint64_t in_bar, Finality finality) -> bool { + const bool inside = phase == Phase::Post + ? position_ms == at + : (!s.has_prev_event || s.prev_event_ms <= position_ms) && position_ms <= at; + if (!inside) { + failure = Status::PositionOutsideWindow; + return false; + } + if (want_points) { + Point point; + point.event_index = index; + point.event = ordinal; + point.bar_key = key; + point.calc_ordinal = in_bar; + point.calc = calc; + point.at_ms = position_ms; + point.phase = phase; + point.finality = finality; + rows.push_back(point); + } + ++planned; + ++calc; + return true; + }; + + // A bar lifecycle opens at the event: the next ordinal, in the interval + // the timebase holds at the event's time, whose end must come after it. + // A timebase without time boundaries holds no interval: the lifecycle + // opens all the same and keeps no end. + const auto open_bar = [&]() -> bool { + if (s.has_last_key && s.last_bar_key == kMaxOrdinal) { + failure = Status::BarKeyExhausted; + return false; + } + Slot slot; + if (!clock(at, slot)) { + failure = Status::NoBarSlot; + return false; + } + if (slot.bounded && slot.end_ms <= at) { + failure = Status::BarEndNotAfterEvent; + return false; + } + s.last_bar_key = s.has_last_key ? s.last_bar_key + 1 : 0; + s.has_last_key = true; + s.bar_open = true; + if (slot.bounded) { + s.open_end_ms = slot.end_ms; + s.cursor_ms = slot.next_open_ms; + } else { + s.open_end_ms = 0; + } + s.calc_in_bar = 0; + return true; + }; + + // Time reaching the end of the open bar closes it before the event's own + // payload is applied, and ends its lifecycle: whatever the event does + // next belongs to a bar opened after it. A timebase without time + // boundaries keeps no end: its bars close on data alone. + if (s.bar_open && at >= s.open_end_ms && has_boundaries(at)) { + if (!emit(Phase::Pre, s.open_end_ms, s.last_bar_key, s.calc_in_bar, + Finality::Confirmed)) { + return refused(failure); + } + s.bar_open = false; + s.open_end_ms = 0; + s.calc_in_bar = 0; + } + + // Quiet intervals the event passes over: each one that ends by the event + // and that the timebase says yields a bar is a bar lifecycle of its own, + // opened and closed by time alone. Closed time yields none, and neither + // does a timebase without time boundaries. + while (s.has_last_key && !s.bar_open) { + Slot slot; + if (!clock(s.cursor_ms, slot)) break; + if (!slot.bounded) break; + if (slot.end_ms > at || slot.next_open_ms <= s.cursor_ms) break; + if (slot.tradable) { + if (s.last_bar_key == kMaxOrdinal) return refused(Status::BarKeyExhausted); + ++s.last_bar_key; + if (!emit(Phase::Pre, slot.end_ms, s.last_bar_key, 0, Finality::Confirmed)) { + return refused(failure); + } + } + s.cursor_ms = slot.next_open_ms; + } + + switch (event.kind) { + case EventKind::Tick: + if (!s.bar_open && !open_bar()) return refused(failure); + if ((cadence & kCadenceTick) != 0) { + if (!emit(Phase::Post, at, s.last_bar_key, s.calc_in_bar, + Finality::Provisional)) { + return refused(failure); + } + ++s.calc_in_bar; + } + break; + case EventKind::WarmupBar: + case EventKind::Bar: + if (!s.bar_open && !open_bar()) return refused(failure); + if (!emit(Phase::Post, at, s.last_bar_key, s.calc_in_bar, Finality::Confirmed)) { + return refused(failure); + } + s.bar_open = false; + s.open_end_ms = 0; + s.calc_in_bar = 0; + break; + case EventKind::AdvanceTime: + if (s.bar_open && (cadence & kCadenceAdvanceTime) != 0) { + if (!emit(Phase::Post, at, s.last_bar_key, s.calc_in_bar, + Finality::Provisional)) { + return refused(failure); + } + ++s.calc_in_bar; + } + break; + } + + s.has_prev_event = true; + s.prev_event_ms = at; + ++s.trace_first; + } + + plan.status = Status::Ok; + plan.count = planned; + plan.after = s; + plan.points = std::move(rows); + return plan; +} + +std::uint64_t digest(const State& state) noexcept { + std::uint64_t accumulator = kFoldSeed; + std::apply( + [&accumulator](const auto&... field) { + ((accumulator = fold_word(accumulator, static_cast(field))), ...); + }, + state.tie()); + return accumulator; +} + +std::uint64_t combine(std::uint64_t legacy, std::uint64_t group) noexcept { + return fold_word(fold_word(legacy, kGroupTag), group); +} + +} // namespace pineforge::native_calc + +namespace pineforge { +inline namespace engine_script_run_v19 { + +// The consumer's schedule group. It is opt-in and per run: the group belongs to +// the run generation it was enabled in (the high-water the next begin must +// exceed), so a begin that follows leaves it disengaged without any reset in +// the begin path, and a consumer that never enables it keeps its continuation +// identity exactly. + +void NativeExecutionConsumer::calc_group_enable() noexcept { + calc_group_.emplace(); + calc_group_->run_generation = consumed_high_water_; +} + +bool NativeExecutionConsumer::calc_group_engaged() const noexcept { + return calc_group_.has_value() && calc_group_->run_generation == consumed_high_water_; +} + +// The script timeframe's interval at an instant, read from the calendar the +// sealing path reads: its next period open is the exclusive end that path +// compares time against. A raw-label partition has no time boundaries (its +// degenerate interval would end where it began): the slot says so, and its bars +// are planned on data alone. +bool NativeExecutionConsumer::calc_slot_at(std::int64_t at_ms, native_calc::Slot& slot) const { + if (uses_raw_label_partition()) { + slot.bounded = false; + return true; + } + if (!script_tf_.valid()) return false; + const auto interval = native_calendar::interval_containing( + calendar_, script_tf_, day_label_origin(at_ms), calendar_memo_); + if (!interval) return false; + slot.end_ms = interval->next_period_open_ms; + slot.next_open_ms = interval->next_input_open_ms; + slot.tradable = interval->last_traded_close_ms > interval->eligible_open_ms + && native_calendar::in_session(calendar_, interval->eligible_open_ms, calendar_memo_); + return true; +} + +native_calc::Plan NativeExecutionConsumer::calc_schedule_plan( + std::uint32_t cadence, const native_calc::Event* events, std::size_t count, + bool want_points) const { + if (!calc_group_engaged()) { + native_calc::Plan refused; + refused.status = native_calc::Status::NotEngaged; + return refused; + } + const native_calc::BarClock clock = [this](std::int64_t at_ms, native_calc::Slot& slot) { + return calc_slot_at(at_ms, slot); + }; + return native_calc::plan_schedule(calc_group_->state, cadence, events, count, clock, + want_points); +} + +native_calc::Plan NativeExecutionConsumer::calc_schedule_apply( + std::uint32_t cadence, const native_calc::Event* events, std::size_t count) { + native_calc::Plan plan = calc_schedule_plan(cadence, events, count, true); + if (plan.status == native_calc::Status::Ok) calc_group_->state = plan.after; + return plan; +} + +std::uint64_t NativeExecutionConsumer::calc_continuation_hash() const noexcept { + const std::uint64_t legacy = continuation_hash(); + if (!calc_group_engaged()) return legacy; + return native_calc::combine(legacy, native_calc::digest(calc_group_->state)); +} + +} // inline namespace engine_script_run_v19 +} // namespace pineforge diff --git a/src/native_calc_plan.hpp b/src/native_calc_plan.hpp new file mode 100644 index 00000000..ff0cf71c --- /dev/null +++ b/src/native_calc_plan.hpp @@ -0,0 +1,96 @@ +#pragma once + +// The pure derivation of a calculation schedule: from the driving events of a +// trace, the cadence a binding declared and the kernel's own bar timeline, which +// bar lifecycle every calculation belongs to, where on the event-time axis it +// sits and whether it confirms its bar. Internal to the kernel; the plain data +// it speaks in is include/pineforge/native_calc_point.hpp. +// +// One function, plan_schedule, serves the count query and the apply that +// commits: it reads a State and writes none, and a caller that commits keeps the +// State it returns. Nothing here reads a clock, a calendar or a consumer: the +// timeline arrives as a BarClock, which the consumer builds over the calendar +// and the script timeframe its own sealing path reads, so the end of a bar is +// derived by the kernel and never stated by a caller. + +#include + +#include +#include +#include +#include +#include + +namespace pineforge::native_calc { + +/// One bar interval of the timebase. +struct Slot { + /// Exclusive end: the nominal open of the next interval, the value the + /// consumer's sealing path compares time against. + std::int64_t end_ms = 0; + /// Where the lookup of the interval after this one starts, skipping closed + /// time. Must lie after the instant that was looked up for the walk to move. + std::int64_t next_open_ms = 0; + /// Whether a quiet interval here yields a carry-forward bar. + bool tradable = false; + /// False for a timebase without time boundaries (a raw-label partition, + /// whose bars close on data alone): the other three fields then mean nothing. + bool bounded = true; +}; + +/// The slot that holds an instant, or false when the timebase has none there. +using BarClock = std::function; + +/// Everything a schedule carries from one schedule to the next. The fields are +/// listed once, in tie(); the digest and every comparison read that list. +struct State { + /// Trace ordinal of the next driving event (one-based). + std::uint64_t trace_first = 1; + /// Whether an event has been planned, and the time of the last one: the + /// lower bound of a Pre position's window. + bool has_prev_event = false; + std::int64_t prev_event_ms = 0; + /// Whether a bar lifecycle was ever opened, and the last ordinal assigned. + bool has_last_key = false; + std::uint64_t last_bar_key = 0; + /// Whether the lifecycle of last_bar_key is still open, the exclusive end the + /// kernel derived for it when it opened (zero while none is open, and zero + /// for a bar of a timebase without time boundaries, which has none) and how + /// many calculations it has had. + bool bar_open = false; + std::int64_t open_end_ms = 0; + std::uint64_t calc_in_bar = 0; + /// Where the next quiet interval is looked up: the interval after the last + /// bar lifecycle's. Meaningful once has_last_key is set. + std::int64_t cursor_ms = 0; + + auto tie() const noexcept { + return std::tie(trace_first, has_prev_event, prev_event_ms, has_last_key, + last_bar_key, bar_open, open_end_ms, calc_in_bar, cursor_ms); + } +}; + +/// The outcome of one derivation. `after` and `points` mean something only when +/// `status` is Ok; a refusal returns the State it was given and no points. +struct Plan { + Status status = Status::Ok; + /// Calculations planned; counted even when the points are not collected. + std::uint64_t count = 0; + State after; + std::vector points; +}; + +/// Derives the calculations `events` make an instance with `cadence` perform, +/// starting from `before`, over the timeline `clock` answers for. Collects the +/// points only when asked; the count is exact either way. +Plan plan_schedule(const State& before, std::uint32_t cadence, const Event* events, + std::size_t count, const BarClock& clock, bool want_points); + +/// A word that changes whenever any field of the State does. +std::uint64_t digest(const State& state) noexcept; + +/// The continuation identity of a consumer whose schedule group is engaged: the +/// consumer's own identity folded with the group's digest. +std::uint64_t combine(std::uint64_t legacy, std::uint64_t group) noexcept; + +} // namespace pineforge::native_calc diff --git a/src/native_execution_consumer.cpp b/src/native_execution_consumer.cpp index 1f14ad44..bdb2fe1b 100644 --- a/src/native_execution_consumer.cpp +++ b/src/native_execution_consumer.cpp @@ -12076,7 +12076,7 @@ uint64_t NativeStrategyHost::native_consumed_high_water() const { uint64_t NativeStrategyHost::native_continuation_hash() const { return NativeExecutionConsumer::bound(*this) - .continuation_hash(); + .calc_continuation_hash(); } void NativeStrategyHost::native_closed_rows_amended(std::size_t first_row) { diff --git a/src/native_execution_consumer.hpp b/src/native_execution_consumer.hpp index 3905cd75..7536b74b 100644 --- a/src/native_execution_consumer.hpp +++ b/src/native_execution_consumer.hpp @@ -7,7 +7,7 @@ #include "native_calendar_memo.hpp" #include "native_selected_epoch.hpp" #include "runtime_ambient.hpp" - +#include "native_calc_plan.hpp" #include #include #include @@ -2365,6 +2365,37 @@ class NativeExecutionConsumer final : public IExecutionConsumer { // capture that may be read (Results, no fault, ready and open). bool observed_epoch_sealed() const noexcept; bool capture_readable() const noexcept; + +public: + // The named-stream schedule group (native_calc_plan.hpp): the optional + // qualified bar-lifecycle key and the calculation schedule derived with it. + // Opt-in and per run: the group is engaged from calc_group_enable until the + // next run begin, and a consumer that never enables it keeps every + // continuation value it had. Enable it at the admitted trace origin, before + // the first event of the trace is planned. + void calc_group_enable() noexcept; + bool calc_group_engaged() const noexcept; + // The calculations `events` make a binding with `cadence` perform, derived + // from the group's state and this consumer's own calendar. The plan reads + // and changes nothing; the apply commits the state a successful plan leaves. + // Both answer NotEngaged until the group is enabled for the run. + native_calc::Plan calc_schedule_plan(std::uint32_t cadence, + const native_calc::Event* events, + std::size_t count, bool want_points) const; + native_calc::Plan calc_schedule_apply(std::uint32_t cadence, + const native_calc::Event* events, + std::size_t count); + // native_continuation_hash's value: continuation_hash() while the group is + // not engaged, that identity folded with the group's state once it is. + std::uint64_t calc_continuation_hash() const noexcept; + +private: + struct CalcGroup { + native_calc::State state; + std::uint64_t run_generation = 0; + }; + std::optional calc_group_; + bool calc_slot_at(std::int64_t at_ms, native_calc::Slot& slot) const; }; inline NativeExecutionConsumer& as_native_consumer(IExecutionConsumer& consumer) { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e537c657..025d4d42 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -145,6 +145,8 @@ set(TEST_SOURCES test_native_htf_subscriptions_twin test_native_calc_timing test_native_calc_timing_twin + test_native_calc_plan + test_native_calc_identity test_native_htf_subscriptions_stream test_native_auxiliary_feed test_native_auxiliary_feed_stream diff --git a/tests/test_native_calc_identity.cpp b/tests/test_native_calc_identity.cpp new file mode 100644 index 00000000..0c969493 --- /dev/null +++ b/tests/test_native_calc_identity.cpp @@ -0,0 +1,402 @@ +// The consumer's schedule group (NativeExecutionConsumer::calc_group_*): its +// identity, its non-interference and its derivation over the consumer's own +// calendar. +// 1. a run that never engages the group keeps the legacy continuation identity +// exactly: the literal below, measured on the base commit; +// 2. an engaged group is part of native_continuation_hash(), every field of its +// state moves it, and the consumer's own continuation_hash() never moves; +// 3. the plan and the apply derive over the consumer's calendar: bar ends are +// the calendar's, the frozen frames T4 c12 and T2 c21 come out as frozen, +// and one trace cut two ways ends in one state; +// 4. a refusal commits nothing; +// 5. the group belongs to its run: the next begin leaves it disengaged; +// 6. a timebase without time boundaries (a raw-label partition) is planned on +// data alone: bars and POST rows, no PRE row, no refusal. +#include + +#include "../src/native_execution_consumer.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace pineforge { +inline namespace engine_script_run_v19 { +// The consumer's private group state, for seeding and reading it. +struct NativeExecutionConsumerProbe { + static native_calc::State& state(NativeExecutionConsumer& consumer) { + return consumer.calc_group_->state; + } +}; +} // inline namespace engine_script_run_v19 +} // namespace pineforge + +namespace { +using namespace pineforge; +using native_calc::Event; +using native_calc::EventKind; +using native_calc::Finality; +using native_calc::Phase; +using native_calc::Plan; +using native_calc::State; +using native_calc::Status; + +int checks = 0; +int failures = 0; + +#define CHECK(label, expression) \ + do { \ + ++checks; \ + if (!(expression)) { \ + ++failures; \ + std::printf("FAIL [%s] line %d: %s\n", label, __LINE__, #expression); \ + } \ + } while (false) + +// The legacy continuation identity of this scenario -- six minute bars, then +// configure_native(spec) and run(bars, 6, "1", "1", false, 4, ENDPOINTS) on the +// Host below -- measured on the base commit fa5f8c5c763eddbaa64a921db49b6603c8a2121e +// (tag v1.5.0-rc.1, tree 5d2cb16965b3aa1ae5fce384b91118b24a78c67d) by box +// i-0c44b43faf99d3373 with a harness (sha256 +// 07e7642149f3dcb09feeff1bde1050e415488abe6efdc0a3bbfc9bad8cba28b9) that holds +// none of this group's code: continuation hash 8797707128881238685 +// (0x7a17bc057ee3529d) and one trade row. +constexpr std::uint64_t kBaseContinuationHash = 8797707128881238685ULL; +constexpr std::size_t kBaseTradeRows = 1; + +class Host final : public NativeStrategyHost { +public: + int bar = 0; + void on_native_bar(const Bar&, const NativeDecisionContext&) override { + // One round trip, so the trade rows the check compares are not empty. + ++bar; + if (bar == 1) submit_market({order_action::Transact{1.0}, "e", ""}); + if (bar == 4) submit_market({execution::Flatten{}, "x", ""}); + } + NativeExecutionConsumer& consumer() { return as_native_consumer(execution_consumer()); } + const std::vector& rows() const { return trades_; } +}; + +std::vector minute_bars(int n) { + std::vector bars; + for (int i = 0; i < n; ++i) { + const double open = 100.0 + i; + bars.push_back({open, open + 2.0, open - 1.0, open + 1.0, 10.0 + i, + static_cast(i) * 60000}); + } + return bars; +} + +NativeRunSpec calc_spec() { + NativeRunSpec spec; + spec.identity = {"native-calc-identity", 1}; + spec.input_tf = "1"; + spec.script_tf = "1"; + spec.ticker = "CALC"; + spec.tickerid = "TEST:CALC"; + spec.type = "crypto"; + spec.currency = "USD"; + spec.basecurrency = "USD"; + spec.description = ""; + spec.volumetype = ""; + spec.timezone = "UTC"; + spec.session = "24x7"; + spec.chart_timezone = ""; + spec.initial_capital = 100000.0; + spec.point_value = 1.0; + spec.account_fx = 1.0; + spec.price_tick = 0.01; + spec.slippage_ticks = 0; + spec.fee_kind = NativeFeeKind::CashPerExecution; + spec.fee_value = 0.0; + return spec; +} + +// The golden scenario on `host`; true when it configured and ran. +bool run_golden(Host& host, const NativeRunSpec& spec) { + const auto bars = minute_bars(6); + if (host.configure_native(spec).status != NativeSetupStatus::Applied) return false; + host.run(bars.data(), static_cast(bars.size()), "1", "1", false, 4, + MagnifierDistribution::ENDPOINTS); + return true; +} + +constexpr std::int64_t kT0 = 1760000400000LL; +constexpr std::uint32_t kBarClose = native_calc::kCadenceBarClose; +constexpr std::uint32_t kBarCloseAndTick = native_calc::kCadenceBarClose | native_calc::kCadenceTick; + +State make_state(std::uint64_t trace_first, bool has_prev, std::int64_t prev, bool has_key, + std::uint64_t key, bool open, std::int64_t end, std::uint64_t in_bar, + std::int64_t cursor) { + State s; + s.trace_first = trace_first; + s.has_prev_event = has_prev; + s.prev_event_ms = prev; + s.has_last_key = has_key; + s.last_bar_key = key; + s.bar_open = open; + s.open_end_ms = end; + s.calc_in_bar = in_bar; + s.cursor_ms = cursor; + return s; +} + +bool same_state(const State& a, const State& b) { return a.tie() == b.tie(); } + +struct Perturbation { + const char* name; + void (*apply)(State&); +}; + +// 1 and 2: non-interference and the group's part in the identity. +void test_identity() { + Host plain; + CHECK("golden scenario configures", run_golden(plain, calc_spec())); + CHECK("golden continuation hash", plain.native_continuation_hash() == kBaseContinuationHash); + CHECK("golden trade rows", plain.rows().size() == kBaseTradeRows); + CHECK("group starts disengaged", !plain.consumer().calc_group_engaged()); + + plain.consumer().calc_group_enable(); + CHECK("group engaged", plain.consumer().calc_group_engaged()); + const std::uint64_t engaged_hash = plain.native_continuation_hash(); + CHECK("an engaged group is part of the identity", engaged_hash != kBaseContinuationHash); + CHECK("the consumer's own identity never moves", + plain.consumer().continuation_hash() == kBaseContinuationHash); + + const Perturbation perturbations[] = { + {"trace_first", [](State& s) { s.trace_first += 1; }}, + {"has_prev_event", [](State& s) { s.has_prev_event = !s.has_prev_event; }}, + {"prev_event_ms", [](State& s) { s.prev_event_ms += 1; }}, + {"has_last_key", [](State& s) { s.has_last_key = !s.has_last_key; }}, + {"last_bar_key", [](State& s) { s.last_bar_key += 1; }}, + {"bar_open", [](State& s) { s.bar_open = !s.bar_open; }}, + {"open_end_ms", [](State& s) { s.open_end_ms += 1; }}, + {"calc_in_bar", [](State& s) { s.calc_in_bar += 1; }}, + {"cursor_ms", [](State& s) { s.cursor_ms += 1; }}, + }; + static_assert(sizeof(perturbations) / sizeof(perturbations[0]) + == std::tuple_size::value, + "a State field was added or dropped: extend the perturbations above"); + for (const Perturbation& perturbation : perturbations) { + State& state = NativeExecutionConsumerProbe::state(plain.consumer()); + const State saved = state; + perturbation.apply(state); + CHECK(perturbation.name, plain.native_continuation_hash() != engaged_hash); + state = saved; + CHECK(perturbation.name, plain.native_continuation_hash() == engaged_hash); + } +} + +// 3: the derivation over the consumer's calendar (24x7, one-minute script bars). +void test_derivation_over_the_calendar() { + Host host; + CHECK("scenario configures", run_golden(host, calc_spec())); + host.consumer().calc_group_enable(); + State& state = NativeExecutionConsumerProbe::state(host.consumer()); + + // T4 frame c12: a tick closes bar 20 at the calendar's end of it and opens bar 21. + state = make_state(12, true, kT0 + 50000, true, 20, true, kT0 + 60000, 0, kT0 + 60000); + const Event tick{EventKind::Tick, kT0 + 60500}; + const Plan planned = host.consumer().calc_schedule_plan(kBarClose, &tick, 1, true); + CHECK("c12 plan", planned.status == Status::Ok); + CHECK("c12 plan", planned.count == 1 && planned.points.size() == 1); + if (planned.points.size() == 1) { + const native_calc::Point& row = planned.points[0]; + CHECK("c12 row", row.event_index == 0 && row.event == 12); + CHECK("c12 row", row.bar_key == 20 && row.calc_ordinal == 0 && row.calc == 0); + CHECK("c12 row", row.at_ms == kT0 + 60000); + CHECK("c12 row", row.phase == Phase::Pre && row.finality == Finality::Confirmed); + } + const Plan applied = host.consumer().calc_schedule_apply(kBarClose, &tick, 1); + CHECK("c12 apply is the plan", applied.status == Status::Ok && applied.count == planned.count); + CHECK("c12 apply commits the state the plan left", + same_state(state, make_state(13, true, kT0 + 60500, true, 21, true, kT0 + 120000, 0, + kT0 + 120000))); + CHECK("c12 end is the calendar's", state.open_end_ms == 1760000520000LL); + + // T2 frame c21: one time advance closes bar 31 and the carry-forward bars + // 32, 33 and 34 the calendar yields for the quiet minutes. + state = make_state(21, true, 1760000470000LL, true, 31, true, 1760000520000LL, 1, + 1760000520000LL); + const Event advance{EventKind::AdvanceTime, 1760000700000LL}; + const Plan c21 = host.consumer().calc_schedule_apply(kBarCloseAndTick, &advance, 1); + CHECK("c21", c21.status == Status::Ok); + CHECK("c21", c21.count == 4 && c21.points.size() == 4); + const std::uint64_t keys[4] = {31, 32, 33, 34}; + const std::uint64_t ordinals[4] = {1, 0, 0, 0}; + const std::int64_t ends[4] = {1760000520000LL, 1760000580000LL, 1760000640000LL, + 1760000700000LL}; + std::size_t rows_seen = 0; + for (std::size_t i = 0; i < c21.points.size() && i < 4; ++i) { + CHECK("c21 row", c21.points[i].event == 21 && c21.points[i].calc == i); + CHECK("c21 row", c21.points[i].bar_key == keys[i]); + CHECK("c21 row", c21.points[i].calc_ordinal == ordinals[i]); + CHECK("c21 row", c21.points[i].at_ms == ends[i]); + CHECK("c21 row", c21.points[i].phase == Phase::Pre); + CHECK("c21 row", c21.points[i].finality == Finality::Confirmed); + ++rows_seen; + } + CHECK("c21 rows seen", rows_seen == 4); + CHECK("c21 commits", same_state(state, make_state(22, true, 1760000700000LL, true, 34, false, + 0, 0, 1760000700000LL))); + + // One trace, two cuts: T5's e20 and e21 as one frame and as two frames. + const State s20 = make_state(20, true, 1760000455000LL, true, 30, true, 1760000460000LL, 4, + 1760000460000LL); + const Event e20{EventKind::Tick, 1760000470000LL}; + const Event e21{EventKind::AdvanceTime, 1760000700000LL}; + const Event both[2] = {e20, e21}; + state = s20; + const Plan frame_b = host.consumer().calc_schedule_apply(kBarCloseAndTick, both, 2); + const State after_b = state; + state = s20; + const Plan frame_a1 = host.consumer().calc_schedule_apply(kBarCloseAndTick, &e20, 1); + const Plan frame_a2 = host.consumer().calc_schedule_apply(kBarCloseAndTick, &e21, 1); + CHECK("cuts", frame_b.status == Status::Ok && frame_a1.status == Status::Ok + && frame_a2.status == Status::Ok); + CHECK("cuts", frame_b.points.size() == 6 && frame_a1.points.size() == 2 + && frame_a2.points.size() == 4); + CHECK("cuts end in one state", same_state(state, after_b)); + CHECK("trace ordinal moved across the frames of one cut", state.trace_first == 22); + std::size_t matched = 0; + if (frame_b.points.size() == 6 && frame_a1.points.size() == 2 + && frame_a2.points.size() == 4) { + for (std::size_t i = 0; i < 6; ++i) { + const native_calc::Point& b = frame_b.points[i]; + const native_calc::Point& a = i < 2 ? frame_a1.points[i] : frame_a2.points[i - 2]; + if (a.event == b.event && a.bar_key == b.bar_key + && a.calc_ordinal == b.calc_ordinal && a.calc == b.calc && a.at_ms == b.at_ms + && a.phase == b.phase && a.finality == b.finality) { + ++matched; + } + } + } + CHECK("cuts plan the same rows", matched == 6); +} + +// 4: a refusal commits nothing. +void test_refusals_commit_nothing() { + Host host; + CHECK("scenario configures", run_golden(host, calc_spec())); + host.consumer().calc_group_enable(); + State& state = NativeExecutionConsumerProbe::state(host.consumer()); + const State seed = make_state(12, true, kT0 + 50000, true, 20, true, kT0 + 60000, 3, + kT0 + 60000); + state = seed; + const Event tick{EventKind::Tick, kT0 + 60500}; + const Plan bad_cadence = host.consumer().calc_schedule_apply(0, &tick, 1); + CHECK("bad cadence", bad_cadence.status == Status::BadCadence && bad_cadence.count == 0); + CHECK("bad cadence commits nothing", same_state(state, seed)); + const Event unknown{static_cast(9), kT0 + 60500}; + const Plan bad_kind = host.consumer().calc_schedule_apply(kBarClose, &unknown, 1); + CHECK("unknown kind", bad_kind.status == Status::UnknownEventKind && bad_kind.points.empty()); + CHECK("unknown kind commits nothing", same_state(state, seed)); + + // The first event of the frame fits; the second needs a key that is gone. + state = make_state(1, false, 0, true, std::numeric_limits::max() - 1, false, 0, + 0, kT0); + const State nearly = state; + const Event two[2] = {{EventKind::Bar, kT0}, {EventKind::Bar, kT0 + 60000}}; + const Plan exhausted = host.consumer().calc_schedule_apply(kBarClose, two, 2); + CHECK("exhausted", exhausted.status == Status::BarKeyExhausted && exhausted.points.empty()); + CHECK("exhaustion commits nothing", same_state(state, nearly)); +} + +// Not engaged: nothing derives and nothing is committed. +void test_not_engaged() { + Host host; + CHECK("scenario configures", run_golden(host, calc_spec())); + const Event tick{EventKind::Tick, kT0}; + CHECK("plan", host.consumer().calc_schedule_plan(kBarClose, &tick, 1, true).status + == Status::NotEngaged); + CHECK("apply", host.consumer().calc_schedule_apply(kBarClose, &tick, 1).status + == Status::NotEngaged); + CHECK("still disengaged", !host.consumer().calc_group_engaged()); +} + +// 5: the group belongs to the run it was enabled in. A host that engaged it in +// its first run and one that never did hold one identity after their second run. +void test_group_belongs_to_its_run() { + const NativeRunSpec first = calc_spec(); + NativeRunSpec second = first; + second.identity.run_number = 2; + + Host engaged_once; + Host never_engaged; + CHECK("first runs", run_golden(engaged_once, first) && run_golden(never_engaged, first)); + engaged_once.consumer().calc_group_enable(); + const Event tick{EventKind::Tick, kT0}; + CHECK("engaged in run 1", engaged_once.consumer().calc_schedule_apply(kBarClose, &tick, 1).status + == Status::Ok); + CHECK("second runs", run_golden(engaged_once, second) && run_golden(never_engaged, second)); + CHECK("disengaged by the next begin", !engaged_once.consumer().calc_group_engaged()); + CHECK("not engaged after the next begin", + engaged_once.consumer().calc_schedule_plan(kBarClose, &tick, 1, true).status + == Status::NotEngaged); + CHECK("one identity again", + engaged_once.native_continuation_hash() == never_engaged.native_continuation_hash()); +} + +// 6: a timebase without time boundaries. Frozen contract: c-boundary.md section +// 12 PXR-A2 ("a timebase without time boundaries has no PRE calculations") and +// proof-matrix.md AV-META case (5) ("no PRE calculation exists"). The packet holds +// no vector for this case, so every expectation below is derived from that rule +// alone: each bar event opens a lifecycle and closes it in the same event, a +// POST row confirmed at the event's own time; bar keys 0, 1, 2 in order; nothing +// planned for the ninety minutes between the first two events; no bar end kept +// and no cursor moved. What a tick, a time advance or a quiet interval does on +// such a timebase is not stated by the rule, and is not tested. Both ways to a +// raw-label partition: an undetected timeframe, and tolerant labels over a +// passthrough pairing (the second keeps a valid script timeframe whose calendar +// intervals must not be used). +void test_raw_label_timebase() { + const Event events[3] = {{EventKind::WarmupBar, kT0}, + {EventKind::Bar, kT0 + 5400000}, + {EventKind::Bar, kT0 + 5400001}}; + const std::uint64_t event_numbers[3] = {1, 2, 3}; + const std::uint64_t keys[3] = {0, 1, 2}; + const std::int64_t times[3] = {kT0, kT0 + 5400000, kT0 + 5400001}; + for (const bool undetected : {true, false}) { + const char* label = undetected ? "undetected timeframe" : "tolerant labels"; + NativeRunSpec spec = calc_spec(); + if (undetected) { + spec.input_tf.clear(); + spec.script_tf.clear(); + spec.timeframe_undetected = true; + } else { + spec.slot_label_policy = NativeSlotLabelPolicy::FeedTolerant; + } + Host host; + CHECK(label, host.configure_native(spec).status == NativeSetupStatus::Applied); + host.consumer().calc_group_enable(); + const Plan applied = host.consumer().calc_schedule_apply(kBarClose, events, 3); + CHECK(label, applied.status == Status::Ok); + CHECK(label, applied.count == 3 && applied.points.size() == 3); + for (std::size_t i = 0; i < applied.points.size() && i < 3; ++i) { + const native_calc::Point& row = applied.points[i]; + CHECK(label, row.event_index == i && row.event == event_numbers[i]); + CHECK(label, row.bar_key == keys[i] && row.calc_ordinal == 0 && row.calc == 0); + CHECK(label, row.at_ms == times[i]); + CHECK(label, row.phase == Phase::Post && row.finality == Finality::Confirmed); + } + CHECK(label, same_state(NativeExecutionConsumerProbe::state(host.consumer()), + make_state(4, true, kT0 + 5400001, true, 2, false, 0, 0, 0))); + } +} + +} // namespace + +int main() { + test_identity(); + test_derivation_over_the_calendar(); + test_refusals_commit_nothing(); + test_not_engaged(); + test_group_belongs_to_its_run(); + test_raw_label_timebase(); + std::printf("test_native_calc_identity: %d failure(s) in %d checks\n", failures, checks); + return failures == 0 ? 0 : 1; +} diff --git a/tests/test_native_calc_plan.cpp b/tests/test_native_calc_plan.cpp new file mode 100644 index 00000000..ac5b8dd5 --- /dev/null +++ b/tests/test_native_calc_plan.cpp @@ -0,0 +1,764 @@ +// The pure schedule derivation (src/native_calc_plan.hpp) against the frozen +// named-stream oracle. Every expected row and every expected state below is a +// literal read off the frozen vectors or worked by hand from the contract; none +// is produced by the derivation it judges. Frame names are the frozen frames: +// * T4 frame c12 (cadence bar close only): one tick closes a bar, exactly one row; +// * T2 frames c20 and c21, and T5's single frame holding both: one tick that +// closes a bar and opens the next, then one time advance that closes four; +// * T3 frames c7, c8, c9: three frames in a row, the trace ordinal moving on; +// * bar replay partitions a and b: bar keys 0..4 however the trace is cut; +// * val-bar-key-u64-max and rf-bar-key-exhausted: the ordinal boundary. +#include "../src/native_calc_plan.hpp" + +#include +#include +#include +#include +#include +#include + +namespace { +using namespace pineforge::native_calc; + +int checks = 0; +int failures = 0; + +#define CHECK(label, expression) \ + do { \ + ++checks; \ + if (!(expression)) { \ + ++failures; \ + std::printf("FAIL [%s] line %d: %s\n", label, __LINE__, #expression); \ + } \ + } while (false) + +constexpr std::int64_t kT0 = 1760000400000LL; +constexpr std::int64_t kMinute = 60000; +constexpr std::uint64_t kMax = std::numeric_limits::max(); +constexpr std::uint32_t kBarCloseOnly = kCadenceBarClose; +constexpr std::uint32_t kBarCloseAndTick = kCadenceBarClose | kCadenceTick; + +// Every interval of `period` milliseconds from the epoch yields a bar. +BarClock grid(std::int64_t period) { + return [period](std::int64_t at, Slot& slot) { + const std::int64_t open = at - ((at % period) + period) % period; + slot.end_ms = open + period; + slot.next_open_ms = open + period; + slot.tradable = true; + return true; + }; +} + +// A minute grid whose closed time [gap_start, gap_end) yields no bar. With +// `jump` the interval before the gap points past it; without, every closed +// interval is visited and is not tradable. +BarClock gap_grid(std::int64_t gap_start, std::int64_t gap_end, bool jump) { + return [=](std::int64_t at, Slot& slot) { + const std::int64_t open = at - ((at % kMinute) + kMinute) % kMinute; + slot.end_ms = open + kMinute; + slot.tradable = !(open >= gap_start && open < gap_end); + slot.next_open_ms = (jump && slot.end_ms == gap_start) ? gap_end : slot.end_ms; + return true; + }; +} + +State make_state(std::uint64_t trace_first, bool has_prev, std::int64_t prev, bool has_key, + std::uint64_t key, bool open, std::int64_t end, std::uint64_t in_bar, + std::int64_t cursor) { + State s; + s.trace_first = trace_first; + s.has_prev_event = has_prev; + s.prev_event_ms = prev; + s.has_last_key = has_key; + s.last_bar_key = key; + s.bar_open = open; + s.open_end_ms = end; + s.calc_in_bar = in_bar; + s.cursor_ms = cursor; + return s; +} + +bool same_state(const State& a, const State& b) { return a.tie() == b.tie(); } + +struct Want { + std::uint64_t event_index; + std::uint64_t event; + std::uint64_t bar_key; + std::uint64_t calc_ordinal; + std::uint64_t calc; + std::int64_t at_ms; + Phase phase; + Finality finality; +}; + +void expect_rows(const char* label, const Plan& plan, const std::vector& want) { + CHECK(label, plan.status == Status::Ok); + CHECK(label, plan.count == want.size()); + CHECK(label, plan.points.size() == want.size()); + if (plan.points.size() != want.size()) return; + for (std::size_t i = 0; i < want.size(); ++i) { + const Point& p = plan.points[i]; + const Want& w = want[i]; + CHECK(label, p.event_index == w.event_index); + CHECK(label, p.event == w.event); + CHECK(label, p.bar_key == w.bar_key); + CHECK(label, p.calc_ordinal == w.calc_ordinal); + CHECK(label, p.calc == w.calc); + CHECK(label, p.at_ms == w.at_ms); + CHECK(label, p.phase == w.phase); + CHECK(label, p.finality == w.finality); + } +} + +bool same_ignoring_index(const Point& a, const Point& b) { + return a.event == b.event && a.bar_key == b.bar_key && a.calc_ordinal == b.calc_ordinal + && a.calc == b.calc && a.at_ms == b.at_ms && a.phase == b.phase + && a.finality == b.finality; +} + +// A refusal plans nothing, counts nothing and hands the state back. +void expect_refusal(const char* label, const Plan& plan, Status why, const State& before) { + CHECK(label, plan.status == why); + CHECK(label, plan.status != Status::Ok); + CHECK(label, plan.count == 0); + CHECK(label, plan.points.empty()); + CHECK(label, same_state(plan.after, before)); +} + +constexpr Phase kPre = Phase::Pre; +constexpr Phase kPost = Phase::Post; +constexpr Finality kConfirmed = Finality::Confirmed; +constexpr Finality kProvisional = Finality::Provisional; + +// T4 frame c12: the consumer's cadence is bar close only, so the tick of e12 +// (T0 + 60500) closes bar 20 at its end, T0 + 60000, and nothing else: its own +// update is not a calculation of this cadence. The tick still opens bar 21. +void test_t4_c12_one_row() { + const State before = make_state(12, true, kT0 + 50000, true, 20, true, kT0 + 60000, 0, + kT0 + 60000); + const Event tick{EventKind::Tick, kT0 + 60500}; + const Plan plan = plan_schedule(before, kBarCloseOnly, &tick, 1, grid(kMinute), true); + expect_rows("t4 c12", plan, {{0, 12, 20, 0, 0, kT0 + 60000, kPre, kConfirmed}}); + CHECK("t4 c12 after", + same_state(plan.after, make_state(13, true, kT0 + 60500, true, 21, true, + kT0 + 120000, 0, kT0 + 120000))); +} + +// T2 frames c20 and c21, T5 partitions a and b. The cadence is bar close and +// tick. Frame c20: the tick of e20 closes bar 30 (its fifth calculation) and its +// own update opens bar 31. Frame c21: the advance to T0 + 300000 closes bar 31 and +// the three carry-forward bars 32, 33 and 34. +void test_t2_t5_partitions() { + const BarClock clock = grid(kMinute); + const State s20 = make_state(20, true, 1760000455000LL, true, 30, true, 1760000460000LL, 4, + 1760000460000LL); + const Event e20{EventKind::Tick, 1760000470000LL}; + const Event e21{EventKind::AdvanceTime, 1760000700000LL}; + const Event both[2] = {e20, e21}; + + const Plan whole = plan_schedule(s20, kBarCloseAndTick, both, 2, clock, true); + expect_rows("t5 whole", whole, + {{0, 20, 30, 4, 0, 1760000460000LL, kPre, kConfirmed}, + {0, 20, 31, 0, 1, 1760000470000LL, kPost, kProvisional}, + {1, 21, 31, 1, 0, 1760000520000LL, kPre, kConfirmed}, + {1, 21, 32, 0, 1, 1760000580000LL, kPre, kConfirmed}, + {1, 21, 33, 0, 2, 1760000640000LL, kPre, kConfirmed}, + {1, 21, 34, 0, 3, 1760000700000LL, kPre, kConfirmed}}); + const State s22 = make_state(22, true, 1760000700000LL, true, 34, false, 0, 0, + 1760000700000LL); + CHECK("t5 whole after", same_state(whole.after, s22)); + + // Partition a: two frames, the second planned from the state the first left. + const Plan first = plan_schedule(s20, kBarCloseAndTick, &e20, 1, clock, true); + expect_rows("t2 c20", first, + {{0, 20, 30, 4, 0, 1760000460000LL, kPre, kConfirmed}, + {0, 20, 31, 0, 1, 1760000470000LL, kPost, kProvisional}}); + CHECK("t2 c20 trace advances", first.after.trace_first == 21); + const Plan second = plan_schedule(first.after, kBarCloseAndTick, &e21, 1, clock, true); + expect_rows("t2 c21", second, + {{0, 21, 31, 1, 0, 1760000520000LL, kPre, kConfirmed}, + {0, 21, 32, 0, 1, 1760000580000LL, kPre, kConfirmed}, + {0, 21, 33, 0, 2, 1760000640000LL, kPre, kConfirmed}, + {0, 21, 34, 0, 3, 1760000700000LL, kPre, kConfirmed}}); + CHECK("t2 c21 trace advances", second.after.trace_first == 22); + CHECK("t5 partitions end in one state", same_state(second.after, whole.after)); + + // The same rows in either partition, except the index into their own frame. + std::size_t matched = 0; + for (std::size_t i = 0; i < first.points.size() && i < whole.points.size(); ++i) { + if (same_ignoring_index(first.points[i], whole.points[i])) ++matched; + } + for (std::size_t i = 0; i < second.points.size() && i + 2 < whole.points.size(); ++i) { + if (same_ignoring_index(second.points[i], whole.points[i + 2])) ++matched; + } + CHECK("t5 partitions plan the same rows", matched == 6); + + // The count query is the same derivation without collecting the rows. + const Plan counted = plan_schedule(s20, kBarCloseAndTick, both, 2, clock, false); + CHECK("count query", counted.status == Status::Ok); + CHECK("count query", counted.count == 6); + CHECK("count query", counted.points.empty()); + CHECK("count query", same_state(counted.after, whole.after)); +} + +// T3 frames c7, c8, c9 in a row: an update, a tick that closes bar 100 and opens +// bar 101, then a time advance that closes bar 101 at exactly the event's time. +// Advance time is not in this cadence, so frame c9 plans only that closure. +void test_t3_three_frames() { + const BarClock clock = grid(kMinute); + const State s7 = make_state(7, true, 1760000680000LL, true, 100, true, 1760000700000LL, 3, + 1760000700000LL); + const Event e7{EventKind::Tick, 1760000690000LL}; + const Event e8{EventKind::Tick, 1760000702000LL}; + const Event e9{EventKind::AdvanceTime, 1760000760000LL}; + const Plan c7 = plan_schedule(s7, kBarCloseAndTick, &e7, 1, clock, true); + expect_rows("t3 c7", c7, {{0, 7, 100, 3, 0, 1760000690000LL, kPost, kProvisional}}); + const Plan c8 = plan_schedule(c7.after, kBarCloseAndTick, &e8, 1, clock, true); + expect_rows("t3 c8", c8, + {{0, 8, 100, 4, 0, 1760000700000LL, kPre, kConfirmed}, + {0, 8, 101, 0, 1, 1760000702000LL, kPost, kProvisional}}); + CHECK("t3 c8 update lands on the next bar", c8.points[1].bar_key == c8.points[0].bar_key + 1); + const Plan c9 = plan_schedule(c8.after, kBarCloseAndTick, &e9, 1, clock, true); + expect_rows("t3 c9", c9, {{0, 9, 101, 1, 0, 1760000760000LL, kPre, kConfirmed}}); + CHECK("t3 trace after c9", c9.after.trace_first == 10); + CHECK("t3 bar 101 is closed", !c9.after.bar_open); +} + +// Bar replay: three warm-up bars and two bars; bar keys 0..4 in the single trace +// and in partitions a (five frames) and b (frames of one to three events). The +// keys of the live bars are the frozen records av-bar-key-3-live and -4-live. +void test_bar_replay_partitions() { + const BarClock clock = grid(kMinute); + const Event trace[5] = {{EventKind::WarmupBar, 1760000220000LL}, + {EventKind::WarmupBar, 1760000280000LL}, + {EventKind::WarmupBar, 1760000340000LL}, + {EventKind::Bar, 1760000400000LL}, + {EventKind::Bar, 1760000460000LL}}; + const Plan whole = plan_schedule(State{}, kBarCloseOnly, trace, 5, clock, true); + expect_rows("bar replay whole", whole, + {{0, 1, 0, 0, 0, 1760000220000LL, kPost, kConfirmed}, + {1, 2, 1, 0, 0, 1760000280000LL, kPost, kConfirmed}, + {2, 3, 2, 0, 0, 1760000340000LL, kPost, kConfirmed}, + {3, 4, 3, 0, 0, 1760000400000LL, kPost, kConfirmed}, + {4, 5, 4, 0, 0, 1760000460000LL, kPost, kConfirmed}}); + + State a; + std::vector a_rows; + for (int i = 0; i < 5; ++i) { + const Plan step = plan_schedule(a, kBarCloseOnly, &trace[i], 1, clock, true); + CHECK("bar replay a", step.status == Status::Ok); + a = step.after; + a_rows.insert(a_rows.end(), step.points.begin(), step.points.end()); + } + State b; + std::vector b_rows; + const std::size_t cuts[4] = {0, 3, 4, 5}; + for (int part = 0; part < 3; ++part) { + const Plan step = plan_schedule(b, kBarCloseOnly, trace + cuts[part], + cuts[part + 1] - cuts[part], clock, true); + CHECK("bar replay b", step.status == Status::Ok); + b = step.after; + b_rows.insert(b_rows.end(), step.points.begin(), step.points.end()); + } + CHECK("bar replay row counts", a_rows.size() == 5 && b_rows.size() == 5); + std::size_t matched = 0; + for (std::size_t i = 0; i < a_rows.size() && i < b_rows.size() && i < whole.points.size(); + ++i) { + if (same_ignoring_index(a_rows[i], whole.points[i]) + && same_ignoring_index(b_rows[i], whole.points[i])) { + ++matched; + } + } + CHECK("bar replay partitions plan the same rows", matched == 5); + CHECK("bar replay a ends where whole ends", same_state(a, whole.after)); + CHECK("bar replay b ends where whole ends", same_state(b, whole.after)); + CHECK("bar replay last key", whole.after.last_bar_key == 4 && whole.after.trace_first == 6); +} + +// val-bar-key-u64-max and rf-bar-key-exhausted: the largest ordinal is a legal +// key; a lifecycle is refused only when it would need the one after it. +void test_bar_key_boundary() { + const BarClock clock = grid(kMinute); + const Event bar{EventKind::Bar, kT0}; + + const State penultimate = make_state(1, false, 0, true, kMax - 1, false, 0, 0, kT0); + const Plan legal = plan_schedule(penultimate, kBarCloseOnly, &bar, 1, clock, true); + expect_rows("u64 max key is legal", legal, {{0, 1, kMax, 0, 0, kT0, kPost, kConfirmed}}); + CHECK("u64 max key is legal", legal.points.size() == 1 && legal.points[0].bar_key == 18446744073709551615ULL); + + const State last = make_state(1, false, 0, true, kMax, false, 0, 0, kT0); + expect_refusal("rf bar key exhausted", plan_schedule(last, kBarCloseOnly, &bar, 1, clock, true), + Status::BarKeyExhausted, last); + + // An open bar that already holds the largest key needs no new ordinal. + const State holding = make_state(1, true, kT0 - 1000, true, kMax, true, kT0 + kMinute, 2, + kT0 + kMinute); + const Event inside{EventKind::Tick, kT0 + 1000}; + expect_rows("tick inside the bar holding the largest key", + plan_schedule(holding, kBarCloseAndTick, &inside, 1, clock, true), + {{0, 1, kMax, 2, 0, kT0 + 1000, kPost, kProvisional}}); + + // The refusal is of the whole schedule: the first bar would have fit. + const Event two[2] = {{EventKind::Bar, kT0}, {EventKind::Bar, kT0 + kMinute}}; + expect_refusal("second lifecycle exhausts the keys", + plan_schedule(penultimate, kBarCloseOnly, two, 2, clock, true), + Status::BarKeyExhausted, penultimate); + + // A carry-forward bar needs an ordinal too. + const State quiet = make_state(5, true, kT0, true, kMax, false, 0, 0, kT0 + kMinute); + const Event later{EventKind::AdvanceTime, kT0 + 5 * kMinute}; + expect_refusal("carry-forward bar exhausts the keys", + plan_schedule(quiet, kBarCloseOnly, &later, 1, clock, true), + Status::BarKeyExhausted, quiet); +} + +// The cadence decides which events calculate; the refusals are not zero rows. +void test_cadence() { + const BarClock clock = grid(kMinute); + const Event tick{EventKind::Tick, kT0 + 1000}; + const Event advance{EventKind::AdvanceTime, kT0 + 30000}; + const State open = make_state(9, true, kT0, true, 3, true, kT0 + kMinute, 2, kT0 + kMinute); + + // A tick under a bar-close-only cadence opens a bar and plans no calculation. + const Plan silent = plan_schedule(State{}, kBarCloseOnly, &tick, 1, clock, true); + CHECK("tick without tick cadence", silent.status == Status::Ok); + CHECK("tick without tick cadence", silent.count == 0); + CHECK("tick without tick cadence", silent.points.empty()); + CHECK("tick without tick cadence", silent.after.has_last_key && silent.after.bar_open); + CHECK("tick without tick cadence", silent.after.last_bar_key == 0 && silent.after.calc_in_bar == 0); + + // A time advance inside the open bar calculates only on an advance-time cadence. + const Plan idle = plan_schedule(open, kBarCloseAndTick, &advance, 1, clock, true); + CHECK("advance without advance cadence", idle.status == Status::Ok); + CHECK("advance without advance cadence", idle.count == 0 && idle.points.empty()); + CHECK("advance without advance cadence", idle.after.trace_first == 10); + const Plan updated = plan_schedule(open, kBarCloseOnly | kCadenceAdvanceTime, &advance, 1, + clock, true); + expect_rows("advance with advance cadence", updated, + {{0, 9, 3, 2, 0, kT0 + 30000, kPost, kProvisional}}); + CHECK("advance with advance cadence", updated.after.calc_in_bar == 3); + + // No open bar, no calculation, and no bar key: nothing to update. + const Plan nothing = plan_schedule(State{}, kBarCloseOnly | kCadenceAdvanceTime, &advance, + 1, clock, true); + CHECK("advance with no bar", nothing.status == Status::Ok); + CHECK("advance with no bar", nothing.count == 0 && nothing.points.empty()); + CHECK("advance with no bar", !nothing.after.has_last_key && !nothing.after.bar_open); + CHECK("advance with no bar", nothing.after.trace_first == 2); + + // A cadence must contain bar close and only the three known bits. + expect_refusal("cadence without bar close", + plan_schedule(State{}, kCadenceTick, &tick, 1, clock, true), + Status::BadCadence, State{}); + expect_refusal("empty cadence", plan_schedule(State{}, 0, &tick, 1, clock, true), + Status::BadCadence, State{}); + expect_refusal("cadence with an unknown bit", + plan_schedule(State{}, kBarCloseOnly | 8u, &tick, 1, clock, true), + Status::BadCadence, State{}); +} + +// No bar key where no bar exists, and none for time alone unless the timebase +// says a quiet interval yields a bar. +void test_no_bar_no_key() { + // A closed bar and a tick still inside its interval: nothing to close. + const State closed = make_state(4, true, kT0 + 60000, true, 6, false, 0, 0, kT0 + 120000); + const Event tick{EventKind::Tick, kT0 + 130000}; + const Plan plan = plan_schedule(closed, kBarCloseAndTick, &tick, 1, grid(kMinute), true); + expect_rows("tick after a closed bar", plan, + {{0, 4, 7, 0, 0, kT0 + 130000, kPost, kProvisional}}); + + // Closed time yields no bar: keys run on across it, with and without a jump. + const std::int64_t gap_start = kT0 + 2 * kMinute; + const std::int64_t gap_end = kT0 + 4 * kMinute; + const State before = make_state(2, true, kT0 + 1000, true, 7, true, kT0 + kMinute, 0, + kT0 + kMinute); + const Event advance{EventKind::AdvanceTime, kT0 + 7 * kMinute}; + const std::vector rows = {{0, 2, 7, 0, 0, kT0 + 1 * kMinute, kPre, kConfirmed}, + {0, 2, 8, 0, 1, kT0 + 2 * kMinute, kPre, kConfirmed}, + {0, 2, 9, 0, 2, kT0 + 5 * kMinute, kPre, kConfirmed}, + {0, 2, 10, 0, 3, kT0 + 6 * kMinute, kPre, kConfirmed}, + {0, 2, 11, 0, 4, kT0 + 7 * kMinute, kPre, kConfirmed}}; + expect_rows("closed time with a jump", + plan_schedule(before, kBarCloseOnly, &advance, 1, + gap_grid(gap_start, gap_end, true), true), rows); + expect_rows("closed time without a jump", + plan_schedule(before, kBarCloseOnly, &advance, 1, + gap_grid(gap_start, gap_end, false), true), rows); +} + +// The end of a bar comes from the timeline and has to come after the event. +void test_derived_end() { + const Event tick{EventKind::Tick, kT0 + 1000}; + const BarClock ends_at_the_event = [](std::int64_t at, Slot& slot) { + slot.end_ms = at; + slot.next_open_ms = at + 1; + slot.tradable = true; + return true; + }; + expect_refusal("end not after the event", + plan_schedule(State{}, kBarCloseAndTick, &tick, 1, ends_at_the_event, true), + Status::BarEndNotAfterEvent, State{}); + const BarClock no_slot = [](std::int64_t, Slot&) { return false; }; + expect_refusal("no slot at the event", + plan_schedule(State{}, kBarCloseAndTick, &tick, 1, no_slot, true), + Status::NoBarSlot, State{}); + + // A bar's retained end is the one its timeline gave, whatever the period. + for (const std::int64_t period : {kMinute, 5 * kMinute, 60 * kMinute}) { + const Plan plan = plan_schedule(State{}, kBarCloseAndTick, &tick, 1, grid(period), true); + CHECK("retained end follows the timeline", plan.status == Status::Ok); + CHECK("retained end follows the timeline", plan.after.open_end_ms == kT0 + period); + CHECK("retained end follows the timeline", plan.after.cursor_ms == kT0 + period); + } +} + +// The window of a Pre position, and the refusal that is not an admission error. +void test_windows() { + const BarClock clock = grid(kMinute); + const Event e{EventKind::Tick, 6000}; + // The retained end lies before the previous event: the kernel's own planned + // position breaks its window. + const State broken = make_state(3, true, 5000, true, 1, true, 1000, 0, 1000); + expect_refusal("pre position below the window", + plan_schedule(broken, kBarCloseAndTick, &e, 1, clock, true), + Status::PositionOutsideWindow, broken); + // Equality at the lower bound is inside the window. + const State at_bound = make_state(3, true, 5000, true, 1, true, 5000, 0, 5000); + const Plan at_the_bound = plan_schedule(at_bound, kBarCloseAndTick, &e, 1, clock, true); + expect_rows("pre position at the lower bound", at_the_bound, + {{0, 3, 1, 0, 0, 5000, kPre, kConfirmed}, + {0, 3, 2, 0, 1, 6000, kPost, kProvisional}}); + // No event before the first: the window is unbounded below, whatever the + // stale previous time says. + const State first = make_state(1, false, 5000, true, 1, true, 1000, 0, 1000); + const Plan unbounded = plan_schedule(first, kBarCloseAndTick, &e, 1, clock, true); + CHECK("first event window", unbounded.status == Status::Ok); + CHECK("first event window", unbounded.points.size() == 2 && unbounded.points[0].at_ms == 1000); +} + +// A kind outside the registry is refused, even behind a valid event. +void test_refusals() { + const BarClock clock = grid(kMinute); + const Event good{EventKind::Tick, kT0}; + for (const std::uint32_t raw : {0u, 5u, 99u}) { + const Event bad{static_cast(raw), kT0}; + const Event pair[2] = {good, bad}; + expect_refusal("unknown kind", plan_schedule(State{}, kBarCloseAndTick, &bad, 1, clock, true), + Status::UnknownEventKind, State{}); + expect_refusal("unknown kind behind a valid event", + plan_schedule(State{}, kBarCloseAndTick, pair, 2, clock, true), + Status::UnknownEventKind, State{}); + } + CHECK("known kinds", is_known(EventKind::WarmupBar) && is_known(EventKind::AdvanceTime)); + expect_refusal("null events", plan_schedule(State{}, kBarCloseOnly, nullptr, 1, clock, true), + Status::BadArguments, State{}); + const Plan empty = plan_schedule(State{}, kBarCloseOnly, nullptr, 0, clock, true); + CHECK("empty schedule", empty.status == Status::Ok && empty.count == 0); + CHECK("empty schedule", same_state(empty.after, State{})); + const State spent = make_state(kMax, false, 0, false, 0, false, 0, 0, 0); + expect_refusal("trace ordinal exhausted", plan_schedule(spent, kBarCloseOnly, &good, 1, clock, true), + Status::TraceExhausted, spent); +} + +// The digest and the field list cover every field of the State. +void test_every_field_counts() { + static_assert(std::tuple_size::value == 9, + "a State field was added or dropped: extend the perturbations below"); + const State base = make_state(5, true, 100, true, 7, true, 200, 3, 300); + State changed[9] = {base, base, base, base, base, base, base, base, base}; + const char* names[9] = {"trace_first", "has_prev_event", "prev_event_ms", "has_last_key", + "last_bar_key", "bar_open", "open_end_ms", "calc_in_bar", "cursor_ms"}; + changed[0].trace_first += 1; + changed[1].has_prev_event = false; + changed[2].prev_event_ms += 1; + changed[3].has_last_key = false; + changed[4].last_bar_key += 1; + changed[5].bar_open = false; + changed[6].open_end_ms += 1; + changed[7].calc_in_bar += 1; + changed[8].cursor_ms += 1; + for (int i = 0; i < 9; ++i) { + CHECK(names[i], digest(changed[i]) != digest(base)); + CHECK(names[i], !same_state(changed[i], base)); + } + CHECK("digest is a function of the state", digest(base) == digest(make_state(5, true, 100, true, 7, true, 200, 3, 300))); + + // The next quiet interval is looked up at the cursor: moving it past bar 32's + // interval leaves two carry-forward bars where it left three. + const BarClock clock = grid(kMinute); + const Event advance{EventKind::AdvanceTime, 1760000700000LL}; + const State at_520 = make_state(21, true, 1760000470000LL, true, 31, true, 1760000520000LL, 1, + 1760000520000LL); + State at_580 = at_520; + at_580.cursor_ms = 1760000580000LL; + CHECK("cursor decides the carry-forward bars", + plan_schedule(at_520, kBarCloseOnly, &advance, 1, clock, false).count == 4); + CHECK("cursor decides the carry-forward bars", + plan_schedule(at_580, kBarCloseOnly, &advance, 1, clock, false).count == 3); +} + +// The quiet-interval walk ends on its own two guards, whatever the clock says. +// A clock that answers false holds no interval, even when it left a good-looking +// one in the slot it was given; and a slot whose next lookup does not lie after +// the cursor cannot move the walk, which would otherwise never end. Both start +// from bar 7 closed with the cursor at kT0 + 1 minute (kT0 is a whole minute), +// and one time advance to kT0 + 6 minutes that passes over the quiet minutes. +void test_walk_terminations() { + const State before = make_state(5, true, kT0, true, 7, false, 0, 0, kT0 + kMinute); + const Event advance{EventKind::AdvanceTime, kT0 + 6 * kMinute}; + + // (a) The timebase holds minute intervals up to the one opening at kT0 + 2 + // minutes, then none; either way the slot it fills in is that of the minute it + // was asked about, so only the return value says the interval is missing. By + // hand: the lookups at kT0 + 1 and kT0 + 2 minutes plan bars 8 and 9, ending + // at kT0 + 2 and kT0 + 3 minutes; the lookup at kT0 + 3 minutes is answered + // false, and the walk stops with the cursor on it. + const BarClock ends_after_two = [](std::int64_t at, Slot& slot) { + const std::int64_t open = at - ((at % kMinute) + kMinute) % kMinute; + slot.end_ms = open + kMinute; + slot.next_open_ms = open + kMinute; + slot.tradable = true; + return open <= kT0 + 2 * kMinute; + }; + const Plan stopped = plan_schedule(before, kBarCloseOnly, &advance, 1, ends_after_two, true); + expect_rows("walk stops when the clock has no slot", stopped, + {{0, 5, 8, 0, 0, kT0 + 2 * kMinute, kPre, kConfirmed}, + {0, 5, 9, 0, 1, kT0 + 3 * kMinute, kPre, kConfirmed}}); + CHECK("walk stops when the clock has no slot", + same_state(stopped.after, make_state(6, true, kT0 + 6 * kMinute, true, 9, false, 0, 0, + kT0 + 3 * kMinute))); + + // (b) A slot that ends by the event but whose next lookup is at the cursor + // (back 0) or before it (back 1). By hand: the first answer ends the walk, so + // the schedule plans nothing, leaves the key and the cursor where they were + // (bar 7, kT0 + 1 minute), and the clock was asked once. The clock gives up + // after a hundred answers, so a walk without this guard ends in a wrong count + // and a wrong number of questions, not in a hang. + for (const std::int64_t back : {0, 1}) { + int asked = 0; + const BarClock stuck = [&asked, back](std::int64_t at, Slot& slot) { + if (++asked > 100) return false; + slot.end_ms = at + 1; + slot.next_open_ms = at - back; + slot.tradable = true; + return true; + }; + const Plan stalled = plan_schedule(before, kBarCloseOnly, &advance, 1, stuck, true); + expect_rows("walk stops on a slot that does not move the cursor", stalled, {}); + CHECK("walk stops on a slot that does not move the cursor", + same_state(stalled.after, make_state(6, true, kT0 + 6 * kMinute, true, 7, false, 0, 0, + kT0 + kMinute))); + CHECK("walk stops on a slot that does not move the cursor", asked == 1); + } +} + +// The trace a timebase without time boundaries plans, whatever its clock offers +// besides saying it is unbounded. By hand from the rule (see below): a warm-up +// bar and two bars, the second ninety minutes after the first, each opens its +// lifecycle and closes it in the same event, a POST confirmed row at its own +// time; keys 0, 1, 2; nothing for the ninety minutes; no end and no cursor kept. +// Then a bar already open with no end (calculation 2 of bar 4) and a bar event +// three days later: the bar closes on data alone, in its own lifecycle, with its +// next calculation ordinal and no PRE row. +void expect_without_boundaries(const char* label, const BarClock& clock) { + const std::int64_t late = kT0 + 90 * kMinute; + const Event trace[3] = {{EventKind::WarmupBar, kT0}, + {EventKind::Bar, late}, + {EventKind::Bar, late + 1}}; + const Plan whole = plan_schedule(State{}, kBarCloseOnly, trace, 3, clock, true); + expect_rows(label, whole, + {{0, 1, 0, 0, 0, kT0, kPost, kConfirmed}, + {1, 2, 1, 0, 0, late, kPost, kConfirmed}, + {2, 3, 2, 0, 0, late + 1, kPost, kConfirmed}}); + CHECK(label, same_state(whole.after, make_state(4, true, late + 1, true, 2, false, 0, 0, 0))); + + const std::int64_t later = kT0 + 3 * 1440 * kMinute; + const State open = make_state(10, true, kT0, true, 4, true, 0, 2, 0); + const Event bar{EventKind::Bar, later}; + const Plan closed = plan_schedule(open, kBarCloseAndTick, &bar, 1, clock, true); + expect_rows(label, closed, {{0, 10, 4, 2, 0, later, kPost, kConfirmed}}); + CHECK(label, same_state(closed.after, make_state(11, true, later, true, 4, false, 0, 0, 0))); +} + +// Frozen contract: c-boundary.md section 12 PXR-A2 ("a timebase without time +// boundaries has no PRE calculations") and proof-matrix.md AV-META case (5) ("no +// PRE calculation exists"). The packet holds no vector for this case, so the +// expectations in expect_without_boundaries are derived from that rule alone +// (A1: a bar event's own close and a data-driven close are POST; A2: POST is +// t(e)). What a tick, a time advance or a quiet interval does on such a timebase +// is not stated by the rule, and is not tested. The clocks: one that only says +// the timebase is unbounded, as the consumer's does, and one that offers an +// interval besides, which the derivation must not use. +void test_timebase_without_time_boundaries() { + const BarClock plain = [](std::int64_t, Slot& slot) { + slot.bounded = false; + return true; + }; + const BarClock offering = [](std::int64_t at, Slot& slot) { + slot.end_ms = at + kMinute; + slot.next_open_ms = at + kMinute; + slot.tradable = true; + slot.bounded = false; + return true; + }; + expect_without_boundaries("no boundaries, no interval offered", plain); + expect_without_boundaries("no boundaries, an interval offered", offering); +} + +// The two clocks of the frozen no-boundaries case, by name, for the raw-label +// rulings below: one that only says the timebase is unbounded, as the consumer's +// own does, and one that offers a minute interval besides, which the derivation +// must not use. +BarClock unbounded_clock() { + return [](std::int64_t, Slot& slot) { + slot.bounded = false; + return true; + }; +} + +BarClock unbounded_clock_offering() { + return [](std::int64_t at, Slot& slot) { + slot.end_ms = at + kMinute; + slot.next_open_ms = at + kMinute; + slot.tradable = true; + slot.bounded = false; + return true; + }; +} + +// Ruling 1 of the raw-label semantics (P-E, ENGINE-TOP 10-11 13:51): a Tick on a +// timebase without time boundaries opens a lifecycle when none is open and plans +// one POST provisional row at the event's own time when the cadence carries the +// tick bit; the bar it leaves open takes the next calculation of its lifecycle. +// The row follows A1 (a tick update ran after the payload: POST) and A2 (POST is +// t(e)); no PRE row can exist on this timebase (c-boundary.md section 12 PXR-A2). +void expect_raw_label_tick(const char* label, const BarClock& clock) { + // (a) Nothing open. By hand: the tick opens key 0 with no end (an unbounded + // lifecycle keeps none) and plans calculation 0 of it at t(e). The state the + // call produced has event 1 consumed (trace 2, previous event kT0), leaves + // the bar open under key 0 with no end and one calculation, cursor 0. + const Event tick{EventKind::Tick, kT0}; + const Plan opened = plan_schedule(State{}, kBarCloseAndTick, &tick, 1, clock, true); + expect_rows(label, opened, {{0, 1, 0, 0, 0, kT0, kPost, kProvisional}}); + CHECK(label, same_state(opened.after, make_state(2, true, kT0, true, 0, true, 0, 1, 0))); + + // (b) A lifecycle already open under key 4 with two calculations. By hand: + // the tick plans the bar's third calculation (in_bar 2 before, 3 after) at + // t(e), keeps the key, and leaves the bar open with no end although the + // event's time lies two minutes past the opening: no boundary exists for + // time to close it at. + const State open = make_state(6, true, kT0, true, 4, true, 0, 2, 0); + const Event later{EventKind::Tick, kT0 + 2 * kMinute}; + const Plan updated = plan_schedule(open, kBarCloseAndTick, &later, 1, clock, true); + expect_rows(label, updated, {{0, 6, 4, 2, 0, kT0 + 2 * kMinute, kPost, kProvisional}}); + CHECK(label, same_state(updated.after, + make_state(7, true, kT0 + 2 * kMinute, true, 4, true, 0, 3, 0))); +} + +// Ruling 2 of the raw-label semantics (P-E, ENGINE-TOP 10-11 13:51): an +// AdvanceTime on a timebase without time boundaries plans one POST provisional +// row at the event's own time when a bar is open and the cadence carries the +// advance-time bit, and closes NOTHING by time; with no bar open it plans +// nothing. +void expect_raw_label_advance(const char* label, const BarClock& clock) { + // (a) An open raw-label bar (key 3, no end, cursor 0 as ruling 5 keeps it) + // two minutes on, its third calculation due. By hand: one POST provisional + // point at t(e) with in_bar 2. The state the call produced has event 9 + // consumed (trace 10, previous event t(e)), and the bar stays open under the + // same key with the same end, the same cursor and one more calculation. + const State open = make_state(9, true, kT0, true, 3, true, 0, 2, 0); + const Event advance{EventKind::AdvanceTime, kT0 + 2 * kMinute}; + const Plan planned = + plan_schedule(open, kBarCloseOnly | kCadenceAdvanceTime, &advance, 1, clock, true); + expect_rows(label, planned, {{0, 9, 3, 2, 0, kT0 + 2 * kMinute, kPost, kProvisional}}); + CHECK(label, same_state(planned.after, + make_state(10, true, kT0 + 2 * kMinute, true, 3, true, 0, 3, 0))); + + // (b) Closed at the advance (bar 5 closed, cursor 0). By hand: no bar to + // update, so no row at all; the event still takes its trace ordinal and + // records its time, and nothing else moves. + const State closed = make_state(7, true, kT0, true, 5, false, 0, 0, 0); + const Plan idle = + plan_schedule(closed, kBarCloseOnly | kCadenceAdvanceTime, &advance, 1, clock, true); + expect_rows(label, idle, {}); + CHECK(label, same_state(idle.after, + make_state(8, true, kT0 + 2 * kMinute, true, 5, false, 0, 0, 0))); +} + +// Ruling 3 of the raw-label semantics (P-E, ENGINE-TOP 10-11 13:51): a quiet +// interval on a timebase without time boundaries plans NOTHING. The walk over +// the quiet intervals is asked once at the cursor, is told the timebase is +// unbounded, and stops: no carry-forward row exists and the cursor does not +// move. By hand: the state stands after a bar event at the epoch (key 2 closed, +// no end, the cursor ruling 5 keeps at 0) and the advance comes four minutes +// later, which a bounded clock asked from 0 turns into the carry-forward PRE +// bars at +1, +2, +3 and +4 minutes. +void expect_raw_label_quiet(const char* label, const BarClock& clock) { + int asked = 0; + const BarClock counted = [&asked, &clock](std::int64_t at, Slot& slot) { + ++asked; + return clock(at, slot); + }; + const State closed = make_state(4, true, 0, true, 2, false, 0, 0, 0); + const Event advance{EventKind::AdvanceTime, 4 * kMinute}; + const Plan quiet = plan_schedule(closed, kBarCloseOnly, &advance, 1, counted, true); + expect_rows(label, quiet, {}); + CHECK(label, same_state(quiet.after, + make_state(5, true, 4 * kMinute, true, 2, false, 0, 0, 0))); + CHECK(label, asked == 1); +} + +// The three rulings as tests, each on both clocks of the frozen no-boundaries +// case: the planned rows and the state must not depend on what the unbounded +// clock offers besides saying it is unbounded. The bounded control inside the +// quiet test is the same state and event on the minute grid, so the silence the +// ruling pins is the timebase's, not the intervals'. +void test_raw_label_tick() { + expect_raw_label_tick("raw-label tick, no interval offered", unbounded_clock()); + expect_raw_label_tick("raw-label tick, an interval offered", unbounded_clock_offering()); +} + +void test_raw_label_advance() { + expect_raw_label_advance("raw-label advance, no interval offered", unbounded_clock()); + expect_raw_label_advance("raw-label advance, an interval offered", unbounded_clock_offering()); +} + +void test_raw_label_quiet() { + const State closed = make_state(4, true, 0, true, 2, false, 0, 0, 0); + const Event advance{EventKind::AdvanceTime, 4 * kMinute}; + const Plan bounded = plan_schedule(closed, kBarCloseOnly, &advance, 1, grid(kMinute), true); + expect_rows("quiet interval on the bounded grid", bounded, + {{0, 4, 3, 0, 0, kMinute, kPre, kConfirmed}, + {0, 4, 4, 0, 1, 2 * kMinute, kPre, kConfirmed}, + {0, 4, 5, 0, 2, 3 * kMinute, kPre, kConfirmed}, + {0, 4, 6, 0, 3, 4 * kMinute, kPre, kConfirmed}}); + CHECK("quiet interval on the bounded grid", + same_state(bounded.after, + make_state(5, true, 4 * kMinute, true, 6, false, 0, 0, 4 * kMinute))); + expect_raw_label_quiet("raw-label quiet interval, no interval offered", unbounded_clock()); + expect_raw_label_quiet("raw-label quiet interval, an interval offered", + unbounded_clock_offering()); +} + +} // namespace + +int main() { + test_t4_c12_one_row(); + test_t2_t5_partitions(); + test_t3_three_frames(); + test_bar_replay_partitions(); + test_bar_key_boundary(); + test_cadence(); + test_no_bar_no_key(); + test_derived_end(); + test_windows(); + test_refusals(); + test_every_field_counts(); + test_walk_terminations(); + test_timebase_without_time_boundaries(); + test_raw_label_tick(); + test_raw_label_advance(); + test_raw_label_quiet(); + std::printf("test_native_calc_plan: %d failure(s) in %d checks\n", failures, checks); + return failures == 0 ? 0 : 1; +}