Lab 4: Values and Type Errors (Milestone 2)

Background

You will replace the two-variant placeholder Value with the real one — nil, booleans, integers, floats, and strings — and implement equality, ordering, concatenation, length, truthiness, and the type errors that arise when an operator meets an operand it cannot use.

This lab produces ADR-004 (value representation) and ADR-005 (the integer/float split), and it is where "dynamic typing" stops being a phrase and becomes a table of decisions you had to make by hand.

Why This Lab Matters

  • Value is the most-copied type in the runtime. Its size shows up in the VM's stack, in every table, in every argument. You will measure it in Section 7; you decide it here.
  • Type errors are where a scripting language earns or loses trust. attempt to multiply a string by a number, with a caret under the string, is the difference between a five-second fix and a fifteen-minute hunt.
  • Two of Lua's comparison rules are traps — exact integer/float equality, and locale-dependent string ordering — and both are places where the obvious implementation is wrong. One of them Ember fixes deliberately.

Prerequisites

  • Labs 1–3 complete.
  • Read Value Representation now. It is filed under Section 2 because it is the foundation of the reference interpreter, but it is the concept chapter for this lab. Read at least its Concepts 1 and 2 before Step 1.

Predict First

  1. size_of::<Value>() for the enum in Step 1. Why is it not 9?
  2. Is 1 == 1.0 true? Is "1" == 1?
  3. Is 0/0 == 0/0?
  4. math.maxinteger + 0.0 == math.maxinteger — true or false? (This is the hard one.)
  5. Are 0 and "" truthy?
  6. #"héllo" — 5 or 6?
  7. "10" + 5 in Lua? In Ember?
  8. "a" < "b" is easy. Is "a" < "B"? What decides?

Step 1: The Real Value

Concept. A tagged union sized for the machine word.

#![allow(unused)]
fn main() {
// src/value.rs
/// A runtime value. 16 bytes, `Copy`, exhaustively matched.
///
/// The heap variants hold an 8-byte HANDLE (index + generation) into the heap's
/// slot table, not a pointer and not the object. Copying a Value copies the
/// handle; two Values with the same handle are the SAME object.
/// See ADR-004 and ADR-006.
#[derive(Copy, Clone, Debug)]
pub enum Value {
    Nil,
    Boolean(bool),
    Integer(i64),
    Float(f64),
    Str(GcRef<EmberStr>),        // Lab 16 makes this real; Lab 4 may use a placeholder
    // Table(GcRef<Table>),      // Lab 13
    // Closure(GcRef<Closure>),  // Lab 14
    // UserData(GcRef<UserData>),// Lab 20
}

#[test]
fn value_is_two_words() {
    // If this ever changes, it is a decision, not an accident. §7 measures what
    // the size costs; this test makes sure nobody changes it silently.
    assert_eq!(std::mem::size_of::<Value>(), 16);
    assert!(std::mem::size_of::<GcRef<EmberStr>>() <= 8);
}
}

Note: Until Lab 16 builds the heap, use a stand-in for strings — Str(std::rc::Rc<str>) is fine and keeps Value Clone but not Copy. Write the size_of test anyway, against whatever it is today, and put a TODO(lab-16) next to it. The point of the test is to make the number visible, and a number you have to update deliberately is exactly what you want.

Why 16 and not 9. The discriminant needs a byte, f64/i64 need 8, and alignment rounds the whole thing to 16. Rust's niche optimization cannot help: f64 and i64 use every bit pattern, so there is no spare niche to hide a tag in. That is precisely the problem NaN boxing solves, by noticing that f64 has 2^52-ish unused NaN bit patterns and hiding the tag and a pointer in there. Ember does not do it. Write ADR-004 now, with option B (NaN boxing) recorded honestly as "8 bytes, requires unsafe, deferred to capstone project 2 where it will be benchmarked".

Checkpoint question. Why does Copy matter for a value type in a VM? What would Clone-only force the dispatch loop to do?


Step 2: Truthiness

#![allow(unused)]
fn main() {
impl Value {
    /// Lua 5.4 §3.3.4: ONLY nil and false are false. Everything else — including
    /// 0, 0.0, "", and NaN — is true.
    pub fn is_truthy(&self) -> bool {
        !matches!(self, Value::Nil | Value::Boolean(false))
    }
}
}

