d858caf67b
docs/specs and docs/plans were retired (prior commit); the source/test
comments that cited them ("spec 0050 §4.1", "spec §Testing N", "per spec",
"the spec's ...") now point at nothing. Strip every such pointer while
preserving the technical substance, the design-ledger contract refs
(C1/C11/C20/C34/C12.1/...), and the Gitea issue refs (#41).
Comment/doc edits only across 20 files — no logic change; full workspace
suite green, clippy clean.
187 lines
9.2 KiB
Rust
187 lines
9.2 KiB
Rust
//! `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.** Let `local = <ctx.now() epoch-ns UTC> 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).** `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<usize> {
|
||
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) 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<AnyColumn> {
|
||
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<i64> {
|
||
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),
|
||
// 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);
|
||
}
|
||
}
|