Skip to main content

cuttlefish_host/
warehouse.rs

1//! Writing a job's results as a medallion warehouse.
2//!
3//! A pipeline that produces JSONL produces something a person can read and
4//! nothing a query engine can. The warehouse is the same rows in Parquet,
5//! laid out the way data engineering already lays this out:
6//!
7//! - **bronze** — every concluded item, success *and* failure, exactly as the
8//!   ledger recorded it. Append-only and lossy about nothing. The failures
9//!   belong here: a bronze layer that silently drops what went wrong is a
10//!   bronze layer you cannot audit, and "which items failed and why" is a
11//!   question people ask of the warehouse, not of the log.
12//! - **silver** — the successful rows, *typed* against the output type the
13//!   node declared. Validation is the point of the layer, so a node that
14//!   declares [`Ty::Json`] has nothing to validate against and gets no silver
15//!   table. That is recorded in the manifest with the reason, rather than
16//!   emitting one JSON-blob column and calling it typed.
17//! - **gold** — the rollup node's own output: curated, aggregate, and by
18//!   nature defined by whoever wrote the spec rather than by cuttlefish.
19//!
20//! Every bronze and silver row carries its own lineage columns. That
21//! duplicates data, deliberately: a Parquet file gets copied, attached, and
22//! handed to somebody who does not have the manifest, and a row that cannot
23//! answer "where did you come from" once separated from its manifest is a row
24//! whose provenance depends on filesystem luck.
25//!
26//! # On the source column
27//!
28//! Lineage records the item's input *verbatim*, as JSON, in `source_input`.
29//! It would read better to publish a `source_uri` — but which key of the input
30//! holds the path is the spec author's business, not cuttlefish's, and a guess
31//! ("try `path`, then `url`, then `file`") produces a column that is right for
32//! the corpora we happened to test and silently empty for everyone else. The
33//! verbatim input is always correct and always complete.
34
35use std::collections::BTreeMap;
36use std::path::{Path, PathBuf};
37use std::sync::Arc;
38
39use arrow_array::builder::{
40    ArrayBuilder, BooleanBuilder, Float64Builder, Int64Builder, StringBuilder,
41};
42use arrow_array::{ArrayRef, RecordBatch};
43use arrow_schema::{DataType, Field, Schema};
44use cuttlefish_abi::Ty;
45
46/// What went wrong writing a warehouse.
47#[derive(Debug, thiserror::Error)]
48pub enum WarehouseError {
49    /// A directory or file could not be created.
50    #[error("creating {path}: {source}")]
51    Create {
52        /// What could not be created.
53        path: PathBuf,
54        /// The underlying filesystem error.
55        #[source]
56        source: std::io::Error,
57    },
58    /// Parquet encoding failed.
59    #[error("writing {path}: {source}")]
60    Write {
61        /// The table being written.
62        path: PathBuf,
63        /// The underlying Parquet error.
64        #[source]
65        source: parquet::errors::ParquetError,
66    },
67    /// Columns and rows did not line up — a bug here, not bad input.
68    #[error("building a record batch for {table}: {source}")]
69    Batch {
70        /// Which layer was being assembled.
71        table: String,
72        /// The underlying Arrow error.
73        #[source]
74        source: arrow_schema::ArrowError,
75    },
76    /// The manifest could not be encoded as JSON.
77    #[error("serializing the manifest: {0}")]
78    Manifest(#[from] serde_json::Error),
79    /// The manifest encoded but could not be written.
80    #[error("writing the manifest to {path}: {source}")]
81    ManifestWrite {
82        /// Where the manifest was to go.
83        path: PathBuf,
84        /// The underlying filesystem error.
85        #[source]
86        source: std::io::Error,
87    },
88}
89
90/// Lineage carried by every bronze and silver row.
91///
92/// Job-level rather than row-level values are still written per row — see the
93/// module docs on why the duplication is deliberate.
94#[derive(Debug, Clone)]
95pub struct Lineage {
96    /// The job directory's name, which is the job id.
97    pub job_id: String,
98    /// The spec this job ran.
99    pub spec_name: String,
100    /// The graph fingerprint the ledger recorded, which pins *which* pipeline
101    /// produced these rows. Two runs of "the same" spec with an edited block
102    /// have different fingerprints, and that difference is the whole reason
103    /// somebody re-reads their warehouse six months later.
104    pub spec_fingerprint: String,
105    /// The chat model, as resolved.
106    pub model: String,
107    /// The embedding model, when the spec declared one.
108    pub embedding_model: Option<String>,
109    /// The cuttlefish that wrote this. A column, not just a manifest field,
110    /// because the row format is this version's and a reader deserves to know
111    /// which version's rules it is reading under.
112    pub cuttlefish_version: String,
113}
114
115/// The lineage columns, in the order they are written.
116///
117/// Shared by bronze and silver so the two layers are joinable on identical
118/// column names and types rather than nearly-identical ones.
119fn lineage_fields() -> Vec<Field> {
120    vec![
121        Field::new("job_id", DataType::Utf8, false),
122        Field::new("node", DataType::Utf8, false),
123        Field::new("item", DataType::Int64, false),
124        Field::new("status", DataType::Utf8, false),
125        Field::new("concluded_at", DataType::Utf8, false),
126        Field::new("source_input", DataType::Utf8, true),
127        Field::new("spec_name", DataType::Utf8, false),
128        Field::new("spec_fingerprint", DataType::Utf8, false),
129        Field::new("model", DataType::Utf8, false),
130        Field::new("embedding_model", DataType::Utf8, true),
131        Field::new("cuttlefish_version", DataType::Utf8, false),
132    ]
133}
134
135/// One concluded item, as the warehouse sees it.
136#[derive(Debug, Clone)]
137pub struct Row {
138    /// The fan-out node that produced this item.
139    pub node: String,
140    /// The item's index in its manifest — the key that ties a warehouse row
141    /// back to `results.jsonl` and to `cuttlefish escalations`.
142    pub item: i64,
143    /// `completed`, `failed`, or `escalated`, verbatim. Kept distinct because
144    /// `escalated` means a human was asked and `failed` means it simply did
145    /// not work.
146    pub status: String,
147    /// When the item concluded, RFC 3339.
148    pub concluded_at: String,
149    /// The item's input, verbatim JSON. `None` for a ledger predating the
150    /// column — absent rather than invented.
151    pub source_input: Option<String>,
152    /// The item's output. `None` for a failure.
153    pub output: Option<serde_json::Value>,
154    /// Why it failed. `None` for a success.
155    pub error: Option<String>,
156}
157
158/// Whether a declared [`Ty`] can be a flat silver column at all.
159///
160/// A nested record, a list, or a handle has no sensible single column. Those
161/// stay in bronze as JSON rather than being flattened into names like
162/// `field__sub__leaf`, which read as schema but are really string
163/// concatenation.
164fn is_flattenable(ty: &Ty) -> bool {
165    match ty {
166        Ty::Text | Ty::Number | Ty::Bool => true,
167        // A *leaf* the author named but whose shape they left open. Kept as
168        // its JSON text. Different from a whole node declaring `Json`, which
169        // gets no silver table at all: there, nothing was named.
170        Ty::Json => true,
171        Ty::Bytes | Ty::Image | Ty::Document => false,
172        Ty::List(_) | Ty::Record(_) => false,
173    }
174}
175
176/// The Arrow type for a declared field, given the values actually present.
177///
178/// Only [`Ty::Number`] consults the values, and only to choose between
179/// `Int64` and `Float64`: JSON has one number type, so the distinction exists
180/// nowhere in the declaration and can only come from the data. Every value
181/// integral means an integer column — the difference between a page count
182/// reading `227` and `227.0`, and between an exact join key and a float one.
183fn column_type(name: &str, ty: &Ty, rows: &[Row]) -> DataType {
184    match ty {
185        Ty::Bool => DataType::Boolean,
186        Ty::Number => {
187            let fractional = rows.iter().filter_map(|r| r.output.as_ref()).any(|out| {
188                out.get(name)
189                    .and_then(|v| v.as_f64())
190                    .is_some_and(|f| f.fract() != 0.0)
191            });
192            if fractional {
193                DataType::Float64
194            } else {
195                // Also the empty and all-null case. An integer column that
196                // turns out to hold no values is harmless; guessing float
197                // would make every downstream key a float forever.
198                DataType::Int64
199            }
200        }
201        _ => DataType::Utf8,
202    }
203}
204
205/// The declared fields that become silver columns, in schema order.
206fn silver_columns(fields: &BTreeMap<String, Ty>) -> Vec<(&String, &Ty)> {
207    fields.iter().filter(|(_, ty)| is_flattenable(ty)).collect()
208}
209
210/// The columns a node's declared output type contributes to silver.
211///
212/// `None` when the node declared no shape with named, flattenable fields —
213/// [`Ty::Json`] most of all. Silver means "validated against a declared
214/// shape", and there is no shape to validate against, so the honest answer is
215/// no table rather than one JSON-blob column called typed.
216///
217/// Takes the rows because [`Ty::Number`] cannot pick between an integer and a
218/// float column without them: every value integral means an integer column.
219pub fn silver_schema(item_output: &Ty, rows: &[Row]) -> Option<Schema> {
220    let Ty::Record(fields) = item_output else {
221        return None;
222    };
223    let declared = silver_columns(fields);
224    if declared.is_empty() {
225        return None;
226    }
227
228    let mut out = lineage_fields();
229    for (name, ty) in declared {
230        // Nullable throughout: a block may legitimately omit an optional
231        // field, and a non-null column would turn that into a write failure
232        // at the end of a long job rather than a null in a cell.
233        //
234        // Prefixed `f_` so a block naming a field `model` or `item` cannot
235        // collide with a lineage column.
236        out.push(Field::new(
237            format!("f_{name}"),
238            column_type(name, ty, rows),
239            true,
240        ));
241    }
242    Some(Schema::new(out))
243}
244
245/// The bronze schema: lineage, plus the raw output and error.
246pub fn bronze_schema() -> Schema {
247    let mut fields = lineage_fields();
248    fields.push(Field::new("output_json", DataType::Utf8, true));
249    fields.push(Field::new("error", DataType::Utf8, true));
250    Schema::new(fields)
251}
252
253/// Fill the lineage columns for one row into the builders that hold them.
254fn push_lineage(builders: &mut [Box<dyn ArrayBuilder>], row: &Row, lineage: &Lineage) {
255    macro_rules! s {
256        ($i:expr, $v:expr) => {
257            builders[$i]
258                .as_any_mut()
259                .downcast_mut::<StringBuilder>()
260                .expect("lineage column is a string column")
261                .append_option($v)
262        };
263    }
264    s!(0, Some(&lineage.job_id));
265    s!(1, Some(&row.node));
266    builders[2]
267        .as_any_mut()
268        .downcast_mut::<Int64Builder>()
269        .expect("`item` is an int column")
270        .append_value(row.item);
271    s!(3, Some(&row.status));
272    s!(4, Some(&row.concluded_at));
273    s!(5, row.source_input.as_ref());
274    s!(6, Some(&lineage.spec_name));
275    s!(7, Some(&lineage.spec_fingerprint));
276    s!(8, Some(&lineage.model));
277    s!(9, lineage.embedding_model.as_ref());
278    s!(10, Some(&lineage.cuttlefish_version));
279}
280
281/// Fresh builders matching `schema`, in order.
282fn builders_for(schema: &Schema) -> Vec<Box<dyn ArrayBuilder>> {
283    schema
284        .fields()
285        .iter()
286        .map(|f| -> Box<dyn ArrayBuilder> {
287            match f.data_type() {
288                DataType::Int64 => Box::new(Int64Builder::new()),
289                DataType::Float64 => Box::new(Float64Builder::new()),
290                DataType::Boolean => Box::new(BooleanBuilder::new()),
291                _ => Box::new(StringBuilder::new()),
292            }
293        })
294        .collect()
295}
296
297/// A JSON value as the text a `Utf8` column should hold.
298///
299/// A JSON string becomes its contents, not a quoted re-encoding: a text field
300/// whose cells all read `"hello"` with the quotes is the classic sign of a
301/// pipeline that serialized one layer too many.
302fn cell_text(value: &serde_json::Value) -> Option<String> {
303    match value {
304        serde_json::Value::Null => None,
305        serde_json::Value::String(s) => Some(s.clone()),
306        other => Some(other.to_string()),
307    }
308}
309
310/// Seal the builders into arrays, in column order.
311fn finish(mut builders: Vec<Box<dyn ArrayBuilder>>) -> Vec<ArrayRef> {
312    builders.iter_mut().map(|b| b.finish()).collect()
313}
314
315/// Build the bronze batch: every row, success and failure alike.
316pub fn bronze_batch(rows: &[Row], lineage: &Lineage) -> Result<RecordBatch, WarehouseError> {
317    let schema = bronze_schema();
318    let mut builders = builders_for(&schema);
319    let lineage_count = lineage_fields().len();
320
321    for row in rows {
322        push_lineage(&mut builders, row, lineage);
323        let output = row.output.as_ref().and_then(cell_text);
324        builders[lineage_count]
325            .as_any_mut()
326            .downcast_mut::<StringBuilder>()
327            .expect("`output_json` is a string column")
328            .append_option(output);
329        builders[lineage_count + 1]
330            .as_any_mut()
331            .downcast_mut::<StringBuilder>()
332            .expect("`error` is a string column")
333            .append_option(row.error.as_ref());
334    }
335
336    RecordBatch::try_new(Arc::new(schema), finish(builders)).map_err(|e| WarehouseError::Batch {
337        table: "bronze".into(),
338        source: e,
339    })
340}
341
342/// Build the silver batch: successful rows only, typed against `item_output`.
343///
344/// Returns `None` when the node declared no shape to validate against.
345pub fn silver_batch(
346    rows: &[Row],
347    lineage: &Lineage,
348    item_output: &Ty,
349) -> Result<Option<RecordBatch>, WarehouseError> {
350    let Some(schema) = silver_schema(item_output, rows) else {
351        return Ok(None);
352    };
353    let Ty::Record(fields) = item_output else {
354        return Ok(None);
355    };
356    let declared = silver_columns(fields);
357
358    let mut builders = builders_for(&schema);
359    let lineage_count = lineage_fields().len();
360
361    for row in rows {
362        // Failures carry no output to type. They are already in bronze, which
363        // is where somebody auditing goes; silver is the layer people join
364        // against, and a half-populated row in it is worse than no row.
365        let Some(output) = &row.output else { continue };
366        push_lineage(&mut builders, row, lineage);
367
368        for (offset, (name, _)) in declared.iter().enumerate() {
369            let column = lineage_count + offset;
370            let value = output.get(name.as_str());
371            let builder = &mut builders[column];
372            match schema.field(column).data_type() {
373                DataType::Int64 => builder
374                    .as_any_mut()
375                    .downcast_mut::<Int64Builder>()
376                    .expect("an Int64 column has an Int64 builder")
377                    .append_option(value.and_then(|v| v.as_i64())),
378                DataType::Float64 => builder
379                    .as_any_mut()
380                    .downcast_mut::<Float64Builder>()
381                    .expect("a Float64 column has a Float64 builder")
382                    .append_option(value.and_then(|v| v.as_f64())),
383                DataType::Boolean => builder
384                    .as_any_mut()
385                    .downcast_mut::<BooleanBuilder>()
386                    .expect("a Boolean column has a Boolean builder")
387                    .append_option(value.and_then(|v| v.as_bool())),
388                _ => builder
389                    .as_any_mut()
390                    .downcast_mut::<StringBuilder>()
391                    .expect("every other column is a string column")
392                    .append_option(value.and_then(cell_text)),
393            }
394        }
395    }
396
397    let arrays = finish(builders);
398    RecordBatch::try_new(Arc::new(schema), arrays)
399        .map(Some)
400        .map_err(|e| WarehouseError::Batch {
401            table: "silver".into(),
402            source: e,
403        })
404}
405
406/// Write one batch to `path` as Parquet.
407pub fn write_parquet(path: &Path, batch: &RecordBatch) -> Result<(), WarehouseError> {
408    if let Some(parent) = path.parent() {
409        std::fs::create_dir_all(parent).map_err(|e| WarehouseError::Create {
410            path: parent.to_path_buf(),
411            source: e,
412        })?;
413    }
414    let file = std::fs::File::create(path).map_err(|e| WarehouseError::Create {
415        path: path.to_path_buf(),
416        source: e,
417    })?;
418
419    let props = parquet::file::properties::WriterProperties::builder()
420        // Snappy rather than zstd: every reader that will open these files
421        // has supported snappy since Parquet existed, and the point of this
422        // output is that somebody else's tool can read it.
423        .set_compression(parquet::basic::Compression::SNAPPY)
424        .build();
425
426    let mut writer = parquet::arrow::ArrowWriter::try_new(file, batch.schema(), Some(props))
427        .map_err(|e| WarehouseError::Write {
428            path: path.to_path_buf(),
429            source: e,
430        })?;
431    writer.write(batch).map_err(|e| WarehouseError::Write {
432        path: path.to_path_buf(),
433        source: e,
434    })?;
435    writer.close().map_err(|e| WarehouseError::Write {
436        path: path.to_path_buf(),
437        source: e,
438    })?;
439    Ok(())
440}
441
442/// What a manifest says about one table.
443#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
444pub struct TableEntry {
445    /// Path relative to the warehouse root, so a warehouse stays valid when
446    /// moved or copied somewhere else.
447    pub path: String,
448    /// How many rows the table holds.
449    pub rows: usize,
450    /// The column names, in schema order.
451    pub columns: Vec<String>,
452}
453
454/// A layer either has a table or has a reason it doesn't.
455#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
456#[serde(untagged)]
457pub enum Layer {
458    /// The table exists; here is where and how big.
459    Written(TableEntry),
460    /// Recorded rather than omitted: a reader who finds no silver table needs
461    /// to know whether the layer was skipped or the job broke.
462    Skipped {
463        /// Why there is no table, in words a spec author can act on.
464        skipped: String,
465    },
466}
467
468/// The manifest written at the warehouse root.
469#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
470pub struct Manifest {
471    /// The job that produced this warehouse.
472    pub job_id: String,
473    /// The spec it ran.
474    pub spec_name: String,
475    /// Which pipeline, exactly — see [`Lineage::spec_fingerprint`].
476    pub spec_fingerprint: String,
477    /// The chat model, as resolved.
478    pub model: String,
479    /// The embedding model, if the spec declared one.
480    #[serde(skip_serializing_if = "Option::is_none")]
481    pub embedding_model: Option<String>,
482    /// The cuttlefish that wrote this.
483    pub cuttlefish_version: String,
484    /// When it was written, RFC 3339.
485    pub written_at: String,
486    /// Raw concluded items, failures included. Keyed by node name: a graph
487    /// may hold more than one fan-out node, and each gets its own tables.
488    pub bronze: BTreeMap<String, Layer>,
489    /// Successful items, typed against each node's declared output.
490    pub silver: BTreeMap<String, Layer>,
491    /// The job's own curated result.
492    pub gold: BTreeMap<String, Layer>,
493}
494
495/// An RFC 3339 timestamp for `written_at` and for the gold row.
496pub fn now_rfc3339() -> String {
497    time::OffsetDateTime::now_utc()
498        .format(&time::format_description::well_known::Rfc3339)
499        .expect("Rfc3339 formatting cannot fail for a valid OffsetDateTime")
500}
501
502/// Write the manifest to `root/manifest.json`.
503pub fn write_manifest(root: &Path, manifest: &Manifest) -> Result<PathBuf, WarehouseError> {
504    std::fs::create_dir_all(root).map_err(|e| WarehouseError::Create {
505        path: root.to_path_buf(),
506        source: e,
507    })?;
508    let path = root.join("manifest.json");
509    let body = serde_json::to_string_pretty(manifest)?;
510    std::fs::write(&path, body).map_err(|e| WarehouseError::ManifestWrite {
511        path: path.clone(),
512        source: e,
513    })?;
514    Ok(path)
515}
516
517/// A [`TableEntry`] describing a batch written at `path` under `root`.
518pub fn entry_for(root: &Path, path: &Path, batch: &RecordBatch) -> TableEntry {
519    TableEntry {
520        path: path
521            .strip_prefix(root)
522            .unwrap_or(path)
523            .to_string_lossy()
524            .into_owned(),
525        rows: batch.num_rows(),
526        columns: batch
527            .schema()
528            .fields()
529            .iter()
530            .map(|f| f.name().clone())
531            .collect(),
532    }
533}
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538
539    fn lineage() -> Lineage {
540        Lineage {
541            job_id: "job-1".into(),
542            spec_name: "index_corpus".into(),
543            spec_fingerprint: "abc123".into(),
544            model: "ollama:llama3.2:1b".into(),
545            embedding_model: Some("ollama:nomic-embed-text".into()),
546            cuttlefish_version: "0.8.0".into(),
547        }
548    }
549
550    fn record(fields: &[(&str, Ty)]) -> Ty {
551        Ty::Record(
552            fields
553                .iter()
554                .map(|(n, t)| (n.to_string(), t.clone()))
555                .collect(),
556        )
557    }
558
559    fn row(item: i64, output: Option<serde_json::Value>, error: Option<&str>) -> Row {
560        Row {
561            node: "extract".into(),
562            item,
563            status: if output.is_some() {
564                "completed"
565            } else {
566                "failed"
567            }
568            .into(),
569            concluded_at: "2026-08-18T00:00:00Z".into(),
570            source_input: Some(format!(r#"{{"path":"doc-{item}.pdf"}}"#)),
571            output,
572            error: error.map(str::to_string),
573        }
574    }
575
576    #[test]
577    fn a_node_declaring_json_gets_no_silver_table() {
578        // The layer means "validated against a declared shape". `Json` names
579        // no shape, so there is nothing to validate and claiming otherwise
580        // would make silver indistinguishable from bronze.
581        assert!(silver_schema(&Ty::Json, &[]).is_none());
582        assert!(silver_schema(&Ty::Record(Default::default()), &[]).is_none());
583        assert!(silver_schema(&Ty::Text, &[]).is_none());
584    }
585
586    #[test]
587    fn silver_columns_follow_the_declared_record() {
588        let ty = record(&[("title", Ty::Text), ("body", Ty::Text)]);
589        let schema = silver_schema(&ty, &[]).expect("a declared record yields a table");
590        let names: Vec<_> = schema.fields().iter().map(|f| f.name().clone()).collect();
591
592        // Lineage first, then the declared fields — prefixed, so a block that
593        // names a field `model` or `item` cannot collide with lineage.
594        assert_eq!(names[0], "job_id");
595        assert!(names.contains(&"f_title".to_string()), "{names:?}");
596        assert!(names.contains(&"f_body".to_string()), "{names:?}");
597        assert!(!names.contains(&"title".to_string()), "{names:?}");
598    }
599
600    #[test]
601    fn a_record_of_only_unflattenable_fields_gets_no_table() {
602        // A record whose every field is a nested list or a handle has nothing
603        // to put in a column, and an all-lineage table with no payload is a
604        // table nobody can use.
605        let ty = record(&[("pages", Ty::List(Box::new(Ty::Text))), ("scan", Ty::Image)]);
606        assert!(silver_schema(&ty, &[]).is_none());
607    }
608
609    #[test]
610    fn bronze_keeps_failures_and_silver_drops_them() {
611        // The split that makes the two layers worth having separately: you
612        // audit in bronze and you join in silver.
613        let rows = vec![
614            row(0, Some(serde_json::json!({"title": "A"})), None),
615            row(1, None, Some("pdf has no text layer")),
616            row(2, Some(serde_json::json!({"title": "C"})), None),
617        ];
618        let ty = record(&[("title", Ty::Text)]);
619
620        let bronze = bronze_batch(&rows, &lineage()).unwrap();
621        assert_eq!(bronze.num_rows(), 3);
622
623        let silver = silver_batch(&rows, &lineage(), &ty).unwrap().unwrap();
624        assert_eq!(silver.num_rows(), 2);
625    }
626
627    #[test]
628    fn a_string_field_is_not_re_encoded_with_its_quotes() {
629        // The classic one-layer-too-many bug: every cell reading `"A"` rather
630        // than `A`, which survives a glance at the schema and ruins every
631        // join downstream.
632        assert_eq!(
633            cell_text(&serde_json::json!("A")),
634            Some("A".to_string()),
635            "a JSON string must become its contents"
636        );
637        assert_eq!(
638            cell_text(&serde_json::json!({"n": 1})),
639            Some(r#"{"n":1}"#.to_string()),
640            "a JSON object keeps its encoding — there is nothing else it could be"
641        );
642        assert_eq!(cell_text(&serde_json::Value::Null), None);
643    }
644
645    #[test]
646    fn a_missing_declared_field_is_null_rather_than_a_write_failure() {
647        // A block that omits an optional field at item 9,000 of 10,000 must
648        // not lose the run.
649        let rows = vec![row(0, Some(serde_json::json!({"title": "A"})), None)];
650        let ty = record(&[("title", Ty::Text), ("subtitle", Ty::Text)]);
651        let silver = silver_batch(&rows, &lineage(), &ty).unwrap().unwrap();
652        assert_eq!(silver.num_rows(), 1);
653        let column = silver
654            .column_by_name("f_subtitle")
655            .expect("the declared field is a column even when unpopulated");
656        assert!(column.is_null(0), "an absent field reads as null");
657    }
658
659    #[test]
660    fn a_declared_number_becomes_a_number_column_not_a_string() {
661        // The whole reason `Ty::Number` was added: SUM and AVG and range
662        // filters have to work without a cast in every query.
663        let rows = vec![row(0, Some(serde_json::json!({"pages": 227})), None)];
664        let ty = record(&[("pages", Ty::Number)]);
665        let schema = silver_schema(&ty, &rows).unwrap();
666        assert_eq!(
667            schema.field_with_name("f_pages").unwrap().data_type(),
668            &DataType::Int64
669        );
670
671        let batch = silver_batch(&rows, &lineage(), &ty).unwrap().unwrap();
672        let column = batch
673            .column_by_name("f_pages")
674            .unwrap()
675            .as_any()
676            .downcast_ref::<arrow_array::Int64Array>()
677            .expect("an integral number column is Int64, not stringified");
678        assert_eq!(column.value(0), 227);
679    }
680
681    #[test]
682    fn one_fractional_value_makes_the_whole_column_a_float() {
683        // Choosing per column, not per cell: a column that is Int64 for the
684        // rows that happen to be whole and Float64 for the rest is not a
685        // column. One fractional value anywhere decides it.
686        let rows = vec![
687            row(0, Some(serde_json::json!({"score": 1})), None),
688            row(1, Some(serde_json::json!({"score": 0.75})), None),
689        ];
690        let ty = record(&[("score", Ty::Number)]);
691        let schema = silver_schema(&ty, &rows).unwrap();
692        assert_eq!(
693            schema.field_with_name("f_score").unwrap().data_type(),
694            &DataType::Float64
695        );
696
697        let batch = silver_batch(&rows, &lineage(), &ty).unwrap().unwrap();
698        let column = batch
699            .column_by_name("f_score")
700            .unwrap()
701            .as_any()
702            .downcast_ref::<arrow_array::Float64Array>()
703            .unwrap();
704        assert_eq!((column.value(0), column.value(1)), (1.0, 0.75));
705    }
706
707    #[test]
708    fn a_declared_bool_becomes_a_boolean_column() {
709        let rows = vec![row(0, Some(serde_json::json!({"has_text": false})), None)];
710        let ty = record(&[("has_text", Ty::Bool)]);
711        let batch = silver_batch(&rows, &lineage(), &ty).unwrap().unwrap();
712        let column = batch
713            .column_by_name("f_has_text")
714            .unwrap()
715            .as_any()
716            .downcast_ref::<arrow_array::BooleanArray>()
717            .expect("a bool column is Boolean, not the string \"false\"");
718        assert!(!column.value(0));
719    }
720
721    #[test]
722    fn a_number_field_a_block_omitted_is_null_not_zero() {
723        // Zero is a real page count. Silently substituting it for "absent"
724        // would make every downstream average wrong in a way nothing reports.
725        let rows = vec![row(0, Some(serde_json::json!({"other": 1})), None)];
726        let ty = record(&[("pages", Ty::Number), ("other", Ty::Number)]);
727        let batch = silver_batch(&rows, &lineage(), &ty).unwrap().unwrap();
728        let column = batch.column_by_name("f_pages").unwrap();
729        assert!(column.is_null(0), "an absent number is null, never 0");
730    }
731
732    #[test]
733    fn every_row_carries_its_own_lineage() {
734        // The property the whole denormalized design exists for: hand one
735        // file to somebody with no manifest and they can still trace it.
736        let rows = vec![row(0, Some(serde_json::json!({"title": "A"})), None)];
737        let bronze = bronze_batch(&rows, &lineage()).unwrap();
738        for column in [
739            "job_id",
740            "spec_fingerprint",
741            "model",
742            "cuttlefish_version",
743            "source_input",
744        ] {
745            let c = bronze
746                .column_by_name(column)
747                .unwrap_or_else(|| panic!("bronze must carry `{column}`"));
748            assert!(!c.is_null(0), "`{column}` must be populated");
749        }
750    }
751}