improve: dependent types review — cycle detection, precision, error quality

- Fix ast_to_pred_expr silent Value fallback → opaque Call prevents false positives
- check_refinement now handles BoolLit/StringLit with human-readable display
- Integer equality/inequality uses exact i64 via try_resolve_ints (no f64 loss)
- Type::Refined handled in unwrap_reactive + new unwrap_refined() utility
- Type alias cycle detection via type_expr_references in Pass 0
- 9 new tests: predicate display, evaluate_static (int/compound/eq_precision),
  unwrap_refined, refined type display, cycle detection, readable violations

127 workspace tests pass (25 in ds-types), 0 failures
This commit is contained in:
enzotar 2026-02-26 11:21:43 -08:00
parent 9ef28bb53a
commit 61c26acfa7
2 changed files with 235 additions and 6 deletions

View file

@ -57,9 +57,16 @@ impl TypeChecker {
/// Check an entire program.
pub fn check_program(&mut self, program: &Program) {
// Pass 0: register type aliases
// Pass 0: register type aliases (with cycle detection)
for decl in &program.declarations {
if let Declaration::TypeAlias(alias) = decl {
// Check for direct self-reference cycle
if Self::type_expr_references(&alias.definition, &alias.name) {
self.error(TypeErrorKind::TypeAliasCycle {
name: alias.name.clone(),
});
continue;
}
let resolved = self.resolve_type_expr(&alias.definition);
self.type_aliases.insert(alias.name.clone(), resolved);
}
@ -253,6 +260,12 @@ impl TypeChecker {
fn ast_to_pred_expr(expr: &Expr) -> PredicateExpr {
match expr {
Expr::Ident(name) if name == "value" => PredicateExpr::Value,
Expr::Ident(name) => {
// Non-value ident in a predicate — treat as an unresolvable reference.
// This allows predicates like `value > min_threshold` where
// min_threshold won't be statically evaluated.
PredicateExpr::Call(name.clone(), vec![]) // model as zero-arg "call"
}
Expr::IntLit(n) => PredicateExpr::IntLit(*n),
Expr::FloatLit(f) => PredicateExpr::FloatLit(*f),
Expr::StringLit(s) => {
@ -269,7 +282,11 @@ impl TypeChecker {
.collect();
PredicateExpr::Call(name.clone(), pred_args)
}
_ => PredicateExpr::Value, // fallback
_ => {
// Unrecognized expression — model as opaque. This will prevent
// static evaluation (returns None) rather than silently succeeding.
PredicateExpr::Call("<expr>".to_string(), vec![])
}
}
}
@ -280,18 +297,26 @@ impl TypeChecker {
&mut self,
predicate: &Predicate,
value_expr: &Expr,
var_name: &str,
_var_name: &str,
type_ann: &TypeExpr,
) {
// Try to get a static value from the expression
let static_val = match value_expr {
Expr::IntLit(n) => Some(PredicateExpr::IntLit(*n)),
Expr::FloatLit(f) => Some(PredicateExpr::FloatLit(*f)),
Expr::BoolLit(b) => Some(PredicateExpr::BoolLit(*b)),
Expr::StringLit(s) => {
let text: String = s.segments.iter().map(|seg| match seg {
ds_parser::StringSegment::Literal(l) => l.clone(),
_ => String::new(),
}).collect();
Some(PredicateExpr::StringLit(text))
}
_ => None,
};
if let Some(val) = static_val {
if let Some(result) = predicate.evaluate_static(&val) {
if let Some(ref val) = static_val {
if let Some(result) = predicate.evaluate_static(val) {
if !result {
// Static violation — compile-time error
let type_name = match type_ann {
@ -302,10 +327,20 @@ impl TypeChecker {
},
_ => "<unknown>".to_string(),
};
let value_display = match value_expr {
Expr::IntLit(n) => n.to_string(),
Expr::FloatLit(f) => f.to_string(),
Expr::BoolLit(b) => b.to_string(),
Expr::StringLit(_) => match val {
PredicateExpr::StringLit(s) => format!("\"{}\"", s),
_ => format!("{:?}", value_expr),
},
_ => format!("{:?}", value_expr),
};
self.error(TypeErrorKind::RefinementViolation {
type_name,
predicate: predicate.display(),
value: format!("{:?}", value_expr),
value: value_display,
});
}
}
@ -313,6 +348,17 @@ impl TypeChecker {
// Dynamic values: accepted (codegen emits runtime guard)
}
/// Check if a TypeExpr references a given name (for cycle detection).
fn type_expr_references(type_expr: &TypeExpr, name: &str) -> bool {
match type_expr {
TypeExpr::Named(n) => n == name,
TypeExpr::Generic(n, params) => {
n == name || params.iter().any(|p| Self::type_expr_references(p, name))
}
TypeExpr::Refined { base, .. } => Self::type_expr_references(base, name),
}
}
/// Check a view declaration.
fn check_view(&mut self, view: &ViewDecl) {
self.in_view = true;
@ -917,4 +963,48 @@ mod tests {
checker.check_program(&program);
assert!(!checker.has_errors(), "Errors: {}", checker.display_errors());
}
#[test]
fn test_type_alias_cycle_detection() {
let mut checker = TypeChecker::new();
// type Foo = Foo (self-referential cycle)
let program = make_program(vec![
Declaration::TypeAlias(ds_parser::TypeAliasDecl {
name: "Foo".to_string(),
definition: ds_parser::TypeExpr::Named("Foo".to_string()),
span: span(),
}),
]);
checker.check_program(&program);
assert!(checker.has_errors());
let msg = checker.display_errors();
assert!(msg.contains("TYPE ALIAS CYCLE"), "Expected cycle error, got: {}", msg);
assert!(msg.contains("Foo"));
}
#[test]
fn test_refinement_violation_shows_readable_value() {
let mut checker = TypeChecker::new();
// let x: Int where value > 0 = -42
let program = make_program(vec![
Declaration::Let(LetDecl {
name: "x".to_string(),
type_annotation: Some(ds_parser::TypeExpr::Refined {
base: Box::new(ds_parser::TypeExpr::Named("Int".to_string())),
predicate: Box::new(Expr::BinOp(
Box::new(Expr::Ident("value".to_string())),
BinOp::Gt,
Box::new(Expr::IntLit(0)),
)),
}),
value: Expr::IntLit(-42),
span: span(),
}),
]);
checker.check_program(&program);
assert!(checker.has_errors());
let msg = checker.display_errors();
assert!(msg.contains("-42"), "Error should show the literal value, got: {}", msg);
assert!(msg.contains("REFINEMENT VIOLATED"));
}
}

View file

@ -176,11 +176,18 @@ impl Predicate {
Some(lv <= rv)
}
Predicate::Eq(l, r) => {
// Prefer exact integer comparison to avoid f64 precision issues
if let Some((lv, rv)) = Self::try_resolve_ints(l, r, value) {
return Some(lv == rv);
}
let lv = Self::resolve_expr(l, value)?;
let rv = Self::resolve_expr(r, value)?;
Some(lv == rv)
}
Predicate::Neq(l, r) => {
if let Some((lv, rv)) = Self::try_resolve_ints(l, r, value) {
return Some(lv != rv);
}
let lv = Self::resolve_expr(l, value)?;
let rv = Self::resolve_expr(r, value)?;
Some(lv != rv)
@ -215,6 +222,27 @@ impl Predicate {
_ => None,
}
}
/// Try to resolve both as integers for exact comparison (avoids f64 precision loss).
fn try_resolve_ints(l: &PredicateExpr, r: &PredicateExpr, value: &PredicateExpr) -> Option<(i64, i64)> {
let lv = match l {
PredicateExpr::Value => match value {
PredicateExpr::IntLit(n) => Some(*n),
_ => None,
},
PredicateExpr::IntLit(n) => Some(*n),
_ => None,
}?;
let rv = match r {
PredicateExpr::Value => match value {
PredicateExpr::IntLit(n) => Some(*n),
_ => None,
},
PredicateExpr::IntLit(n) => Some(*n),
_ => None,
}?;
Some((lv, rv))
}
}
impl PredicateExpr {
@ -238,6 +266,15 @@ impl Type {
pub fn unwrap_reactive(&self) -> &Type {
match self {
Type::Signal(inner) | Type::Derived(inner) => inner,
Type::Refined { base, .. } => base.unwrap_reactive(),
other => other,
}
}
/// Strip refinement wrapper, returning the base type.
pub fn unwrap_refined(&self) -> &Type {
match self {
Type::Refined { base, .. } => base.unwrap_refined(),
other => other,
}
}
@ -338,4 +375,106 @@ mod tests {
// Non-reactive returns self
assert_eq!(*Type::Int.unwrap_reactive(), Type::Int);
}
#[test]
fn test_predicate_display() {
let pred = Predicate::Gt(
Box::new(PredicateExpr::Value),
Box::new(PredicateExpr::IntLit(0)),
);
assert_eq!(pred.display(), "value > 0");
let compound = Predicate::And(
Box::new(Predicate::Gte(
Box::new(PredicateExpr::Value),
Box::new(PredicateExpr::IntLit(0)),
)),
Box::new(Predicate::Lte(
Box::new(PredicateExpr::Value),
Box::new(PredicateExpr::IntLit(100)),
)),
);
assert_eq!(compound.display(), "value >= 0 and value <= 100");
}
#[test]
fn test_evaluate_static_int() {
let pred = Predicate::Gt(
Box::new(PredicateExpr::Value),
Box::new(PredicateExpr::IntLit(0)),
);
assert_eq!(pred.evaluate_static(&PredicateExpr::IntLit(5)), Some(true));
assert_eq!(pred.evaluate_static(&PredicateExpr::IntLit(0)), Some(false));
assert_eq!(pred.evaluate_static(&PredicateExpr::IntLit(-1)), Some(false));
}
#[test]
fn test_evaluate_static_eq_integer_precision() {
// Test that integer equality uses exact comparison, not f64
let pred = Predicate::Eq(
Box::new(PredicateExpr::Value),
Box::new(PredicateExpr::IntLit(42)),
);
assert_eq!(pred.evaluate_static(&PredicateExpr::IntLit(42)), Some(true));
assert_eq!(pred.evaluate_static(&PredicateExpr::IntLit(43)), Some(false));
}
#[test]
fn test_evaluate_static_compound() {
// value >= 0 and value <= 100
let pred = Predicate::And(
Box::new(Predicate::Gte(
Box::new(PredicateExpr::Value),
Box::new(PredicateExpr::IntLit(0)),
)),
Box::new(Predicate::Lte(
Box::new(PredicateExpr::Value),
Box::new(PredicateExpr::IntLit(100)),
)),
);
assert_eq!(pred.evaluate_static(&PredicateExpr::IntLit(50)), Some(true));
assert_eq!(pred.evaluate_static(&PredicateExpr::IntLit(0)), Some(true));
assert_eq!(pred.evaluate_static(&PredicateExpr::IntLit(100)), Some(true));
assert_eq!(pred.evaluate_static(&PredicateExpr::IntLit(-1)), Some(false));
assert_eq!(pred.evaluate_static(&PredicateExpr::IntLit(101)), Some(false));
}
#[test]
fn test_unwrap_refined() {
let refined = Type::Refined {
base: Box::new(Type::Int),
predicate: Predicate::Gt(
Box::new(PredicateExpr::Value),
Box::new(PredicateExpr::IntLit(0)),
),
};
assert_eq!(*refined.unwrap_refined(), Type::Int);
// Non-refined returns self
assert_eq!(*Type::Int.unwrap_refined(), Type::Int);
}
#[test]
fn test_refined_type_display() {
let refined = Type::Refined {
base: Box::new(Type::Int),
predicate: Predicate::Gt(
Box::new(PredicateExpr::Value),
Box::new(PredicateExpr::IntLit(0)),
),
};
assert_eq!(refined.display(), "Int where value > 0");
}
#[test]
fn test_refined_unwrap_reactive() {
// Refined wrapping a non-reactive type
let refined = Type::Refined {
base: Box::new(Type::Int),
predicate: Predicate::Gt(
Box::new(PredicateExpr::Value),
Box::new(PredicateExpr::IntLit(0)),
),
};
assert_eq!(*refined.unwrap_reactive(), Type::Int);
}
}