Skip to main content

_diffctx/config/
env_overrides.rs

1//! Shared helpers for reading config parameters from environment variables.
2//!
3//! Used by `category_weights.rs` and the Group-C operational-parameter
4//! overrides documented in `docs/engineering/parameter-strategy.md`. The pattern is:
5//! `Lazy::new` reads the env var once at first access; tests verify the
6//! pure parser (`parse_*_or_default`) directly so they do not need to
7//! mutate process-global env state.
8
9pub fn parse_f64_or_default(raw: Option<String>, default: f64) -> f64 {
10    raw.and_then(|s| s.parse::<f64>().ok())
11        .filter(|v| v.is_finite() && *v >= 0.0)
12        .unwrap_or(default)
13}
14
15pub fn parse_fraction_or_default(raw: Option<String>, default: f64) -> f64 {
16    parse_f64_or_default(raw, default).clamp(0.0, 1.0)
17}
18
19/// Parse a fraction strictly inside the open interval (0, 1).
20/// Used for parameters where 0.0 or 1.0 produce algorithmic degeneracy
21/// (e.g. PPR_ALPHA=1.0 makes restart probability zero, yielding all-zero rankings).
22pub fn parse_open_fraction_or_default(raw: Option<String>, default: f64) -> f64 {
23    const EPS: f64 = 1e-4;
24    parse_f64_or_default(raw, default).clamp(EPS, 1.0 - EPS)
25}
26
27pub fn parse_usize_or_default(raw: Option<String>, default: usize) -> usize {
28    raw.and_then(|s| s.parse::<usize>().ok()).unwrap_or(default)
29}
30
31pub fn parse_u32_or_default(raw: Option<String>, default: u32) -> u32 {
32    raw.and_then(|s| s.parse::<u32>().ok()).unwrap_or(default)
33}
34
35pub fn read_env_f64(name: &str, default: f64) -> f64 {
36    parse_f64_or_default(std::env::var(name).ok(), default)
37}
38
39pub fn read_env_fraction(name: &str, default: f64) -> f64 {
40    parse_fraction_or_default(std::env::var(name).ok(), default)
41}
42
43pub fn read_env_open_fraction(name: &str, default: f64) -> f64 {
44    parse_open_fraction_or_default(std::env::var(name).ok(), default)
45}
46
47pub fn read_env_usize(name: &str, default: usize) -> usize {
48    parse_usize_or_default(std::env::var(name).ok(), default)
49}
50
51pub fn read_env_u32(name: &str, default: u32) -> u32 {
52    parse_u32_or_default(std::env::var(name).ok(), default)
53}
54
55#[cfg(test)]
56mod tests {
57    use super::*;
58
59    #[test]
60    fn f64_accepts_finite_nonneg() {
61        assert_eq!(parse_f64_or_default(Some("0.42".into()), 1.0), 0.42);
62        assert_eq!(parse_f64_or_default(Some("0".into()), 1.0), 0.0);
63    }
64
65    #[test]
66    fn f64_rejects_negative_and_nonfinite() {
67        assert_eq!(parse_f64_or_default(Some("-0.5".into()), 1.0), 1.0);
68        assert_eq!(parse_f64_or_default(Some("nan".into()), 1.0), 1.0);
69        assert_eq!(parse_f64_or_default(Some("inf".into()), 1.0), 1.0);
70    }
71
72    #[test]
73    fn fraction_clamps_into_unit_interval() {
74        assert_eq!(parse_fraction_or_default(Some("0.5".into()), 0.7), 0.5);
75        assert_eq!(parse_fraction_or_default(Some("1.05".into()), 0.7), 1.0);
76        assert_eq!(parse_fraction_or_default(Some("42".into()), 0.7), 1.0);
77        assert_eq!(parse_fraction_or_default(Some("-0.5".into()), 0.7), 0.7);
78        assert_eq!(parse_fraction_or_default(Some("nan".into()), 0.7), 0.7);
79        assert_eq!(parse_fraction_or_default(None, 0.7), 0.7);
80    }
81
82    #[test]
83    fn open_fraction_clamps_to_open_interval() {
84        // Boundary 1.0 → degenerate (PPR α=1 zeros all rankings); must clamp.
85        let v_one = parse_open_fraction_or_default(Some("1.0".into()), 0.6);
86        assert!(
87            v_one < 1.0,
88            "open fraction must clamp 1.0 below 1; got {v_one}"
89        );
90        assert!(
91            v_one > 0.99,
92            "clamp must stay near 1.0, not collapse to default"
93        );
94        // Boundary 0.0 → also degenerate; must clamp above 0.
95        let v_zero = parse_open_fraction_or_default(Some("0.0".into()), 0.6);
96        assert!(
97            v_zero > 0.0,
98            "open fraction must clamp 0.0 above 0; got {v_zero}"
99        );
100        // Interior values pass through.
101        assert_eq!(parse_open_fraction_or_default(Some("0.6".into()), 0.0), 0.6);
102        // Above 1.0 also clamped.
103        assert!(parse_open_fraction_or_default(Some("42".into()), 0.6) < 1.0);
104    }
105
106    #[test]
107    fn f64_falls_back_on_missing_or_unparseable() {
108        assert_eq!(parse_f64_or_default(None, 0.7), 0.7);
109        assert_eq!(parse_f64_or_default(Some("hello".into()), 0.7), 0.7);
110        assert_eq!(parse_f64_or_default(Some("".into()), 0.7), 0.7);
111    }
112
113    #[test]
114    fn usize_parses_or_falls_back() {
115        assert_eq!(parse_usize_or_default(Some("42".into()), 7), 42);
116        assert_eq!(parse_usize_or_default(Some("-1".into()), 7), 7);
117        assert_eq!(parse_usize_or_default(None, 7), 7);
118    }
119
120    #[test]
121    fn u32_parses_or_falls_back() {
122        assert_eq!(parse_u32_or_default(Some("24".into()), 8), 24);
123        assert_eq!(parse_u32_or_default(Some("nope".into()), 8), 8);
124    }
125}
126
127// Every test above verifies the pure parser with a literal env value, never
128// with the env-var *name* it is documented under -- so a typo'd name (e.g.
129// `DIFFCTX_OP_EGO_PER_HOP_DECAY` instead of the real `DIFFCTX_EGO_PER_HOP_DECAY`
130// read at `config/scoring.rs:16`) sweeps nothing and every test above still
131// passes. These tests close that gap by checking the *set of names* rather
132// than mutating process-global env state per variable (flaky under parallel
133// `cargo test`).
134#[cfg(test)]
135mod override_name_consistency {
136    use std::collections::BTreeSet;
137    use std::path::{Path, PathBuf};
138
139    use regex::Regex;
140
141    fn crate_root() -> PathBuf {
142        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
143    }
144
145    fn extract(text: &str, re: &Regex) -> BTreeSet<String> {
146        re.captures_iter(text).map(|c| c[1].to_string()).collect()
147    }
148
149    fn collect_rs_files(dir: &Path, out: &mut Vec<PathBuf>) {
150        let Ok(entries) = std::fs::read_dir(dir) else {
151            return;
152        };
153        for entry in entries.flatten() {
154            let path = entry.path();
155            if path.is_dir() {
156                collect_rs_files(&path, out);
157            } else if path.extension().is_some_and(|e| e == "rs") {
158                out.push(path);
159            }
160        }
161    }
162
163    // Every `DIFFCTX_*` literal read through the `read_env_*` helpers
164    // defined in this module -- the actual Group-C operational-parameter
165    // plumbing, as opposed to the handful of unrelated toggles
166    // (`DIFFCTX_OBJECTIVE`, `DIFFCTX_MAX_FRAGMENTS`,
167    // `DIFFCTX_MAX_EDGES_PER_NODE`, `DIFFCTX_NO_COMMIT_SIGNAL`,
168    // `DIFFCTX_TOKEN_CACHE_*`) that call `std::env::var`/`var_os` directly
169    // and are explicitly out of scope per parameter-strategy.md's "other
170    // internal toggles" callout. This file (`env_overrides.rs`) is excluded
171    // from the scan: it defines the generic readers but never hardcodes a
172    // `DIFFCTX_*` name itself, and excluding it keeps this test's own
173    // literals (below) out of the scanned set.
174    fn code_literal_set() -> BTreeSet<String> {
175        let re = Regex::new(
176            r#"read_env_(?:f64|fraction|open_fraction|usize|u32)\(\s*"(DIFFCTX_[A-Z0-9_]+)""#,
177        )
178        .unwrap();
179        let mut files = Vec::new();
180        collect_rs_files(&crate_root().join("src"), &mut files);
181        let mut set = BTreeSet::new();
182        for file in files {
183            if file.file_name().and_then(|n| n.to_str()) == Some("env_overrides.rs") {
184                continue;
185            }
186            let text =
187                std::fs::read_to_string(&file).unwrap_or_else(|e| panic!("reading {file:?}: {e}"));
188            set.extend(extract(&text, &re));
189        }
190        set
191    }
192
193    fn script_param_set() -> BTreeSet<String> {
194        let re = Regex::new(r#"\("(DIFFCTX_[A-Z0-9_]+)","#).unwrap();
195        let path = crate_root().join("../../scripts/sensitivity_check.py");
196        let text =
197            std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("reading {path:?}: {e}"));
198        extract(&text, &re)
199    }
200
201    // Names in the Tier-3 *table rows* only. Restricting to lines starting
202    // with `|` excludes the prose blockquote earlier in the same section
203    // (the "other internal toggles" callout), which names
204    // `DIFFCTX_OBJECTIVE`/`DIFFCTX_MAX_FRAGMENTS`/`DIFFCTX_NO_COMMIT_SIGNAL`
205    // in backticks as examples of what is deliberately *not* in the table.
206    fn documented_tier3_set() -> BTreeSet<String> {
207        let re = Regex::new(r"`(DIFFCTX_[A-Z0-9_]+)`").unwrap();
208        let path = crate_root().join("../../docs/engineering/parameter-strategy.md");
209        let text =
210            std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("reading {path:?}: {e}"));
211        let start = text
212            .find("### Tier 3")
213            .expect("parameter-strategy.md must have a '### Tier 3' section header");
214        let end = text[start..]
215            .find("The Tier-1 pair")
216            .map(|i| start + i)
217            .unwrap_or(text.len());
218        let table_rows: String = text[start..end]
219            .lines()
220            .filter(|line| line.trim_start().starts_with('|'))
221            .collect::<Vec<_>>()
222            .join("\n");
223        extract(&table_rows, &re)
224    }
225
226    // Read via `read_env_*` but intentionally not in the Tier-3 table:
227    // `DIFFCTX_OP_SELECTION_CORE_BUDGET_FRACTION` -- `core_budget_fraction`
228    // is Tier-1 (calibrated), exposed only so `scripts/sensitivity_check.py`
229    // can sweep it (documented in prose, not the table -- see
230    // parameter-strategy.md's "env-overridable ... for sweeps" note).
231    const TIER1_EXTRAS_READ_BUT_NOT_TABLED: &[&str] =
232        &["DIFFCTX_OP_SELECTION_CORE_BUDGET_FRACTION"];
233
234    #[test]
235    fn every_script_swept_name_is_actually_read_in_code() {
236        let code = code_literal_set();
237        let script = script_param_set();
238        assert!(
239            script.len() >= 10,
240            "sensitivity_check.py's OPERATIONAL_PARAMS parsed too small ({}) \
241             -- the extraction regex likely drifted from the source format",
242            script.len()
243        );
244        let phantom: Vec<&String> = script.iter().filter(|n| !code.contains(*n)).collect();
245        assert!(
246            phantom.is_empty(),
247            "scripts/sensitivity_check.py sweeps names never read by any \
248             config module (dead sweep points, measuring the default at \
249             every perturbation): {phantom:?}"
250        );
251    }
252
253    #[test]
254    fn documented_tier3_names_match_the_code_reads_that_back_them() {
255        let code = code_literal_set();
256        let tier3_code: BTreeSet<String> = code
257            .into_iter()
258            .filter(|n| !TIER1_EXTRAS_READ_BUT_NOT_TABLED.contains(&n.as_str()))
259            .collect();
260        let documented = documented_tier3_set();
261        assert!(
262            documented.len() >= 10,
263            "parameter-strategy.md's Tier-3 table parsed too small ({}) \
264             -- the section markers or extraction regex likely drifted",
265            documented.len()
266        );
267        assert_eq!(
268            tier3_code, documented,
269            "crate's Tier-3 env-read set and parameter-strategy.md's table \
270             disagree -- a var was added/renamed/removed on one side only"
271        );
272    }
273}