on master help sign in
Verify refined-type introductions at compile time; fix ADT ctor-variance wire parse

Two independent fixes. (1) Refined-type introductions of a compile-time-constant value now evaluate the predicate at compile time (right-by-construction) instead of always deferring to the runtime write-check: - Record-field writes (inline/named relations, nested records): the field check site const-evals a statically-known value against the field's refined predicate when the write path has suppressed the introduction guard. - Function return position: check_const_refined_intro closes a hole where a violating constant could be laundered into a refined type with no check (it never touches a source write). - Whole-record refined aliases written to a source (Rel VP): record_refined_pred_for recovers the alias from Ty::Alias and synthesizes a whole-record predicate; check_const_rows_against_pred const-evals it per constant row at the Set arm. - base.the type-witness ascription (base.the (Nat) (0-5)) bypassed both the guard and the application const-eval; its value check now const-evals too. Supporting: extract_literal folds constant arithmetic; eval_refine_predicate_on_const renders a constant of any shape (incl. record literals) and runs pred value through the in-process JIT. Non-constant introductions still defer to the runtime write-check or are statically rejected (use refine). (2) base.compile ADT constructor-variance check was dead: the host prepends its data declarations to the expected-type wire string as "data Name = Ctor {…}", but split_host_data_decls / parse_data_decl_ctors expected the bare "Name Ctor {…}" form and bailed on the lowercase "data" keyword and "=", leaving the host ctor set EMPTY so every variance check vacuously passed. A snippet returning a ctor the host can't consume, or a payload field of the wrong type, was wrongly accepted. Parse the "data Name = …" form correctly so the covariant/contravariant ctor-set and field checks actually run. Tests: refinements.rs pins whole-record / return-position / the-ascription / runtime-nonconstant cells; persistence.rs pins ctor-subset accepted (covariant return), ctor-superset rejected, and ctor payload-field-type drift rejected.

