From d3ace8f0ec6be701bbef509ff000ebea8b8057f4 Mon Sep 17 00:00:00 2001 From: Tam Nguyen Duc <1218621+tamnd@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:32:03 +0700 Subject: [PATCH] Store an INT128 column as sixteen bytes at a fixed stride Every number a column has held so far rides the sixty four bit scalar lane. This adds the first one that does not. The layout is the one BINARY(16) already had: sixteen little endian bytes a row at a fixed stride, on the blob side of the store, with no per row offsets because every row is the same width. Reaching it took one line, because props::fixed_octets is the single hook that puts a column on that layout and write_columns is already generic over it. Answering Some(16) for IntBits::B128 gets the padding, the segment write and the reader for nothing. The value carrier is Value::Decimal at scale nought. An exact numeric of scale nought is what a whole number is, it prints without a point, it compares equal to the integer of the same size, and it holds the whole i128 range: MAX_DIGITS = 38 is a cap on what a declaration may ask for and not on what the carrier holds. UINT128 and INT256 are refused, and the refusal lands at the declaration rather than at the first insert. Sixteen bytes would hold a UINT128 happily, but the exact numeric carries a signed hundred and twenty eight bit number, so the top half of the unsigned range is a range no value could name; INT256 has no carrier here at all. A column whose declared range no value can reach would take a row in and refuse to give it back, which is worse than saying so where the user wrote it. That is where the wide decimal's refusal lands and for the same reason, so the two agree. Three paths are closed on purpose rather than by accident. The vector layer has no lane for sixteen bytes and would have handed the column back as a string vector, a number dressed as text, so check_col refuses it the way it already refuses a zoned and a decimal column. sqlite's INTEGER is sixty four bits, so a staged INT128 column would have lost its top half silently, and the converter refuses instead. ColBuf::for_type matches on the exact declared type and so has no arm for it, which refuses a bulk append when the appender opens. The declared form and the column form stay one encoding, since the new extended code is written by extended_bytes and both go through it, so an INT128 a graph type may name is an INT128 a column can hold. Directory version 13. A file written before it has no column of this type to read, so nothing older changes shape. Left open: DECIMAL(19..=38, s), which is now nearly free on this plane, being the same sixteen bytes plus the scale from the declared type. --- conformance/cases/error.yaml | 17 +++++ crates/zu-zu1/src/props.rs | 115 +++++++++++++++++++++++++++++++--- crates/zu/src/convert.rs | 13 ++++ crates/zu/src/declare.rs | 60 ++++++++++++++++++ crates/zu/src/insert.rs | 36 ++++++++++- crates/zu/src/query.rs | 24 ++++++- crates/zu/src/snapshot.rs | 30 ++++++--- crates/zu/tests/graph_type.rs | 21 ++++--- 8 files changed, 290 insertions(+), 26 deletions(-) diff --git a/conformance/cases/error.yaml b/conformance/cases/error.yaml index 58966065..2fb954bf 100644 --- a/conformance/cases/error.yaml +++ b/conformance/cases/error.yaml @@ -213,6 +213,23 @@ cases: - type: INT64 value: "18" + - name: an-int128-column-is-a-declaration-a-graph-type-takes + doc: A signed hundred and twenty eight bit integer is a column, stored as sixteen bytes a row at a fixed stride rather than in the sixty four bit lane every other number rides. It reads back as an exact numeric of scale nought, which is a carrier the engine has for the whole of that range. The declaration is the setup because a catalog statement returns no rows, so what the case asserts is that it did not raise. + setup: + - "CREATE GRAPH TYPE wide { (:Ledger {n :: INT128}) }" + query: RETURN 128 AS bits + columns: + - bits + rows: + - values: + - type: INT64 + value: "128" + + - name: an-unsigned-wide-integer-no-value-can-name + doc: The unsigned hundred and twenty eight bit integer is refused where the signed one is taken, and the reason is the value side rather than the storage side. Sixteen bytes would hold one; the engine's exact numeric carries a signed hundred and twenty eight bit number, so the top half of the unsigned range is a range no value could name. A column that took a row in and could not give it back would be worse than a refusal at the declaration, which is where the user wrote it. + query: "CREATE GRAPH TYPE t { (:P {v :: UINT128}) }" + raises: "42000" + - name: an-embedding-column-is-a-declaration-a-graph-type-takes doc: A property declared LIST[768] is the embedding column, and a graph type takes it. The declaration is the setup here because a catalog statement returns no rows, so what the case asserts is that it did not raise. What the catalog keeps is the bound and the element's nullability, which are what every later check about the column is made against, and a catalog that took the declaration and forgot either of them would be worse than one that refused it. setup: diff --git a/crates/zu-zu1/src/props.rs b/crates/zu-zu1/src/props.rs index bc3d2fa0..ab4c9579 100644 --- a/crates/zu-zu1/src/props.rs +++ b/crates/zu-zu1/src/props.rs @@ -76,8 +76,11 @@ use crate::txn::Cell; /// lives in the type. Version 12 lets a list column's rows say their /// count in fewer than four bytes, and writes the width they said it in /// into the column entry, which is the second field there that is about -/// the encoding rather than about the declaration. -const PROPS_VERSION: u16 = 12; +/// the encoding rather than about the declaration. Version 13 adds the +/// hundred and twenty eight bit integer, which is the first column type +/// that is a number and does not ride the lane: sixteen bytes at a fixed +/// stride, on the side of the store a `BINARY(16)` already uses. +const PROPS_VERSION: u16 = 13; const MAX_NAME_LEN: usize = 256; /// The code a list column is written under, followed on disk by the @@ -234,7 +237,10 @@ fn type_bytes(ty: &LogicalType) -> Option> { /// nothing beside it, since the type is the whole of what it says. A /// decimal takes its precision and its scale, and `extended_bytes` is /// where the precisions a lane word holds are told from the ones it does -/// not, because that answer has to be the same for the catalog. +/// not, because that answer has to be the same for the catalog. An +/// integer wider than the lane takes the same road for the same reason: +/// `INT128` has no code, and which of the wide integers has an encoding +/// at all is a question `extended_bytes` answers once for both callers. /// /// What is left unstorable is a list of anything but a fixed width /// element, whose row has to carry lengths of its own; saying so here is @@ -248,7 +254,8 @@ fn column_type_bytes(ty: &LogicalType) -> Option> { | LogicalType::Bytes { .. } | LogicalType::ZonedTime | LogicalType::ZonedDatetime - | LogicalType::Decimal { .. } => extended_bytes(ty), + | LogicalType::Decimal { .. } + | LogicalType::Int { .. } => extended_bytes(ty), _ => None, } } @@ -423,6 +430,12 @@ const EXT_LIST: u8 = 4; /// read a row: the lane holds unscaled units and the scale is what says /// a unit is a hundredth. const EXT_DECIMAL: u8 = 5; +/// A signed hundred and twenty eight bit integer. It takes nothing +/// beside the code, because the type is the whole of what it says, and +/// it is in the extended form rather than in [`TYPE_CODES`] because that +/// table doubles as the list element table and no list element is +/// sixteen bytes wide. +const EXT_INT128: u8 = 6; /// The count that stands for a bound nobody wrote, since a length is a /// `u32` and every one of them is a length somebody could write. @@ -458,6 +471,19 @@ fn extended_bytes(ty: &LogicalType) -> Option> { Some(match ty { LogicalType::ZonedTime => vec![EXTENDED_CODE, EXT_ZONED_TIME], LogicalType::ZonedDatetime => vec![EXTENDED_CODE, EXT_ZONED_DATETIME], + // The signed hundred and twenty eight bit integer, and that one + // alone of the wide integers. `UINT128` is not here because the + // engine's exact numeric carries an `i128`, so the top half of a + // `u128` is a range no value could name, and `INT256` is not + // here because the engine has no carrier for one at all. A + // column whose declared range no value can reach would take a + // row in and refuse to give it back, so the refusal lands at the + // declaration instead, the way the wide decimal's does. + LogicalType::Int { + signed: true, + bits: IntBits::B128, + precision: None, + } => vec![EXTENDED_CODE, EXT_INT128], // A decimal is written where its unscaled units fit the lane, // and refused where they do not. The bound belongs here rather // than one caller up because the declared form and the column @@ -530,6 +556,13 @@ fn decode_extended_type(bytes: &[u8], pos: &mut usize) -> Result { if kind == EXT_ZONED_DATETIME { return Ok(LogicalType::ZonedDatetime); } + if kind == EXT_INT128 { + return Ok(LogicalType::Int { + signed: true, + bits: IntBits::B128, + precision: None, + }); + } if kind == EXT_DECIMAL { let mut digits = || -> Result { let end = *pos + 2; @@ -1117,6 +1150,15 @@ impl PropValues<'_> { // them a number is in the declared type beside them, which // is why this can be the same empty lane an integer column // gets without the two columns being the same column. + // And a hundred and twenty eight bit integer holds none of + // sixteen byte runs, because it is on the blob side. The two + // integer arms are two arms for the same reason a `BINARY` + // column and an `INT64` one are: what an empty column holds + // none of is whatever its rows would have been. + LogicalType::Int { + bits: IntBits::B128, + .. + } => PropValues::Bytes(&[]), LogicalType::Int { .. } | LogicalType::Decimal { .. } => PropValues::Int(&[]), LogicalType::Float { .. } => PropValues::Float(&[]), LogicalType::Str { .. } => PropValues::Str(&[]), @@ -1919,6 +1961,14 @@ fn write_props( /// characters and a character is one to four octets, so the width it /// fixes is a width in a unit the storage does not count in. A bound on /// characters is a check; a bound on octets is a layout. +/// +/// A hundred and twenty eight bit integer is one of these, and it is the +/// only one that is not a byte string. The lane is sixty four bits and +/// this is twice that, so the number goes on the blob side of the store +/// as sixteen little endian bytes, which is where a `BINARY(16)` already +/// goes and by the same layout: one width, no offsets. What tells the +/// two apart afterwards is the column's own type, the way it tells a +/// byte string from a character string. pub(crate) fn fixed_octets(ty: &LogicalType) -> Option { match ty { LogicalType::Bytes { @@ -1926,6 +1976,10 @@ pub(crate) fn fixed_octets(ty: &LogicalType) -> Option { max: Some(max), .. } if min == max && *min > 0 => Some(*min), + LogicalType::Int { + bits: IntBits::B128, + .. + } => Some(16), _ => None, } } @@ -1986,7 +2040,33 @@ fn check_declared( // than a different encoding. What is left to check is the // promise: a word outside the declared range is a row the // column's own type says is not there. - (LogicalType::Int { signed, bits, .. }, PropValues::Int(words)) => { + // A hundred and twenty eight bit integer is the exception, and + // it is one because it is not on the lane: sixteen bytes a row + // at a fixed stride, so what is checked is the width and not a + // range. The type is the whole of what a value of it may be, and + // a row of another length is one the reader would walk into the + // next row for. + ( + LogicalType::Int { + bits: IntBits::B128, + .. + }, + PropValues::Bytes(rows), + ) => { + for (row, bytes) in rows.iter().enumerate() { + if !column.holds(row) || bytes.len() == 16 { + continue; + } + return Err(ZuError::InvalidArgument(format!( + "column '{name}' is declared {ty} and row {row} holds {} octets", + bytes.len() + ))); + } + return Ok(()); + } + (LogicalType::Int { signed, bits, .. }, PropValues::Int(words)) + if *bits != IntBits::B128 => + { for (row, &word) in words.iter().enumerate() { if !column.holds(row) || bits.holds(word, *signed) { continue; @@ -5947,6 +6027,27 @@ mod tests { assert!(column_type_bytes(&ty).is_none(), "{ty}"); assert!(!storable(&ty), "{ty}"); } + // The wide integers split the same way and for the same reason. + // A signed hundred and twenty eight bit column is storable, + // because the engine's exact numeric carries an `i128` and so + // every value the declaration admits is a value that can be + // handed back. The unsigned one and the two hundred and fifty + // six bit one are not, because half of one range and all of the + // other have no carrier, and a column that took a row in and + // could not give it back would be worse than a refusal at the + // declaration. + let wide = LogicalType::int(IntBits::B128); + assert_eq!(column_type_bytes(&wide), declared_type_bytes(&wide)); + assert!(storable(&wide), "{wide}"); + for ty in [ + LogicalType::uint(IntBits::B128), + LogicalType::int(IntBits::B256), + LogicalType::uint(IntBits::B256), + ] { + assert!(declared_type_bytes(&ty).is_none(), "{ty}"); + assert!(column_type_bytes(&ty).is_none(), "{ty}"); + assert!(!storable(&ty), "{ty}"); + } } #[test] @@ -6139,6 +6240,7 @@ mod tests { }, true, ), + (LogicalType::int(IntBits::B128), true), // Declarable and not storable. Each of these is an entry in // schema/06 section 6, and S2 turns them true one at a time. ( @@ -6148,7 +6250,6 @@ mod tests { }, false, ), - (LogicalType::int(IntBits::B128), false), (LogicalType::int(IntBits::B256), false), (LogicalType::uint(IntBits::B128), false), (LogicalType::float(FloatBits::B16), false), @@ -6212,7 +6313,7 @@ mod tests { // encoding S2 owes, and taking one off is a deliberate diff. assert_eq!( unstorable.len(), - 19, + 18, "the unstorable set changed: {unstorable:?}" ); } diff --git a/crates/zu/src/convert.rs b/crates/zu/src/convert.rs index 04c1497e..f272ad82 100644 --- a/crates/zu/src/convert.rs +++ b/crates/zu/src/convert.rs @@ -64,6 +64,19 @@ fn sqlite_type(ty: &LogicalType, name: &str) -> Result { LogicalType::Bytes { .. } => ColumnType::Blob, LogicalType::Float { .. } => ColumnType::Real, LogicalType::Bool => ColumnType::Boolean, + // sqlite's INTEGER is sixty four bits wide, so the hundred and + // twenty eight bit integer has no storage class here either. It + // is refused rather than narrowed, because a staging form that + // silently drops the top half of a value is worse than one that + // says it cannot carry it. + LogicalType::Int { + bits: IntBits::B128, + .. + } => { + return Err(ZuError::InvalidArgument(format!( + "column '{name}' holds {ty}, which has no sqlite storage class" + ))); + } LogicalType::Int { .. } => ColumnType::Integer, // The temporal columns keep their declaration on the way out, // so a file that round trips through sqlite comes back holding diff --git a/crates/zu/src/declare.rs b/crates/zu/src/declare.rs index b86468cc..dced3a42 100644 --- a/crates/zu/src/declare.rs +++ b/crates/zu/src/declare.rs @@ -1277,6 +1277,66 @@ mod tests { assert_eq!(err.gqlstatus().map(|s| s.code()), Some("22003")); } + /// An `INT128` column holds numbers no lane word could, and hands + /// them back whole. + /// + /// This is the first column that is a number and is not stored in + /// the scalar lane. Sixteen little endian bytes a row at a fixed + /// stride is the layout `BINARY(16)` already had, so the storage + /// side asked for nothing new; what is new is that the bytes are + /// read as a number rather than as a run of octets. + /// + /// A value comes back as an exact numeric of scale nought, which is + /// what a whole number is and what the engine has a carrier for. It + /// prints without a point and compares equal to the integer of the + /// same size, so a caller who wrote 7 reads 7. + #[test] + fn a_declared_int128_column_holds_a_number_wider_than_a_lane() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("wide.zu1"); + seeded(&path); + let mut session = Session::open(&path).expect("open"); + // The largest and smallest values thirty eight digits can spell, + // which is the widest a declaration may ask a decimal for, and a + // small one to show the ordinary case still reads plainly. + let big = "99999999999999999999999999999999999999"; + for stmt in [ + "CREATE PROPERTY GRAPH TYPE t { (:ledger {n :: INT128}) }".to_string(), + "CREATE GRAPH g TYPED t".to_string(), + "USE g INSERT (l:ledger {n: 7})".to_string(), + format!("USE g INSERT (l:ledger {{n: CAST('{big}' AS DECIMAL(38,0))}})"), + format!("USE g INSERT (l:ledger {{n: CAST('-{big}' AS DECIMAL(38,0))}})"), + ] { + session + .run(&stmt, &[]) + .expect("the graph, its type, its rows"); + } + + let rows = session + .run("USE g MATCH (l:ledger) RETURN l.n AS n ORDER BY n", &[]) + .expect("the rows read back"); + let read: Vec = rows + .rows + .iter() + .map(|row| match &row[0] { + Value::Decimal(d) => d.to_string(), + other => panic!("expected an exact numeric, got {other:?}"), + }) + .collect(); + assert_eq!(read, [format!("-{big}"), "7".to_string(), big.to_string()]); + + // A number with a fraction is not a value of an integer column, + // and it is refused rather than rounded, which is the rule the + // decimal column above set. + let err = session + .run( + "USE g INSERT (l:ledger {n: CAST('1.5' AS DECIMAL(2,1))})", + &[], + ) + .expect_err("an integer column holds no halves"); + assert_eq!(err.gqlstatus().map(|s| s.code()), Some("22003")); + } + /// A refusal is asked once whether this module can do anything for /// it, and the answer is held with it, so the second send of the /// same bad statement does not parse it again to find out. What diff --git a/crates/zu/src/insert.rs b/crates/zu/src/insert.rs index a35577ff..20cf4c5d 100644 --- a/crates/zu/src/insert.rs +++ b/crates/zu/src/insert.rs @@ -22,7 +22,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; use zu_common::gqlstatus::{Subject, codes}; -use zu_common::{Decimal, FloatBits, GqlStatus, LogicalType, Result, Temporal, ZuError}; +use zu_common::{Decimal, FloatBits, GqlStatus, IntBits, LogicalType, Result, Temporal, ZuError}; use zu_query::binder::{BoundExpr, BoundInsertNode, BoundInsertRel}; use crate::deleted::Deleted; @@ -684,6 +684,40 @@ pub(crate) fn cell(ty: &LogicalType, value: &Value, key: &str) -> Result { }; Ok(match (ty, value) { (LogicalType::Bool, Value::Bool(b)) => Cell::Int(u64::from(*b)), + // A hundred and twenty eight bit integer goes down the blob side + // of the store, as sixteen little endian bytes, because a cell + // is one word and this is two. It has to come before the integer + // arms below or the word would go in the lane and the top half + // of the number would be gone. + // + // What it takes is an exact numeric of scale nought, which is + // what a whole number is, and an `Int`, which is one already. A + // value with a fraction is refused rather than rounded, for the + // reason a decimal column refuses one: the caller who meant to + // drop it says so with a cast. + ( + LogicalType::Int { + bits: IntBits::B128, + .. + }, + Value::Decimal(_) | Value::Int(_), + ) => { + let given = match value { + Value::Decimal(d) => *d, + Value::Int(n) => Decimal::new(i128::from(*n), 0), + _ => unreachable!("matched just above"), + }; + match given.rescale(0) { + Some(whole) => Cell::Str(whole.unscaled().to_le_bytes().to_vec()), + None => { + return Err(ZuError::gql( + codes::C22003, + format!("property '{key}' holds {ty}, and {given} is not a value of it"), + ) + .about(Subject::Property(key.to_string()))); + } + } + } // A column declared narrower than the lane is a promise about // the values, and this is where the promise is kept: the word // that goes in is the same word either way, so nothing later diff --git a/crates/zu/src/query.rs b/crates/zu/src/query.rs index 44d8a621..545f7197 100644 --- a/crates/zu/src/query.rs +++ b/crates/zu/src/query.rs @@ -24,7 +24,7 @@ use crate::zu1::graph::{Direction, GraphReader}; use crate::zu1::props::{ ListElement, PropsReader, list_elements, load_props, load_props_at, zoned, }; -use zu_common::{Decimal, FloatBits, LogicalType, Temporal}; +use zu_common::{Decimal, FloatBits, IntBits, LogicalType, Temporal}; /// The same rows read down their columns, which is the shape every /// client that hands a result to Arrow, pandas or polars needs. @@ -662,6 +662,28 @@ fn column_value( reader.read_str(db, col, row, &mut bytes)?; Ok(Value::Bytes(bytes)) } + // A hundred and twenty eight bit integer is the same blob read + // as a number: sixteen little endian bytes, because the lane is + // sixty four bits and this is twice that. + // + // It comes back as an exact numeric of scale nought rather than + // as `Value::Int`, which holds sixty four bits and would lose + // the half of the column that the type is for. An exact numeric + // at scale nought is an integer, prints as one and compares + // equal to one, so `p.id = 5` is true of a row holding five; + // what it also is, is a hundred and twenty eight bit one. + LogicalType::Int { + bits: IntBits::B128, + .. + } => { + let mut bytes = Vec::new(); + reader.read_str(db, col, row, &mut bytes)?; + let word: [u8; 16] = bytes.as_slice().try_into().map_err(|_| ZuError::Corrupt { + what: "props column", + detail: format!("'{key}' row {row} is {} octets and not 16", bytes.len()), + })?; + Ok(Value::Decimal(Decimal::new(i128::from_le_bytes(word), 0))) + } // A stored list comes back as the list value the rest of the // engine already has, element by element through the same // reading a scalar column of that type gets, so `b.xs` and a diff --git a/crates/zu/src/snapshot.rs b/crates/zu/src/snapshot.rs index ce3b1e0e..44e8742a 100644 --- a/crates/zu/src/snapshot.rs +++ b/crates/zu/src/snapshot.rs @@ -502,17 +502,27 @@ fn check_col(reader: &PropsReader, col: ColId) -> Result { reader.columns().len() ))); } - // Two column types ride the lane and are not lanes the vector layer - // has. A zoned column's words are instants, and the offsets that - // make them zoned are a plane a vector has no room for. A decimal - // column's words are unscaled units, and the scale that makes them - // a number is in the declared type, which a vector does not carry. - // `vector_col` already keeps both out of a plan, and this is the - // same rule said where the read happens, so a caller that reached - // here another way is refused rather than handed words dressed as - // integers. + // Three column types are stored in a shape the vector layer has no + // lane for. A zoned column's words are instants, and the offsets + // that make them zoned are a plane a vector has no room for. A + // decimal column's words are unscaled units, and the scale that + // makes them a number is in the declared type, which a vector does + // not carry. A hundred and twenty eight bit integer is sixteen + // bytes at a fixed stride, so the gather below would hand it back + // as a string vector, which is a number dressed as text. + // `vector_col` already keeps all three out of a plan, and this is + // the same rule said where the read happens, so a caller that + // reached here another way is refused rather than handed words + // dressed as something else. let column = &reader.columns()[ix]; - if zoned(&column.ty) || matches!(column.ty, LogicalType::Decimal { .. }) { + let wide_int = matches!( + column.ty, + LogicalType::Int { + bits: IntBits::B128, + .. + } + ); + if zoned(&column.ty) || matches!(column.ty, LogicalType::Decimal { .. }) || wide_int { return Err(ZuError::InvalidArgument(format!( "column '{}' holds {}, which the vector layer has no lane for", column.name, column.ty diff --git a/crates/zu/tests/graph_type.rs b/crates/zu/tests/graph_type.rs index d2304883..add1016e 100644 --- a/crates/zu/tests/graph_type.rs +++ b/crates/zu/tests/graph_type.rs @@ -101,16 +101,23 @@ fn probe() -> Vec<(&'static str, Answer)> { // twelve digits of them fit a word and this is a column a // statement can fill. ("DECIMAL(12,2)", Answer::Declared), + // The signed hundred and twenty eight bit integer is the first + // number here that does not ride the lane. It is sixteen bytes + // at a fixed stride, the layout BINARY(16) above already uses, + // and it reads back as an exact numeric of scale zero, which is + // a carrier the engine has. + ("INT128", Answer::Declared), // Spelled and not stored. This is the list S2 works through. // The condition is 42000 for all of them, which is what S1 // replaced the sentence about corruption with. // - // The wide decimal is here for the same reason INT128 is: its - // unscaled units want more than a lane word, and the lane is - // sixty four bits. It is the one row on this list whose sibling - // is on the list above. - ("DECIMAL(38,2)", cannot_write(27)), - ("INT128", cannot_write(28)), + // The wide decimal's unscaled units want more than a lane word + // and the lane is sixty four bits. The two wide integers below + // it are refused for a different reason: the top half of a + // `UINT128` and the whole of an `INT256` are ranges no value + // the engine carries can name, so a column of one would take a + // row in and be unable to give it back. + ("DECIMAL(38,2)", cannot_write(28)), ("INT256", cannot_write(29)), ("UINT128", cannot_write(30)), ("FLOAT16", cannot_write(31)), @@ -145,7 +152,7 @@ fn a_graph_type_declares_the_types_the_frontier_says_it_can() { declared += 1; } } - assert_eq!(declared, 26, "the declarable set changed"); + assert_eq!(declared, 27, "the declarable set changed"); } /// The frontier has a far side, and one type is on it.