Skip to main content

axon/
temporal_context.rs

1//! v2.46.0 — declared cognitive time: the runtime half of `now:`.
2//!
3//! The cognitive completion of `axon://logic/time_is_an_explicit_input`
4//! (v2.27.0): a step (or the program's `context` frame) DECLARES the IANA zone
5//! its cognition runs in (`now: "America/Bogota"`, v2.46.0); this module
6//! SUPPLIES the instant and RENDERS the deterministic system-prompt line;
7//! the envelope RECORDS `(captured_utc, tz, tzdb_version, zones)` so the
8//! exact prompt the model saw is reconstructible byte-for-byte.
9//!
10//! Three laws, mirrored from v2.27.0:
11//! - **One instant per run.** The capture happens once (lazily, at the
12//!   first `now:`-bearing step) and every subsequent step renders THAT
13//!   instant in its declared zone — two steps in one run can never
14//! disagree about "now" (plan vivo section 5, the per-run fork).
15//! - **The frontend format-checked; the runtime is the authority.** A zone
16//!   that passes `axon-T892`'s shape law but is not in the tz database
17//!   fails CLOSED here (a loud dispatch error, never a silent omission).
18//! - **Replayable.** The rendered line is a pure function of
19//!   `(capture, zone, tzdb version)` — [`render_line`] has no clock read.
20
21use chrono::{DateTime, SecondsFormat, Utc};
22use serde::{Deserialize, Serialize};
23
24/// The run's single captured instant. `Copy` — cheap to lift out of the
25/// shared state without borrowing across a render.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub struct TemporalCapture {
28    pub utc: DateTime<Utc>,
29}
30
31impl TemporalCapture {
32    /// Capture from the wall clock (production).
33    pub fn system() -> Self {
34        Self { utc: Utc::now() }
35    }
36
37    /// Capture from an explicit instant (tests / replay).
38    pub fn at(utc: DateTime<Utc>) -> Self {
39        Self { utc }
40    }
41}
42
43/// Shared per-run temporal state: the lazily-set capture + the zones
44/// actually rendered (first-use order, deduplicated). Lives behind
45/// `Arc<Mutex<…>>` on the dispatch context so `par` branches share ONE
46/// capture and the collector reads the final state after the walk (the
47/// v2.21.0 `store_row_counts` discipline — the lock is never held across
48/// an `.await`).
49#[derive(Debug, Default)]
50pub struct TemporalState {
51    pub capture: Option<TemporalCapture>,
52    pub zones: Vec<String>,
53}
54
55/// The envelope/audit record: what instant the run saw, under which tz
56/// database, rendered in which declared zones. Elided from the wire when
57/// absent (`skip_serializing_if` at the envelope field) — every pre-v2.46.0
58/// flow's wire stays byte-identical.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct TemporalRecord {
61    /// RFC 3339 UTC instant of the run's single capture.
62    pub captured_utc: String,
63    /// IANA tz-database release the render resolved against (v2.27.0).
64    pub tzdb_version: String,
65    /// Declared zones actually rendered this run, first-use order.
66    pub zones: Vec<String>,
67}
68
69/// A declared zone that passed the compile-time format law but is not in
70/// this build's tz database. Fail-closed: the step errors, loudly.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct UnknownZone {
73    pub zone: String,
74}
75
76impl std::fmt::Display for UnknownZone {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        write!(
79            f,
80            "declared `now:` zone '{}' is not a known IANA timezone (tzdb {})",
81            self.zone,
82            crate::window::tz_db_version()
83        )
84    }
85}
86
87/// Render the deterministic system-prompt line for `capture` in `zone`.
88/// Pure — no clock read; DST-correct via chrono-tz (the v2.27.0 machinery).
89/// The line shape is versioned by convention: changing it is a wire-visible
90/// prompt change and must be release-noted.
91pub fn render_line(capture: &TemporalCapture, zone: &str) -> Result<String, UnknownZone> {
92    let tz = crate::window::parse_tz(zone).ok_or_else(|| UnknownZone {
93        zone: zone.to_string(),
94    })?;
95    let local = capture.utc.with_timezone(&tz);
96    Ok(format!(
97        "Current datetime: {} ({}; tzdb {}; captured at run start).",
98        local.to_rfc3339_opts(SecondsFormat::Secs, false),
99        zone,
100        crate::window::tz_db_version()
101    ))
102}
103
104/// The effective zone for a step: its own `now:` overrides the frame's
105/// (`context` declaration) — absent both, no temporal injection.
106pub fn effective_zone<'a>(
107    step_zone: Option<&'a str>,
108    frame_zone: Option<&'a str>,
109) -> Option<&'a str> {
110    step_zone.or(frame_zone)
111}
112
113/// Compose the step's effective system prompt: the base prompt plus — when
114/// a zone is declared — the rendered temporal line. Captures lazily into
115/// `state` (once per run) and records the zone (first-use order). This is
116/// the single seam both engines call, so the injected text is identical by
117/// construction on the streaming and non-streaming paths.
118pub fn compose_effective_system(
119    base: &str,
120    step_zone: Option<&str>,
121    frame_zone: Option<&str>,
122    state: &mut TemporalState,
123) -> Result<String, UnknownZone> {
124    let Some(zone) = effective_zone(step_zone, frame_zone) else {
125        return Ok(base.to_string());
126    };
127    let capture = *state
128        .capture
129        .get_or_insert_with(TemporalCapture::system);
130    let line = render_line(&capture, zone)?;
131    if !state.zones.iter().any(|z| z == zone) {
132        state.zones.push(zone.to_string());
133    }
134    Ok(if base.is_empty() {
135        line
136    } else {
137        format!("{base}\n\n{line}")
138    })
139}
140
141/// Project the run's final temporal state into the envelope record.
142/// `None` when no `now:`-bearing step ever rendered (zero wire drift).
143pub fn record_of(state: &TemporalState) -> Option<TemporalRecord> {
144    let capture = state.capture?;
145    if state.zones.is_empty() {
146        return None;
147    }
148    Some(TemporalRecord {
149        captured_utc: capture.utc.to_rfc3339_opts(SecondsFormat::Secs, true),
150        tzdb_version: crate::window::tz_db_version().to_string(),
151        zones: state.zones.clone(),
152    })
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use chrono::TimeZone;
159
160    fn fixed() -> TemporalCapture {
161        // 2026-07-07 19:33:05 UTC — 14:33:05 in Bogotá (UTC-5, no DST).
162        TemporalCapture::at(Utc.with_ymd_and_hms(2026, 7, 7, 19, 33, 5).unwrap())
163    }
164
165    #[test]
166    fn render_is_deterministic_and_zone_correct() {
167        let cap = fixed();
168        let l1 = render_line(&cap, "America/Bogota").unwrap();
169        let l2 = render_line(&cap, "America/Bogota").unwrap();
170        assert_eq!(l1, l2, "pure function of (capture, zone, tzdb)");
171        assert!(
172            l1.contains("2026-07-07T14:33:05-05:00"),
173            "Bogotá renders UTC-5: {l1}"
174        );
175        assert!(l1.contains("(America/Bogota; tzdb "));
176        assert!(l1.contains("captured at run start"));
177    }
178
179    #[test]
180    fn render_utc() {
181        let l = render_line(&fixed(), "UTC").unwrap();
182        assert!(l.contains("2026-07-07T19:33:05+00:00"), "{l}");
183    }
184
185    #[test]
186    fn render_is_dst_correct() {
187        // 2026-01-07 19:33 UTC — New York is EST (UTC-5) in January…
188        let winter = TemporalCapture::at(Utc.with_ymd_and_hms(2026, 1, 7, 19, 33, 5).unwrap());
189        let l = render_line(&winter, "America/New_York").unwrap();
190        assert!(l.contains("14:33:05-05:00"), "EST: {l}");
191        // …and EDT (UTC-4) in July.
192        let l = render_line(&fixed(), "America/New_York").unwrap();
193        assert!(l.contains("15:33:05-04:00"), "EDT: {l}");
194    }
195
196    #[test]
197    fn unknown_zone_fails_closed() {
198        // Passes the frontend shape law (contains '/', no edge slashes) but
199        // is NOT in the tz database — the runtime is the authority.
200        let err = render_line(&fixed(), "Fake/Zone").unwrap_err();
201        assert_eq!(err.zone, "Fake/Zone");
202        assert!(err.to_string().contains("not a known IANA timezone"));
203    }
204
205    #[test]
206    fn step_zone_overrides_frame_zone() {
207        assert_eq!(effective_zone(Some("UTC"), Some("America/Bogota")), Some("UTC"));
208        assert_eq!(effective_zone(None, Some("America/Bogota")), Some("America/Bogota"));
209        assert_eq!(effective_zone(None, None), None);
210    }
211
212    #[test]
213    fn compose_injects_once_per_zone_and_shares_one_capture() {
214        let mut state = TemporalState {
215            capture: Some(fixed()),
216            zones: Vec::new(),
217        };
218        let s1 = compose_effective_system("BASE", Some("UTC"), None, &mut state).unwrap();
219        assert!(s1.starts_with("BASE\n\n"), "{s1}");
220        assert!(s1.contains("Current datetime:"));
221        let s2 =
222            compose_effective_system("BASE", None, Some("America/Bogota"), &mut state).unwrap();
223        assert!(s2.contains("14:33:05-05:00"), "same capture, Bogotá zone: {s2}");
224        // Zones recorded first-use order, deduplicated.
225        let _ = compose_effective_system("BASE", Some("UTC"), None, &mut state).unwrap();
226        assert_eq!(state.zones, vec!["UTC".to_string(), "America/Bogota".to_string()]);
227    }
228
229    #[test]
230    fn compose_without_zone_is_identity_and_records_nothing() {
231        let mut state = TemporalState::default();
232        let s = compose_effective_system("BASE", None, None, &mut state).unwrap();
233        assert_eq!(s, "BASE");
234        assert!(state.capture.is_none(), "no capture without a declared zone");
235        assert!(record_of(&state).is_none());
236    }
237
238    #[test]
239    fn record_projects_capture_and_zones() {
240        let mut state = TemporalState {
241            capture: Some(fixed()),
242            zones: Vec::new(),
243        };
244        let _ = compose_effective_system("", Some("America/Bogota"), None, &mut state).unwrap();
245        let rec = record_of(&state).expect("record");
246        assert_eq!(rec.captured_utc, "2026-07-07T19:33:05Z");
247        assert_eq!(rec.zones, vec!["America/Bogota".to_string()]);
248        assert_eq!(rec.tzdb_version, crate::window::tz_db_version());
249        // Wire shape: serializes with exactly these three keys.
250        let json = serde_json::to_string(&rec).unwrap();
251        assert!(json.contains("\"captured_utc\""));
252        assert!(json.contains("\"tzdb_version\""));
253        assert!(json.contains("\"zones\""));
254    }
255}