fix(core): a non-finite cell value crosses the wire, in the spelling the format already uses (improvise-55v2)

serde_json writes a non-finite f64 as null, and null does not read back as one,
so CellValue::Number(INFINITY) serialized to {"Number":null} and the frame
carrying it was unreadable. The websocket reader skips an unparseable frame and
the worker logs a console error, so one poisoned cell silently dropped a whole
batch — and to_string returned Ok, so the send site could not notice either.

The ticket offered two answers and the format had already chosen. Measured
before changing anything: inf, -inf and nan all round-trip losslessly through
.improv — format_number writes them, the grammar's bare_value accepts them, the
walker maps them back. So refusing them before sending would make a legal saved
document unprojectable to a browser, which is a new failure rather than a fix.
It also settles the ticket's second location: resolve.rs's typed() accepting inf
is correct, not half a defect.

Number now serializes through an untagged adaptor: a JSON number when finite,
one of the three tokens when not. A finite cell's bytes are unchanged, and a
JSON integer still reads as a number rather than as a token.

The tokens are one home — nonfinite_token/parse_nonfinite beside CellValue,
called by the adaptor and by persistence/{format,parse}.rs. Three copies stood
before and a fourth was about to be written. format_f64 is deliberately not
folded in: it is the display layer and these three are a format.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
This commit is contained in:
Edward Langley
2026-09-22 19:00:13 -07:00
parent 6e10bb3afa
commit 5280683f12
6 changed files with 262 additions and 16 deletions
@@ -36,15 +36,10 @@ fn agg_keyword(agg: &AggFunc) -> &'static str {
}
fn format_number(n: f64) -> String {
if n.is_infinite() {
return if n.is_sign_positive() {
"inf".to_string()
} else {
"-inf".to_string()
};
}
if n.is_nan() {
return "nan".to_string();
// The token is the model's, not this file's: the wire spells it the same
// way and a second copy is a second answer (improvise-55v2).
if let Some(token) = crate::model::cell::nonfinite_token(n) {
return token.to_string();
}
if n.fract() == 0.0 && n.abs() < 1e15 {
format!("{}", n as i64)
@@ -366,3 +366,52 @@ mod tests {
);
}
}
#[cfg(test)]
mod nonfinite_agreement {
use crate::model::cell::{CellKey, CellValue};
use crate::workbook::Workbook;
fn key() -> CellKey {
CellKey::new(vec![
("R".to_string(), "a".to_string()),
("_Measure".to_string(), "V".to_string()),
])
}
#[test]
fn a_nonfinite_cell_is_spelled_the_same_on_disk_and_on_the_wire() {
for v in [f64::INFINITY, f64::NEG_INFINITY, f64::NAN] {
let mut wb = Workbook::new("P");
wb.model.add_category("R").expect("under the limit");
wb.model
.category_mut("R")
.expect("just added")
.add_item("a");
wb.model
.category_mut("_Measure")
.expect("always exists")
.add_item("V");
wb.model.set_cell(key(), CellValue::Number(v));
let text = super::format_md(&wb);
let on_disk = text
.lines()
.find_map(|l| l.strip_prefix("R=a, _Measure=V = "))
.expect("the data line");
let on_wire = serde_json::to_value(CellValue::Number(v))
.expect("serializes")
.get("Number")
.and_then(|n| n.as_str().map(String::from))
.expect("a non-finite value is a token on the wire");
assert_eq!(on_disk, on_wire, "one spelling, two carriers");
let back = super::parse_md(&text).expect("reparses");
match back.model.data.get(&key()) {
Some(CellValue::Number(n)) if v.is_nan() => assert!(n.is_nan()),
Some(CellValue::Number(n)) => assert_eq!(*n, v),
other => panic!("{v} reparsed as {other:?}"),
}
}
}
}
+7 -6
View File
@@ -265,12 +265,13 @@ fn walk_cell_value(value_pair: Pair<'_, Rule>) -> Result<CellValue> {
let inner = next(&mut value_pair.into_inner(), "pipe_quoted")?;
CellValue::Text(unescape_pipe_body(inner.as_str()))
}
Rule::bare_value => match value_pair.as_str().trim() {
"inf" => CellValue::Number(f64::INFINITY),
"-inf" => CellValue::Number(f64::NEG_INFINITY),
"nan" => CellValue::Number(f64::NAN),
s => CellValue::Text(s.to_string()),
},
Rule::bare_value => {
let s = value_pair.as_str().trim();
match crate::model::cell::parse_nonfinite(s) {
Some(n) => CellValue::Number(n),
None => CellValue::Text(s.to_string()),
}
}
_ => CellValue::Text(value_pair.as_str().to_string()),
};
Ok(value)