Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions agents/anyplot/code/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
ISO dates), so no `sep`, `decimal` or date format is needed. The column dtypes from
the dataset profile map to pandas dtypes that keep missing values (`integer` to
`Int64`, `number` to `float64`, `boolean` to `boolean`, `text` to `string`); datetime
columns go to `parse_dates`. `import pandas as pd` is added after the last module-level
import when the file does not have it.
columns go to `parse_dates`. A dataset with a number column is read with
`float_precision="round_trip"`, so every float is the one `data.csv` writes in its
shortest round-trip form (`ROUND_TRIP`). `import pandas as pd` is added after the last
module-level import when the file does not have it.

`data.csv` writes a missing cell as an empty cell and nothing else, so a text cell such as
`NA` (Namibia, North America), `None` or `null` is a value there. pandas reads those tokens
Expand All @@ -24,7 +26,7 @@

import ast
import json
from typing import get_args
from typing import Literal, get_args

from ..data.parse import MISSING_VALUES
from ..schemas import ColumnDtype
Expand Down Expand Up @@ -113,6 +115,21 @@ def na_values(dtype_map: dict[str, str], dates: list[str]) -> dict[str, list[str
return values


ROUND_TRIP: Literal["round_trip"] = "round_trip"
"""pandas' exact float reader: its default C reader misreads about one in four 17-digit numbers by one unit in
the last place (measured on pandas 3.0, 2026-10-15), so the plot would not draw the values `data.csv` holds."""


ISO_DATES: Literal["ISO8601"] = "ISO8601"
"""The `date_format` of every date column: `data.csv` writes ISO dates only, and without it pandas guesses a
format per column and warns "Could not infer format" for years such as 0001 (review of #12196)."""


def round_trip(dtype_map: dict[str, str]) -> bool:
"""Whether the read needs `float_precision="round_trip"`: some column is read as float64."""
return PANDAS_DTYPES["number"] in dtype_map.values()


def _literal(text: str) -> str:
"""A double-quoted Python string literal (JSON escapes are valid Python escapes)."""
return json.dumps(text, ensure_ascii=False)
Expand All @@ -132,11 +149,15 @@ def _statement(indent: str, dtype_map: dict[str, str], dates: list[str]) -> str:
f"{_literal(name)}: " + ('[""]' if dtype_map.get(name) == TEXT_DTYPE else NA_NAME)
for name in [*dtype_map, *(name for name in dates if name not in dtype_map)]
]
exact = round_trip(dtype_map)
arguments = [_literal(DATA_FILE)]
if dtype_items:
arguments.append("dtype={" + ", ".join(dtype_items) + "}")
if date_items:
arguments.append("parse_dates=[" + ", ".join(date_items) + "]")
arguments.append(f'date_format="{ISO_DATES}"')
if exact:
arguments.append(f'float_precision="{ROUND_TRIP}"')
if per_column:
arguments += ["keep_default_na=False", "na_values={" + ", ".join(na_items) + "}"]
prefix = _tokens_statement(indent) + "\n" + indent if per_column else ""
Expand All @@ -150,6 +171,9 @@ def _statement(indent: str, dtype_map: dict[str, str], dates: list[str]) -> str:
lines += [f"{inner}dtype={{", *(f"{entry}{item}," for item in dtype_items), f"{inner}}},"]
if date_items:
lines += [f"{inner}parse_dates=[", *(f"{entry}{item}," for item in date_items), f"{inner}],"]
lines.append(f'{inner}date_format="{ISO_DATES}",')
if exact:
lines.append(f'{inner}float_precision="{ROUND_TRIP}",')
if per_column:
lines += [f"{inner}keep_default_na=False,", f"{inner}na_values={{", *(f"{entry}{item}," for item in na_items)]
lines.append(f"{inner}}},")
Expand Down
309 changes: 288 additions & 21 deletions agents/anyplot/data/parse.py

Large diffs are not rendered by default.

399 changes: 336 additions & 63 deletions agents/anyplot/data/prepare.py

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions agents/anyplot/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,14 +185,18 @@ class ColumnProfile(_ServerContract):

`mean` and `median` are set for integer and number columns only (six significant
digits), so the root can say what a column holds, such as values that sum to 100.
The rounding is for the model only: the page never shows them, and `data.csv` keeps
every value (owner decision of 2026-10-15). An integer column's `min` and `max` are
exact; past 2^53 the browser's float64 numbers lose their last digits, which only
touches the binding hint that names a negative minimum (kept, owner decision).
"""

name: ColumnName
dtype: ColumnDtype
missing: NonNegativeInt
unique: NonNegativeInt
min: float | Cell | None = None
max: float | Cell | None = None
min: int | float | Cell | None = None # an integer column's exactly: a float rounds past 2**53
max: int | float | Cell | None = None
mean: float | None = None
median: float | None = None
top: list[Cell] = Field(default_factory=list, max_length=MAX_TOP_VALUES)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@
"synthetic"
],
"source": "synthetic: agents/evals/make_stress.py",
"bound_expected": "parse renames duplicates to Value_2, Value_3, Value_4 (case-sensitive: value stays), one warning each",
"bound_expected": "parse renames duplicates to Value_2, Value_3, Value_4 (case-sensitive: value stays), one warning each, plus one that counts the numbers written without their trailing zeros (`70.20` as `70.2`)",
"needs_model": false,
"static": {
"dataset": "ok",
"columns": 5,
"warnings": 3,
"warnings": 4,
"bindings": "complete",
"loader": "ok"
}
Expand Down
5 changes: 3 additions & 2 deletions agents/evals/make_stress.py
Original file line number Diff line number Diff line change
Expand Up @@ -866,8 +866,9 @@ def _cases() -> list[StressCase]:
duplicate_headers(),
[_binding("x", "Value_2"), _binding("y", "Value_3")],
"unknown",
"parse renames duplicates to Value_2, Value_3, Value_4 (case-sensitive: value stays), one warning each",
{"dataset": "ok", "columns": 5, "warnings": 3, "bindings": "complete", "loader": "ok"},
"parse renames duplicates to Value_2, Value_3, Value_4 (case-sensitive: value stays), one warning each, "
"plus one that counts the numbers written without their trailing zeros (`70.20` as `70.2`)",
{"dataset": "ok", "columns": 5, "warnings": 4, "bindings": "complete", "loader": "ok"},
),
StressCase(
"dates-centuries",
Expand Down
10 changes: 8 additions & 2 deletions agents/evals/stress.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@

from agents.anyplot.briefs import adapter_profile, profile_json, profile_summary, spec_brief
from agents.anyplot.code.edits import apply_plan, new_literal_chars
from agents.anyplot.code.loader import loader_arguments, na_values
from agents.anyplot.code.loader import ISO_DATES, ROUND_TRIP, loader_arguments, na_values, round_trip
from agents.anyplot.code.regions import find_regions
from agents.anyplot.data.bindings import check_bindings, default_bindings
from agents.anyplot.data.parse import MAX_INPUT_BYTES, ParsedDataset, ParseError, parse_dataset
Expand Down Expand Up @@ -171,7 +171,13 @@ def loader_outcome(parsed: ParsedDataset) -> tuple[str, float]:
try:
missing = na_values(dtypes, dates)
frame = pd.read_csv(
io.StringIO(parsed.csv), dtype=dtypes, parse_dates=dates, keep_default_na=missing is None, na_values=missing
io.StringIO(parsed.csv),
dtype=dtypes,
parse_dates=dates,
keep_default_na=missing is None,
na_values=missing,
float_precision=ROUND_TRIP if round_trip(dtypes) else None,
date_format=ISO_DATES if dates else None,
)
except Exception as exc: # the class is the finding; the message could quote the data
return type(exc).__name__, (time.perf_counter() - started) * 1000
Expand Down
29 changes: 29 additions & 0 deletions app/src/sections/agent-chat/DataPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,35 @@ describe('DataPanel bindings', () => {
expect(onBind).toHaveBeenCalledWith('y2', 'hats');
});

it('draws line breaks and space runs of a preview cell visibly instead of folding them', () => {
const base = stateWith({ x: 'month' }, { missingRoles: [] });
const state = {
...base,
dataset: base.dataset && {
...base.dataset,
parsed: {
...base.dataset.parsed,
preview: [['2026-01', '1', '2', '3', 'north\nwest end']],
},
},
};
render(
<DataPanel
state={state}
onParse={vi.fn()}
onConfirmTranscription={vi.fn()}
onDiscardTranscription={vi.fn()}
onSendFormula={vi.fn()}
onBind={vi.fn()}
onConfirmData={vi.fn()}
onUndo={vi.fn()}
/>
);
const cell = screen.getByTitle('line break').closest('td');
expect(cell).toHaveTextContent('north↵ (line break) west·· (2 spaces) end');
expect(screen.getByTitle('2 spaces')).toBeInTheDocument();
});

it('warns under the dropdown when a role that needs values of 0 or more gets a negative column', () => {
const radius = role('radius', { non_negative: true });
const columns = [
Expand Down
29 changes: 28 additions & 1 deletion app/src/sections/agent-chat/DataPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ import {
FILE_BUSY_TEXT,
FILE_CONVERTING_TEXT,
} from 'src/sections/agent-chat/messages';
import { previewSegments } from 'src/sections/agent-chat/previewCell';
import { inStep2 } from 'src/sections/agent-chat/steps';
import {
actionButtonSx,
Expand All @@ -160,6 +161,30 @@ function countOf(count: number, noun: string): string {
return `${count.toLocaleString('en-US')} ${noun}${count === 1 ? '' : 's'}`;
}

/** One preview cell, its line breaks and space runs drawn as muted marks a screen reader names. */
function PreviewCell({ cell }: { cell: string }) {
return (
<>
{previewSegments(cell).map((segment, index) => {
if (!segment.mark) return <span key={index}>{segment.text}</span>;
const label = { break: 'line break', tab: 'tab', spaces: `${segment.text.length} spaces` }[
segment.mark
];
return (
<span key={index} title={label}>
<Box component="span" aria-hidden="true" sx={{ color: 'var(--ink-muted)' }}>
{segment.text}
</Box>
<Box component="span" sx={visuallyHiddenSx}>
{` (${label}) `}
</Box>
</span>
);
})}
</>
);
}

/** The first rows of a table under its column names and types. */
function PreviewTable({ columns, rows }: { columns: ColumnProfile[]; rows: string[][] }) {
return (
Expand Down Expand Up @@ -214,7 +239,9 @@ function PreviewTable({ columns, rows }: { columns: ColumnProfile[]; rows: strin
{rows.map((row, rowIndex) => (
<tr key={rowIndex}>
{row.map((cell, cellIndex) => (
<td key={cellIndex}>{cell}</td>
<td key={cellIndex}>
<PreviewCell cell={cell} />
</td>
))}
</tr>
))}
Expand Down
48 changes: 48 additions & 0 deletions app/src/sections/agent-chat/previewCell.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';

import { previewSegments } from 'src/sections/agent-chat/previewCell';

describe('previewSegments', () => {
it('leaves a cell without breaks or space runs as one segment', () => {
expect(previewSegments('Zürich Nord')).toEqual([{ text: 'Zürich Nord' }]);
expect(previewSegments('')).toEqual([]);
});

it('marks every line break, whatever its form', () => {
expect(previewSegments('one\ntwo\r\nthree\rfour')).toEqual([
{ text: 'one' },
{ text: '↵', mark: 'break' },
{ text: 'two' },
{ text: '↵', mark: 'break' },
{ text: 'three' },
{ text: '↵', mark: 'break' },
{ text: 'four' },
]);
});

it('shows each space of a run, so `a b` never reads as `a b`', () => {
expect(previewSegments('a b c d')).toEqual([
{ text: 'a' },
{ text: '··', mark: 'spaces' },
{ text: 'b' },
{ text: '···', mark: 'spaces' },
{ text: 'c d' },
]);
});

it('marks a tab, which a cell that never wraps would show as a space', () => {
expect(previewSegments('a\tb')).toEqual([
{ text: 'a' },
{ text: '→', mark: 'tab' },
{ text: 'b' },
]);
});

it('marks breaks and runs at the edges and next to each other', () => {
expect(previewSegments('\n x')).toEqual([
{ text: '↵', mark: 'break' },
{ text: '··', mark: 'spaces' },
{ text: 'x' },
]);
});
});
40 changes: 40 additions & 0 deletions app/src/sections/agent-chat/previewCell.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* The preview's cell text with its whitespace made visible (owner decision of 2026-10-15).
*
* The server sends each preview cell with its line breaks and runs of spaces as they are
* (`parse.preview_cell`). A table cell drawn as HTML would fold them into one space, so the
* preview would show `a b` or a two-line cell as something the data does not hold. Each
* line break becomes `↵`, each tab `→` (the cells do not wrap, so a tab would look like a
* space) and every space of a run of two or more `·`; a single space between words stays
* a space. The marks are segments of their own, so the page can
* draw them muted and name them for a screen reader.
*/

export const LINE_BREAK_MARK = '↵';
export const SPACE_MARK = '·';
export const TAB_MARK = '→';

export interface PreviewSegment {
text: string;
/** `break` for a line break, `spaces` for a run of spaces, `tab` for a tab; absent for the cell's own text. */
mark?: 'break' | 'spaces' | 'tab';
}

const WHITESPACE = /\r\n|\r|\n| {2,}|\t/g;

export function previewSegments(cell: string): PreviewSegment[] {
const segments: PreviewSegment[] = [];
let last = 0;
for (const match of cell.matchAll(WHITESPACE)) {
const at = match.index ?? 0;
if (at > last) segments.push({ text: cell.slice(last, at) });
const run = match[0];
if (run === '\t') segments.push({ text: TAB_MARK, mark: 'tab' });
else if (run.startsWith(' '))
segments.push({ text: SPACE_MARK.repeat(run.length), mark: 'spaces' });
else segments.push({ text: LINE_BREAK_MARK, mark: 'break' });
last = at + run.length;
}
if (last < cell.length) segments.push({ text: cell.slice(last) });
return segments;
}
25 changes: 25 additions & 0 deletions changelog.d/agent-data-fidelity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
### Fixed

- **Pasted data keeps every digit, or a warning says what changed.** Owner rule of
2026-10-15: precision is never lost silently. The exported `plot.py` and the render
read `data.csv` with `float_precision="round_trip"` (pandas' default reader had
misread about one in four 17-digit numbers); a number with more digits than a float
holds, from CSV, JSON or a Python literal, is stored rounded with a warning that
counts the cells; whole numbers past the 64-bit range stay text; one warning counts
the numbers whose spelling went (`1.50` as `1.5`, `1e3` as `1000.0`); a text cell
that lost surrounding spaces or became missing (`None`, `-`), and NFC composition, are
counted in warnings, and so are JSON's `NaN` and `Infinity`; nanosecond times beside a
date outside 1677 to 2262 stay text with a warning instead of turning text in the
loader; the profile's integer `min` and `max` are exact; the data panel's preview
shows line breaks as `↵`, tabs as `→` and space runs as `·` instead of folding them
(#12196).
- **Data operations no longer round or wrap numbers without a word.** `set_type number`
reads whole numbers exactly; an `aggregate` sum of whole numbers no longer wraps
around past 2^63; `cumsum`, row sums and differences stay whole; `pivot_longer`
counts the whole numbers its shared column rounds, and no change line can lose its
count to the length cap. Date-times keep their fraction of a second and their UTC
offset, a year-first time such as `2024-06-01T13:00:00Z` is no longer read as
6 January, and `set_type datetime` on different UTC offsets reads them in UTC with a
change line naming the offsets instead of crashing the operation. Conditions on a
column with a UTC offset compare moments instead of crashing the tool, and conditions
on whole numbers compare exactly (#12196).
Loading
Loading