Three lines, and it is one of the most consequential decisions in a dynamic language. Compare:

LanguageFalsy values
Lua, Embernil, false
PythonNone, False, 0, 0.0, "", [], {}, set(), and anything with __bool__/__len__
JavaScriptundefined, null, false, 0, -0, 0n, "", NaN
Rubynil, false

Lua's rule is the smallest and the least surprising, and it means if x then tests presence, not non-emptiness. Test it explicitly — this is the kind of rule people assume rather than check:

#![allow(unused)]
fn main() {
#[test]
fn only_nil_and_false_are_falsy() {
    for src in ["0", "0.0", "\"\"", "0/0"] {
        assert_eq!(eval(&format!("return not {src}")), Value::Boolean(false),
                   "{src} must be truthy in Ember (Lua 5.4 §3.3.4)");
    }
    for src in ["nil", "false"] {
        assert_eq!(eval(&format!("return not {src}")), Value::Boolean(true));
    }
}
}

Step 3: Equality — Never Coerce

#![allow(unused)]
fn main() {
/// Lua 5.4 §3.4.4: values of DIFFERENT TYPES are never equal, except that
/// integers and floats are both "number" and compare by mathematical value.
pub fn raw_eq(a: Value, b: Value) -> bool {
    use Value::*;
    match (a, b) {
        (Nil, Nil)                 => true,
        (Boolean(x), Boolean(y))   => x == y,
        (Integer(x), Integer(y))   => x == y,
        (Float(x),   Float(y))     => x == y,          // NaN != NaN falls out of IEEE-754
        (Integer(x), Float(y))     => int_eq_float(x, y),
        (Float(x),   Integer(y))   => int_eq_float(y, x),
        (Str(x),     Str(y))       => str_eq(x, y),
        _                          => false,           // "1" == 1 is FALSE
    }
}
}

int_eq_float is the trap. The obvious implementation is x as f64 == y, and it is wrong:

lua -e 'print(math.maxinteger + 0.0 == math.maxinteger)'   # false
lua -e 'print(math.maxinteger + 0.0)'                       # 9.2233720368548e+18

i64::MAX is 2^63 − 1. Converting it to f64 rounds up to exactly 2^63, so x as f64 == y reports equality for a float that is not the integer. Lua gets this right by converting in the other direction: a float equals an integer only if the float has no fractional part and is exactly representable as an i64.

#![allow(unused)]
fn main() {
fn int_eq_float(i: i64, f: f64) -> bool {
    // Convert the FLOAT to an integer, not the integer to a float. Only exact
    // conversions count. Lua 5.4 does this in luaV_equalobj / luaV_tointegerns.
    f.floor() == f && f >= -(2f64.powi(63)) && f < 2f64.powi(63) && (f as i64) == i
}
}

Write the test with i64::MAX. Then predict, and check against lua, what 2^53 and 2^53 + 1 do — that is where f64 stops being able to represent consecutive integers, and it is a real bug source in any system that moves numbers between a script and a database.

Warning: Ordering (<, <=) has the same hazard and needs the same care. 1 < 1e300 is easy; math.maxinteger < 2.0^63 is not. Lua's LTnum in lvm.c handles it in four cases; read it (rg -n 'LTnum|LEnum|luai_numlt' lvm.c) after you have tried to write it yourself.


Step 4: Ordering, and a Deliberate Divergence

#![allow(unused)]
fn main() {
fn lt(a: Value, b: Value, span: Span) -> Result<bool> {
    use Value::*;
    Ok(match (a, b) {
        (Integer(x), Integer(y)) => x < y,
        (Float(x),   Float(y))   => x < y,
        (Integer(x), Float(y))   => int_lt_float(x, y),
        (Float(x),   Integer(y)) => float_lt_int(x, y),
        // BYTE-WISE comparison. Lua uses strcoll(), which is LOCALE-DEPENDENT.
        // See the note below: this is a deliberate divergence. (ADR-009)
        (Str(x),     Str(y))     => bytes(x) < bytes(y),
        (a, b) => return Err(type_error_cmp(a, b, span)),
    })
}
}

Note — a real determinism bug in a real language. Lua 5.4's l_strcmp (in lvm.c) calls strcoll, so string ordering depends on the process locale. table.sort on strings can therefore produce different orders on two machines running the same program. Verify it:

