Skip to main content

godspeed_sdk/
record.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Structured records - the typed `Table` that flows through GodspeedOS pipes.
3//!
4//! POSIX pipes carry text, so structured data is flattened to a string and `grep`/`awk`/`cut`
5//! re-parse it back. GodspeedOS keeps the structure: a producer emits a **`Table`** (typed
6//! columns + rows); filters operate on real fields (`where mem>0`), and JSON/YAML/the grid are
7//! *renderings* of the model, not the model itself. This module is that model, lifted into the
8//! SDK so **any** service - not just the shell - can build records, filter them, and render them
9//! to JSON/YAML. See `docs/records.md`.
10//!
11//! ## Building a pipe-friendly service
12//!
13//! A service participates in a record pipe today without any new kernel surface: build a
14//! `Table`, render it with [`Table::to_json`], and emit the bytes (EOT-terminated) like any byte
15//! producer (`docs/pipes.md`). The shell's `| from json` lifts it back to records:
16//!
17//! ```ignore
18//! let mut t = Table::new(&["name", "n"]);
19//! let n = t.intern(b"alpha");
20//! t.add_row(&[n, Value::Int(1)]);
21//! let mut buf = MyBuf::new();        // any RecordSink (e.g. wrapping an IPC message)
22//! t.to_json(&mut buf);              // → `[ {"name": "alpha", "n": 1} ]`
23//! ```
24//!
25//! To cross a **service** boundary *as records* (skipping the JSON round-trip), use the bounded
26//! binary **wire codec** instead: [`Table::encode`] on the producer, [`Table::decode`] on the
27//! consumer. It is the `Table` itself on the wire - compact and typed, not JSON. `examples/roster`
28//! produces records this way; the shell decodes them straight into a `Table`.
29//!
30//! ## Bounds (§26.6)
31//!
32//! Everything is fixed-size and stack-resident - no heap, loud on overflow: at most
33//! [`REC_MAX_COLS`] columns, [`REC_MAX_ROWS`] rows, a [`REC_ARENA`]-byte string arena, and
34//! [`REC_COL_NAME`]-byte column names. Overflowing any bound sets [`Table::overflow`].
35//! One input is ignored WITHOUT setting it: values past the column count in an `add_row` call.
36
37use core::cmp::Ordering;
38
39/// Maximum columns in a table.
40pub const REC_MAX_COLS: usize = 8;
41/// Maximum rows in a table.
42pub const REC_MAX_ROWS: usize = 64;
43/// Backing store (bytes) for interned `Str` cell values.
44pub const REC_ARENA: usize = 4 * 1024;
45/// Maximum column-name length (bytes).
46pub const REC_COL_NAME: usize = 24;
47
48/// Magic prefix of the binary wire encoding (`Table::encode`/`decode`). Lets a decoder reject a
49/// non-record byte stream loudly instead of misparsing it.
50pub const REC_WIRE_MAGIC: &[u8; 4] = b"GSR1";
51
52/// A typed cell. `Str` points into the owning [`Table`]'s arena (no lifetimes, no heap).
53#[derive(Clone, Copy)]
54pub enum Value {
55    /// A string, stored as an `(offset, length)` into the table's arena.
56    Str { off: u32, len: u32 },
57    /// An unsigned integer.
58    Int(u64),
59    /// A SIGNED integer: a reading that can be below zero. Pulled into existence by `wifi list`, whose
60    /// signal is in dBm - always negative - and is the raw fact (`utilities/0_conventions.md` rule 7):
61    /// the magnitude would be a different number, and a string would sort by bytes, which orders
62    /// `-9` after `-80`. Its own variant rather than a change to `Int`, so every count, size and tick
63    /// keeps the type it had. Compares numerically with `Int` (`sort`, `where`) and is reduced by the
64    /// aggregators through `cell_num_wide` (i128); only the u64 `cell_int` accessor refuses a negative.
65    Signed(i64),
66    /// An absent / null cell.
67    Empty,
68}
69
70/// A numeric-column reducer (the `sum`/`min`/`max`/`avg` pipe aggregators, docs/scripting.md §5).
71#[derive(Clone, Copy)]
72pub enum AggOp { Sum, Min, Max, Avg }
73
74/// Why an aggregate failed - each maps to a loud shell notice, never a silent 0.
75#[derive(Clone, Copy)]
76pub enum AggErr { NoColumn, NonNumeric }
77
78/// A sink the renderers write bytes into - the caller's bridge to a console, a capture buffer,
79/// or an IPC message. Implement it for whatever the bytes should go to.
80pub trait RecordSink {
81    /// Append `bytes` to the sink.
82    fn put(&mut self, bytes: &[u8]);
83}
84
85/// A bounded table of typed rows - the canonical structured-pipe value. Owned column names (so a
86/// `from_json` parse can name columns dynamically), rows of [`Value`], and a byte arena holding
87/// the `Str` cells. All inline, no heap.
88pub struct Table {
89    col_names: [[u8; REC_COL_NAME]; REC_MAX_COLS],
90    col_lens: [u8; REC_MAX_COLS],
91    ncols: usize,
92    rows: [[Value; REC_MAX_COLS]; REC_MAX_ROWS],
93    nrows: usize,
94    arena: [u8; REC_ARENA],
95    alen: usize,
96    overflow: bool,
97}
98
99impl Table {
100    /// A new table with the given column names (in order).
101    pub fn new(cols: &[&str]) -> Self {
102        let mut t = Table {
103            col_names: [[0u8; REC_COL_NAME]; REC_MAX_COLS], col_lens: [0; REC_MAX_COLS], ncols: 0,
104            rows: [[Value::Empty; REC_MAX_COLS]; REC_MAX_ROWS], nrows: 0,
105            arena: [0u8; REC_ARENA], alen: 0, overflow: false,
106        };
107        for c in cols { t.add_col(c.as_bytes()); }
108        t
109    }
110
111    /// Add a column by name; returns its index (or `None` if full / name too long → overflow).
112    pub fn add_col(&mut self, name: &[u8]) -> Option<usize> {
113        if self.ncols >= REC_MAX_COLS || name.len() > REC_COL_NAME { self.overflow = true; return None; }
114        let i = self.ncols;
115        self.col_names[i][..name.len()].copy_from_slice(name);
116        self.col_lens[i] = name.len() as u8;
117        self.ncols += 1;
118        Some(i)
119    }
120
121    /// Copy bytes into the arena and return a `Str` value (or `Empty` if the arena is full).
122    pub fn intern(&mut self, s: &[u8]) -> Value {
123        if self.alen + s.len() > REC_ARENA { self.overflow = true; return Value::Empty; }
124        let off = self.alen as u32;
125        self.arena[self.alen..self.alen + s.len()].copy_from_slice(s);
126        self.alen += s.len();
127        Value::Str { off, len: s.len() as u32 }
128    }
129
130    /// Append a row (values in column order). Loud-bounded: extra rows set [`Table::overflow`].
131    pub fn add_row(&mut self, vals: &[Value]) {
132        if self.nrows >= REC_MAX_ROWS { self.overflow = true; return; }
133        // More values than columns is a row that does not fit, and says so like every other
134        // bound here; the extras were dropped silently until 2026-10-10 (`backlog/fixed/80` S7).
135        if vals.len() > self.ncols { self.overflow = true; }
136        for (i, v) in vals.iter().take(self.ncols).enumerate() { self.rows[self.nrows][i] = *v; }
137        self.nrows += 1;
138    }
139
140    /// Number of columns.
141    pub fn ncols(&self) -> usize { self.ncols }
142    /// Number of rows.
143    pub fn nrows(&self) -> usize { self.nrows }
144
145    /// The value of a numeric cell; `None` for a string or absent one. The twin of
146    /// [`Table::cell_bytes`], for the same reason: deriving a view from a table beats rendering it
147    /// and parsing the text back.
148    pub fn cell_int(&self, r: usize, c: usize) -> Option<u64> {
149        if r >= self.nrows || c >= self.ncols { return None; }
150        self.cell_num(self.rows[r][c])
151    }
152
153    /// The bytes of a string cell; empty for a numeric or absent one.
154    ///
155    /// Exists so one table can be DERIVED from another - `events deps` reads the event table to count
156    /// what a service actually called. Without it the only way to consume a `Table` was to render it
157    /// and re-parse the text, which is the round-trip this record model exists to avoid.
158    pub fn cell_bytes(&self, r: usize, c: usize) -> &[u8] {
159        if r >= self.nrows || c >= self.ncols { return b""; }
160        match self.rows[r][c] {
161            Value::Str { .. } => self.cell_str(self.rows[r][c]),
162            _ => b"",
163        }
164    }
165    /// True if any bound was exceeded while building (rows/cols/arena/name).
166    pub fn overflow(&self) -> bool { self.overflow }
167
168    /// Column `c`'s name bytes.
169    pub fn col_name(&self, c: usize) -> &[u8] { &self.col_names[c][..self.col_lens[c] as usize] }
170
171    fn col_index(&self, name: &str) -> Option<usize> {
172        (0..self.ncols).find(|&i| self.col_name(i) == name.as_bytes())
173    }
174
175    /// Resolve a cell's text: a `Str` from the arena, or the empty slice for non-strings.
176    fn cell_str(&self, v: Value) -> &[u8] {
177        match v {
178            Value::Str { off, len } => &self.arena[off as usize..(off + len) as usize],
179            _ => &[],
180        }
181    }
182
183    // ── filters / ops ─────────────────────────────────────────────────────────────────────
184
185    /// Keep only rows whose column `col` satisfies `<op> val` (in place). Ops: `=`/`==` `!=` `>`
186    /// `<` `>=` `<=` `~`(contains). Numeric when both sides parse as numbers, else textual.
187    /// Returns `false` (table unchanged) if `col` is not a column.
188    /// Keep rows matching `col op val`. Returns false if `col` is not a column - and in that case the
189    /// table is EMPTIED, not left alone.
190    ///
191    /// It used to be left alone, so `where peeer contains reply` printed a notice and then handed the
192    /// whole table onward: a mistyped column produced output identical to "everything matched", and a
193    /// `| count` downstream reported the full count. A predicate that could not be evaluated has not
194    /// selected anything, and saying so with zero rows is the honest answer (invariant 12) - the loud
195    /// notice stays, but it is no longer contradicted by the data underneath it.
196    pub fn filter(&mut self, col: &str, op: &str, val: &str) -> bool {
197        let ci = match self.col_index(col) { Some(i) => i, None => { self.nrows = 0; return false; } };
198        let mut keep = 0usize;
199        for r in 0..self.nrows {
200            if row_matches(self, r, ci, op, val) {
201                if keep != r { self.rows[keep] = self.rows[r]; }
202                keep += 1;
203            }
204        }
205        self.nrows = keep;
206        true
207    }
208
209    /// Keep only the named columns, in the given order (in place). Returns `false` (table
210    /// unchanged) if any name is not a column. The arena (string storage) is untouched.
211    pub fn select(&mut self, names: &[&str]) -> bool {
212        let mut new_names = [[0u8; REC_COL_NAME]; REC_MAX_COLS];
213        let mut new_lens = [0u8; REC_MAX_COLS];
214        let mut map = [0usize; REC_MAX_COLS];
215        let mut nc = 0usize;
216        for &name in names {
217            if name.is_empty() { continue; }
218            match self.col_index(name) {
219                Some(oi) if nc < REC_MAX_COLS => {
220                    new_names[nc] = self.col_names[oi];
221                    new_lens[nc] = self.col_lens[oi];
222                    map[nc] = oi;
223                    nc += 1;
224                }
225                Some(_) => {}
226                None => return false,
227            }
228        }
229        for r in 0..self.nrows {
230            let old = self.rows[r];
231            for i in 0..nc { self.rows[r][i] = old[map[i]]; }
232            for i in nc..self.ncols { self.rows[r][i] = Value::Empty; }
233        }
234        self.col_names = new_names;
235        self.col_lens = new_lens;
236        self.ncols = nc;
237        true
238    }
239
240    /// Order rows by column `col` (numeric when both ints, else by bytes), descending if
241    /// `reverse`. Returns `false` (table unchanged) if `col` is not a column.
242    pub fn sort(&mut self, col: &str, reverse: bool) -> bool {
243        let ci = match self.col_index(col) { Some(i) => i, None => return false };
244        let n = self.nrows;
245        let arena = &self.arena; // disjoint field borrow: rows sorted mutably, arena read-only
246        self.rows[..n].sort_unstable_by(|a, b| {
247            let o = cmp_values(a[ci], b[ci], arena);
248            if reverse { o.reverse() } else { o }
249        });
250        true
251    }
252
253    /// A cell as a number: an `Int` directly, or a `Str` of ASCII digits; `None` otherwise (a
254    /// non-numeric or empty cell - the caller turns that into a loud [`AggErr::NonNumeric`]).
255    fn cell_num(&self, v: Value) -> Option<u64> {
256        match v {
257            Value::Int(n) => Some(n),
258            Value::Signed(n) => u64::try_from(n).ok(),
259            Value::Str { off, len } => {
260                let b = &self.arena[off as usize..(off + len) as usize];
261                if b.is_empty() { return None; }
262                let mut acc: u64 = 0;
263                for &c in b {
264                    if !c.is_ascii_digit() { return None; }
265                    acc = acc.saturating_mul(10).saturating_add((c - b'0') as u64);
266                }
267                Some(acc)
268            }
269            Value::Empty => None,
270        }
271    }
272
273    /// A cell as a number for the aggregators, signed or not: `i128` holds every `u64` and every `i64`
274    /// exactly. A `Str` of ASCII digits, with an optional leading `-`, counts; anything else is `None`.
275    fn cell_num_wide(&self, v: Value) -> Option<i128> {
276        match v {
277            Value::Int(n) => Some(n as i128),
278            Value::Signed(n) => Some(n as i128),
279            Value::Str { off, len } => {
280                let b = &self.arena[off as usize..(off + len) as usize];
281                let (neg, digits) = match b.split_first() {
282                    Some((b'-', rest)) => (true, rest),
283                    _ => (false, b),
284                };
285                if digits.is_empty() || !digits.iter().all(|c| c.is_ascii_digit()) { return None; }
286                let mut acc: i128 = 0;
287                for &c in digits {
288                    acc = acc.saturating_mul(10).saturating_add((c - b'0') as i128);
289                }
290                Some(if neg { -acc } else { acc })
291            }
292            Value::Empty => None,
293        }
294    }
295
296    /// Reduce a numeric column to a scalar (§5). Loud: `NoColumn` if `col` is not a column,
297    /// `NonNumeric` if any cell is not a number - never a silent 0. Empty table reduces to 0.
298    /// `avg` is integer, truncated toward zero. Signed columns reduce too (`wifi list | max dbm` is the
299    /// strongest signal), which is why the result is wide.
300    pub fn aggregate(&self, col: &str, op: AggOp) -> Result<i128, AggErr> {
301        let ci = self.col_index(col).ok_or(AggErr::NoColumn)?;
302        if self.nrows == 0 { return Ok(0); }
303        let (mut sum, mut mn, mut mx) = (0i128, i128::MAX, i128::MIN);
304        for r in 0..self.nrows {
305            let n = self.cell_num_wide(self.rows[r][ci]).ok_or(AggErr::NonNumeric)?;
306            sum = sum.saturating_add(n);
307            if n < mn { mn = n; }
308            if n > mx { mx = n; }
309        }
310        Ok(match op {
311            AggOp::Sum => sum,
312            AggOp::Min => mn,
313            AggOp::Max => mx,
314            AggOp::Avg => sum / self.nrows as i128,
315        })
316    }
317
318    // ── renderers (edge formats) ──────────────────────────────────────────────────────────
319
320    /// Render as an aligned text grid (the default view). String cells render in full (via the
321    /// arena), so a long value is never silently clipped (§3.12).
322    pub fn to_grid(&self, out: &mut impl RecordSink) {
323        let w = self.grid_widths();
324        self.grid_header(out, &w);
325        for r in 0..self.nrows { self.grid_row(out, r, &w); }
326    }
327
328    /// Column widths for the grid rendering, computed over EVERY row.
329    ///
330    /// Split out so a caller can render rows one at a time. A pager scrolling a long table needs the
331    /// columns to stay put between frames, which means the widths must come from the whole table and
332    /// not from the slice currently on screen.
333    pub fn grid_widths(&self) -> [usize; REC_MAX_COLS] {
334        let mut w = [0usize; REC_MAX_COLS];
335        for c in 0..self.ncols { w[c] = self.col_name(c).len(); }
336        for r in 0..self.nrows {
337            for c in 0..self.ncols {
338                let n = cell_width(self, self.rows[r][c]);
339                if n > w[c] { w[c] = n; }
340            }
341        }
342        w
343    }
344
345    /// The grid header line, at the widths from [`Table::grid_widths`].
346    pub fn grid_header(&self, out: &mut impl RecordSink, w: &[usize; REC_MAX_COLS]) {
347        for c in 0..self.ncols {
348            out.put(self.col_name(c));
349            pad(out, w[c].saturating_sub(self.col_name(c).len()) + 2);
350        }
351        out.put(b"\n");
352    }
353
354    /// One row of the grid, at the widths from [`Table::grid_widths`]. An out-of-range `r` writes
355    /// nothing, so a pager asking past the end gets a blank line rather than a panic.
356    pub fn grid_row(&self, out: &mut impl RecordSink, r: usize, w: &[usize; REC_MAX_COLS]) {
357        if r >= self.nrows { return; }
358        let mut scratch = [0u8; 24];
359        for c in 0..self.ncols {
360            let n = match self.rows[r][c] {
361                Value::Str { .. } => { let s = self.cell_str(self.rows[r][c]); out.put(s); s.len() }
362                v => { let n = fmt_cell(self, v, &mut scratch); out.put(&scratch[..n]); n }
363            };
364            pad(out, w[c].saturating_sub(n) + 2);
365        }
366        out.put(b"\n");
367    }
368
369    /// Render as a JSON array of objects (`to json`). Values are plain ASCII today - a real
370    /// string-escaper is a documented follow-up.
371    pub fn to_json(&self, out: &mut impl RecordSink) {
372        out.put(b"[\n");
373        for r in 0..self.nrows {
374            out.put(b"  {");
375            for c in 0..self.ncols {
376                if c > 0 { out.put(b", "); }
377                out.put(b"\"");
378                out.put(self.col_name(c));
379                out.put(b"\": ");
380                match self.rows[r][c] {
381                    Value::Int(_) | Value::Signed(_) => {
382                        let mut b = [0u8; 24];
383                        let n = fmt_cell(self, self.rows[r][c], &mut b);
384                        out.put(&b[..n]);
385                    }
386                    Value::Empty => out.put(b"null"),
387                    Value::Str { .. } => {
388                        out.put(b"\"");
389                        out.put(self.cell_str(self.rows[r][c]));
390                        out.put(b"\"");
391                    }
392                }
393            }
394            out.put(if r + 1 < self.nrows { b"},\n" } else { b"}\n" });
395        }
396        out.put(b"]\n");
397    }
398
399    /// Render as YAML - a list of mappings (`to yaml`). String cells render in full.
400    pub fn to_yaml(&self, out: &mut impl RecordSink) {
401        let mut scratch = [0u8; 24];
402        for r in 0..self.nrows {
403            for c in 0..self.ncols {
404                out.put(if c == 0 { b"- " } else { b"  " });
405                out.put(self.col_name(c));
406                out.put(b": ");
407                match self.rows[r][c] {
408                    Value::Str { .. } => out.put(self.cell_str(self.rows[r][c])),
409                    v => { let n = fmt_cell(self, v, &mut scratch); out.put(&scratch[..n]); }
410                }
411                out.put(b"\n");
412            }
413        }
414    }
415
416    // ── from json (text → records bridge) ─────────────────────────────────────────────────
417
418    /// Parse a JSON array of flat objects into a `Table` - the `from json` bridge. Bounded
419    /// subset: `[ {"k": v, …}, … ]` with string / number / `true|false` / `null` values, **no
420    /// nesting**. The first object defines the columns; later objects fill known columns (new
421    /// keys ignored). On malformed input, returns `Err(reason)` with a bare static reason (the
422    /// caller adds any `from json:` prefix).
423    #[inline(never)]
424    pub fn from_json(input: &[u8]) -> Result<Table, &'static str> {
425        let mut t = Table::new(&[]);
426        let b = input;
427        let mut i = json_ws(b, 0);
428        if i >= b.len() || b[i] != b'[' { return Err("expected a JSON array '[ … ]'"); }
429        i = json_ws(b, i + 1);
430        if i < b.len() && b[i] == b']' { return Ok(t); }
431        let mut first_obj = true;
432        loop {
433            i = json_ws(b, i);
434            if i >= b.len() || b[i] != b'{' { return Err("expected an object '{ … }'"); }
435            i = json_ws(b, i + 1);
436            let mut row = [Value::Empty; REC_MAX_COLS];
437            if i < b.len() && b[i] == b'}' { i += 1; } else {
438                loop {
439                    i = json_ws(b, i);
440                    let (ks, ke, kn) = match json_string(b, i) {
441                        Some(x) => x,
442                        None => return Err("expected a \"key\""),
443                    };
444                    i = json_ws(b, kn);
445                    if i >= b.len() || b[i] != b':' { return Err("expected ':'"); }
446                    i = json_ws(b, i + 1);
447                    let v;
448                    if i < b.len() && b[i] == b'"' {
449                        let (vs, ve, vn) = match json_string(b, i) {
450                            Some(x) => x, None => return Err("unterminated string"),
451                        };
452                        v = t.intern(&b[vs..ve]);
453                        i = vn;
454                    } else if b[i..].starts_with(b"true") { v = Value::Int(1); i += 4; }
455                    else if b[i..].starts_with(b"false") { v = Value::Int(0); i += 5; }
456                    else if b[i..].starts_with(b"null") { v = Value::Empty; i += 4; }
457                    else if i < b.len() && (b[i] == b'-' || b[i].is_ascii_digit()) {
458                        let s = i;
459                        if b[i] == b'-' { i += 1; }
460                        while i < b.len() && b[i].is_ascii_digit() { i += 1; }
461                        if i < b.len() && (b[i] == b'.' || b[i] == b'e' || b[i] == b'E') {
462                            while i < b.len() && !matches!(b[i], b',' | b'}' | b' ' | b'\t' | b'\n' | b'\r') { i += 1; }
463                            v = t.intern(&b[s..i]);
464                        } else {
465                            // A negative integer is a Signed cell; it used to fail the u64 parse and
466                            // arrive as null, silently.
467                            let txt = core::str::from_utf8(&b[s..i]).ok();
468                            v = match txt.and_then(|x| x.parse::<u64>().ok()) {
469                                Some(n) => Value::Int(n),
470                                None => txt.and_then(|x| x.parse::<i64>().ok()).map(Value::Signed).unwrap_or(Value::Empty),
471                            };
472                        }
473                    } else {
474                        return Err("unsupported value (nested objects/arrays not supported)");
475                    }
476                    let key = &b[ks..ke];
477                    let ci = (0..t.ncols).find(|&c| t.col_name(c) == key);
478                    let ci = match ci {
479                        Some(c) => Some(c),
480                        None if first_obj => t.add_col(key),
481                        None => None,
482                    };
483                    if let Some(ci) = ci { row[ci] = v; }
484                    i = json_ws(b, i);
485                    if i < b.len() && b[i] == b',' { i += 1; continue; }
486                    if i < b.len() && b[i] == b'}' { i += 1; break; }
487                    return Err("expected ',' or '}'");
488                }
489            }
490            // This table's columns only - see the same line in `decode`, which had the same fault.
491            t.add_row(&row[..t.ncols]);
492            first_obj = false;
493            i = json_ws(b, i);
494            if i < b.len() && b[i] == b',' { i += 1; continue; }
495            if i < b.len() && b[i] == b']' { return Ok(t); }
496            return Err("expected ',' or ']'");
497        }
498    }
499
500    // ── wire codec (records across a service boundary) ────────────────────────────────────
501
502    /// Encode the table into a compact, *bounded* binary form for crossing a **service**
503    /// boundary as records (no JSON round-trip). This is emphatically **not** JSON - it is the
504    /// `Table` itself on the wire. Layout:
505    ///
506    /// ```text
507    /// magic "GSR1" | ncols:u8 | nrows:u8
508    /// per column:  name_len:u8 | name bytes
509    /// per cell:    tag:u8 (0=empty 1=int 2=str 3=signed)
510    ///              int → val:u64-le ; str → len:u16-le | bytes ; empty → (nothing)
511    /// ```
512    ///
513    /// Symmetric with [`Table::decode`]. The whole encoding is bounded by the table's own
514    /// bounds; a producer sends it as one IPC message (≤ 4 KiB) for a small table, or chunks it
515    /// - the shell drains chunks until EOT, then decodes. (A chunked producer must never emit a
516    /// lone `0x04` chunk, which is the EOT marker; the magic guarantees the first chunk is not.)
517    pub fn encode(&self, out: &mut impl RecordSink) {
518        out.put(REC_WIRE_MAGIC);
519        out.put(&[self.ncols as u8, self.nrows as u8]);
520        for c in 0..self.ncols {
521            let name = self.col_name(c);
522            out.put(&[name.len() as u8]);
523            out.put(name);
524        }
525        for r in 0..self.nrows {
526            for c in 0..self.ncols {
527                match self.rows[r][c] {
528                    Value::Empty => out.put(&[0u8]),
529                    Value::Int(i) => { out.put(&[1u8]); out.put(&i.to_le_bytes()); }
530                    Value::Signed(i) => { out.put(&[3u8]); out.put(&i.to_le_bytes()); }
531                    Value::Str { .. } => {
532                        let s = self.cell_str(self.rows[r][c]);
533                        out.put(&[2u8]);
534                        out.put(&(s.len() as u16).to_le_bytes());
535                        out.put(s);
536                    }
537                }
538            }
539        }
540    }
541
542    /// Decode the binary form produced by [`Table::encode`]. Validates the magic and every length
543    /// against the table bounds and the buffer end - a truncated, oversized, or non-record buffer
544    /// is a loud `Err`, never a misparse (§3.12). Strings intern into the new table's arena.
545    #[inline(never)]
546    pub fn decode(bytes: &[u8]) -> Result<Table, &'static str> {
547        let b = bytes;
548        if b.len() < 6 || &b[..4] != &REC_WIRE_MAGIC[..] { return Err("not a record stream (bad magic)"); }
549        let mut p = 4usize;
550        let ncols = b[p] as usize; p += 1;
551        let nrows = b[p] as usize; p += 1;
552        if ncols > REC_MAX_COLS { return Err("record has too many columns"); }
553        if nrows > REC_MAX_ROWS { return Err("record has too many rows"); }
554        let mut t = Table::new(&[]);
555        for _ in 0..ncols {
556            if p >= b.len() { return Err("truncated record (column count)"); }
557            let nl = b[p] as usize; p += 1;
558            if p + nl > b.len() { return Err("truncated record (column name)"); }
559            if t.add_col(&b[p..p + nl]).is_none() { return Err("record column rejected (name too long)"); }
560            p += nl;
561        }
562        for _ in 0..nrows {
563            let mut row = [Value::Empty; REC_MAX_COLS];
564            for cell in row.iter_mut().take(ncols) {
565                if p >= b.len() { return Err("truncated record (cell tag)"); }
566                let tag = b[p]; p += 1;
567                *cell = match tag {
568                    0 => Value::Empty,
569                    1 => {
570                        if p + 8 > b.len() { return Err("truncated record (int)"); }
571                        let mut a = [0u8; 8];
572                        a.copy_from_slice(&b[p..p + 8]); p += 8;
573                        Value::Int(u64::from_le_bytes(a))
574                    }
575                    3 => {
576                        if p + 8 > b.len() { return Err("truncated record (signed int)"); }
577                        let mut a = [0u8; 8];
578                        a.copy_from_slice(&b[p..p + 8]); p += 8;
579                        Value::Signed(i64::from_le_bytes(a))
580                    }
581                    2 => {
582                        if p + 2 > b.len() { return Err("truncated record (string length)"); }
583                        let len = u16::from_le_bytes([b[p], b[p + 1]]) as usize; p += 2;
584                        if p + len > b.len() { return Err("truncated record (string)"); }
585                        let v = t.intern(&b[p..p + len]); p += len;
586                        v
587                    }
588                    _ => return Err("bad record cell tag"),
589                };
590            }
591            // THIS TABLE'S COLUMNS, not the whole `REC_MAX_COLS` scratch row. Since `add_row` calls more
592            // values than columns an overflow (`backlog/fixed/80` S7, `507c2d55`), passing the scratch row made
593            // every decoded table of fewer than 8 columns report a truncation that did not happen - a
594            // record service piped into anything printed "result exceeded the record bound" over three
595            // rows. Found by `ask`, the first record service whose pipe a test read closely.
596            t.add_row(&row[..ncols]);
597        }
598        Ok(t)
599    }
600}
601
602/// Parse a compact predicate token `col<op>val` (e.g. `mem>0`, `state=BlockRecv`, `name!=x`).
603/// The operator is the longest match (`!=`/`>=`/`<=`/`==` before `=`/`>`/`<`/`~`); before it is
604/// the column, after it the value. `None` if no operator is present.
605pub fn parse_predicate(tok: &str) -> Option<(&str, &str, &str)> {
606    // `contains` IS A WORD, and words are the vocabulary here (`utilities/0_conventions.md` rule 4:
607    // "Subcommands are words, never single-letter flags. A word means the same thing across every
608    // utility"). The comparison operators `= != > < >= <=` are self-evident to anyone who has seen
609    // arithmetic; `~` was the one nobody could read without being told, which is exactly the cost that
610    // rule exists to avoid. It is REPLACED rather than aliased - rule 3 rejects synonyms outright.
611    //
612    // Written spaced (`peer contains reply`) because that is how a word reads. The symbolic operators
613    // stay unspaced, as they are written everywhere else.
614    let t = tok.trim();
615    if let Some(i) = t.find(" contains ") {
616        return Some((t[..i].trim(), "~", t[i + " contains ".len()..].trim()));
617    }
618    for op in ["!=", ">=", "<=", "=="] {
619        if let Some(i) = t.find(op) { return Some((&t[..i], op, &t[i + op.len()..])); }
620    }
621    for op in ["=", ">", "<"] {
622        if let Some(i) = t.find(op) { return Some((&t[..i], &t[i..i + 1], &t[i + 1..])); }
623    }
624    None
625}
626
627// ── private helpers ───────────────────────────────────────────────────────────────────────
628
629fn pad(out: &mut impl RecordSink, n: usize) {
630    for _ in 0..n { out.put(b" "); }
631}
632
633/// Format one cell into `buf`, returning its length. Strings copy out (clamped to `buf`); ints
634/// are decimal. Used for the numeric/scratch path; full-string rendering bypasses this.
635fn fmt_cell(t: &Table, v: Value, buf: &mut [u8; 24]) -> usize {
636    match v {
637        Value::Str { .. } => {
638            let s = t.cell_str(v);
639            let n = s.len().min(buf.len());
640            buf[..n].copy_from_slice(&s[..n]);
641            n
642        }
643        Value::Int(i) => fmt_u64(i, buf),
644        Value::Signed(i) if i < 0 => {
645            buf[0] = b'-';
646            let mut rest = [0u8; 24];
647            let n = fmt_u64(i.unsigned_abs(), &mut rest);
648            buf[1..1 + n].copy_from_slice(&rest[..n]);
649            1 + n
650        }
651        Value::Signed(i) => fmt_u64(i as u64, buf),
652        Value::Empty => 0,
653    }
654}
655
656/// `x` in decimal at the front of `buf`; returns the length.
657fn fmt_u64(x: u64, buf: &mut [u8; 24]) -> usize {
658    let mut tmp = [0u8; 20];
659    let mut p = tmp.len();
660    let mut x = x;
661    loop { p -= 1; tmp[p] = b'0' + (x % 10) as u8; x /= 10; if x == 0 { break; } }
662    let n = tmp.len() - p;
663    buf[..n].copy_from_slice(&tmp[p..]);
664    n
665}
666
667/// Display width of a cell: a string's full arena length, else its formatted (numeric) length.
668fn cell_width(t: &Table, v: Value) -> usize {
669    match v {
670        Value::Str { len, .. } => len as usize,
671        Value::Int(_) | Value::Signed(_) => { let mut b = [0u8; 24]; fmt_cell(t, v, &mut b) }
672        Value::Empty => 0,
673    }
674}
675
676/// Does row `r`'s column `ci` satisfy `<op> val`? Numeric if both are numbers, else textual.
677fn row_matches(t: &Table, r: usize, ci: usize, op: &str, val: &str) -> bool {
678    let cell = t.rows[r][ci];
679    let cell_num: Option<i128> = match cell {
680        Value::Int(i) => Some(i as i128),
681        Value::Signed(i) => Some(i as i128),
682        Value::Str { .. } => core::str::from_utf8(t.cell_str(cell)).ok().and_then(|s| s.parse::<i128>().ok()),
683        Value::Empty => None,
684    };
685    if let (Some(cn), Ok(vn)) = (cell_num, val.parse::<i128>()) {
686        return match op {
687            "=" | "==" => cn == vn,
688            "!=" => cn != vn,
689            ">" => cn > vn,
690            "<" => cn < vn,
691            ">=" => cn >= vn,
692            "<=" => cn <= vn,
693            _ => false,
694        };
695    }
696    let cs = t.cell_str(cell);
697    let vb = val.as_bytes();
698    match op {
699        "=" | "==" => cs == vb,
700        "!=" => cs != vb,
701        "~" => contains(cs, vb),
702        _ => false,
703    }
704}
705
706/// Resolve a value to its comparable bytes (arena slice for `Str`, empty otherwise).
707fn val_str<'a>(v: Value, arena: &'a [u8]) -> &'a [u8] {
708    match v { Value::Str { off, len } => &arena[off as usize..(off + len) as usize], _ => &[] }
709}
710
711/// Order two cells: numeric when both are integers (signed or not), else by bytes.
712fn cmp_values(a: Value, b: Value, arena: &[u8]) -> Ordering {
713    let num = |v: Value| match v {
714        Value::Int(x) => Some(x as i128),
715        Value::Signed(x) => Some(x as i128),
716        _ => None,
717    };
718    match (num(a), num(b)) {
719        (Some(x), Some(y)) => x.cmp(&y),
720        _ => val_str(a, arena).cmp(val_str(b, arena)),
721    }
722}
723
724/// Byte-substring test (private copy; the shell keeps its own for `find`/`match`).
725fn contains(haystack: &[u8], needle: &[u8]) -> bool {
726    if needle.is_empty() { return true; }
727    if needle.len() > haystack.len() { return false; }
728    (0..=haystack.len() - needle.len()).any(|i| &haystack[i..i + needle.len()] == needle)
729}
730
731fn json_ws(b: &[u8], mut i: usize) -> usize {
732    while i < b.len() && (b[i] == b' ' || b[i] == b'\t' || b[i] == b'\n' || b[i] == b'\r') { i += 1; }
733    i
734}
735
736/// At a `"`, scan to the closing quote (a `\`-escaped char is skipped). Returns (content start,
737/// content end, index past the closing quote). Escapes are passed through literally.
738fn json_string(b: &[u8], i: usize) -> Option<(usize, usize, usize)> {
739    if i >= b.len() || b[i] != b'"' { return None; }
740    let start = i + 1;
741    let mut j = start;
742    while j < b.len() {
743        if b[j] == b'\\' { j += 2; continue; }
744        if b[j] == b'"' { return Some((start, j, j + 1)); }
745        j += 1;
746    }
747    None
748}