diff --git a/Cargo.lock b/Cargo.lock index ba74e9f..8194135 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -102,6 +102,8 @@ name = "aura-std" version = "0.1.0" dependencies = [ "aura-core", + "chrono", + "chrono-tz", ] [[package]] @@ -181,6 +183,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf", +] + [[package]] name = "cipher" version = "0.4.4" @@ -546,6 +558,24 @@ dependencies = [ "hmac", ] +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -689,6 +719,12 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" diff --git a/crates/aura-std/Cargo.toml b/crates/aura-std/Cargo.toml index db7090f..cfdaa1e 100644 --- a/crates/aura-std/Cargo.toml +++ b/crates/aura-std/Cargo.toml @@ -7,3 +7,10 @@ publish.workspace = true [dependencies] aura-core = { path = "../aura-core" } +# DST-correct wall-clock math for the `Session` node (spec 0050 §4.5). A vetted +# standard crate for timezone/DST — never hand-rolled (C16 per-case policy). +# `chrono` is already a transitive workspace dep (0.4 via aura-ingest's +# data-server); aligned to that major. `chrono-tz` brings the IANA `Europe/Berlin` +# zone + DST transitions. +chrono = { version = "0.4", default-features = false, features = ["clock"] } +chrono-tz = { version = "0.10", default-features = false } diff --git a/crates/aura-std/src/lib.rs b/crates/aura-std/src/lib.rs index 1ab03b7..f1aaf0d 100644 --- a/crates/aura-std/src/lib.rs +++ b/crates/aura-std/src/lib.rs @@ -26,6 +26,7 @@ mod latch; mod lincomb; mod recorder; mod resample; +mod session; mod sim_broker; mod sma; mod sub; @@ -40,6 +41,7 @@ pub use latch::Latch; pub use lincomb::LinComb; pub use recorder::Recorder; pub use resample::Resample; +pub use session::Session; pub use sim_broker::SimBroker; pub use sma::Sma; pub use sub::Sub; diff --git a/crates/aura-std/src/session.rs b/crates/aura-std/src/session.rs new file mode 100644 index 0000000..1f6fce9 --- /dev/null +++ b/crates/aura-std/src/session.rs @@ -0,0 +1,186 @@ +//! `Session` — maps the current cycle's UTC instant to a **Frankfurt session bar +//! index**, emitting `bars_since_open: i64` = the count of completed bar-periods +//! since the session open, in **LOCAL (tz-aware, DST-correct) time**. +//! +//! This is the one node in the session-breakout vocabulary that carries domain +//! knowledge (the Frankfurt open + timezone + DST). It admits `chrono` / +//! `chrono-tz` to `aura-std` — a vetted standard crate for wall-clock/DST math, +//! never hand-rolled (C16 per-case policy). +//! +//! **Schema.** ONE input `trigger: f64, Firing::Any` whose **value is ignored**: +//! it is wired from the resampler's `close15` only so the node fires exactly once +//! per completed 15m bar. `Session` reads only `ctx.now()` + its baked config. +//! Output ONE `i64` field `bars_since_open`. **No scalar params** — the open +//! time, timezone, and period are *structural* construction config, baked at +//! build (like `SimBroker::builder(fee)` bakes its fee); a timezone is not a +//! scalar, so it is captured by the closure, not declared as a param. +//! +//! **Semantics (spec 0050 §4).** Let `local = converted +//! to tz` (chrono-tz, DST-correct). Let +//! `mins = (local.hour()*60 + local.minute()) - (open_hour*60 + open_minute)`. +//! Emit `bars_since_open = mins / period_minutes` (i64 division). The resampler +//! buckets land on exact :00/:15/:30/:45 boundaries, so in-session bar closes are +//! exact positive multiples: the 09:00–09:15 bar closes at **09:15 → 1**, +//! 09:30–09:45 closes at **09:45 → 3**, 10:00–10:15 closes at **10:15 → 5**. +//! Pre-open instants give `<= 0` (non-actionable — the downstream `EqConst(==3)` +//! / `EqConst(==5)` gates simply never match; there is no separate in-session +//! bool gate, `bars_since_open` alone is the contract). +//! +//! **Off-by-one (close-instant indexing, spec 0050 §4.1).** `ctx.now()` at a +//! resampler emission is the **just-closed** bar's *close instant*, so "the bar +//! that closed at 09:45" reads `bars_since_open == 3`. +//! +//! **Cold guard.** If the trigger column is empty (not yet fired) → `None`. + +use aura_core::{Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, ScalarKind}; +use chrono::{TimeZone, Timelike}; + +/// Frankfurt-session bar indexer. Holds the open offset (minutes past local +/// midnight), the IANA timezone, and the bar period — baked at construction, +/// never streamed and never a swept param (a timezone is not a scalar). +pub struct Session { + open_minutes: i64, // open_hour*60 + open_minute, minutes past local midnight + tz: chrono_tz::Tz, + period_minutes: i64, + out: [Cell; 1], +} + +impl Session { + /// Build a `Session` for an open at `open_hour:open_minute` local `tz` time, + /// indexing bars of `period_minutes` width (must be >= 1). + pub fn new(open_hour: u32, open_minute: u32, tz: chrono_tz::Tz, period_minutes: i64) -> Self { + assert!(period_minutes >= 1, "Session period_minutes must be >= 1"); + Self { + open_minutes: open_hour as i64 * 60 + open_minute as i64, + tz, + period_minutes, + out: [Cell::from_i64(0)], + } + } + + /// The param-generic recipe for a blueprint primitive. The open time, + /// timezone, and period are **structural** config baked into the closure + /// (like `SimBroker::builder` bakes `pip_size`), NOT scalar params — the + /// declared param list is empty and the `build_fn` ignores its slice (like + /// `Gt` / `Latch`). + pub fn builder(open_hour: u32, open_minute: u32, tz: chrono_tz::Tz, period_minutes: i64) -> PrimitiveBuilder { + PrimitiveBuilder::new( + "Session", + NodeSchema { + inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "trigger".into() }], + output: vec![FieldSpec { name: "bars_since_open".into(), kind: ScalarKind::I64 }], + params: vec![], + }, + move |_| Box::new(Session::new(open_hour, open_minute, tz, period_minutes)), + ) + } +} + +impl Node for Session { + fn lookbacks(&self) -> Vec { + vec![1] + } + + fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> { + if ctx.f64_in(0).is_empty() { + return None; // cold: trigger has not fired yet + } + // Convert the engine's epoch-ns UTC instant (close-instant, spec 0050 + // §4.1) into tz-aware LOCAL wall-clock — chrono-tz applies the correct + // DST offset for this date, so the same local minute reads the same bar + // index in summer (CEST) and winter (CET). + let local = self.tz.timestamp_nanos(ctx.now().0); + let mins = local.hour() as i64 * 60 + local.minute() as i64 - self.open_minutes; + // i64 division: in-session closes land on exact period boundaries, so the + // value is the bar index; pre-open is <= 0 (non-actionable). + self.out[0] = Cell::from_i64(mins / self.period_minutes); + Some(&self.out) + } + + fn label(&self) -> String { + format!("Session({}m@{})", self.period_minutes, self.tz) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use aura_core::{AnyColumn, Scalar, Timestamp}; + use chrono::TimeZone; + use chrono_tz::Europe::Berlin; + + /// One depth-1 f64 trigger column, as bootstrap sizes it from `lookbacks`. + fn trigger_input() -> Vec { + vec![AnyColumn::with_capacity(ScalarKind::F64, 1)] + } + + /// Drive one fired eval: push an (ignored-value) trigger into slot 0 and step + /// the node at the cycle instant built from Berlin **local wall-clock** + /// `(year, month, day, hour, minute)` — + /// `Berlin.with_ymd_and_hms(...).timestamp_nanos_opt()` converts the local + /// instant to the epoch-ns UTC the engine streams. Returns `bars_since_open`. + fn step_local( + node: &mut Session, + inputs: &mut [AnyColumn], + trigger: f64, + local: (i32, u32, u32, u32, u32), + ) -> Option { + let (y, mo, d, h, mi) = local; + let ns = Berlin.with_ymd_and_hms(y, mo, d, h, mi, 0).unwrap().timestamp_nanos_opt().unwrap(); + inputs[0].push(Scalar::f64(trigger)).unwrap(); + node.eval(Ctx::new(inputs, Timestamp(ns))).map(|r| r[0].i64()) + } + + #[test] + fn session_indexes_bars_since_frankfurt_open_in_local_dst_aware_time() { + // THE HEADLINE PROPERTY: bars_since_open = (local wall-clock minutes past + // the 09:00 Frankfurt open) / period, in tz-aware LOCAL time. Because the + // close instant is the bucket boundary (:00/:15/:30/:45, spec 0050 §4.1), + // in-session bar closes are exact positive multiples of the bar index. + // + // Built from Berlin local wall-clock via chrono-tz so the assertion is + // self-checking across DST — the SAME local 09:45 must read 3 in both the + // CEST summer (UTC+2) and the CET winter (UTC+1), even though the raw + // epoch-ns differs by an hour. That equality is what proves the node uses + // tz-aware local time, not a fixed UTC offset. + let mut node = Session::new(9, 0, Berlin, 15); + let mut inputs = trigger_input(); + + // --- (1) Summer (CEST, UTC+2), 2024-07-01 — the in-session sequence. + // 09:15→1, 09:30→2, 09:45→3, 10:00→4, 10:15→5. The trigger VALUE is + // arbitrary (0.0) — proving it is ignored. --- + assert_eq!(step_local(&mut node, &mut inputs, 0.0, (2024, 7, 1, 9, 15)), Some(1)); + assert_eq!(step_local(&mut node, &mut inputs, 0.0, (2024, 7, 1, 9, 30)), Some(2)); + assert_eq!(step_local(&mut node, &mut inputs, 0.0, (2024, 7, 1, 9, 45)), Some(3)); + assert_eq!(step_local(&mut node, &mut inputs, 0.0, (2024, 7, 1, 10, 0)), Some(4)); + assert_eq!(step_local(&mut node, &mut inputs, 0.0, (2024, 7, 1, 10, 15)), Some(5)); + + // --- (2) DST-CORRECTNESS (load-bearing): Winter (CET, UTC+1), + // 2024-01-02, local 09:45 → 3. The UTC offset is one hour LESS than + // summer, so the raw epoch-ns for the same wall-clock differs — yet + // local 09:45 still yields 3. A fixed-offset implementation would be + // off by one bar here. --- + assert_eq!(step_local(&mut node, &mut inputs, 0.0, (2024, 1, 2, 9, 45)), Some(3)); + + // --- (3) Trigger value is ignored: a WILD trigger (999.0) at summer + // local 09:45 still reads 3 — the output depends only on ctx.now(). --- + assert_eq!(step_local(&mut node, &mut inputs, 999.0, (2024, 7, 1, 9, 45)), Some(3)); + + // --- (4) Pre-open: summer local 08:45 is BEFORE the 09:00 open, so + // bars_since_open <= 0 — non-actionable; it must be neither 3 nor 5, + // so the downstream EqConst gates never match out of session. --- + let pre = step_local(&mut node, &mut inputs, 0.0, (2024, 7, 1, 8, 45)).unwrap(); + assert!(pre <= 0, "pre-open bars_since_open must be <= 0, got {pre}"); + assert_ne!(pre, 3); + assert_ne!(pre, 5); + } + + #[test] + fn session_is_none_until_trigger_fires() { + // Cold guard (C8): an empty trigger column means the resampler has not yet + // emitted a completed bar — the node has nothing to index, so it filters. + let mut node = Session::new(9, 0, Berlin, 15); + let inputs = trigger_input(); + assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(0))), None); + } +}