LC_ALL=C     lua -e 't={"a","B"}; table.sort(t); print(t[1],t[2])'
LC_ALL=en_US.UTF-8 lua -e 't={"a","B"}; table.sort(t); print(t[1],t[2])'

If your system has both locales installed, those print different results. Ember compares bytes, always, because determinism is a product requirement and a ranking policy that reorders itself based on an environment variable is not a policy. Write ADR-009 and the entry in appendix/lua-differences.md.

This is the single best example in Section 1 of why "just copy Lua" is not a design method. Lua's choice is defensible for its audience — text processing for humans in a locale. Ember's audience is a service that must produce the same ranking on every replica. Same mechanism, different requirement, different answer, written down.


Step 5: Concatenation and Length

#![allow(unused)]
fn main() {
// `..` — Lua 5.4 §3.4.6: strings and NUMBERS concatenate; everything else errors.
BinOp::Concat => match (l, r) {
    (Str(_), Str(_)) | (Str(_), Integer(_)) | (Str(_), Float(_))
    | (Integer(_), Str(_)) | (Float(_), Str(_))
    | (Integer(_), Integer(_)) | (Integer(_), Float(_))
    | (Float(_), Integer(_)) | (Float(_), Float(_)) => {
        Value::Str(self.intern(&format!("{}{}", tostring(l), tostring(r))))
    }
    _ => return Err(self.concat_type_error(l, r, lhs.span(), rhs.span())),
},

// `#` — Lua 5.4 §3.4.7: BYTE length for strings.
UnOp::Len => match v {
    Value::Str(s) => Value::Integer(self.str_bytes(s).len() as i64),
    _ => return Err(rt_operand(span, "attempt to get length of", v)),
},
}

Two asymmetries worth noticing, because both are deliberate.

  1. Ember coerces numbers to strings in .. but does not coerce strings to numbers in +. Lua does both. The asymmetry has a reason: every number has exactly one string form, so 1 .. "" is total and unsurprising; but "10" + 5 requires parsing, which can fail, which means a type-correct-looking program has a run-time parse in it. Section 5's users will write article.score + 1 where score came from JSON as a string, and Ember should tell them, not guess. Divergence documented; ADR entry written.
  2. # counts bytes, not characters. #"héllo" is 6, because é is two bytes in UTF-8. That matches Lua (whose strings are byte strings) but surprises everyone. Ember's strings are UTF-8 Strings, which makes this a slightly odd hybrid — flag it in docs/limitations.md and revisit in Lab 16, where you decide whether Ember's strings are text or bytes. Do not decide it now; just be honest that it is undecided.

Step 6: Type Errors That Point at the Right Operand

This is the deliverable that users will actually notice.

#![allow(unused)]
fn main() {
fn arith_type_error(op: BinOp, l: Value, r: Value,
                    lspan: Span, rspan: Span) -> EmberError {
    // Blame the FIRST operand that is not a number. Lua does the same, and it
    // is why `nil * 2` says "a nil value" rather than "these operands".
    let (bad, span) = if !l.is_number() { (l, lspan) } else { (r, rspan) };
    EmberError {
        kind: ErrorKind::Runtime,
        message: format!("attempt to {} a {} value", verb(op), bad.type_name()),
        span: Some(span),
        traceback: Vec::new(),
    }
}

fn verb(op: BinOp) -> &'static str {
    match op {
        BinOp::Add => "add", BinOp::Sub => "subtract", BinOp::Mul => "multiply",
        BinOp::Div | BinOp::IDiv => "divide", BinOp::Mod => "take the modulus of",
        BinOp::Pow => "exponentiate", BinOp::Concat => "concatenate",
        _ => "operate on",
    }
}

impl Value {
    /// The name a SCRIPT AUTHOR sees. Integer and Float are both "number",
    /// because that is the type in the language even though they are distinct
    /// subtypes in the implementation. Lab 21's `math.type` exposes the subtype.
    pub fn type_name(&self) -> &'static str {
        match self {
            Value::Nil => "nil", Value::Boolean(_) => "boolean",
            Value::Integer(_) | Value::Float(_) => "number",
            Value::Str(_) => "string",
        }
    }
}
}

The design decision is blame the first non-number operand, and it requires operand spans — which the parser preserved because Binary merges its children's spans rather than using the operator's. Two labs and one design decision apart. Note that in your journal; it is the clearest example so far of a cheap early choice paying a visible dividend.


