From fda2e70456e67cbdd90470b93a4a028610e854ef Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 9 Sep 2026 03:56:44 +0800 Subject: [PATCH] fix: preserve unknown numeric conditions through boolean expressions --- crates/fidc-core/src/numeric_expr_vm.rs | 243 +++++++++++++++--- .../fidc-core/src/platform_expr_strategy.rs | 77 +++++- 2 files changed, 275 insertions(+), 45 deletions(-) diff --git a/crates/fidc-core/src/numeric_expr_vm.rs b/crates/fidc-core/src/numeric_expr_vm.rs index e8d4a38..171576d 100644 --- a/crates/fidc-core/src/numeric_expr_vm.rs +++ b/crates/fidc-core/src/numeric_expr_vm.rs @@ -11,6 +11,7 @@ pub(crate) enum ValueType { pub(crate) enum Value { Number(f64), Boolean(bool), + Missing(ValueType), } impl Value { @@ -18,20 +19,28 @@ impl Value { match self { Self::Number(_) => ValueType::Number, Self::Boolean(_) => ValueType::Boolean, + Self::Missing(value_type) => value_type, } } pub(crate) fn as_number(self) -> Option { match self { Self::Number(value) => Some(value), - Self::Boolean(_) => None, + Self::Boolean(_) | Self::Missing(_) => None, } } pub(crate) fn as_bool(self) -> Option { match self { 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, Greater, GreaterEqual, + And, + Or, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -134,7 +145,7 @@ enum Instruction { Binary(BinaryOp), Call { builtin: Builtin, argc: u8 }, JumpIfFalse(usize), - JumpIfTrue(usize), + ShortCircuit { on: bool, target: usize }, Jump(usize), Return, } @@ -165,7 +176,7 @@ impl Program { let mut pc = 0usize; while let Some(instruction) = self.instructions.get(pc) { match *instruction { - Instruction::Push(value) => scratch.stack.push(value), + Instruction::Push(value) => scratch.stack.push(value.normalized()), Instruction::LoadVariable(index) => { let index = usize::from(index); let cached = scratch.variables[index]; @@ -173,7 +184,8 @@ impl Program { Some(value) => value, None => { 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 { return Err(EvalError::new(format!( "variable {} expected {:?}, got {:?}", @@ -219,15 +231,23 @@ impl Program { scratch.stack.push(value); } 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 { pc = target; continue; } } - Instruction::JumpIfTrue(target) => { - let condition = pop_bool(&mut scratch.stack)?; - if condition { + Instruction::ShortCircuit { on, target } => { + let condition = + scratch.stack.last().copied().ok_or_else(|| { + EvalError::new("stack underflow during short circuit") + })?; + if condition.as_bool() == Some(on) { pc = target; continue; } @@ -284,31 +304,56 @@ fn pop(stack: &mut Vec) -> Result { stack.pop().ok_or_else(|| EvalError::new("stack underflow")) } -fn pop_bool(stack: &mut Vec) -> Result { - pop(stack)? - .as_bool() - .ok_or_else(|| EvalError::new("boolean operand required")) -} - fn number(value: Value) -> Result { + if value == Value::Missing(ValueType::Number) { + return Ok(f64::NAN); + } value .as_number() .ok_or_else(|| EvalError::new("numeric operand required")) } fn eval_unary(operator: UnaryOp, value: Value) -> Result { - match operator { + if matches!(value, Value::Missing(_)) { + return Ok(value); + } + let result: Result = match operator { UnaryOp::Negate => Ok(Value::Number(-number(value)?)), UnaryOp::Not => { Ok(Value::Boolean(!value.as_bool().ok_or_else(|| { EvalError::new("boolean operand required for !") })?)) } - } + }; + Ok(result?.normalized()) } fn eval_binary(operator: BinaryOp, lhs: Value, rhs: Value) -> Result { - 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 = match operator { BinaryOp::Add => 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)?)), @@ -356,7 +401,27 @@ fn eval_binary(operator: BinaryOp, lhs: Value, rhs: Value) -> Result -f64::EPSILON, )) } + BinaryOp::And | BinaryOp::Or => unreachable!(), + }; + Ok(result?.normalized()) +} + +pub(crate) fn finite_comparison(operator: &str, lhs: f64, rhs: f64) -> Option { + 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 { @@ -382,7 +447,16 @@ fn eval_builtin(builtin: Builtin, args: &[Value]) -> Result { .ok_or_else(|| EvalError::new("missing builtin argument")) .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::Floor => Value::Number(numeric(0)?.floor()), Builtin::Ceil => Value::Number(numeric(0)?.ceil()), @@ -393,7 +467,13 @@ fn eval_builtin(builtin: Builtin, args: &[Value]) -> Result { Builtin::Pow => Value::Number(numeric(0)?.powf(numeric(1)?)), Builtin::Log => Value::Number(numeric(0)?.ln()), 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 => { let value = numeric(0)?; Value::Boolean(value >= numeric(1)? && value <= numeric(2)?) @@ -417,13 +497,15 @@ fn eval_builtin(builtin: Builtin, args: &[Value]) -> Result { }) } Builtin::Iff => { - let condition = args - .first() - .and_then(|value| value.as_bool()) - .ok_or_else(|| EvalError::new("iff condition must be boolean"))?; + let condition = match args.first().copied() { + Some(Value::Boolean(value)) => value, + Some(Value::Missing(ValueType::Boolean)) => false, + _ => return Err(EvalError::new("iff condition must be boolean")), + }; if condition { args[1] } else { args[2] } } - }) + }; + Ok(result.normalized()) } #[derive(Debug, Clone, PartialEq)] @@ -1119,24 +1201,20 @@ where let lhs_type = self.expression(lhs)?; require_type(lhs_type, ValueType::Boolean, position)?; let branch = self.instructions.len(); - self.instructions.push(match operator { - ParsedBinaryOp::And => Instruction::JumpIfFalse(usize::MAX), - ParsedBinaryOp::Or => Instruction::JumpIfTrue(usize::MAX), - _ => unreachable!(), + self.instructions.push(Instruction::ShortCircuit { + on: operator == ParsedBinaryOp::Or, + target: usize::MAX, }); let rhs_type = self.expression(rhs)?; 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 - .push(Instruction::Push(Value::Boolean(matches!( - operator, - ParsedBinaryOp::Or - )))); + .push(Instruction::Binary(if operator == ParsedBinaryOp::And { + BinaryOp::And + } else { + BinaryOp::Or + })); let end_target = self.instructions.len(); - patch_jump(&mut self.instructions, branch, short_target)?; - patch_jump(&mut self.instructions, end_jump, end_target)?; + patch_jump(&mut self.instructions, branch, end_target)?; return Ok(ValueType::Boolean); } @@ -1285,7 +1363,7 @@ fn patch_jump( }; match instruction { Instruction::JumpIfFalse(value) - | Instruction::JumpIfTrue(value) + | Instruction::ShortCircuit { target: value, .. } | Instruction::Jump(value) => { *value = target; 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] fn short_circuit_does_not_resolve_unused_variable() { let program = compile("false && missing", |name| { diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 73ad31a..cb7858f 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -1388,6 +1388,15 @@ pub struct PlatformSelectionQuotePlan { pub diagnostics: Vec, } +fn checked_rhai_comparison( + operator: &str, + lhs: f64, + rhs: f64, +) -> Result> { + 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 { if rhs.abs() <= f64::EPSILON { fallback @@ -1565,21 +1574,48 @@ impl PlatformExprStrategy { pub fn new(config: PlatformExprStrategyConfig) -> Self { 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("floor", |value: f64| value.floor()); engine.register_fn("ceil", |value: f64| value.ceil()); engine.register_fn("abs", |value: f64| value.abs()); - engine.register_fn("min", |lhs: f64, rhs: f64| lhs.min(rhs)); - engine.register_fn("max", |lhs: f64, rhs: f64| lhs.max(rhs)); + engine.register_fn("min", |lhs: f64, rhs: f64| { + 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("pow", |lhs: f64, rhs: f64| lhs.powf(rhs)); engine.register_fn("log", |value: f64| value.ln()); engine.register_fn("exp", |value: f64| value.exp()); - engine.register_fn("clamp", |value: f64, low: f64, high: f64| { - value.clamp(low, high) + engine.register_fn("clamp", |value: f64, low: f64, high: f64| -> Result> { + 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| { - value >= low && value <= high + engine.register_fn("between", |value: f64, low: f64, high: f64| -> Result> { + 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( "nz", @@ -8330,6 +8366,7 @@ impl PlatformExprStrategy { return match value { NumericVmValue::Number(number) => Ok(number), 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)?; @@ -8360,6 +8397,7 @@ impl PlatformExprStrategy { return match value { NumericVmValue::Boolean(boolean) => Ok(boolean), NumericVmValue::Number(number) => Ok(number.is_finite() && number != 0.0), + NumericVmValue::Missing(_) => Ok(false), }; } 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.effective_turnover_ratio.is_nan()); 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(&present_stock), None).unwrap()); for field in ["turnover_ratio", "effective_turnover_ratio"] {