fix: preserve unknown numeric conditions through boolean expressions
This commit is contained in:
@@ -11,6 +11,7 @@ pub(crate) enum ValueType {
|
|||||||
pub(crate) enum Value {
|
pub(crate) enum Value {
|
||||||
Number(f64),
|
Number(f64),
|
||||||
Boolean(bool),
|
Boolean(bool),
|
||||||
|
Missing(ValueType),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Value {
|
impl Value {
|
||||||
@@ -18,20 +19,28 @@ impl Value {
|
|||||||
match self {
|
match self {
|
||||||
Self::Number(_) => ValueType::Number,
|
Self::Number(_) => ValueType::Number,
|
||||||
Self::Boolean(_) => ValueType::Boolean,
|
Self::Boolean(_) => ValueType::Boolean,
|
||||||
|
Self::Missing(value_type) => value_type,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn as_number(self) -> Option<f64> {
|
pub(crate) fn as_number(self) -> Option<f64> {
|
||||||
match self {
|
match self {
|
||||||
Self::Number(value) => Some(value),
|
Self::Number(value) => Some(value),
|
||||||
Self::Boolean(_) => None,
|
Self::Boolean(_) | Self::Missing(_) => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn as_bool(self) -> Option<bool> {
|
pub(crate) fn as_bool(self) -> Option<bool> {
|
||||||
match self {
|
match self {
|
||||||
Self::Boolean(value) => Some(value),
|
Self::Boolean(value) => Some(value),
|
||||||
Self::Number(_) => None,
|
Self::Number(_) | Self::Missing(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalized(self) -> Self {
|
||||||
|
match self {
|
||||||
|
Self::Number(value) if !value.is_finite() => Self::Missing(ValueType::Number),
|
||||||
|
value => value,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,6 +112,8 @@ enum BinaryOp {
|
|||||||
LessEqual,
|
LessEqual,
|
||||||
Greater,
|
Greater,
|
||||||
GreaterEqual,
|
GreaterEqual,
|
||||||
|
And,
|
||||||
|
Or,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -134,7 +145,7 @@ enum Instruction {
|
|||||||
Binary(BinaryOp),
|
Binary(BinaryOp),
|
||||||
Call { builtin: Builtin, argc: u8 },
|
Call { builtin: Builtin, argc: u8 },
|
||||||
JumpIfFalse(usize),
|
JumpIfFalse(usize),
|
||||||
JumpIfTrue(usize),
|
ShortCircuit { on: bool, target: usize },
|
||||||
Jump(usize),
|
Jump(usize),
|
||||||
Return,
|
Return,
|
||||||
}
|
}
|
||||||
@@ -165,7 +176,7 @@ impl Program {
|
|||||||
let mut pc = 0usize;
|
let mut pc = 0usize;
|
||||||
while let Some(instruction) = self.instructions.get(pc) {
|
while let Some(instruction) = self.instructions.get(pc) {
|
||||||
match *instruction {
|
match *instruction {
|
||||||
Instruction::Push(value) => scratch.stack.push(value),
|
Instruction::Push(value) => scratch.stack.push(value.normalized()),
|
||||||
Instruction::LoadVariable(index) => {
|
Instruction::LoadVariable(index) => {
|
||||||
let index = usize::from(index);
|
let index = usize::from(index);
|
||||||
let cached = scratch.variables[index];
|
let cached = scratch.variables[index];
|
||||||
@@ -173,7 +184,8 @@ impl Program {
|
|||||||
Some(value) => value,
|
Some(value) => value,
|
||||||
None => {
|
None => {
|
||||||
let expected_type = self.variable_types[index];
|
let expected_type = self.variable_types[index];
|
||||||
let value = resolve(index, &self.variables[index], expected_type)?;
|
let value =
|
||||||
|
resolve(index, &self.variables[index], expected_type)?.normalized();
|
||||||
if value.value_type() != expected_type {
|
if value.value_type() != expected_type {
|
||||||
return Err(EvalError::new(format!(
|
return Err(EvalError::new(format!(
|
||||||
"variable {} expected {:?}, got {:?}",
|
"variable {} expected {:?}, got {:?}",
|
||||||
@@ -219,15 +231,23 @@ impl Program {
|
|||||||
scratch.stack.push(value);
|
scratch.stack.push(value);
|
||||||
}
|
}
|
||||||
Instruction::JumpIfFalse(target) => {
|
Instruction::JumpIfFalse(target) => {
|
||||||
let condition = pop_bool(&mut scratch.stack)?;
|
// Like CASE WHEN, only a known true condition takes this branch.
|
||||||
|
let condition = match pop(&mut scratch.stack)? {
|
||||||
|
Value::Boolean(value) => value,
|
||||||
|
Value::Missing(ValueType::Boolean) => false,
|
||||||
|
_ => return Err(EvalError::new("boolean operand required")),
|
||||||
|
};
|
||||||
if !condition {
|
if !condition {
|
||||||
pc = target;
|
pc = target;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Instruction::JumpIfTrue(target) => {
|
Instruction::ShortCircuit { on, target } => {
|
||||||
let condition = pop_bool(&mut scratch.stack)?;
|
let condition =
|
||||||
if condition {
|
scratch.stack.last().copied().ok_or_else(|| {
|
||||||
|
EvalError::new("stack underflow during short circuit")
|
||||||
|
})?;
|
||||||
|
if condition.as_bool() == Some(on) {
|
||||||
pc = target;
|
pc = target;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -284,31 +304,56 @@ fn pop(stack: &mut Vec<Value>) -> Result<Value, EvalError> {
|
|||||||
stack.pop().ok_or_else(|| EvalError::new("stack underflow"))
|
stack.pop().ok_or_else(|| EvalError::new("stack underflow"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pop_bool(stack: &mut Vec<Value>) -> Result<bool, EvalError> {
|
|
||||||
pop(stack)?
|
|
||||||
.as_bool()
|
|
||||||
.ok_or_else(|| EvalError::new("boolean operand required"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn number(value: Value) -> Result<f64, EvalError> {
|
fn number(value: Value) -> Result<f64, EvalError> {
|
||||||
|
if value == Value::Missing(ValueType::Number) {
|
||||||
|
return Ok(f64::NAN);
|
||||||
|
}
|
||||||
value
|
value
|
||||||
.as_number()
|
.as_number()
|
||||||
.ok_or_else(|| EvalError::new("numeric operand required"))
|
.ok_or_else(|| EvalError::new("numeric operand required"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn eval_unary(operator: UnaryOp, value: Value) -> Result<Value, EvalError> {
|
fn eval_unary(operator: UnaryOp, value: Value) -> Result<Value, EvalError> {
|
||||||
match operator {
|
if matches!(value, Value::Missing(_)) {
|
||||||
|
return Ok(value);
|
||||||
|
}
|
||||||
|
let result: Result<Value, EvalError> = match operator {
|
||||||
UnaryOp::Negate => Ok(Value::Number(-number(value)?)),
|
UnaryOp::Negate => Ok(Value::Number(-number(value)?)),
|
||||||
UnaryOp::Not => {
|
UnaryOp::Not => {
|
||||||
Ok(Value::Boolean(!value.as_bool().ok_or_else(|| {
|
Ok(Value::Boolean(!value.as_bool().ok_or_else(|| {
|
||||||
EvalError::new("boolean operand required for !")
|
EvalError::new("boolean operand required for !")
|
||||||
})?))
|
})?))
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
Ok(result?.normalized())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn eval_binary(operator: BinaryOp, lhs: Value, rhs: Value) -> Result<Value, EvalError> {
|
fn eval_binary(operator: BinaryOp, lhs: Value, rhs: Value) -> Result<Value, EvalError> {
|
||||||
match operator {
|
if matches!(operator, BinaryOp::And | BinaryOp::Or) {
|
||||||
|
let (lhs, rhs) = (lhs.as_bool(), rhs.as_bool());
|
||||||
|
let result = match operator {
|
||||||
|
BinaryOp::And if lhs == Some(false) || rhs == Some(false) => Some(false),
|
||||||
|
BinaryOp::And if lhs == Some(true) && rhs == Some(true) => Some(true),
|
||||||
|
BinaryOp::Or if lhs == Some(true) || rhs == Some(true) => Some(true),
|
||||||
|
BinaryOp::Or if lhs == Some(false) && rhs == Some(false) => Some(false),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
return Ok(result
|
||||||
|
.map(Value::Boolean)
|
||||||
|
.unwrap_or(Value::Missing(ValueType::Boolean)));
|
||||||
|
}
|
||||||
|
if matches!(lhs, Value::Missing(_)) || matches!(rhs, Value::Missing(_)) {
|
||||||
|
let value_type = match operator {
|
||||||
|
BinaryOp::Add
|
||||||
|
| BinaryOp::Subtract
|
||||||
|
| BinaryOp::Multiply
|
||||||
|
| BinaryOp::Divide
|
||||||
|
| BinaryOp::Remainder => ValueType::Number,
|
||||||
|
_ => ValueType::Boolean,
|
||||||
|
};
|
||||||
|
return Ok(Value::Missing(value_type));
|
||||||
|
}
|
||||||
|
let result: Result<Value, EvalError> = match operator {
|
||||||
BinaryOp::Add => Ok(Value::Number(number(lhs)? + number(rhs)?)),
|
BinaryOp::Add => Ok(Value::Number(number(lhs)? + number(rhs)?)),
|
||||||
BinaryOp::Subtract => Ok(Value::Number(number(lhs)? - number(rhs)?)),
|
BinaryOp::Subtract => Ok(Value::Number(number(lhs)? - number(rhs)?)),
|
||||||
BinaryOp::Multiply => Ok(Value::Number(number(lhs)? * number(rhs)?)),
|
BinaryOp::Multiply => Ok(Value::Number(number(lhs)? * number(rhs)?)),
|
||||||
@@ -356,7 +401,27 @@ fn eval_binary(operator: BinaryOp, lhs: Value, rhs: Value) -> Result<Value, Eval
|
|||||||
(lhs - rhs) / float_comparison_scale(lhs, rhs) > -f64::EPSILON,
|
(lhs - rhs) / float_comparison_scale(lhs, rhs) > -f64::EPSILON,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
BinaryOp::And | BinaryOp::Or => unreachable!(),
|
||||||
|
};
|
||||||
|
Ok(result?.normalized())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn finite_comparison(operator: &str, lhs: f64, rhs: f64) -> Option<bool> {
|
||||||
|
if !lhs.is_finite() || !rhs.is_finite() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let operator = match operator {
|
||||||
|
"==" => BinaryOp::Equal,
|
||||||
|
"!=" => BinaryOp::NotEqual,
|
||||||
|
"<" => BinaryOp::Less,
|
||||||
|
"<=" => BinaryOp::LessEqual,
|
||||||
|
">" => BinaryOp::Greater,
|
||||||
|
">=" => BinaryOp::GreaterEqual,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
eval_binary(operator, Value::Number(lhs), Value::Number(rhs))
|
||||||
|
.ok()?
|
||||||
|
.as_bool()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn float_comparison_scale(lhs: f64, rhs: f64) -> f64 {
|
fn float_comparison_scale(lhs: f64, rhs: f64) -> f64 {
|
||||||
@@ -382,7 +447,16 @@ fn eval_builtin(builtin: Builtin, args: &[Value]) -> Result<Value, EvalError> {
|
|||||||
.ok_or_else(|| EvalError::new("missing builtin argument"))
|
.ok_or_else(|| EvalError::new("missing builtin argument"))
|
||||||
.and_then(number)
|
.and_then(number)
|
||||||
};
|
};
|
||||||
Ok(match builtin {
|
if !matches!(builtin, Builtin::Nz | Builtin::SafeDiv | Builtin::Iff)
|
||||||
|
&& args.iter().any(|value| matches!(value, Value::Missing(_)))
|
||||||
|
{
|
||||||
|
return Ok(Value::Missing(if builtin == Builtin::Between {
|
||||||
|
ValueType::Boolean
|
||||||
|
} else {
|
||||||
|
ValueType::Number
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
let result = match builtin {
|
||||||
Builtin::Round => Value::Number(numeric(0)?.round()),
|
Builtin::Round => Value::Number(numeric(0)?.round()),
|
||||||
Builtin::Floor => Value::Number(numeric(0)?.floor()),
|
Builtin::Floor => Value::Number(numeric(0)?.floor()),
|
||||||
Builtin::Ceil => Value::Number(numeric(0)?.ceil()),
|
Builtin::Ceil => Value::Number(numeric(0)?.ceil()),
|
||||||
@@ -393,7 +467,13 @@ fn eval_builtin(builtin: Builtin, args: &[Value]) -> Result<Value, EvalError> {
|
|||||||
Builtin::Pow => Value::Number(numeric(0)?.powf(numeric(1)?)),
|
Builtin::Pow => Value::Number(numeric(0)?.powf(numeric(1)?)),
|
||||||
Builtin::Log => Value::Number(numeric(0)?.ln()),
|
Builtin::Log => Value::Number(numeric(0)?.ln()),
|
||||||
Builtin::Exp => Value::Number(numeric(0)?.exp()),
|
Builtin::Exp => Value::Number(numeric(0)?.exp()),
|
||||||
Builtin::Clamp => Value::Number(numeric(0)?.clamp(numeric(1)?, numeric(2)?)),
|
Builtin::Clamp => {
|
||||||
|
let (value, low, high) = (numeric(0)?, numeric(1)?, numeric(2)?);
|
||||||
|
if low > high {
|
||||||
|
return Err(EvalError::new("clamp lower bound exceeds upper bound"));
|
||||||
|
}
|
||||||
|
Value::Number(value.clamp(low, high))
|
||||||
|
}
|
||||||
Builtin::Between => {
|
Builtin::Between => {
|
||||||
let value = numeric(0)?;
|
let value = numeric(0)?;
|
||||||
Value::Boolean(value >= numeric(1)? && value <= numeric(2)?)
|
Value::Boolean(value >= numeric(1)? && value <= numeric(2)?)
|
||||||
@@ -417,13 +497,15 @@ fn eval_builtin(builtin: Builtin, args: &[Value]) -> Result<Value, EvalError> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
Builtin::Iff => {
|
Builtin::Iff => {
|
||||||
let condition = args
|
let condition = match args.first().copied() {
|
||||||
.first()
|
Some(Value::Boolean(value)) => value,
|
||||||
.and_then(|value| value.as_bool())
|
Some(Value::Missing(ValueType::Boolean)) => false,
|
||||||
.ok_or_else(|| EvalError::new("iff condition must be boolean"))?;
|
_ => return Err(EvalError::new("iff condition must be boolean")),
|
||||||
|
};
|
||||||
if condition { args[1] } else { args[2] }
|
if condition { args[1] } else { args[2] }
|
||||||
}
|
}
|
||||||
})
|
};
|
||||||
|
Ok(result.normalized())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
@@ -1119,24 +1201,20 @@ where
|
|||||||
let lhs_type = self.expression(lhs)?;
|
let lhs_type = self.expression(lhs)?;
|
||||||
require_type(lhs_type, ValueType::Boolean, position)?;
|
require_type(lhs_type, ValueType::Boolean, position)?;
|
||||||
let branch = self.instructions.len();
|
let branch = self.instructions.len();
|
||||||
self.instructions.push(match operator {
|
self.instructions.push(Instruction::ShortCircuit {
|
||||||
ParsedBinaryOp::And => Instruction::JumpIfFalse(usize::MAX),
|
on: operator == ParsedBinaryOp::Or,
|
||||||
ParsedBinaryOp::Or => Instruction::JumpIfTrue(usize::MAX),
|
target: usize::MAX,
|
||||||
_ => unreachable!(),
|
|
||||||
});
|
});
|
||||||
let rhs_type = self.expression(rhs)?;
|
let rhs_type = self.expression(rhs)?;
|
||||||
require_type(rhs_type, ValueType::Boolean, rhs.position())?;
|
require_type(rhs_type, ValueType::Boolean, rhs.position())?;
|
||||||
let end_jump = self.instructions.len();
|
|
||||||
self.instructions.push(Instruction::Jump(usize::MAX));
|
|
||||||
let short_target = self.instructions.len();
|
|
||||||
self.instructions
|
self.instructions
|
||||||
.push(Instruction::Push(Value::Boolean(matches!(
|
.push(Instruction::Binary(if operator == ParsedBinaryOp::And {
|
||||||
operator,
|
BinaryOp::And
|
||||||
ParsedBinaryOp::Or
|
} else {
|
||||||
))));
|
BinaryOp::Or
|
||||||
|
}));
|
||||||
let end_target = self.instructions.len();
|
let end_target = self.instructions.len();
|
||||||
patch_jump(&mut self.instructions, branch, short_target)?;
|
patch_jump(&mut self.instructions, branch, end_target)?;
|
||||||
patch_jump(&mut self.instructions, end_jump, end_target)?;
|
|
||||||
return Ok(ValueType::Boolean);
|
return Ok(ValueType::Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1285,7 +1363,7 @@ fn patch_jump(
|
|||||||
};
|
};
|
||||||
match instruction {
|
match instruction {
|
||||||
Instruction::JumpIfFalse(value)
|
Instruction::JumpIfFalse(value)
|
||||||
| Instruction::JumpIfTrue(value)
|
| Instruction::ShortCircuit { target: value, .. }
|
||||||
| Instruction::Jump(value) => {
|
| Instruction::Jump(value) => {
|
||||||
*value = target;
|
*value = target;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1394,6 +1472,93 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nullable_boolean_truth_table_preserves_unknown_under_negation() {
|
||||||
|
let unknown = Value::Missing(ValueType::Boolean);
|
||||||
|
let states = [Value::Boolean(false), Value::Boolean(true), unknown];
|
||||||
|
let and = [
|
||||||
|
[states[0], states[0], states[0]],
|
||||||
|
[states[0], states[1], unknown],
|
||||||
|
[states[0], unknown, unknown],
|
||||||
|
];
|
||||||
|
let or = [
|
||||||
|
[states[0], states[1], unknown],
|
||||||
|
[states[1], states[1], states[1]],
|
||||||
|
[unknown, states[1], unknown],
|
||||||
|
];
|
||||||
|
for (i, lhs) in states.iter().enumerate() {
|
||||||
|
for (j, rhs) in states.iter().enumerate() {
|
||||||
|
let values = [("lhs", *lhs), ("rhs", *rhs)];
|
||||||
|
assert_eq!(evaluate("lhs && rhs", &values), and[i][j]);
|
||||||
|
assert_eq!(evaluate("lhs || rhs", &values), or[i][j]);
|
||||||
|
assert_eq!(evaluate("!!(lhs && rhs)", &values), and[i][j]);
|
||||||
|
assert_eq!(evaluate("!!(lhs || rhs)", &values), or[i][j]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(evaluate("!value", &[("value", unknown)]), unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_numeric_operands_do_not_become_boolean_false_or_zero() {
|
||||||
|
let unknown = Value::Missing(ValueType::Boolean);
|
||||||
|
for missing in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||||
|
for operator in ["==", "!=", "<", "<=", ">", ">="] {
|
||||||
|
let values = [("value", Value::Number(missing))];
|
||||||
|
assert_eq!(evaluate(&format!("value {operator} 0.0"), &values), unknown);
|
||||||
|
assert_eq!(
|
||||||
|
evaluate(&format!("!(0.0 {operator} value)"), &values),
|
||||||
|
unknown
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
evaluate(&format!("!((value + 1.0) {operator} 0.0)"), &values),
|
||||||
|
unknown
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let missing = [("value", Value::Number(f64::NAN))];
|
||||||
|
assert_eq!(evaluate("!(min(value, 1.0) > 0.0)", &missing), unknown);
|
||||||
|
assert_eq!(evaluate("!between(value, 0.0, 1.0)", &missing), unknown);
|
||||||
|
assert_eq!(evaluate("!(1.0 / 0.0 > 0.0)", &[]), unknown);
|
||||||
|
assert_eq!(evaluate("!(sqrt(-1.0) > 0.0)", &[]), unknown);
|
||||||
|
assert_eq!(evaluate("nz(value, 7.0)", &missing), Value::Number(7.0));
|
||||||
|
assert_eq!(
|
||||||
|
evaluate("nz(value, 0.0) == 0.0", &missing),
|
||||||
|
Value::Boolean(true)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nullable_short_circuit_and_explicit_conditional_branches_are_lazy() {
|
||||||
|
for source in [
|
||||||
|
"false && missing",
|
||||||
|
"true || missing",
|
||||||
|
"if true { true } else { missing }",
|
||||||
|
] {
|
||||||
|
let program = compile(source, |_| Some(ValueType::Boolean)).unwrap();
|
||||||
|
program
|
||||||
|
.evaluate(&mut Scratch::default(), |_, _, _| {
|
||||||
|
Err(EvalError::new("unused input must not be resolved"))
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let unknown = Value::Missing(ValueType::Boolean);
|
||||||
|
assert_eq!(
|
||||||
|
evaluate("if value { 1.0 } else { 2.0 }", &[("value", unknown)]),
|
||||||
|
Value::Number(2.0)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
evaluate("iff(value, 1.0, 2.0)", &[("value", unknown)]),
|
||||||
|
Value::Number(2.0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_clamp_returns_error_without_panicking() {
|
||||||
|
let program = compile("clamp(1.0, 2.0, 0.0)", |_| None).unwrap();
|
||||||
|
let result = program.evaluate(&mut Scratch::default(), |_, _, _| unreachable!());
|
||||||
|
assert!(result.unwrap_err().to_string().contains("lower bound"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn short_circuit_does_not_resolve_unused_variable() {
|
fn short_circuit_does_not_resolve_unused_variable() {
|
||||||
let program = compile("false && missing", |name| {
|
let program = compile("false && missing", |name| {
|
||||||
|
|||||||
@@ -1388,6 +1388,15 @@ pub struct PlatformSelectionQuotePlan {
|
|||||||
pub diagnostics: Vec<String>,
|
pub diagnostics: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn checked_rhai_comparison(
|
||||||
|
operator: &str,
|
||||||
|
lhs: f64,
|
||||||
|
rhs: f64,
|
||||||
|
) -> Result<bool, Box<rhai::EvalAltResult>> {
|
||||||
|
numeric_expr_vm::finite_comparison(operator, lhs, rhs)
|
||||||
|
.ok_or_else(|| format!("missing_numeric_operand: operator={operator}").into())
|
||||||
|
}
|
||||||
|
|
||||||
fn platform_safe_div(lhs: f64, rhs: f64, fallback: f64) -> f64 {
|
fn platform_safe_div(lhs: f64, rhs: f64, fallback: f64) -> f64 {
|
||||||
if rhs.abs() <= f64::EPSILON {
|
if rhs.abs() <= f64::EPSILON {
|
||||||
fallback
|
fallback
|
||||||
@@ -1565,21 +1574,48 @@ impl PlatformExprStrategy {
|
|||||||
|
|
||||||
pub fn new(config: PlatformExprStrategyConfig) -> Self {
|
pub fn new(config: PlatformExprStrategyConfig) -> Self {
|
||||||
let mut engine = Engine::new();
|
let mut engine = Engine::new();
|
||||||
|
// Dynamic scripts cannot carry a nullable boolean through Rhai's
|
||||||
|
// logical operators. Reject an unknown comparison instead of letting
|
||||||
|
// native NaN comparisons turn missing data into a buy/sell signal.
|
||||||
|
for operator in ["==", "!=", "<", "<=", ">", ">="] {
|
||||||
|
engine.register_fn(operator, move |lhs: f64, rhs: f64| {
|
||||||
|
checked_rhai_comparison(operator, lhs, rhs)
|
||||||
|
});
|
||||||
|
engine.register_fn(operator, move |lhs: f64, rhs: i64| {
|
||||||
|
checked_rhai_comparison(operator, lhs, rhs as f64)
|
||||||
|
});
|
||||||
|
engine.register_fn(operator, move |lhs: i64, rhs: f64| {
|
||||||
|
checked_rhai_comparison(operator, lhs as f64, rhs)
|
||||||
|
});
|
||||||
|
}
|
||||||
engine.register_fn("round", |value: f64| value.round());
|
engine.register_fn("round", |value: f64| value.round());
|
||||||
engine.register_fn("floor", |value: f64| value.floor());
|
engine.register_fn("floor", |value: f64| value.floor());
|
||||||
engine.register_fn("ceil", |value: f64| value.ceil());
|
engine.register_fn("ceil", |value: f64| value.ceil());
|
||||||
engine.register_fn("abs", |value: f64| value.abs());
|
engine.register_fn("abs", |value: f64| value.abs());
|
||||||
engine.register_fn("min", |lhs: f64, rhs: f64| lhs.min(rhs));
|
engine.register_fn("min", |lhs: f64, rhs: f64| {
|
||||||
engine.register_fn("max", |lhs: f64, rhs: f64| lhs.max(rhs));
|
if lhs.is_finite() && rhs.is_finite() { lhs.min(rhs) } else { f64::NAN }
|
||||||
|
});
|
||||||
|
engine.register_fn("max", |lhs: f64, rhs: f64| {
|
||||||
|
if lhs.is_finite() && rhs.is_finite() { lhs.max(rhs) } else { f64::NAN }
|
||||||
|
});
|
||||||
engine.register_fn("sqrt", |value: f64| value.sqrt());
|
engine.register_fn("sqrt", |value: f64| value.sqrt());
|
||||||
engine.register_fn("pow", |lhs: f64, rhs: f64| lhs.powf(rhs));
|
engine.register_fn("pow", |lhs: f64, rhs: f64| lhs.powf(rhs));
|
||||||
engine.register_fn("log", |value: f64| value.ln());
|
engine.register_fn("log", |value: f64| value.ln());
|
||||||
engine.register_fn("exp", |value: f64| value.exp());
|
engine.register_fn("exp", |value: f64| value.exp());
|
||||||
engine.register_fn("clamp", |value: f64, low: f64, high: f64| {
|
engine.register_fn("clamp", |value: f64, low: f64, high: f64| -> Result<f64, Box<rhai::EvalAltResult>> {
|
||||||
value.clamp(low, high)
|
if !value.is_finite() || !low.is_finite() || !high.is_finite() {
|
||||||
|
return Ok(f64::NAN);
|
||||||
|
}
|
||||||
|
if low > high {
|
||||||
|
return Err("clamp lower bound exceeds upper bound".into());
|
||||||
|
}
|
||||||
|
Ok(value.clamp(low, high))
|
||||||
});
|
});
|
||||||
engine.register_fn("between", |value: f64, low: f64, high: f64| {
|
engine.register_fn("between", |value: f64, low: f64, high: f64| -> Result<bool, Box<rhai::EvalAltResult>> {
|
||||||
value >= low && value <= high
|
if !value.is_finite() || !low.is_finite() || !high.is_finite() {
|
||||||
|
return Err("missing_numeric_operand: function=between".into());
|
||||||
|
}
|
||||||
|
Ok(value >= low && value <= high)
|
||||||
});
|
});
|
||||||
engine.register_fn(
|
engine.register_fn(
|
||||||
"nz",
|
"nz",
|
||||||
@@ -8330,6 +8366,7 @@ impl PlatformExprStrategy {
|
|||||||
return match value {
|
return match value {
|
||||||
NumericVmValue::Number(number) => Ok(number),
|
NumericVmValue::Number(number) => Ok(number),
|
||||||
NumericVmValue::Boolean(boolean) => Ok(if boolean { 1.0 } else { 0.0 }),
|
NumericVmValue::Boolean(boolean) => Ok(if boolean { 1.0 } else { 0.0 }),
|
||||||
|
NumericVmValue::Missing(_) => Ok(f64::NAN),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
let value = self.eval_dynamic(ctx, expr, day, stock, position)?;
|
let value = self.eval_dynamic(ctx, expr, day, stock, position)?;
|
||||||
@@ -8360,6 +8397,7 @@ impl PlatformExprStrategy {
|
|||||||
return match value {
|
return match value {
|
||||||
NumericVmValue::Boolean(boolean) => Ok(boolean),
|
NumericVmValue::Boolean(boolean) => Ok(boolean),
|
||||||
NumericVmValue::Number(number) => Ok(number.is_finite() && number != 0.0),
|
NumericVmValue::Number(number) => Ok(number.is_finite() && number != 0.0),
|
||||||
|
NumericVmValue::Missing(_) => Ok(false),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
let value = self.eval_dynamic(ctx, expr, day, stock, position)?;
|
let value = self.eval_dynamic(ctx, expr, day, stock, position)?;
|
||||||
@@ -16604,6 +16642,33 @@ mod tests {
|
|||||||
assert!(missing_stock.turnover_ratio.is_nan());
|
assert!(missing_stock.turnover_ratio.is_nan());
|
||||||
assert!(missing_stock.effective_turnover_ratio.is_nan());
|
assert!(missing_stock.effective_turnover_ratio.is_nan());
|
||||||
assert_eq!(present_stock.turnover_ratio, 0.0);
|
assert_eq!(present_stock.turnover_ratio, 0.0);
|
||||||
|
for predicate in [
|
||||||
|
"!(model_score > 0.0)",
|
||||||
|
"!(model_score != 0.0)",
|
||||||
|
"!between(model_score, 0.0, 1.0)",
|
||||||
|
"!(min(model_score, 1.0) > 0.0)",
|
||||||
|
"!(model_score > 0.0) || false",
|
||||||
|
] {
|
||||||
|
assert!(!strategy.eval_bool(&ctx, predicate, &day, Some(&missing_stock), None).unwrap(), "{predicate}");
|
||||||
|
}
|
||||||
|
for predicate in [
|
||||||
|
"!(model_score > 0.0) || true",
|
||||||
|
"!(model_score > 0.0 && false)",
|
||||||
|
] {
|
||||||
|
assert!(strategy.eval_bool(&ctx, predicate, &day, Some(&missing_stock), None).unwrap(), "{predicate}");
|
||||||
|
}
|
||||||
|
for predicate in [
|
||||||
|
"symbol == \"000001.SZ\" && !(model_score > 0.0)",
|
||||||
|
"symbol == \"000001.SZ\" && !(model_score > 0)",
|
||||||
|
"symbol == \"000001.SZ\" && !(0 < model_score)",
|
||||||
|
] {
|
||||||
|
let error = strategy.eval_bool(&ctx, predicate, &day, Some(&missing_stock), None).unwrap_err();
|
||||||
|
assert!(error.to_string().contains("missing_numeric_operand"), "{error}");
|
||||||
|
}
|
||||||
|
assert!(!strategy.eval_bool(
|
||||||
|
&ctx, "symbol == \"OTHER\" && !(model_score > 0.0)",
|
||||||
|
&day, Some(&missing_stock), None,
|
||||||
|
).unwrap());
|
||||||
assert!(!strategy.eval_bool(&ctx, "model_score", &day, Some(&missing_stock), None).unwrap());
|
assert!(!strategy.eval_bool(&ctx, "model_score", &day, Some(&missing_stock), None).unwrap());
|
||||||
assert!(strategy.eval_bool(&ctx, "model_score", &day, Some(&present_stock), None).unwrap());
|
assert!(strategy.eval_bool(&ctx, "model_score", &day, Some(&present_stock), None).unwrap());
|
||||||
for field in ["turnover_ratio", "effective_turnover_ratio"] {
|
for field in ["turnover_ratio", "effective_turnover_ratio"] {
|
||||||
|
|||||||
Reference in New Issue
Block a user