The Trace

$ ember run -e 'return "x" * 2'
<argv>:1:8: error: attempt to multiply a string value

   1 │ return "x" * 2
     │        ^^^

$ ember run -e 'return 1 + nil'
<argv>:1:12: error: attempt to add a nil value

   1 │ return 1 + nil
     │            ^^^

$ ember run -e 'return #true'
<argv>:1:9: error: attempt to get length of a boolean value

   1 │ return #true
     │         ^^^^

$ ember run --types -e 'return 1 == 1.0'
true (boolean)
$ ember run --types -e 'return 3 // 2'
1 (number: integer)
$ ember run --types -e 'return 3 / 2'
1.5 (number: float)
$ ember run --types -e 'return "a" .. 1'
a1 (string)

Note the caret positions: under "x" in the first, under nil in the second. The caret is under the operand, never under the operator, and never under the whole expression. Check yours.

--types is a debugging flag added in this lab; it prints Value::type_name() plus the numeric subtype. It exists because type() and math.type() are library functions and there are no function calls until Lab 7, and you need to see the subtype now.


Expected Output

$ ember run -e 'return 1 == 1.0'          # numbers compare by value across subtypes
true
$ ember run -e 'return "1" == 1'          # different types are never equal
false
$ ember run -e 'return 0/0 == 0/0'        # NaN is not equal to itself
false
$ ember run -e 'return not 0'             # 0 is TRUTHY
false
$ ember run -e 'return #"héllo"'          # BYTES, not characters
6
$ ember run -e 'return "b" < "a"'
false
$ ember run -e 'return "10" + 5'
<argv>:1:8: error: attempt to add a string value

   1 │ return "10" + 5
     │        ^^^^

That last one is a deliberate divergence — lua -e 'print("10" + 5)' prints 15. If you have not yet written it in appendix/lua-differences.md, do it before moving on.


Debugging Steps

size_of::<Value>() is 24

You have a variant carrying something larger than 8 bytes — usually String (24 bytes) instead of a handle or Rc<str> (16 bytes as a fat pointer). Rc<str> is a fat pointer and will make Value 24 bytes; Rc<String> is thin and gives 16. This is a good moment to feel why handles win.

math.maxinteger + 0.0 == math.maxinteger is true

You compared i as f64 == f. See Step 3.

The caret is under the operator

arith_type_error is being given the Binary node's span rather than the operands'. Thread lhs.span() and rhs.span() down.

Comparing two strings gives a type error

Your lt match has the (Str, Str) arm after a catch-all, or the arm is missing entirely.

1 .. 2 produces 3

Your parser mapped .. to Add. Check binop() in the parser — DotDot and Plus are adjacent in the token enum and this is a real copy-paste bug.

"a" < 1 panics

A match arm falling through to unreachable!() instead of returning a type error. Never unreachable!() on a Value combination; the whole point of the enum is that the combinations are enumerable and each one is either valid or an error.


Experiment

CLAIM. Automatic string→number coercion converts a type error into a wrong answer, and the failure surfaces far from its cause.

METHOD. Add coercion behind a flag: --lua-coercion makes "10" + 5 produce 15. Write a 20-line "policy" that reads a score from a table of strings, adds a boost, and compares against a threshold. Introduce one field that is a string when it should be a number. Run with and without the flag.

PREDICTION. With coercion on, where does the program go wrong, and how far is that from the line containing the bug? Without it?

RESULT. Record both, in docs/learning/05-values.md. Then decide whether ADR-005's divergence stands. It may not — that is a legitimate outcome, as long as the decision is now evidence-based.


Test

