Standard library / primitives
CSV, stats & charts
-- Doc example: CSV parsing, descriptive statistics and native SVG charts (all pure).
intent: "doc example: csv, stats and charts"
let raw be "month,total\njan,10\nfeb,25\nmar,17\napr,25\n"
let rows be csv_parse(raw, {"types": {"total": "int"}})
let svg be chart_svg("bar", rows, {"x": "month", "y": "total", "title": "Sales"})
print("rows = " + text(length(rows)) + ", median = " + text(median([10, 25, 17, 25])) + ", svg = " + text(starts_with(svg, "<svg")))
test "csv_parse: rows are maps (same shape sql() returns), lossless by default"
let plain be csv_parse("a,b\n00123,x\n")
assert_eq(plain[0]["a"], "00123")
assert_eq(type_of(plain[0]["a"]), "text")
assert_eq(rows[1]["total"], 25)
test "csv round-trip survives embedded commas and quotes"
let tricky be [{"name": "a,b", "note": "say \"hi\""}]
assert_eq(csv_parse(csv_encode(tricky)), tricky)
test "median / percentile / histogram (NumPy semantics)"
assert_eq(median([1, 2, 3, 4]), 2.5)
assert_eq(percentile([1, 2, 3, 4], 25), 1.75)
let h be histogram(range(10), 5)
assert_eq(h["counts"], [2, 2, 2, 2, 2])
assert_eq(length(h["edges"]), 6)
test "chart_svg is deterministic and escapes data text"
assert(contains(svg, "<svg"))
assert_eq(svg, chart_svg("bar", rows, {"x": "month", "y": "total", "title": "Sales"}))
let hostile be chart_svg("pie", {"<b>x</b>": 4, "y": 2})
assert(contains(hostile, "<b>"))
test "chart errors are clear and catchable"
let msg be ""
try
chart_svg("bar", [])
recover e
set msg to e
assert(contains(msg, "data is empty"))
test "every chart option, exercised: title/x_label/y_label/legend/colors/width/height/background"
let multi be [{"m": "a", "sales": 10, "costs": 6}, {"m": "b", "sales": 25, "costs": 12}]
let full be chart_svg("bar", multi, {
"x": "m", "y": ["sales", "costs"],
"title": "Sales vs costs", "x_label": "Month", "y_label": "USD",
"legend": true, "colors": ["#112233", "#445566"],
"width": 400, "height": 300, "background": "#fcfcfb"
})
assert(contains(full, "Sales vs costs"))
assert(contains(full, "Month") and contains(full, "USD"))
assert(contains(full, "sales") and contains(full, "costs"))
assert(contains(full, "#112233") and contains(full, "#445566"))
assert(contains(full, "viewBox=\"0 0 400 300\""))
-- legend: false hides the series names; then the other data shapes:
assert(contains(chart_svg("line", multi, {"x": "m", "y": "sales", "legend": false}), ">sales<") == false)
assert(contains(chart_svg("pie", {"a": 3, "b": 1}), "<path"))
assert(contains(chart_svg("line", [1, 2, 3]), "<polyline"))
assert(contains(chart_svg("scatter", [[1, 2], [3, 4]]), "<circle"))
test "csv options, exercised: headers/delimiter/numbers/eol"
let semi be csv_parse("a;b\n1;2\n", {"delimiter": ";", "numbers": true})
assert_eq(semi[0]["b"], 2)
let positional be csv_parse("1,2\n3,4\n", {"headers": false})
assert_eq(positional[1][0], "3")
assert_eq(csv_encode([{"a": "1", "b": "2"}], {"headers": ["b"], "eol": "\n"}), "b\n2\n")
test "histogram bins: integer or explicit edges (last bin closed, out-of-range dropped)"
assert_eq(histogram([1, 2, 3, 4], [0, 2, 4])["counts"], [1, 3])
assert_eq(histogram([-5, 1, 99], [0, 2])["counts"], [1])
test "business kinds: area/heatmap/histogram/boxplot/donut/waterfall + stack"
let multi be [{"m": "a", "sales": 10, "costs": 6}, {"m": "b", "sales": 25, "costs": 12}]
-- stack is an OPT on bar/area (there is no "stacked_bar" kind)
assert(contains(chart_svg("bar", multi, {"x": "m", "y": ["sales", "costs"], "stack": true}), "<rect"))
assert(contains(chart_svg("area", multi, {"x": "m", "y": ["sales", "costs"], "stack": true}), "<polygon"))
-- heatmap: tidy rows (x/y/value) or a matrix (+ x_labels/y_labels); scale auto/sequential/diverging
let cells be [{"d": "mon", "h": "9", "v": 1}, {"d": "mon", "h": "10", "v": 5}, {"d": "tue", "h": "9", "v": 9}]
assert(contains(chart_svg("heatmap", cells, {"x": "h", "y": "d", "value": "v"}), "<rect"))
let diverging be chart_svg("heatmap", [[-8, 0], [4, 9]], {"x_labels": ["q1", "q2"], "y_labels": ["a", "b"], "scale": "diverging", "center": 0})
assert(contains(diverging, "<rect"))
-- histogram plots raw numbers OR the exact map histogram() returns (same binning)
let data be [1, 2, 2, 3, 3, 3, 9]
assert_eq(chart_svg("histogram", data, {"bins": 4}), chart_svg("histogram", histogram(data, 4)))
-- boxplot: >= 2 values per group; quartiles match percentile()
assert(contains(chart_svg("boxplot", {"web": [1, 2, 3, 4, 100], "tel": [2, 3, 8, 9]}), "<circle"))
-- donut = pie with a hole (same rules); waterfall takes DELTAS, total is opt-in
assert(contains(chart_svg("donut", {"a": 4, "b": 2}), "<path"))
assert(contains(chart_svg("waterfall", {"sales": 100, "costs": -40}, {"total": true}), ">Total<"))
test "theme dark + kind/opt validation errors are clear and catchable"
let multi be [{"m": "a", "sales": 10, "costs": 6}, {"m": "b", "sales": 25, "costs": 12}]
let dark be chart_svg("bar", multi, {"x": "m", "y": "sales", "theme": "dark"})
assert(contains(dark, "<svg"))
assert(dark != chart_svg("bar", multi, {"x": "m", "y": "sales"}))
let msg be ""
try
chart_svg("treemap", [1])
recover e
set msg to e
assert(contains(msg, "valid kinds are: area, bar, boxplot, donut, heatmap, histogram, line, pie, scatter, waterfall"))
set msg to ""
try
chart_svg("pie", {"a": 1}, {"stack": true})
recover e
set msg to e
assert(contains(msg, "bar, area"))
set msg to ""
try
chart_svg("heatmap", [[1]], {"center": 5})
recover e
set msg to e
assert(contains(msg, "diverging"))
test "svg_to_png / svg_to_pdf: every option, real deterministic bytes"
let png be svg_to_png(svg, {"scale": 2})
assert(contains(decode(png, "hex"), "89504e470d0a1a0a"))
assert_eq(png, svg_to_png(svg, {"scale": 2}))
let sized be svg_to_png(svg, {"width": 320, "background": "#ffffff", "max_pixels": 1000000})
assert(contains(decode(sized, "hex"), "89504e470d0a1a0a"))
let pdf be svg_to_pdf(svg, {"width": 400})
assert(contains(decode(pdf, "hex"), "255044462d"))
-- the anti-DoS ceiling names its option:
let msg be ""
try
svg_to_png(svg, {"width": 10000000})
recover e
set msg to e
assert(contains(msg, "max_pixels"))
test "stats: percentile 0..100, quantile 0..1, std sample, nothing skipped, NaN propagates"
assert_eq(quantile([1, 2, 3, 4], 0.25), 1.75)
assert_eq(percentile([1, 2, 3, 4], 50), quantile([1, 2, 3, 4], 0.5))
assert_error(() => percentile([1, 2], 101))
assert_error(() => quantile([1, 2], 50))
assert_eq(std([1, 2, 3, 4]), 1.2909944487358056)
assert_eq(std([1, 2, 3, 4], ddof = 0), 1.118033988749895)
assert_eq(median([1, nothing, 3]), 2.0)
assert(is_nan(median([1, nan, 3])))
assert_error(() => histogram([1, nothing, 3])) -- histogram takes numbers only
assert_error(() => histogram([1, nan, 3]))
assert_error(() => histogram([]))
test "charts take summarize output; a nothing in a numeric field is an error"
let sales be [{"r": "n", "v": 10}, {"r": "s", "v": 5}, {"r": "n", "v": 1}]
let by_r be summarize(sales, "r", {"total": sum_of("v")})
assert(contains(chart_svg("bar", by_r, {"x": "r", "y": "total"}), "<rect"))
assert_error(() => chart_svg("bar", [{"x": "a", "y": nothing}], {"x": "x", "y": "y"}))
assert(contains(chart_svg("bar", fill_missing([{"x": "a", "y": nothing}], 0), {"x": "x", "y": "y"}), "<svg"))
-- The message of an error, or nothing when the call did not fail.
task error_of(f)
try
f()
recover e
give e
give nothing
test "x_axis rotates, thins and formats the category labels (engine v0.6.45+)"
let days be apply((i) => {"day": add_days(date(2026, 1, 1), i), "n": i}, range(0, 30))
let c be chart_svg("bar", days, {"x": "day", "y": "n", "x_axis": {"angle": 30, "every": 7, "format": "%d/%m"}})
assert(contains(c, ">08/01<")) -- every 7th label, formatted with strftime
assert(not contains(c, ">02/01<")) -- the ones in between are skipped
assert(contains(c, "rotate(-30 "))
-- without x_axis the SVG is byte for byte the one it always was
assert_eq(svg, chart_svg("bar", rows, {"x": "month", "y": "total", "title": "Sales"}))
assert(contains(error_of(() => chart_svg("pie", {"a": 1}, {"x_axis": {"angle": 45}})), "only applies to: bar, line, area, heatmap, boxplot, waterfall"))
assert(contains(error_of(() => chart_svg("bar", rows, {"x": "month", "y": "total", "x_axis": {"angel": 45}})), "unknown x_axis key \"angel\" (valid: angle, every, format)"))
test "qr_matrix / qr_svg encode a QR code (ISO/IEC 18004)"
let m be qr_matrix("https://synsema.dev")
assert_eq(length(m), length(m[0])) -- square: rows of 0/1
assert_eq(m[0][0], 1) -- the finder pattern's corner
assert_eq(length(qr_matrix("hi", {"version": 5})), 37) -- version v is 17 + 4v modules a side
let q be qr_svg("hi", {"ecc": "H", "module": 4, "margin": 4, "dark": "#112233", "light": "#ffffff"})
assert(starts_with(q, "<svg"))
assert(contains(q, "#112233"))
assert_eq(q, qr_svg("hi", {"ecc": "H", "module": 4, "margin": 4, "dark": "#112233", "light": "#ffffff"}))
assert_eq(error_of(() => qr_svg("hi", {"ecc": "X"})), "qr_svg: ecc must be \"L\", \"M\", \"Q\" or \"H\", got \"X\"")
assert(contains(error_of(() => qr_svg("hi", {"size": 4})), "valid: ecc, version, module, margin, dark, light"))
-- the SVG converts like any other
assert(contains(decode(svg_to_png(q), "hex"), "89504e470d0a1a0a"))
test "svg_to_png refuses a <text> over 16 384 characters unless you raise max_text_chars"
let long be join(apply((i) => "a", range(0, 20000)), "")
let doc be `<svg xmlns="http://www.w3.org/2000/svg" width="100" height="20"><text x="0" y="15">{long}</text></svg>`
let e be error_of(() => svg_to_png(doc))
assert(contains(e, "a <text> has 20000 characters, above the 16384 character limit"))
assert(contains(e, "max_text_chars"))
assert_eq(type_of(svg_to_png(doc, {"max_text_chars": 20000})), "bytes")The report pipeline is native and pure (no capability — it also works inside sandbox): data → aggregate → chart. It is data-source-agnostic: everything consumes plain values, so rows from sql(), mongo_find, csv_parse, parquet_read, summarize or a literal all plot the same.
CSV§
csv_parse(text, opts?) returns a list of maps (first row = headers — the same shape sql() returns), so a CSV feeds summarize and the charts directly. Type the columns you need; an empty field is nothing (missing):
let rows be csv_parse(read_file("sales.csv"), {"types": {"month": "text", "total": "decimal"}})
write_file("out.csv", csv_encode(rows))
Every option of csv_parse and csv_encode (types, missing, headers, delimiter, numbers, eol, escape_formulas), Parquet and JSON Lines are on Files & I/O. Grouping, joining and pivoting rows before you chart them: Data analysis.
Descriptive statistics§
median(x), percentile(x, p), quantile(x, q), std(x), var(x) and histogram(x, bins?) work on a list of numbers or a numeric array, with NumPy semantics.
percentile(x, p)takespin 0–100;quantile(x, q)takesqin 0–1. Both interpolate linearly;percentile(x, 50) == quantile(x, 0.5) == median(x). Apoutside its range is an error that names the other function.stdandvarare sample statistics (ddof = 1, like pandas and ExcelSTDEV.S); the population figure isstd(x, ddof = 0).- The reductions skip
nothing(missing) and propagate NaN:median([1, nothing, 3])→2.0,median([1, nan, 3])→nan. The full rules, withaxis =and exact decimals: Bytes, math & arrays. histogramtakes numbers only: anothingor a NaN in its data is an error (drop_missing/fill_nanfirst).binsis an integer (default 10, equispaced over[min, max]) or an explicit ascending edge list; the result is{"counts": [..], "edges": [..]}withlength(edges) == length(counts) + 1, the last bin closed, and out-of-range values dropped when edges are explicit. Empty data → a clear error.- Also:
mode,corr,cov,polyfit/polyval,lstsq(Bytes, math & arrays).
Charts§
chart_svg(kind, data, opts?) returns plain SVG text — embed it with { raw svg } in a render() template, serve it with respond(svg, "image/svg+xml"), or save it with write_file. Deterministic: same input, byte-identical output.
Kinds (an unknown kind errors listing exactly this set): "area", "bar", "boxplot", "donut", "heatmap", "histogram", "line", "pie", "scatter", "waterfall". There is no "stacked_bar" kind: stacking is the {"stack": true} option on bar/area.
Data shapes: list of maps + {"x": "field", "y": "field"} (multi-series: "y": [..]; heatmap takes {"x", "y", "value"}; boxplot groups by x), map of label→value (bar/line/area/pie/donut; in waterfall the value is the DELTA, not the running total; in boxplot it is label→list of numbers), list of numbers (bar/line/area/histogram, x = index; boxplot = a single box), [x, y] pairs (line/scatter/area), a 1-D array, a matrix (list of lists or 2-D array — heatmap only; rows = y, columns = x, with optional x_labels/y_labels), or the {"counts", "edges"} map that histogram() returns (histogram kind only — it shares the builtin's binning). Pie/donut take one series of non-negative values.
Options common to all kinds (every name below is doctest-verified):
| Option | Meaning |
|---|---|
"title" | chart title (also the SVG's accessible <title>) |
"x" / "y" | field names when data is a list of maps; "y" may be a list → one series per field. On any other data shape they error instead of being silently ignored |
"x_label" / "y_label" | axis labels |
"legend" | true/false; default: shown automatically for ≥2 series or pie/donut. On heatmap the legend is the gradient bar |
"width" / "height" | canvas in px (defaults 640×360) |
"colors" | list of hex colors that replaces the default palette. Heatmap: ≥2 gradient stops; waterfall: [up, down, total] |
"background" | hex fill (default: transparent) |
"theme" | "light" (default) or "dark" — brightened series, dark-ready ink/grid/scales. An explicit "colors" beats the theme |
Per-kind options (using one on the wrong kind errors naming the kinds that take it; an unknown option errors listing all valid ones — a typo never passes silently):
| Kind | Extra options | Semantics |
|---|---|---|
bar, area | "stack" | true stacks the series. Bar: positives stack up, negatives down. Stacked area with mixed signs at one x → error (suggests stacked bar). Single series + stack = harmless no-op |
boxplot | — (x/y = group/value) | Tukey: box q1–q3 (the same linear interpolation as percentile()), whiskers at 1.5×IQR, outliers as dots. At least 2 values per group |
heatmap | "value", "x_labels", "y_labels", "scale", "center" | "scale": "auto" (default: sequential when all values share a sign; diverging centered on 0 when they cross it), "sequential" or "diverging". "center" requires an explicit {"scale": "diverging"}. Missing cell (tidy form) = transparent; duplicate cell → error |
histogram | "bins" | integer (default 10) or ascending edge list — a float like 4.0 errors. chart_svg("histogram", data, {"bins": n}) produces the same SVG as chart_svg("histogram", histogram(data, n)) |
waterfall | "total" | values are deltas; the running total is computed for you. true appends the "Total" bar (or pass a text label). Delta 0 is valid. Semantic CVD-safe colors: blue up / orange down / ink total (not green/red — override with "colors") |
bar, line, area, heatmap, boxplot, waterfall | "x_axis" (engine v0.6.45+) | the category labels under the X axis: {"angle": 45, "every": 2, "format": "%b %d"}. angle rotates them (0–90 degrees); every shows one label out of n (without it, the usual automatic thinning, about 12 visible); format is the strftime of format_time, applied when the x values are dates or datetimes. Each key is optional; an unknown key is an error that lists angle, every, format. Without x_axis the SVG is byte for byte the one it was before |
Defaults include a colorblind-safe 8-color palette in fixed order (more than 8 series/slices is an error — colors are never cycled; group into "Other" or pass {"colors": [..]}), a single y-axis, bars that always include zero, and XSS-safe escaping of all data text. NaN/infinite values in plotted data → a clear error, and so does a nothing in a numeric field — drop_missing(rows, "col") or fill_missing first. A secret as a label renders [redacted]; as a numeric value it is a type error.
Charts agents can read§
Inside content(), the chart(...) node negotiates per client — same URL:
- HTML → the inline SVG
- Markdown (
.mdorAccept: text/markdown) → the chart title + a table of the data - JSON (
.json) →{"type": "chart", "kind", "title", ...}+ the kind's data fields
Exact agent-facing output per kind (Markdown table headers are in English even when your data is not — they are runtime output, stable to parse):
| Kind | Markdown | JSON data fields |
|---|---|---|
| bar/line/pie/scatter/area/donut | table: x column + one per series | "series": [{"name", "points": [[x, y], ..]}] (+ "stack": true only when stacked) |
| heatmap | matrix: rows = y_labels, columns = x_labels | "x_labels", "y_labels", "values": [[..]] (missing cell = null) |
| histogram | | range | count | — ranges [a, b), last one [a, b] | "counts", "edges" |
| boxplot | | group | min | q1 | median | q3 | max | outliers | | "groups": [{"name", "min", "q1", "median", "q3", "max", "outliers": [..]}] |
| waterfall | | label | delta | running | (+ total row if requested) | "steps": [{"label", "delta", "running"}], "total" |
route "GET /report/:name"
let rows be sql("SELECT month, total FROM sales ORDER BY month")
give content(page([
heading(1, "Sales 2026"),
chart("bar", rows, {"x": "month", "y": "total", "title": "Sales by month"})
], {"title": "Report"}))
A human sees the chart; an agent fetching .md gets the numbers. No other language does this natively.
PNG / PDF export§
svg_to_png(svg, opts?) and svg_to_pdf(svg, opts?) convert any SVG text (a chart, a handwritten SVG) to bytes — for email attachments, downloads, or printing:
write_file("report.png", svg_to_png(svg, {"scale": 2})) -- needs file.write
route "GET /report.pdf"
give binary(svg_to_pdf(svg), "application/pdf")
- PNG opts (all doctest-verified):
"width"/"height"in px (one alone keeps the aspect ratio),"scale"(e.g.2for retina — conflicts with width/height, explicit error),"background"(hex; default transparent),"max_pixels"(an overridable safety ceiling, default ~16.7M — the error names the option),"max_text_chars"and"fonts"(both below). "max_text_chars"(engine v0.6.45+, PNG and PDF): a<text>longer than 16 384 characters is refused — counting its<tspan>s and the text a<tref>pulls in, with entities expanded, also inside an SVG embedded in adata:URL (and for the icons ofsynsema build --iconandsynsema init --pwa). The renderer lays a text out in a time that grows with the square of its length: 16 k characters take 0.3 s, 64 k 2.6 s, 128 k 10 s, 400 k several minutes — a title that comes in the data could hold aserveworker for minutes. The error says how many characters there are:svg_to_png: a <text> has 20000 characters, above the 16384 character limit (its layout time grows with the square of its length); split it into several <text> or raise it with {"max_text_chars": n}. The limit is per<text>, so a long document with one<text>per line is not affected. Content the renderer draws many times (<use>, markers, masks) multiplies the cost of the text inside it; a limit for that comes in a later version.- PDF opts:
"width"/"height"in points — one alone scales proportionally; both together must match the SVG's aspect ratio (clear error otherwise). The PDF is single-page vector: crisp at any zoom. - Deterministic: one embedded sans font (DejaVu Sans), so text rasterizes identically on every platform. Same input → byte-identical output. Your own fonts (engine v0.6.20+):
opts.fontsis a list of.ttf/.otfpaths — each needsfile.read(a font bundled bysynsema buildneeds none) — loaded for that call only, so afont-family="Arial"in the SVG resolves when you passC:/Windows/Fonts/arial.ttf; unknown families still fall back to DejaVu. Same SVG + same fonts → same bytes. - Fallback per glyph (engine v0.6.45+): with
opts.fonts, a character your font lacks (an arrow, an emoji, a CJK character) falls back on its own and the rest of the text keeps your font, as in a browser (before, the whole run came out in the fallback font). Withoutopts.fontsthe output is byte for byte the same as before. When the per-glyph layout would leave a missing glyph (your font forms a ligature or a combining mark in that text, or the fallback is a.ttccollection), the whole document is laid out the old way, where the fallback replaces the run. - Font limits (engine v0.6.45+): at most 16 fonts with 64 faces in total per call (a collection counts all its faces: pass the single faces you use instead of a whole "Super OTC"), and at most 16 fallback fonts tried for one run of text (beyond that, the rest shows as missing glyphs). A font that would make every lookup slow (more than 16 character-map subtables, a format 13 subtable with more than 4096 groups, a face with more than 64 tables) is refused, and the error names the font. Real fonts — one CJK collection per weight, color emoji, variable fonts, Apple's LastResort — are accepted.
- Safe by construction: embedded
<script>never executes, and external<image href>(URLs or local paths) is never fetched — a pure builtin touches neither network nor disk. Both are pure, so they also work insidesandbox. - Honest limits: the PNG is the static state (scripts/animations ignored); unknown font families fall back to the embedded font; glyphs it lacks (full CJK, color emoji) render as tofu unless you pass a font that has them in
opts.fonts.
QR codes (engine v0.6.45+)§
qr_matrix(data, opts?) encodes a text as a QR code (ISO/IEC 18004) and returns its modules as rows of 0/1 (1 is dark), without the quiet zone. qr_svg(data, opts?) draws it as SVG text, with the quiet zone, ready for svg_to_png/svg_to_pdf, a page or a template. Both are pure and deterministic.
"ecc": error correction"L","M"(default),"Q"or"H"— a higher level survives more damage and needs more modules."version": 1 to 40 (a version-v code is 17 + 4v modules a side); without it, the smallest that fits.qr_svgonly:"module"(px per module),"margin"(the quiet zone, in modules),"dark"and"light"(hex colors).
An unknown option is an error that lists the valid ones (valid: ecc, version, module, margin, dark, light); a bad value says what it takes (qr_svg: ecc must be "L", "M", "Q" or "H", got "X").
let link be "https://synsema.dev"
let rows be qr_matrix(link) -- [[1, 1, 1, …], …]: 1 = dark module
let svg be qr_svg(link, {"ecc": "Q", "module": 8, "margin": 4, "dark": "#111111", "light": "#ffffff"})
let png be svg_to_png(svg) -- a PNG for an email or a label printer
print(length(rows)) -- 25: modules a side