feat(sweep-key): generic filesystem-portable member key + LongOnly bool node
Generalises the #105 foot-gun: the family-member trace key was derived from two hardcoded axis names (f{fast}s{slow}), collision-free only over the built-in SMA grid. It is now derived from the axes that actually VARY (SweepBinder::varying_axes) and rendered as a filesystem-conformant directory component: - charset restricted to [A-Za-z0-9._-] (valid on Linux/Windows/macOS, also URL-path- and cloud-sync-safe); any other byte sanitised; - case-less values (i64/timestamp decimal, bool true/false, f64 via Display, no scientific notation) so two members of one family never differ only by letter case -> no silent overwrite on case-insensitive filesystems (NTFS/APFS); - length-capped (MAX_KEY=200) with a version-stable FNV-1a fallback for the unbounded-grid edge. Pinned singleton axes are omitted; the SMA sweep's member dirs change from f2s4 to signals.trend.fast.length-2_signals.trend.slow.length-4 (its --trace integration test + the stale prose updated accordingly). Adds the first aura-std node with a bool *param*, LongOnly (a long-only exposure gate: enabled=true clamps short exposure to >=0, false passes through) — the block the momentum demo strategy (next commit) sweeps to prove the key generic over a bool axis. The engine change is one additive pure read accessor; no existing signature changes, trace state stays out of the engine. Self-verified: cargo build --workspace, cargo test --workspace (all green incl. the LongOnly node tests, the member_key worked-examples corpus, the varying_axes accessor test, and the updated SMA sweep-trace integration test), cargo clippy --workspace --all-targets -D warnings. refs #105
This commit is contained in:
+143
-39
@@ -27,6 +27,7 @@ use aura_registry::{
|
||||
};
|
||||
use aura_std::{Ema, Exposure, LinComb, Recorder, SimBroker, Sma, Sub};
|
||||
use std::sync::mpsc::{self, Receiver};
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// The pip size the built-in *synthetic* harnesses run at: a 5-decimal FX major
|
||||
/// (`EURUSD`-shaped). The synthetic streams carry no instrument, so there is no
|
||||
@@ -174,25 +175,60 @@ fn persist_traces(
|
||||
}
|
||||
}
|
||||
|
||||
/// The content-derived member key for a swept grid point: the two varying axes
|
||||
/// of the built-in grid (`signals.trend.fast.length`, `signals.trend.slow.length`).
|
||||
/// Deterministic + collision-free over that grid (distinct points differ in ≥1
|
||||
/// of these). `named` is the `zip_params(&space, point)` the manifest already builds.
|
||||
///
|
||||
/// A key-driving axis that is absent from `named` is a grid/key drift, not data:
|
||||
/// the collision-free promise above would silently break (two points collapsing
|
||||
/// to a shared `f…s…` dir that overwrite each other's traces). So a missing axis
|
||||
/// panics, naming the axis — the same caller-bug-is-a-panic idiom as `Scalar::as_i64`.
|
||||
fn sweep_member_key(named: &[(String, Scalar)]) -> String {
|
||||
let val = |name: &str| {
|
||||
named
|
||||
.iter()
|
||||
.find(|(n, _)| n.as_str() == name)
|
||||
.unwrap_or_else(|| panic!("sweep_member_key: grid is missing the key axis '{name}'"))
|
||||
.1
|
||||
.as_i64()
|
||||
};
|
||||
format!("f{}s{}", val("signals.trend.fast.length"), val("signals.trend.slow.length"))
|
||||
/// The maximum length (bytes) of an on-disk member-key path component. Comfortably
|
||||
/// under the 255-byte POSIX `NAME_MAX` and inside Windows' 260-char `MAX_PATH`
|
||||
/// once the `runs/traces/<name>/` prefix and `/<tap>.json` suffix are added.
|
||||
const MAX_KEY: usize = 200;
|
||||
|
||||
/// Map any byte outside the portable directory-name charset `[A-Za-z0-9._-]` to
|
||||
/// `_`. The single source of filesystem-portability for an on-disk path component
|
||||
/// (valid on Linux / Windows / macOS, also URL-path- and cloud-sync-safe).
|
||||
fn sanitize_component(s: &str) -> String {
|
||||
s.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { c } else { '_' })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Render a scalar value case-lessly: integers/timestamps as decimal digits, bool
|
||||
/// as `true`/`false`, f64 via Rust's `Display` (decimal, shortest round-trip, NO
|
||||
/// scientific notation). Case-less rendering is what keeps two members of one
|
||||
/// family from ever differing only by letter case (case-insensitive-FS safety).
|
||||
fn render_value(v: &Scalar) -> String {
|
||||
match v {
|
||||
Scalar::I64(n) => n.to_string(),
|
||||
Scalar::F64(x) => x.to_string(),
|
||||
Scalar::Bool(b) => b.to_string(),
|
||||
Scalar::Timestamp(t) => t.0.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// FNV-1a-64 over `bytes` — a fixed, version-stable, non-cryptographic hash used
|
||||
/// only as the over-cap member-key disambiguator (`std`'s `DefaultHasher` is
|
||||
/// explicitly unstable across releases, so it cannot name an on-disk artefact).
|
||||
fn fnv1a64(bytes: &[u8]) -> u64 {
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for &b in bytes {
|
||||
h ^= b as u64;
|
||||
h = h.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// The portable, collision-free member key for a swept grid point: one `name-value`
|
||||
/// token per *varying* axis (in `named`'s param-space slot order), joined by `_`,
|
||||
/// every token sanitised to the portable charset. Pinned (singleton) axes carry no
|
||||
/// information and are omitted. A 1-point grid (no varying axis) keys as `"m"`. A
|
||||
/// key over `MAX_KEY` degrades to a conformant `h-<16-hex>` FNV fallback so the
|
||||
/// key is one valid path component for ANY grid (the #105 generalisation).
|
||||
fn member_key(named: &[(String, Scalar)], varying: &HashSet<String>) -> String {
|
||||
let key: String = named
|
||||
.iter()
|
||||
.filter(|(n, _)| varying.contains(n))
|
||||
.map(|(n, v)| format!("{}-{}", sanitize_component(n), sanitize_component(&render_value(v))))
|
||||
.collect::<Vec<_>>()
|
||||
.join("_");
|
||||
let key = if key.is_empty() { "m".to_string() } else { key };
|
||||
if key.len() <= MAX_KEY { key } else { format!("h-{:016x}", fnv1a64(key.as_bytes())) }
|
||||
}
|
||||
|
||||
/// Build the serve-ready `ChartData` from a run's read-back traces by the spec-§6
|
||||
@@ -469,14 +505,17 @@ fn sample_blueprint() -> Composite {
|
||||
fn sweep_family(trace: Option<&str>) -> SweepFamily {
|
||||
let bp = sample_blueprint_with_sinks().0;
|
||||
let space = bp.param_space();
|
||||
bp.axis("signals.trend.fast.length", [2, 3])
|
||||
let binder = bp
|
||||
.axis("signals.trend.fast.length", [2, 3])
|
||||
.axis("signals.trend.slow.length", [4, 5])
|
||||
.axis("signals.momentum.fast.length", [2])
|
||||
.axis("signals.momentum.slow.length", [4])
|
||||
.axis("signals.momentum.signal.length", [3])
|
||||
.axis("signals.blend.weights[0]", [1.0])
|
||||
.axis("signals.blend.weights[1]", [1.0])
|
||||
.axis("exposure.scale", [0.5])
|
||||
.axis("exposure.scale", [0.5]);
|
||||
let varying: HashSet<String> = binder.varying_axes().into_iter().collect();
|
||||
binder
|
||||
.sweep(|point| {
|
||||
let (bp, rx_eq, rx_ex) = sample_blueprint_with_sinks();
|
||||
let mut h = bp
|
||||
@@ -489,7 +528,7 @@ fn sweep_family(trace: Option<&str>) -> SweepFamily {
|
||||
let eq_rows = rx_eq.try_iter().collect::<Vec<_>>();
|
||||
let ex_rows = rx_ex.try_iter().collect::<Vec<_>>();
|
||||
let named = zip_params(&space, point);
|
||||
let key = sweep_member_key(&named);
|
||||
let key = member_key(&named, &varying);
|
||||
let manifest = sim_optimal_manifest(named, window, 0, SYNTHETIC_PIP_SIZE);
|
||||
if let Some(name) = trace {
|
||||
persist_traces(&format!("{name}/{key}"), &manifest, &eq_rows, &ex_rows);
|
||||
@@ -1404,25 +1443,90 @@ mod tests {
|
||||
assert_eq!(r1.manifest.commit, r2.manifest.commit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweep_member_key_maps_the_two_named_axes() {
|
||||
// The happy path the doc comment promises: the two varying axes drive
|
||||
// the `fNsM` key, so distinct grid points yield distinct keys.
|
||||
let named = vec![
|
||||
("signals.trend.fast.length".to_string(), Scalar::i64(2)),
|
||||
("signals.trend.slow.length".to_string(), Scalar::i64(5)),
|
||||
];
|
||||
assert_eq!(sweep_member_key(&named), "f2s5");
|
||||
fn pair(name: &str, v: Scalar) -> (String, Scalar) {
|
||||
(name.to_string(), v)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "signals.trend.slow.length")]
|
||||
fn sweep_member_key_fails_loudly_on_a_missing_axis() {
|
||||
// Property: the key is collision-free over the grid, so a key-driving
|
||||
// axis that is absent (a future grid/key drift) must fail loudly, not
|
||||
// silently collapse to a shared `f…s0` dir that overwrites a sibling's
|
||||
// trace. The panic names the offending axis.
|
||||
let drifted = vec![("signals.trend.fast.length".to_string(), Scalar::i64(2))];
|
||||
let _ = sweep_member_key(&drifted);
|
||||
fn member_key_renders_varying_axes_portably() {
|
||||
let named = vec![
|
||||
pair("ema.length", Scalar::i64(5)),
|
||||
pair("exposure.scale", Scalar::f64(0.5)),
|
||||
pair("longonly.enabled", Scalar::bool(true)),
|
||||
];
|
||||
let varying: std::collections::HashSet<String> =
|
||||
named.iter().map(|(n, _)| n.clone()).collect();
|
||||
let key = member_key(&named, &varying);
|
||||
assert_eq!(key, "ema.length-5_exposure.scale-0.5_longonly.enabled-true");
|
||||
assert!(key.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn member_key_omits_pinned_axes() {
|
||||
let named = vec![
|
||||
pair("ema.length", Scalar::i64(5)),
|
||||
pair("exposure.scale", Scalar::f64(0.5)),
|
||||
pair("longonly.enabled", Scalar::bool(false)),
|
||||
];
|
||||
let mut varying = std::collections::HashSet::new();
|
||||
varying.insert("longonly.enabled".to_string());
|
||||
assert_eq!(member_key(&named, &varying), "longonly.enabled-false");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn member_key_handles_negative_float_and_sanitises_names() {
|
||||
let named = vec![pair("exposure.scale", Scalar::f64(-0.5))];
|
||||
let varying: std::collections::HashSet<String> =
|
||||
["exposure.scale".to_string()].into_iter().collect();
|
||||
assert_eq!(member_key(&named, &varying), "exposure.scale--0.5");
|
||||
|
||||
let named2 = vec![pair("weird key!", Scalar::f64(1.0))];
|
||||
let varying2: std::collections::HashSet<String> =
|
||||
["weird key!".to_string()].into_iter().collect();
|
||||
assert_eq!(member_key(&named2, &varying2), "weird_key_-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn member_key_is_m_when_no_axis_varies() {
|
||||
let named = vec![pair("exposure.scale", Scalar::f64(0.5))];
|
||||
let varying = std::collections::HashSet::new();
|
||||
assert_eq!(member_key(&named, &varying), "m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn member_key_caps_length_with_conformant_hash_fallback() {
|
||||
let named: Vec<(String, Scalar)> = (0..40)
|
||||
.map(|i| pair(&format!("some.long.axis.path.number.{i}"), Scalar::i64(i)))
|
||||
.collect();
|
||||
let varying: std::collections::HashSet<String> =
|
||||
named.iter().map(|(n, _)| n.clone()).collect();
|
||||
let key = member_key(&named, &varying);
|
||||
assert!(key.len() <= MAX_KEY, "over-cap key not bounded: {} bytes", key.len());
|
||||
assert!(key.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')));
|
||||
let mut named2 = named.clone();
|
||||
named2[0].1 = Scalar::i64(999);
|
||||
assert_ne!(key, member_key(&named2, &varying), "distinct over-cap inputs must differ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn member_key_collision_free_over_a_non_trend_axis_set() {
|
||||
// #105 regression: vary exposure.scale + a bool (NOT the old hardcoded
|
||||
// trend.fast/slow). Distinct points -> distinct keys; the old f…s… key
|
||||
// would have collapsed them all to one dir.
|
||||
let varying: std::collections::HashSet<String> =
|
||||
["exposure.scale".to_string(), "longonly.enabled".to_string()].into_iter().collect();
|
||||
let p = |s: f64, b: bool| {
|
||||
vec![
|
||||
pair("ema.length", Scalar::i64(5)), // pinned -> omitted from key
|
||||
pair("exposure.scale", Scalar::f64(s)),
|
||||
pair("longonly.enabled", Scalar::bool(b)),
|
||||
]
|
||||
};
|
||||
let keys: Vec<String> = [(0.5, true), (0.5, false), (1.0, true), (1.0, false)]
|
||||
.iter()
|
||||
.map(|&(s, b)| member_key(&p(s, b), &varying))
|
||||
.collect();
|
||||
let unique: std::collections::HashSet<&String> = keys.iter().collect();
|
||||
assert_eq!(unique.len(), 4, "distinct points must yield distinct keys: {keys:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -716,7 +716,8 @@ fn mc_trace_persists_a_member_dir_per_seed() {
|
||||
|
||||
/// Property: `aura sweep --trace <name>` persists one standalone, round-tripping
|
||||
/// run-dir per grid point. The built-in grid is fast∈{2,3} × slow∈{4,5} = 4
|
||||
/// points, content-keyed f2s4/f2s5/f3s4/f3s5; a plain `aura sweep` writes nothing.
|
||||
/// points, content-keyed by the varying axes (signals.trend.fast.length /
|
||||
/// signals.trend.slow.length); a plain `aura sweep` writes nothing.
|
||||
#[test]
|
||||
fn sweep_trace_persists_a_member_dir_per_grid_point() {
|
||||
let cwd = temp_cwd("sweep-trace");
|
||||
@@ -731,7 +732,12 @@ fn sweep_trace_persists_a_member_dir_per_grid_point() {
|
||||
.output()
|
||||
.expect("spawn aura sweep --trace");
|
||||
assert!(traced.status.success(), "traced sweep exit: {:?}", traced.status);
|
||||
for key in ["f2s4", "f2s5", "f3s4", "f3s5"] {
|
||||
for key in [
|
||||
"signals.trend.fast.length-2_signals.trend.slow.length-4",
|
||||
"signals.trend.fast.length-2_signals.trend.slow.length-5",
|
||||
"signals.trend.fast.length-3_signals.trend.slow.length-4",
|
||||
"signals.trend.fast.length-3_signals.trend.slow.length-5",
|
||||
] {
|
||||
let dir = cwd.join(format!("runs/traces/swp/{key}"));
|
||||
assert!(dir.join("index.json").exists(), "{key} index.json missing");
|
||||
assert!(dir.join("equity.json").exists(), "{key} equity tap missing");
|
||||
@@ -791,7 +797,7 @@ fn walkforward_trace_persists_a_member_dir_per_oos_window() {
|
||||
/// grid point's content (not a runtime ordinal counter) is that members run in
|
||||
/// parallel across threads (C1: parallelism *across* sims); a thread-race in the
|
||||
/// keying or the persisted bytes would silently regress determinism. A runtime
|
||||
/// counter would shuffle which member lands in `f2s4` vs `f3s5` run-to-run; this
|
||||
/// counter would shuffle which member lands in which content-keyed dir run-to-run; this
|
||||
/// test fails loudly if that drift is ever reintroduced.
|
||||
#[test]
|
||||
fn sweep_trace_is_byte_deterministic_across_runs() {
|
||||
|
||||
@@ -388,6 +388,14 @@ impl SweepBinder {
|
||||
self
|
||||
}
|
||||
|
||||
/// The names of the axes that vary (more than one value) — the axes that
|
||||
/// distinguish one grid point from another. Declaration order; callers derive
|
||||
/// set membership (the key's token order comes from param-space slot order,
|
||||
/// not from this list). A pure read accessor; it runs no sweep.
|
||||
pub fn varying_axes(&self) -> Vec<String> {
|
||||
self.axes.iter().filter(|(_, v)| v.len() > 1).map(|(n, _)| n.clone()).collect()
|
||||
}
|
||||
|
||||
/// Resolve the named axes against `param_space()` into a positional grid and
|
||||
/// run the disjoint sweep.
|
||||
pub fn sweep<F>(self, run_one: F) -> Result<SweepFamily, BindError>
|
||||
@@ -1011,6 +1019,19 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn varying_axes_names_only_the_multi_value_axes() {
|
||||
let bp = composite_sma_cross_harness().0;
|
||||
let names: Vec<String> = bp.param_space().into_iter().map(|p| p.name).collect();
|
||||
assert!(names.len() >= 3, "fixture must have >= 3 param slots: {names:?}");
|
||||
// vary the first axis (>1 value), pin the other two (1 value each).
|
||||
let binder = bp
|
||||
.axis(names[0].as_str(), vec![Scalar::i64(2), Scalar::i64(3)])
|
||||
.axis(names[1].as_str(), vec![Scalar::i64(4)])
|
||||
.axis(names[2].as_str(), vec![Scalar::f64(0.5)]);
|
||||
assert_eq!(binder.varying_axes(), vec![names[0].clone()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_axes_empty_axis() {
|
||||
let space = vec![ParamSpec { name: "a".into(), kind: ScalarKind::I64 }];
|
||||
|
||||
@@ -24,6 +24,7 @@ mod exposure;
|
||||
mod gt;
|
||||
mod latch;
|
||||
mod lincomb;
|
||||
mod longonly;
|
||||
mod recorder;
|
||||
mod resample;
|
||||
mod session;
|
||||
@@ -39,6 +40,7 @@ pub use exposure::Exposure;
|
||||
pub use gt::Gt;
|
||||
pub use latch::Latch;
|
||||
pub use lincomb::LinComb;
|
||||
pub use longonly::LongOnly;
|
||||
pub use recorder::Recorder;
|
||||
pub use resample::Resample;
|
||||
pub use session::Session;
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//! `LongOnly` — a long-only exposure gate. The bool param `enabled`: when true,
|
||||
//! short (negative) exposure is clamped to 0 (a long-only constraint); when
|
||||
//! false, exposure passes through unchanged. One f64 input, one f64 output.
|
||||
//! Emits `None` until its input is present (warm-up filter, C8).
|
||||
|
||||
use aura_core::{
|
||||
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
|
||||
ScalarKind,
|
||||
};
|
||||
|
||||
/// Long-only exposure gate driven by a bool param. `enabled=true` →
|
||||
/// `max(exposure, 0.0)`; `enabled=false` → `exposure` unchanged.
|
||||
pub struct LongOnly {
|
||||
enabled: bool,
|
||||
out: [Cell; 1],
|
||||
}
|
||||
|
||||
impl LongOnly {
|
||||
/// Build a long-only gate. `enabled` toggles the short-clamp.
|
||||
pub fn new(enabled: bool) -> Self {
|
||||
Self { enabled, out: [Cell::from_f64(0.0)] }
|
||||
}
|
||||
|
||||
/// The param-generic recipe: declares the bool param `enabled` and builds
|
||||
/// through `LongOnly::new` (the slice is kind-checked before `build`, so the
|
||||
/// typed `p[0].bool()` read is total). First aura-std node with a bool param.
|
||||
pub fn builder() -> PrimitiveBuilder {
|
||||
PrimitiveBuilder::new(
|
||||
"LongOnly",
|
||||
NodeSchema {
|
||||
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "exposure".into() }],
|
||||
output: vec![FieldSpec { name: "exposure".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![ParamSpec { name: "enabled".into(), kind: ScalarKind::Bool }],
|
||||
},
|
||||
|p| Box::new(LongOnly::new(p[0].bool())),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Node for LongOnly {
|
||||
fn lookbacks(&self) -> Vec<usize> {
|
||||
vec![1]
|
||||
}
|
||||
|
||||
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
|
||||
let w = ctx.f64_in(0);
|
||||
if w.is_empty() {
|
||||
return None; // not yet warmed up (C8 filter)
|
||||
}
|
||||
self.out[0] = Cell::from_f64(if self.enabled { w[0].max(0.0) } else { w[0] });
|
||||
Some(&self.out)
|
||||
}
|
||||
|
||||
fn label(&self) -> String {
|
||||
format!("LongOnly({})", self.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use aura_core::{AnyColumn, Scalar, Timestamp};
|
||||
|
||||
#[test]
|
||||
fn long_only_gates_negatives_when_enabled() {
|
||||
let mut g = LongOnly::new(true);
|
||||
let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
|
||||
// (input exposure, expected output) for enabled=true: negatives clamp to 0.
|
||||
let cases = [(0.6_f64, 0.6_f64), (-0.6, 0.0), (1.0, 1.0), (-1.0, 0.0)];
|
||||
for (sig, want) in cases {
|
||||
inputs[0].push(Scalar::f64(sig)).unwrap();
|
||||
assert_eq!(
|
||||
g.eval(Ctx::new(&inputs, Timestamp(0))),
|
||||
Some([Cell::from_f64(want)].as_slice())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_only_passes_through_when_disabled() {
|
||||
let mut g = LongOnly::new(false);
|
||||
let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
|
||||
let cases = [(0.6_f64, 0.6_f64), (-0.6, -0.6)];
|
||||
for (sig, want) in cases {
|
||||
inputs[0].push(Scalar::f64(sig)).unwrap();
|
||||
assert_eq!(
|
||||
g.eval(Ctx::new(&inputs, Timestamp(0))),
|
||||
Some([Cell::from_f64(want)].as_slice())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_only_is_none_until_input_present() {
|
||||
let mut g = LongOnly::new(true);
|
||||
let inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
|
||||
assert_eq!(g.eval(Ctx::new(&inputs, Timestamp(0))), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_only_param_is_a_bool_named_enabled() {
|
||||
let builder = LongOnly::builder();
|
||||
let schema = builder.schema();
|
||||
assert_eq!(schema.params.len(), 1);
|
||||
assert_eq!(schema.params[0].name, "enabled");
|
||||
assert_eq!(schema.params[0].kind, ScalarKind::Bool);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user