Skip to main content

polydat_core/library/
context.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Context state nodes: non-deterministic, session-scoped values.
5//!
6//! These nodes produce values from the execution environment rather
7//! than the coordinate space. They break the deterministic model
8//! and should be used deliberately.
9//!
10//! SRD-80b Phase E migration. All authoring goes through
11//! `#[polydat_node]`. Three shapes appear here:
12//!
13//! * Pure clock / OS reads (`current_epoch_millis`, `thread_id`) —
14//!   plain body, marked `Nondeterministic`.
15//! * Construction-frozen captures (`session_start_millis`,
16//!   `elapsed_millis`, `tmp_dir`, `env_or`) — use
17//!   `#[poly_const(setup_fn, from = ())]` (or `from = <const_arg>`
18//!   when the capture depends on a const) to compute the cached
19//!   value once at construction. The body just reads the cache.
20//! * Fallible construction (`env`) — body returns
21//!   `Result<String, String>`. The macro emits `try_new` and
22//!   propagates `Err` as a workload-compile error via the build
23//!   closure.
24
25use std::sync::atomic::{AtomicU64, Ordering};
26use std::time::{SystemTime, UNIX_EPOCH};
27
28/// Current wall-clock time in epoch milliseconds.
29///
30/// Signature: `() -> (u64)`. Non-deterministic — clock read per eval.
31#[crate::polydat_node(
32    category = Context,
33    purity = Nondeterministic("reads system clock"),
34)]
35fn current_epoch_millis() -> u64 {
36    SystemTime::now()
37        .duration_since(UNIX_EPOCH)
38        .unwrap()
39        .as_millis() as u64
40}
41
42/// Helper for the time-capture setup fns: read epoch millis now.
43/// Plain function pointer compatible with `#[poly_const(fn, from = ())]`.
44fn capture_epoch_millis() -> u64 {
45    SystemTime::now()
46        .duration_since(UNIX_EPOCH)
47        .unwrap()
48        .as_millis() as u64
49}
50
51fn session_start_millis_jit_constants(node: &SessionStartMillis) -> Vec<u64> {
52    vec![node.start]
53}
54
55/// Session start time in epoch milliseconds, frozen at construction.
56///
57/// Signature: `() -> (u64)`. Deterministic within a session.
58///
59/// Captured-at-construction values are marked Nondeterministic so
60/// they are excluded from const-fold identity (workload hash stays
61/// stable across runs even though the captured value differs).
62#[crate::polydat_node(
63    category = Context,
64    purity = Nondeterministic("session start time captured from system clock"),
65    jit_constants = session_start_millis_jit_constants,
66)]
67fn session_start_millis(#[poly_const(capture_epoch_millis, from = ())] start: &u64) -> u64 {
68    *start
69}
70
71fn elapsed_millis_jit_constants(node: &ElapsedMillis) -> Vec<u64> {
72    vec![node.start]
73}
74
75/// Elapsed milliseconds since session start.
76///
77/// Signature: `() -> (u64)`. Non-deterministic, grows monotonically.
78#[crate::polydat_node(
79    category = Context,
80    purity = Nondeterministic("monotonic elapsed time from system clock"),
81    jit_constants = elapsed_millis_jit_constants,
82)]
83fn elapsed_millis(#[poly_const(capture_epoch_millis, from = ())] start: &u64) -> u64 {
84    let now = SystemTime::now()
85        .duration_since(UNIX_EPOCH)
86        .unwrap()
87        .as_millis() as u64;
88    now.saturating_sub(*start)
89}
90
91/// Current OS thread numeric identifier.
92///
93/// Signature: `() -> (u64)`. Non-deterministic — value depends on
94/// the scheduling thread.
95#[crate::polydat_node(
96    category = Context,
97    purity = Nondeterministic("OS thread identity varies across fibers"),
98)]
99fn thread_id() -> u64 {
100    thread_local! {
101        // `ThreadId` is opaque; the numeric id is extracted once per
102        // thread via the Debug formatter (`ThreadId(N)`).
103        static THREAD_ID: u64 = {
104            let id = std::thread::current().id();
105            let id_str = format!("{id:?}");
106            let num = id_str.trim_start_matches("ThreadId(").trim_end_matches(')');
107            num.parse().unwrap_or(0)
108        };
109    }
110    THREAD_ID.with(|id| *id)
111}
112
113/// Environment variable read, frozen at construction.
114///
115/// Signature: `env(name: const str) -> str`. Reads the named env
116/// var once at workload-compile time; the captured value is
117/// returned on every eval. Errors at construction when the
118/// variable is unset — use `env_or` for a defaulted form.
119///
120/// SRD-80b Phase E: fallible construction. The body returns
121/// `Result<String, String>`; the macro runs it once inside
122/// `try_new`, caches the Ok value, and propagates Err as a
123/// build-time error.
124#[crate::polydat_node(category = Context)]
125fn env(name: Const<&str>) -> Result<String, String> {
126    let var = name.0;
127    std::env::var(var).map_err(|_| {
128        format!(
129            "env('{var}'): environment variable not set; \
130         use env_or('{var}', '<default>') if a fallback is acceptable",
131        )
132    })
133}
134
135/// Environment variable read with default, frozen at construction.
136///
137/// Signature: `env_or(name: const str, default: const str) -> str`.
138/// Reads the named env var at construction; falls back to the
139/// literal `default` when the variable is unset. The captured
140/// value is constant for the session.
141#[crate::polydat_node(category = Context)]
142fn env_or(
143    name: Const<&str>,
144    default: Const<&str>,
145    #[poly_const(capture_env_opt, from = name)] captured: &Option<String>,
146) -> String {
147    match captured {
148        Some(v) => v.clone(),
149        None => default.0.to_string(),
150    }
151}
152
153/// Setup helper for `env_or`: read the env var into `Option<String>`.
154/// `None` indicates the var is unset; the body picks the default.
155fn capture_env_opt(name: &str) -> Option<String> {
156    std::env::var(name).ok()
157}
158
159/// System temp directory, frozen at construction.
160///
161/// Signature: `tmp_dir() -> str`.
162#[crate::polydat_node(category = Context)]
163fn tmp_dir(#[poly_const(capture_tmp_dir, from = ())] path: &String) -> String {
164    path.clone()
165}
166
167/// Setup helper for `tmp_dir`: capture `std::env::temp_dir()` as
168/// a UTF-8 string. Falls back to `/tmp` on non-UTF-8 paths
169/// (extremely rare on modern systems).
170fn capture_tmp_dir() -> String {
171    std::env::temp_dir()
172        .to_str()
173        .map(String::from)
174        .unwrap_or_else(|| "/tmp".to_string())
175}
176
177/// Monotonic counter (non-deterministic). SRD-80 PR B.11 migration.
178///
179/// Returns 0, 1, 2, ... across all calls. Thread-safe via AtomicU64.
180#[crate::polydat_node(
181    category = Context,
182    purity = Nondeterministic("monotonic counter incremented per call"),
183)]
184fn counter(
185    #[poly_default(0u64)] start: Const<u64>,
186    #[poly_const(AtomicU64::new, from = start)] count: &AtomicU64,
187) -> u64 {
188    count.fetch_add(1, Ordering::Relaxed)
189}
190
191/// Cursor limit: passes the input value through unchanged.
192///
193/// The compiler inserts this node when a cursor carries a `limit`,
194/// shadowing the cursor's ordinal wire with it. The clamp itself is
195/// the cursor system's, which reads `max_items` from this node's
196/// const slot; the node exists so that the clamp is visible in the
197/// graph rather than applied invisibly beside it.
198///
199/// Signature: `limit(input: u64, max_items: u64) -> u64`
200#[crate::polydat_node(category = Context)]
201fn limit(input: u64, max_items: Const<u64>) -> u64 {
202    let _ = max_items;
203    input
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use crate::ast::{PolydatNode, Value};
210
211    #[test]
212    fn current_epoch_millis_reasonable() {
213        let node = CurrentEpochMillis::new();
214        let mut out = [Value::None];
215        node.eval(&[], &mut out);
216        let millis = out[0].as_u64();
217        // Should be after 2024-01-01 (1704067200000)
218        assert!(millis > 1_704_067_200_000);
219    }
220
221    #[test]
222    fn session_start_frozen() {
223        let node = SessionStartMillis::new();
224        let mut out1 = [Value::None];
225        let mut out2 = [Value::None];
226        node.eval(&[], &mut out1);
227        node.eval(&[], &mut out2);
228        assert_eq!(out1[0].as_u64(), out2[0].as_u64());
229    }
230
231    #[test]
232    fn elapsed_grows() {
233        let node = ElapsedMillis::new();
234        let mut out = [Value::None];
235        node.eval(&[], &mut out);
236        let e1 = out[0].as_u64();
237        // Elapsed should be non-negative
238        assert!(e1 < 1000, "elapsed should be small right after creation");
239    }
240
241    #[test]
242    fn counter_increments() {
243        let node = Counter::new(0);
244        let mut out = [Value::None];
245        node.eval(&[], &mut out);
246        assert_eq!(out[0].as_u64(), 0);
247        node.eval(&[], &mut out);
248        assert_eq!(out[0].as_u64(), 1);
249        node.eval(&[], &mut out);
250        assert_eq!(out[0].as_u64(), 2);
251    }
252
253    #[test]
254    fn counter_starting_at() {
255        let node = Counter::new(100);
256        let mut out = [Value::None];
257        node.eval(&[], &mut out);
258        assert_eq!(out[0].as_u64(), 100);
259        node.eval(&[], &mut out);
260        assert_eq!(out[0].as_u64(), 101);
261    }
262
263    /// Generate a unique env-var name per test so concurrent test
264    /// threads can't collide on the same key. The process env is
265    /// global state; using fixed names like `TEST_VAR` makes
266    /// tests order-dependent.
267    fn unique_var(tag: &str) -> String {
268        use std::time::{SystemTime, UNIX_EPOCH};
269        let nanos = SystemTime::now()
270            .duration_since(UNIX_EPOCH)
271            .unwrap()
272            .as_nanos();
273        format!("__NBRS_TEST_{tag}_{nanos:x}")
274    }
275
276    #[test]
277    fn env_captures_value_at_construction() {
278        let var = unique_var("ENV");
279        unsafe {
280            std::env::set_var(&var, "captured-value");
281        }
282        let node = Env::try_new(var.clone()).expect("env should read the set var");
283        // Mutating the env after construction must NOT change the
284        // node's output — the value is frozen at construction.
285        unsafe {
286            std::env::set_var(&var, "later-value");
287        }
288        let mut out = [Value::None];
289        node.eval(&[], &mut out);
290        assert_eq!(out[0].as_str().to_string(), "captured-value");
291        unsafe {
292            std::env::remove_var(&var);
293        }
294    }
295
296    #[test]
297    fn env_errors_when_var_unset() {
298        let var = unique_var("ENV_MISSING");
299        unsafe {
300            std::env::remove_var(&var);
301        }
302        match Env::try_new(var.clone()) {
303            Ok(_) => panic!("Env::try_new should fail when the var is unset"),
304            Err(err) => {
305                assert!(
306                    err.contains(&var),
307                    "error should name the missing var: {err}"
308                );
309                assert!(
310                    err.contains("env_or"),
311                    "error should suggest env_or as the defaulted alternative: {err}"
312                );
313            }
314        }
315    }
316
317    #[test]
318    fn env_or_uses_default_when_var_unset() {
319        let var = unique_var("ENV_OR_MISSING");
320        unsafe {
321            std::env::remove_var(&var);
322        }
323        let node = EnvOr::new(var.clone(), "fallback".to_string());
324        let mut out = [Value::None];
325        node.eval(&[], &mut out);
326        assert_eq!(out[0].as_str().to_string(), "fallback");
327    }
328
329    #[test]
330    fn env_or_uses_var_value_when_set() {
331        let var = unique_var("ENV_OR_SET");
332        unsafe {
333            std::env::set_var(&var, "real-value");
334        }
335        let node = EnvOr::new(var.clone(), "fallback".to_string());
336        let mut out = [Value::None];
337        node.eval(&[], &mut out);
338        assert_eq!(out[0].as_str().to_string(), "real-value");
339        unsafe {
340            std::env::remove_var(&var);
341        }
342    }
343
344    #[test]
345    fn env_or_captures_at_construction_not_each_eval() {
346        let var = unique_var("ENV_OR_FROZEN");
347        unsafe {
348            std::env::set_var(&var, "first");
349        }
350        let node = EnvOr::new(var.clone(), "ignored-default".to_string());
351        unsafe {
352            std::env::set_var(&var, "second");
353        }
354        let mut out = [Value::None];
355        node.eval(&[], &mut out);
356        assert_eq!(
357            out[0].as_str().to_string(),
358            "first",
359            "env_or must freeze its value at construction; later env mutations are invisible"
360        );
361        unsafe {
362            std::env::remove_var(&var);
363        }
364    }
365
366    #[test]
367    fn tmp_dir_returns_a_path() {
368        let node = TmpDir::new();
369        let mut out = [Value::None];
370        node.eval(&[], &mut out);
371        let s = out[0].as_str().to_string();
372        assert!(!s.is_empty(), "tmp_dir() should produce a non-empty path");
373    }
374
375    #[test]
376    fn tmp_dir_is_stable_across_evals() {
377        let node = TmpDir::new();
378        let mut a = [Value::None];
379        let mut b = [Value::None];
380        node.eval(&[], &mut a);
381        node.eval(&[], &mut b);
382        assert_eq!(a[0].as_str(), b[0].as_str());
383    }
384
385    /// DSL-level integration: env_or / tmp_dir resolve through the
386    /// registry and produce kernels that compile cleanly.
387    #[test]
388    fn env_or_compiles_through_dsl() {
389        let var = unique_var("DSL_ENV_OR");
390        unsafe {
391            std::env::set_var(&var, "x-value");
392        }
393        let src = format!("v := env_or(\"{var}\", \"fallback\")\n",);
394        let kernel = crate::dsl::compile_polydat_interpreter(&src).expect("compile env_or");
395        unsafe {
396            std::env::remove_var(&var);
397        }
398        // The output should be the captured value. We can't read
399        // the kernel's outputs directly without an eval pass; the
400        // shape check (compiled cleanly, registered in DSL) is
401        // what this test asserts.
402        let names = kernel.program().output_names();
403        assert!(names.contains(&"v"), "expected output 'v' in {names:?}");
404    }
405
406    #[test]
407    fn tmp_dir_compiles_through_dsl_in_string_template() {
408        // Confirms the existing string-template machinery accepts
409        // function calls like `{tmp_dir()}` in Polydat string literals
410        // — no new syntax needed for the resumable-test-fixture
411        // workload's path composition.
412        let src = "path := \"{tmp_dir()}/data\"\n";
413        let kernel = crate::dsl::compile_polydat_interpreter(src)
414            .expect("compile tmp_dir() interpolated in a string");
415        let names = kernel.program().output_names();
416        assert!(
417            names.contains(&"path"),
418            "expected output 'path' in {names:?}"
419        );
420    }
421}