#![allow(unused)]
fn main() {
#[test]
fn equality_never_coerces_across_types() {
    // Lua 5.4 §3.4.4. Verified: lua -e 'print("1" == 1, 1 == 1.0, nil == false)'
    assert_eq!(eval("return \"1\" == 1"),   Value::Boolean(false));
    assert_eq!(eval("return nil == false"), Value::Boolean(false));
    assert_eq!(eval("return 1 == 1.0"),     Value::Boolean(true));
}

#[test]
fn integer_float_equality_is_exact_not_lossy() {
    // i64::MAX rounds UP to 2^63 as an f64, so `i as f64 == f` reports a false
    // equality. Convert the float to an integer instead. Lua 5.4 luaV_equalobj.
    assert_eq!(eval("return 9223372036854775807 == 9223372036854775807 + 0.0"),
               Value::Boolean(false));
    assert_eq!(eval("return 9007199254740992 == 9007199254740992.0"),   // 2^53
               Value::Boolean(true));
}

#[test]
fn nan_is_not_equal_to_itself() {
    assert_eq!(eval("return 0/0 == 0/0"), Value::Boolean(false));
    assert_eq!(eval("return 0/0 ~= 0/0"), Value::Boolean(true));
}

#[test]
fn string_ordering_is_bytewise_regardless_of_locale() {
    // ADR-009: Ember diverges from Lua, which uses strcoll(). This test is the
    // reason: it must give the same answer under every LC_ALL.
    assert_eq!(eval("return \"B\" < \"a\""), Value::Boolean(true));   // 'B'=0x42 < 'a'=0x61
}

#[test]
fn type_errors_blame_the_operand_and_carry_its_span() {
    let e = try_eval("return 1 + nil").unwrap_err();
    assert_eq!(e.kind, ErrorKind::Runtime);
    assert!(e.message.contains("nil"), "message must name the type: {}", e.message);
    let sp = e.span.unwrap();
    assert_eq!((sp.start, sp.end), (11, 14), "span must cover `nil`, not the whole expression");
}

#[test]
fn length_is_bytes_not_characters() {
    assert_eq!(eval("return #\"héllo\""), Value::Integer(6));
}
}

Challenge Extensions

  1. Exact mixed ordering. Implement int_lt_float and float_lt_int correctly for all magnitudes, then property-test against a i128-based reference over random i64/f64 pairs. This is genuinely tricky and it is exactly what Lua's LTnum does.
  2. NaN boxing, measured. Implement Value as a NaN-boxed u64 behind a feature flag. Get the test suite passing. Benchmark benches/dispatch.rs. Report the delta and the unsafe line count. This is capstone project 2 arriving early; if you do it now, do it after Section 7 has a baseline.
  3. A Value that is Copy with an Rc string. It cannot be — explain precisely why in terms of Drop, and use that to argue for the handle design in Lab 15.
  4. Locale experiment. Install a second locale and demonstrate Lua's table.sort producing two different orders. Screenshot it into docs/learning/05-values.md. It is the most persuasive artifact you will produce in Section 1.
  5. Error message audit. Write a test that evaluates every invalid operator/type pair (7 operators × 5 types × 5 types) and asserts that each error names the operator's verb, the offending type, and has a span inside the offending operand. Roughly 175 cases; the loop is ten lines and it will find three bugs.

Deliverables

  • Value has all five variants; size_of is asserted in a test with a comment explaining the number.
  • Truthiness, equality, ordering, concatenation, and length implemented and tested.
  • int_eq_float is exact; the i64::MAX test passes.
  • String ordering is byte-wise, with ADR-009 written and the divergence documented.
  • Type errors name the operator's verb and the offending type, and their span covers the offending operand.
  • ember run --types prints the value and its type/subtype.
  • appendix/lua-differences.md contains: no string→number coercion in arithmetic, byte-wise string ordering, and whatever else Step 4's comparison loop turned up.
  • docs/adr/ADR-004-value-representation.md and ADR-005-integer-float-split.md written, each with the rejected options and their real costs.
  • tests/golden/ has at least 12 programs with expected output.
  • Milestone 2 complete.

Validation / Self-check

  1. Why is Value 16 bytes and not 9? What technique makes it 8, and what does that cost?
  2. Give Lua's truthiness rule and contrast it with Python's and JavaScript's. Which is smallest?
  3. Why is x as f64 == y the wrong way to compare an integer with a float? Give the failing value.
  4. Why does Ember compare strings byte-wise, and what specific real-world failure does Lua's choice permit?
  5. Why does Ember coerce numbers to strings in .. but not strings to numbers in +? State the asymmetry in one sentence.
  6. #"héllo" is 6. Is that a bug? What is actually undecided here, and which lab decides it?
  7. In return 1 + nil, which operand is blamed, why, and which earlier design decision makes the caret land correctly?
  8. Name three places in this lab where the obvious Rust standard-library function gives a subtly non-Lua answer.

Next: Section 2 — The Reference Interpreter.