commit5aefee78e579ad08b50f1f4e37b87b4122487c8b authoriko <ilyakooo0@gmail.com> dateFri 4 Sep 2026, 13:40 (1 month ago) parentb81982b
4 files changed+471−15
crates/knot-compiler/src/codegen.rs+45 crates/knot-compiler/src/infer.rs+209−8 crates/knot-jit/tests/persistence.rs+62 crates/knot-jit/tests/refinements.rs+155−7
crates/knot-compiler/src/codegen.rs+45
@@ -21979,10 +21979,39 @@ impl CompileLit {
2197921979
2198021980/// Extract a literal from an AST expression (peeling annotations).
2198121981pub(crate) fn extract_literal(expr: &ast::Expr) -> Option<CompileLit> {
21982+ use knot::ast::{BinOp, UnaryOp};
2198221983 match &expr.node {
2198321984 ast::ExprKind::Lit(ast::Literal::Int(s)) => s.parse::<i64>().ok().map(CompileLit::Int),
2198421985 ast::ExprKind::Lit(ast::Literal::Float(f)) => Some(CompileLit::Float(*f)),
2198521986 ast::ExprKind::Lit(ast::Literal::Text(s)) => Some(CompileLit::Text(s.clone())),
21987+ // Fold constant arithmetic so a compile-time-known value built from
21988+ // literals — e.g. `age (0 - 5)` written into a refined field — is
21989+ // recognized and checked against the predicate at compile time instead
21990+ // of deferring to the runtime write-check. Only pure numeric/string
21991+ // ops fold; anything effectful or non-constant yields None (the caller
21992+ // falls back to the runtime check).
21993+ ast::ExprKind::BinOp { op, lhs, rhs } => {
21994+ let l = extract_literal(lhs)?;
21995+ let r = extract_literal(rhs)?;
21996+ match (op, l, r) {
21997+ (BinOp::Add, CompileLit::Int(a), CompileLit::Int(b)) => Some(CompileLit::Int(a.checked_add(b)?)),
21998+ (BinOp::Sub, CompileLit::Int(a), CompileLit::Int(b)) => Some(CompileLit::Int(a.checked_sub(b)?)),
21999+ (BinOp::Mul, CompileLit::Int(a), CompileLit::Int(b)) => Some(CompileLit::Int(a.checked_mul(b)?)),
22000+ (BinOp::Div, CompileLit::Int(a), CompileLit::Int(b)) if b != 0 => Some(CompileLit::Int(a.checked_div(b)?)),
22001+ (BinOp::Mod, CompileLit::Int(a), CompileLit::Int(b)) if b != 0 => Some(CompileLit::Int(a.checked_rem(b)?)),
22002+ (BinOp::Add, CompileLit::Float(a), CompileLit::Float(b)) => Some(CompileLit::Float(a + b)),
22003+ (BinOp::Sub, CompileLit::Float(a), CompileLit::Float(b)) => Some(CompileLit::Float(a - b)),
22004+ (BinOp::Mul, CompileLit::Float(a), CompileLit::Float(b)) => Some(CompileLit::Float(a * b)),
22005+ (BinOp::Div, CompileLit::Float(a), CompileLit::Float(b)) => Some(CompileLit::Float(a / b)),
22006+ (BinOp::Concat, CompileLit::Text(a), CompileLit::Text(b)) => Some(CompileLit::Text(format!("{a}{b}"))),
22007+ _ => None,
22008+ }
22009+ }
22010+ ast::ExprKind::UnaryOp { op: UnaryOp::Neg, operand } => match extract_literal(operand)? {
22011+ CompileLit::Int(a) => Some(CompileLit::Int(a.checked_neg()?)),
22012+ CompileLit::Float(a) => Some(CompileLit::Float(-a)),
22013+ CompileLit::Text(_) => None,
22014+ },
2198622015 ast::ExprKind::Annot { expr, .. } => extract_literal(expr),
2198722016 _ => None,
2198822017 }
@@ -22050,6 +22079,22 @@ pub(crate) fn eval_refine_predicate_pub(pred: &ast::Expr, lit: &CompileLit) -> O
2205022079 eval_refine_predicate(pred, lit)
2205122080}
2205222081
22082+/// Const-check a refinement predicate against a compile-time-constant
22083+/// *expression* of any shape (not just a `CompileLit` scalar) by rendering it
22084+/// to source and running `pred expr` through the in-process JIT. This reaches
22085+/// whole-record values — e.g. `[{age (0-5)}]` written to `Rel VP` where `VP`
22086+/// is a record-level refined alias — which have no `CompileLit` representation.
22087+/// Returns `Some(bool)` when the predicate can be evaluated purely, `None`
22088+/// otherwise (caller falls back to the runtime write-check).
22089+pub(crate) fn eval_refine_predicate_on_const(
22090+ pred: &ast::Expr,
22091+ value: &ast::Expr,
22092+) -> Option<bool> {
22093+ let vsrc = knot::format::render_expr_source(value);
22094+ let src = format!("({}) ({vsrc})", knot::format::render_expr_source(pred));
22095+ eval_pure_to_bool(&src)
22096+}
22097+
2205322098/// Evaluate an expression to a boolean, with the refinement variable
2205422099/// `param_name` bound to `lit`. Returns `None` if the expression can't be
2205522100/// evaluated (e.g. references a variable other than `param_name`).
crates/knot-compiler/src/infer.rs+209−8
@@ -1617,6 +1617,133 @@ impl Infer {
16171617 }
16181618 }
16191619
1620+ /// Compile-time check: a compile-time-constant `body` flowing where the
1621+ /// *nominal refined* type `expected` is required (e.g. a function whose
1622+ /// declared return type is `Nat` returning a literal) is run through the
1623+ /// predicate now. A passing constant is right-by-construction; a failing
1624+ /// one is a compile error. Anything not statically evaluable is left to the
1625+ /// normal path (which requires `refine`). Reports the error but does not
1626+ /// alter the subsequent unification — the caller still runs its usual
1627+ /// check, so type errors surface normally on top of this refinement error.
1628+ fn check_const_refined_intro(&mut self, body: &ast::Expr, expected: &Ty) {
1629+ let applied = self.apply(expected);
1630+ let refined_name = match applied.peel_alias() {
1631+ Ty::Con(name, args) if args.is_empty() && self.refined_types.contains_key(name) => {
1632+ name.clone()
1633+ }
1634+ _ => return,
1635+ };
1636+ let Some(lit) = crate::codegen::extract_literal_with_consts(body, &self.const_literals)
1637+ else {
1638+ return;
1639+ };
1640+ let pred = self.refined_types.get(&refined_name).unwrap().1.clone();
1641+ if let Some(false) = crate::codegen::eval_refine_predicate_pub(&pred, &lit) {
1642+ self.error(
1643+ format!(
1644+ "constant {} does not satisfy the predicate for refined type `{}`",
1645+ lit.display(),
1646+ refined_name
1647+ ),
1648+ body.span,
1649+ );
1650+ }
1651+ }
1652+
1653+ /// For a relation element type that names (or structurally is) a record
1654+ /// alias carrying refined fields, synthesize its whole-record predicate.
1655+ /// Returns `(type_name, predicate)` where `predicate` is
1656+ /// `\row -> pred(row.f1) && pred(row.f2) && …`.
1657+ fn record_refined_pred_for(&self, elem: Ty) -> Option<(String, ast::Expr)> {
1658+ let applied = self.apply(&elem);
1659+ // The alias name survives as `Ty::Alias(name, _)`; look up its
1660+ // per-field refined predicates directly.
1661+ if let Ty::Alias(name, _) = &applied
1662+ && let Some(fps) = self.refined_field_preds.get(name)
1663+ && !fps.is_empty()
1664+ {
1665+ let pred = self.synthesize_record_pred(fps)?;
1666+ return Some((name.clone(), pred));
1667+ }
1668+ None
1669+ }
1670+
1671+ /// Build the whole-record predicate for a record alias from its per-field
1672+ /// predicates. Each `refined_field_preds` entry is already a row-level
1673+ /// lambda `\row -> <field-pred> row.fi`, so the whole-record predicate is
1674+ /// `\row -> p1(row) && … && pn(row)`.
1675+ fn synthesize_record_pred(&self, fps: &[(String, ast::Expr)]) -> Option<ast::Expr> {
1676+ let dummy = knot::ast::Span { start: 0, end: 0 };
1677+ let mut conj: Option<ast::Expr> = None;
1678+ for (_field, field_pred) in fps {
1679+ // `(field_pred) __row` — apply the row-level field predicate to the
1680+ // row being checked.
1681+ let applied = ast::Expr {
1682+ node: ast::ExprKind::App {
1683+ func: Box::new(field_pred.clone()),
1684+ arg: Box::new(ast::Expr {
1685+ node: ast::ExprKind::Var(ast::Binding::User("__row".to_string())),
1686+ span: dummy,
1687+ }),
1688+ },
1689+ span: dummy,
1690+ };
1691+ conj = Some(match conj {
1692+ None => applied,
1693+ Some(acc) => ast::Expr {
1694+ node: ast::ExprKind::BinOp {
1695+ op: ast::BinOp::And,
1696+ lhs: Box::new(acc),
1697+ rhs: Box::new(applied),
1698+ },
1699+ span: dummy,
1700+ },
1701+ });
1702+ }
1703+ let conj = conj?;
1704+ Some(ast::Expr {
1705+ node: ast::ExprKind::Lambda {
1706+ params: vec![ast::Pat {
1707+ node: ast::PatKind::Var("__row".into()),
1708+ span: dummy,
1709+ }],
1710+ ty_params: Vec::new(),
1711+ body: Box::new(conj),
1712+ },
1713+ span: dummy,
1714+ })
1715+ }
1716+
1717+ /// Compile-time check for a whole-record refined element type written to a
1718+ /// source: evaluate the record-level predicate against each constant row
1719+ /// literal in the written value. A passing row is right-by-construction; a
1720+ /// failing one is a compile error rather than a runtime write-abort. Rows
1721+ /// that aren't statically evaluable are skipped (the runtime write-check
1722+ /// still validates them).
1723+ fn check_const_rows_against_pred(
1724+ &mut self,
1725+ value: &ast::Expr,
1726+ type_name: &str,
1727+ pred: &ast::Expr,
1728+ ) {
1729+ let rows: Vec<&ast::Expr> = match &value.node {
1730+ ast::ExprKind::List(elems) => elems.iter().collect(),
1731+ // A single record literal written directly (no list wrapper).
1732+ ast::ExprKind::Record(fields) if !fields.is_empty() => vec![value],
1733+ _ => return,
1734+ };
1735+ for row in rows {
1736+ if let Some(false) = crate::codegen::eval_refine_predicate_on_const(pred, row) {
1737+ self.error(
1738+ format!(
1739+ "constant row does not satisfy the predicate for refined type `{type_name}`",
1740+ ),
1741+ row.span,
1742+ );
1743+ }
1744+ }
1745+ }
1746+
16201747 fn resolve_refined_base(&mut self, name: &str, span: Span) -> Option<Ty> {
16211748 let mut visited: Vec<String> = vec![name.to_string()];
16221749 let mut current = self.refined_types.get(name)?.0.clone();
@@ -7391,6 +7518,13 @@ impl Infer {
73917518 // The first value arg (if present in this same app)
73927519 // is CHECKED against the ascribed type.
73937520 if let Some(first) = value_args.next() {
7521+ // A constant ascribed to a refined type
7522+ // (`base.the (Nat) (0-5)`): evaluate the
7523+ // predicate now so a violating constant is a
7524+ // compile error, not a silent launder (the
7525+ // type-witness path bypasses the introduction
7526+ // guard and never touches a source write).
7527+ self.check_const_refined_intro(first, &ascribed);
73947528 self.check_expr(first, &ascribed);
73957529 result = ascribed.clone();
73967530 }
@@ -7573,6 +7707,18 @@ impl Infer {
75737707 // refined function parameters encountered inside the value.
75747708 let source_refined = self.refined_names_in(&target_inner);
75757709 let prev_suppress = self.suppress_refine_intro.replace(source_refined);
7710+ // Whole-record refined element type (`Rel VP` where `VP` is a
7711+ // record-level refined alias): the per-field check doesn't see
7712+ // the record-level predicate, so const-evaluate it here against
7713+ // each constant row literal. A passing constant is
7714+ // right-by-construction; a failing one is a compile error
7715+ // rather than a runtime write-abort. Non-constant rows defer to
7716+ // the runtime write-check as before.
7717+ if let Ty::Relation(elem) = self.apply(&target_inner).peel_alias() {
7718+ if let Some((ename, pred)) = self.record_refined_pred_for((**elem).clone()) {
7719+ self.check_const_rows_against_pred(value, &ename, &pred);
7720+ }
7721+ }
75767722 self.check_expr(value, &target_inner);
75777723 self.suppress_refine_intro = prev_suppress;
75787724 Ty::IO(Box::new(Ty::unit()))
@@ -7987,6 +8133,12 @@ ast::ExprKind::Atomic(inner) => {
79878133 for (param, p_ty) in params.iter().zip(peeled.iter()) {
79888134 self.check_pattern(param, p_ty);
79898135 }
8136+ // A constant returned where the declared return type is a
8137+ // refined type: evaluate the predicate at compile time so a
8138+ // violating constant is a build error (by-construction),
8139+ // rather than silently laundering it into the refined type
8140+ // with no check anywhere (it never touches a source write).
8141+ self.check_const_refined_intro(body, &current);
79908142 self.check_expr(body, &current);
79918143 self.pop_scope();
79928144 } else {
@@ -8058,7 +8210,45 @@ ast::ExprKind::Atomic(inner) => {
80588210 self.check_expr(&f.value, &sig_ty);
80598211 field_tys.insert(fname.clone(), sig_ty);
80608212 } else if let Some(exp_ty) = expected_fields.get(&fname) {
8061− self.check_expr(&f.value, exp_ty);
8213+ // Refined field with a statically-known value
8214+ // being introduced (the write path suppresses the
8215+ // introduction guard): verify the predicate at
8216+ // compile time instead of deferring to the runtime
8217+ // write-check. A constant that satisfies it is
8218+ // right-by-construction; one that violates it is a
8219+ // compile error, not a runtime abort. Non-constant
8220+ // values (None) fall through to the guarded check,
8221+ // which under suppression defers to the runtime.
8222+ let applied = self.apply(exp_ty);
8223+ if let Ty::Con(rname, rargs) = applied.peel_alias()
8224+ && rargs.is_empty()
8225+ && self.refined_types.contains_key(rname)
8226+ && self.refine_intro_suppressed(rname)
8227+ && let Some(lit) = crate::codegen::extract_literal_with_consts(
8228+ &f.value,
8229+ &self.const_literals,
8230+ )
8231+ {
8232+ let pred = self.refined_types.get(rname).unwrap().1.clone();
8233+ match crate::codegen::eval_refine_predicate_pub(&pred, &lit) {
8234+ Some(true) => {
8235+ let base =
8236+ self.resolve_refined_base(rname, f.value.span).unwrap_or_else(|| applied.clone());
8237+ self.check_expr(&f.value, &base);
8238+ }
8239+ Some(false) => {
8240+ self.error(
8241+ format!(
8242+ "field '{fname}' does not satisfy '{rname}' predicate",
8243+ ),
8244+ f.value.span,
8245+ );
8246+ }
8247+ None => self.check_expr(&f.value, exp_ty),
8248+ }
8249+ } else {
8250+ self.check_expr(&f.value, exp_ty);
8251+ }
80628252 field_tys.insert(fname.clone(), exp_ty.clone());
80638253 } else {
80648254 let val_ty = self.infer_expr(&f.value);
@@ -13823,9 +14013,13 @@ fn split_host_data_decls(src: &str) -> (CtorSets, String) {
1382314013 let mut rest = src;
1382414014 loop {
1382514015 let trimmed = rest.trim_start();
13826− // Host ADTs are declared `Name Ctor {} Ctor {f T}` — a leading
13827− // uppercase name, a gap, then whitespace-separated constructors.
13828− if !trimmed.starts_with(|c: char| c.is_uppercase()) {
14016+ // Host ADTs arrive as `data Name = Ctor {} Ctor {f T}` lines (rendered
14017+ // by the host) or, in the older in-tree form, `Name Ctor {} …`. A
14018+ // leading `data` keyword or uppercase name marks a decl; anything else
14019+ // (a bare type like `Int 1` or `Priority`) is the type to check.
14020+ let is_decl = trimmed.starts_with("data ")
14021+ || trimmed.starts_with(|c: char| c.is_uppercase());
14022+ if !is_decl {
1382914023 return (sets, trimmed.to_string());
1383014024 }
1383114025 let (line, next) = match trimmed.find('\n') {
@@ -13851,13 +14045,20 @@ fn split_host_data_decls(src: &str) -> (CtorSets, String) {
1385114045/// can re-parse them into real `Ty`s and unify against the snippet's payload
1385214046/// types. Returns `None` if the line isn't a well-formed variant decl.
1385314047fn parse_data_decl_ctors(line: &str) -> Option<(String, CtorList)> {
13854− // `Name` then a gap, then the constructor arms.
13855− let mut it = line.trim().splitn(2, char::is_whitespace);
14048+ // Wire form is `data Name = Ctor {} Ctor {f T}` (rendered by the host);
14049+ // the older in-tree form is `Name Ctor {} …`. Normalise both to
14050+ // `Name <ctor-arms>` before splitting.
14051+ let line = line.trim();
14052+ let line = line.strip_prefix("data ").unwrap_or(line).trim_start();
14053+ // `Name` then a gap (or ` = `), then the constructor arms.
14054+ let mut it = line.splitn(2, char::is_whitespace);
1385614055 let name = it.next()?.trim().to_string();
1385714056 let rhs = it.next()?;
1385814057 if name.is_empty() {
1385914058 return None;
1386014059 }
14060+ // Drop a leading `=` separating the name from the arms.
14061+ let rhs = rhs.trim_start().strip_prefix('=').map(str::trim_start).unwrap_or(rhs);
1386114062 // A decl body must contain a `{` (a constructor with a payload record);
1386214063 // a bare `Int 1` / `Priority` (the type to check) has none — not a decl.
1386314064 if !rhs.contains('{') {
@@ -14118,8 +14319,8 @@ fn check_adt_ctors(
1411814319 }
1411914320 };
1412014321 if !sets_ok {
14121− return false;
14122− }
14322+ return false;
14323+ }
1412314324 // Payload field types, at matching variance. The CONSUMED side destructures
1412414325 // the value, so every field it binds must be PRESENT in the produced side
1412514326 // with a subsumable type; extra fields on the produced side are ignored.
crates/knot-jit/tests/persistence.rs+62
@@ -904,3 +904,65 @@ Rel Account *accounts
904904 "\"ok: [2]\"\n{}",
905905 );
906906}
907+
908+// ── base.compile ADT constructor variance ─────────────────────────────────
909+//
910+// The expected type may reference a host ADT. The host prepends its `data`
911+// declarations to the wire string so the JIT can compare constructor SETS (not
912+// just names) with the polarity each position demands: a RETURN position is
913+// covariant (snippet ⊆ host — the snippet may produce fewer constructors than
914+// the host is prepared to consume), a PARAMETER is contravariant, and payload
915+// FIELD types are checked at the matching variance. A regression that dropped
916+// the host's data decls (or mis-parsed the `data Name = …` wire form) left the
917+// ctor set empty and made every case vacuously pass — these pin the real
918+// behavior.
919+
920+#[test]
921+fn compile_accepts_return_with_fewer_ctors() {
922+ // Covariant return: the snippet returns only `Red`/`Green` where the host
923+ // expects `{Red, Green, Blue}` — a subset, so it compiles.
924+ assert_stdout(
925+ "compile_ctor_subset",
926+ r#"with {
927+Color Red {} Green {} Blue {}
928+}
929+(? (base.the (Result Text (Int 1 -> Color)) (base.compile "with {\nColor Red {} Green {}\n}\n\\x -> (Color.Red {})"))
930+ Result.Ok {value _} base.println "compiled"
931+ Result.Err {error _} base.println "err")"#,
932+ "\"compiled\"\n{}",
933+ );
934+}
935+
936+#[test]
937+fn compile_rejects_return_with_extra_ctor() {
938+ // Covariant return: the snippet returns `Blue`, which the host's
939+ // `{Red, Green}` cannot consume — a snippet-only ctor, so it is rejected.
940+ assert_stdout(
941+ "compile_ctor_superset",
942+ r#"with {
943+Color Red {} Green {}
944+}
945+(? (base.the (Result Text (Int 1 -> Color)) (base.compile "with {\nColor Red {} Green {} Blue {}\n}\n\\x -> (Color.Blue {})"))
946+ Result.Ok {value _} base.println "compiled"
947+ Result.Err {error _} base.println "err")"#,
948+ "\"err\"\n{}",
949+ );
950+}
951+
952+#[test]
953+fn compile_rejects_ctor_field_type_drift() {
954+ // Same single ctor `W`, but the payload field type drifts (`Text` in the
955+ // snippet, `Int 1` in the host). The consumer (host) binds `n` at `Int 1`,
956+ // so a snippet producing `Text` must be rejected.
957+ assert_stdout(
958+ "compile_ctor_field_drift",
959+ r#"with {
960+Wrap W {n (Int 1)}
961+}
962+(? (base.the (Result Text (Int 1 -> Wrap)) (base.compile "with {\nWrap W {n Text}\n}\n\\x -> (Wrap.W {n \"s\"})"))
963+ Result.Ok {value _} base.println "compiled"
964+ Result.Err {error _} base.println "err")"#,
965+ "\"err\"\n{}",
966+ );
967+}
968+
crates/knot-jit/tests/refinements.rs+155−7
@@ -79,29 +79,61 @@ R {lo (Int 1) hi (Int 1)} \r -> r.lo <= r.hi
7979
8080#[test]
8181fn write_validation_rejects_violation() {
82− // A refined field on a source relation is validated on write; a violation
83− // aborts the write.
82+ // A statically-known value that violates a refined field is rejected at
83+ // COMPILE time — by-construction means the predicate runs on constants
84+ // during the build, so the program never reaches the runtime write-check.
8485 let dir = e2e::TempDir::fresh("refine_write");
86+ let src = r#"with {
87+Nat Int 1 \x -> x >= 0
88+Rel {name Text age Nat} *people
89+}
90+(|
91+ *people = [{name "a" age (0 - 5)}]
92+ base.println "wrote"
93+ yield {})"#;
94+ let src_path = dir.path().join("refine_write.knot");
95+ std::fs::write(&src_path, src).unwrap();
96+ let build = std::process::Command::new(e2e::knot_bin())
97+ .arg("build")
98+ .arg(&src_path)
99+ .arg("-o")
100+ .arg(dir.path().join("refine_write"))
101+ .output()
102+ .expect("run knot build");
103+ let stderr = String::from_utf8_lossy(&build.stderr);
104+ assert!(
105+ !build.status.success() && stderr.contains("does not satisfy"),
106+ "expected a compile-time refinement rejection, got status={} stderr={}",
107+ build.status,
108+ stderr
109+ );
110+}
111+
112+#[test]
113+fn write_validation_runtime_catches_nonconstant() {
114+ // A value the compiler cannot statically evaluate (a lambda parameter)
115+ // still defers to the runtime write-check, which aborts the write.
116+ let dir = e2e::TempDir::fresh("refine_write_rt");
85117 e2e::build_in_dir(
86− "refine_write",
118+ "refine_write_rt",
87119 r#"with {
88120Nat Int 1 \x -> x >= 0
89121Rel {name Text age Nat} *people
90122}
91123(|
92− *people = [{name "a" age (0 - 5)}]
124+ _ <- (base.traverse (\a -> (*people = [{name "a" age a}])) [(0 - 5)])
93125 base.println "wrote"
94126 yield {})"#,
95127 dir.path(),
96128 );
97− let out = std::process::Command::new(dir.path().join("refine_write"))
129+ let out = std::process::Command::new(dir.path().join("refine_write_rt"))
98130 .current_dir(dir.path())
99131 .output()
100132 .expect("run");
101133 let stderr = String::from_utf8_lossy(&out.stderr);
102134 assert!(
103135 !out.status.success() || stderr.contains("refin") || stderr.contains("violation"),
104− "expected write-validation rejection, got stdout={} stderr={}",
136+ "expected runtime write-validation rejection, got stdout={} stderr={}",
105137 String::from_utf8_lossy(&out.stdout),
106138 stderr
107139 );
@@ -122,4 +154,120 @@ Rel {name Text age Nat} *people
122154 // count is a one-element relation now (Rel Int), so it shows as [1].
123155 "\"[1]\"\n{}",
124156 );
125−}
157+}
158+
159+/// Build `src`, returning `(status_ok, stderr)` — for asserting a compile-time
160+/// rejection without running the binary.
161+fn build_outcome(name: &str, src: &str) -> (bool, String) {
162+ let dir = e2e::TempDir::fresh(name);
163+ let src_path = dir.path().join(format!("{name}.knot"));
164+ std::fs::write(&src_path, src).unwrap();
165+ let build = std::process::Command::new(e2e::knot_bin())
166+ .arg("build")
167+ .arg(&src_path)
168+ .arg("-o")
169+ .arg(dir.path().join(name))
170+ .output()
171+ .expect("run knot build");
172+ (build.status.success(), String::from_utf8_lossy(&build.stderr).into_owned())
173+}
174+
175+#[test]
176+fn write_validation_rejects_whole_record_refined_alias() {
177+ // A constant row violating a whole-record refined alias (`VP`) written to
178+ // `Rel VP` is rejected at COMPILE time, not deferred to the runtime
179+ // write-check.
180+ let (ok, stderr) = build_outcome(
181+ "refine_whole_rec",
182+ r#"with {
183+VP {age (Int 1 \x -> x >= 0)}
184+Rel VP *people
185+}
186+(|
187+ *people = [{age (0 - 5)}]
188+ base.println "wrote"
189+ yield {})"#,
190+ );
191+ assert!(
192+ !ok && stderr.contains("does not satisfy"),
193+ "expected compile-time rejection of whole-record refined write, got ok={ok} stderr={stderr}"
194+ );
195+}
196+
197+#[test]
198+fn write_validation_accepts_whole_record_refined_alias_valid() {
199+ assert_stdout(
200+ "refine_whole_rec_ok",
201+ r#"with {
202+VP {age (Int 1 \x -> x >= 0)}
203+Rel VP *people
204+}
205+(|
206+ *people = [{age 30}]
207+ base.println (base.show (base.map (\p -> p.age) *people))
208+ yield {})"#,
209+ "\"[30]\"\n{}",
210+ );
211+}
212+
213+#[test]
214+fn refined_return_rejects_violating_constant() {
215+ // A function whose declared return type is a refined scalar, returning a
216+ // violating constant, is a compile error — by-construction, no silent
217+ // laundering into the refined type.
218+ let (ok, stderr) = build_outcome(
219+ "refine_ret_bad",
220+ r#"with {
221+Nat Int 1 \x -> x >= 0
222+Nat -> Nat f (\x -> (0 - 5))
223+}
224+base.println "done""#,
225+ );
226+ assert!(
227+ !ok && stderr.contains("does not satisfy"),
228+ "expected compile-time rejection of refined return, got ok={ok} stderr={stderr}"
229+ );
230+}
231+
232+#[test]
233+fn refined_return_accepts_valid_constant() {
234+ assert_stdout(
235+ "refine_ret_ok",
236+ r#"with {
237+Nat Int 1 \x -> x >= 0
238+Nat -> Nat f (\x -> (5 - 0))
239+}
240+base.println (base.show (f 0))"#,
241+ "\"5\"\n{}",
242+ );
243+}
244+
245+#[test]
246+fn the_ascription_rejects_violating_constant() {
247+ // `base.the (Nat) (0-5)`: the type-witness path checks the value against
248+ // the ascribed refined type. A violating constant is a compile error, not
249+ // a silent launder into `Nat` (the witness never touches a source write).
250+ let (ok, stderr) = build_outcome(
251+ "refine_the_bad",
252+ r#"with {
253+Nat Int 1 \x -> x >= 0
254+}
255+base.println (base.show (base.the (Nat) (0 - 5)))"#,
256+ );
257+ assert!(
258+ !ok && stderr.contains("does not satisfy"),
259+ "expected compile-time rejection of `base.the` refined ascription, got ok={ok} stderr={stderr}"
260+ );
261+}
262+
263+#[test]
264+fn the_ascription_accepts_valid_constant() {
265+ assert_stdout(
266+ "refine_the_ok",
267+ r#"with {
268+Nat Int 1 \x -> x >= 0
269+}
270+base.println (base.show (base.the (Nat) 5))"#,
271+ "\"5\"\n{}",
272+ );
273+}