---
slug: 37a-data-analysis
title: Data analysis (tables)
description: pandas-style data analysis in Synsema without a DataFrame — tables as lists of maps, summarize with sum_of/mean_of/count(), join (inner, left, right, outer, semi, anti), pivot, count_by, missing data (nothing vs NaN), a full CSV/Parquet pipeline, and lineage() / receipt() to prove which data produced a result.
example_ids: [data-analysis]
---

# Data analysis (tables)

```synsema
-- Doc example: data analysis on tables (lists of maps) — load typed CSV and Parquet, decide what
-- missing means, join, summarize, pivot, then prove which data produced the result (lineage + receipt).
intent: "doc example: data analysis"
require file("_doctest_analysis_sales.csv")
require file("_doctest_analysis_regions.parquet")
require sign("ANALYST")

-- The inputs. A real pipeline reads files someone else wrote; here the program writes them first so
-- the example is self-contained.
write_file("_doctest_analysis_sales.csv", "order,day,region,amount\n1,2026-01-05,north,120.50\n2,2026-01-06,south,80\n3,2026-01-19,north,\n4,2026-02-02,west,150.25\n5,2026-02-03,north,200\n6,2026-02-10,south,NA\n")
write_file("_doctest_analysis_regions.parquet", parquet_write([{"region": "north", "manager": "Ana"}, {"region": "south", "manager": "Luis"}, {"region": "east", "manager": "Mei"}]))

-- 1. Load with types: an empty field (and the NA mark) is nothing = missing
let sales be csv_parse(read_file("_doctest_analysis_sales.csv"), {"types": {"order": "int", "day": "date", "amount": "decimal"}, "missing": ["NA"]})
let regions be parquet_read(read_file_bytes("_doctest_analysis_regions.parquet"))

-- 2. Decide what missing means: an order without an amount is dropped
let clean be drop_missing(sales, "amount")

-- 3. Join (a region with no match gets nothing, then a label), then aggregate one row per region
let with_manager be fill_missing(join(clean, regions, "region", "left"), {"manager": "unassigned"})
let by_region be summarize(with_manager, "region", {"total": sum_of("amount"), "orders": count(), "avg": mean_of("amount"), "manager": first_of("manager")})
let ranked be sort_by(by_region, (r) => r.total, desc = true)

-- 4. Region x month
let monthly be pivot(apply(clean, (r) => merge(r, {"month": format_time(truncate(r.day, "month"), "%Y-%m")})), "region", "month", "amount", sum_of("amount"))

each r in ranked
    print(`{r.region}: {r.total} in {r.orders} orders ({r.manager})`)

-- 5. Prove it: which inputs, which program, which result
let key be as_secret(bytes("0707070707070707070707070707070707070707070707070707070707070707", "hex"), "ANALYST")
let did be did_key_encode(ed25519_pubkey(key))
let proof be receipt({"sign": key, "verification_method": did + "#" + did_key_decode(did).multibase, "result": ranked})

test "load: typed columns, and missing values are nothing"
    assert_eq(length(sales), 6)
    assert_eq(sales[0].day, date(2026, 1, 5))
    assert_eq(sales[0].amount, 120.50d)
    assert_eq(sales[2].amount, nothing)                                   -- empty field
    assert_eq(sales[5].amount, nothing)                                   -- the NA mark
    assert_eq(count_missing(collect(sales, "amount")), 2)
    assert_eq(count(collect(sales, "amount")), 4)                         -- present values
    assert_eq(regions[0], {"region": "north", "manager": "Ana"})

test "summarize: one row per group, aggregates skip nothing"
    assert_eq(ranked[0], {"region": "north", "total": 320.50d, "orders": 2, "avg": 160.25d, "manager": "Ana"})
    assert_eq(collect(ranked, "region"), ["north", "west", "south"])
    assert_eq(join(clean, regions, "region", "left")[2].manager, nothing)  -- west has no match in regions
    assert_eq(ranked[1].manager, "unassigned")                            -- filled on purpose
    -- without dropping first, the aggregates skip the missing amounts but count() counts rows
    let raw be summarize(sales, "region", {"total": sum_of("amount"), "n": count(), "priced": (g) => count(collect(g, "amount"))})
    assert_eq(raw[0], {"region": "north", "total": 320.50d, "n": 3, "priced": 2})

test "every aggregate"
    let rows be [{"r": "n", "m": 10}, {"r": "s", "m": 5}, {"r": "n", "m": 1}, {"r": "s", "m": nothing}]
    let s be summarize(rows, "r", {"sum": sum_of("m"), "mean": mean_of("m"), "min": min_of("m"), "max": max_of("m"), "median": median_of("m"), "q": quantile_of("m", 0.5), "first": first_of("m"), "distinct": n_unique_of("m"), "rows": count(), "span": (g) => max(collect(g, "m")) - min(collect(g, "m"))})
    assert_eq(s[0], {"r": "n", "sum": 11, "mean": 5.5, "min": 1, "max": 10, "median": 5.5, "q": 5.5, "first": 10, "distinct": 2, "rows": 2, "span": 9})
    assert_eq(s[1].first, 5)
    -- by several columns (each key column kept) or by a function (the key lands in "key")
    let two be summarize([{"a": 1, "b": "x", "v": 1}, {"a": 1, "b": "y", "v": 2}, {"a": 1, "b": "x", "v": 3}], ["a", "b"], {"s": sum_of("v")})
    assert_eq(two, [{"a": 1, "b": "x", "s": 4}, {"a": 1, "b": "y", "s": 2}])
    let by_month be summarize(clean, (r) => truncate(r.day, "month"), {"total": sum_of("amount")})
    assert_eq(by_month, [{"key": date(2026, 1, 1), "total": 200.50d}, {"key": date(2026, 2, 1), "total": 350.25d}])
    -- a group whose values are all missing: sum_of is 0, the others nothing
    let uneven be summarize([{"g": "a", "v": 1}, {"g": "c"}], "g", {"s": sum_of("v"), "m": mean_of("v")})
    assert_eq(uneven[1], {"g": "c", "s": 0, "m": nothing})

test "a column no row has is an error, not a silent zero"
    assert_error(() => summarize(clean, "region", {"t": sum_of("amuont")}))
    assert_error(() => group_by(clean, "regoin"))
    assert_error(() => summarize(clean, "region", {"region": count()}))  -- would overwrite the key

test "group_by returns [{key, items}] in first-appearance order; count_by counts, most frequent first"
    let groups be group_by(clean, "region")
    assert_eq(collect(groups, "key"), ["north", "south", "west"])
    assert_eq(length(groups[0].items), 2)
    assert_eq(group_by([1, 1.0, 2], (v) => v), [{"key": 1, "items": [1, 1.0]}, {"key": 2, "items": [2]}])
    assert_eq(count_by(sales, "region"), [{"key": "north", "count": 3}, {"key": "south", "count": 2}, {"key": "west", "count": 1}])
    assert_eq(count_by(["x", "y", "x"]), [{"key": "x", "count": 2}, {"key": "y", "count": 1}])

test "join: inner, left, right, outer, semi, anti"
    let L be [{"id": 1, "a": "x"}, {"id": 2, "a": "y"}]
    let R be [{"id": 1, "b": "p"}, {"id": 3, "b": "q"}]
    assert_eq(join(L, R, "id"), [{"id": 1, "a": "x", "b": "p"}])                          -- inner is the default
    assert_eq(join(L, R, "id", "left"), [{"id": 1, "a": "x", "b": "p"}, {"id": 2, "a": "y", "b": nothing}])
    assert_eq(join(L, R, "id", "right"), [{"id": 1, "a": "x", "b": "p"}, {"id": 3, "a": nothing, "b": "q"}])
    assert_eq(join(L, R, "id", "outer"), [{"id": 1, "a": "x", "b": "p"}, {"id": 2, "a": "y", "b": nothing}, {"id": 3, "a": nothing, "b": "q"}])
    assert_eq(join(L, R, "id", "semi"), [{"id": 1, "a": "x"}])
    assert_eq(join(L, R, "id", "anti"), [{"id": 2, "a": "y"}])
    assert_eq(join([{"a": 1, "b": 1, "v": "x"}], [{"a": 1, "b": 1, "w": "y"}], ["a", "b"]), [{"a": 1, "b": 1, "v": "x", "w": "y"}])
    assert_eq(join(L, [{"id": 1, "a": "z"}], "id", "left"), [{"id": 1, "a": "x", "a_right": "z"}, {"id": 2, "a": "y", "a_right": nothing}])
    assert_error(() => join([{"k": 1, "b": 1, "b_right": 2}], [{"k": 1, "b": 3}], "k"))  -- b_right is taken
    assert_eq(join([{"k": nothing, "a": 1}], [{"k": nothing, "b": 2}], "k"), [])          -- a nothing key never matches
    assert_eq(join(["a", "b"], "-"), "a-b")                                               -- two arguments: the TEXT join

test "pivot: one row per index value, one column per distinct value"
    assert_eq(monthly, [{"region": "north", "2026-01": 120.50d, "2026-02": 200d}, {"region": "south", "2026-01": 80d, "2026-02": nothing}, {"region": "west", "2026-01": nothing, "2026-02": 150.25d}])
    let P be [{"d": "mon", "p": "a", "v": 1}, {"d": "mon", "p": "b", "v": 2}, {"d": "tue", "p": "a", "v": 3}]
    assert_eq(pivot(P, "d", "p", "v"), [{"d": "mon", "a": 1, "b": 2}, {"d": "tue", "a": 3, "b": nothing}])
    assert_error(() => pivot(P + [{"d": "mon", "p": "a", "v": 10}], "d", "p", "v"))      -- two rows in one cell: pass agg
    assert_eq(pivot(P + [{"d": "mon", "p": "a", "v": 10}], "d", "p", "v", sum_of("v"))[0].a, 11)
    assert_error(() => pivot([{"d": 1, "c": 1, "v": 1}, {"d": 1, "c": "1", "v": 1}], "d", "c", "v"))   -- 1 and "1" → one column name

test "missing data: nothing is missing, NaN is invalid"
    assert_eq(is_missing(nothing), true)
    assert_eq(fill_missing([1, nothing], 0), [1, 0])
    assert_eq(fill_missing([{"a": nothing, "b": nothing}], 0), [{"a": 0, "b": 0}])
    assert_eq(fill_missing([{"a": nothing, "b": nothing}], {"a": 1}), [{"a": 1, "b": nothing}])
    assert_eq(drop_missing([1, nothing, 2]), [1, 2])
    assert_eq(drop_missing([{"a": 1, "b": nothing}, {"a": 2, "b": 3}]), [{"a": 2, "b": 3}])     -- any column
    assert_eq(drop_missing([{"a": 1, "b": nothing}, {"a": nothing, "b": 2}], "a"), [{"a": 1, "b": nothing}])
    assert_eq(drop_missing([{"a": 1, "b": nothing}, {"a": 1}], ["a", "b"]), [])            -- an absent column counts as missing
    assert_eq(fill_nan([nan, 1], 0), [0, 1])
    assert_eq(mean([1, nothing, 3]), 2.0)
    assert(is_nan(mean([1, nan, 3])))

test "lineage: every input the program read, with its sha256"
    let l be lineage()
    assert_eq(collect(l, "source"), ["read_file", "read_file_bytes", "parquet_read"])
    assert_eq(l[0].what, "_doctest_analysis_sales.csv")
    assert_eq(l[0].encoding, "text")
    assert_eq("0x" + l[0].sha256, hex(sha256(read_file("_doctest_analysis_sales.csv"))))   -- anyone can recompute it
    assert_eq(l[1].encoding, "bytes")
    assert_eq(l[2].what, "bytes " + text(l[1].bytes))                      -- a file passed as bytes: its size, never its content
    assert_eq("0x" + l[2].sha256, hex(sha256(canonical_json(regions))))    -- encoding "jcs"

test "receipt: the lineage travels as inputs, signed, next to the program and the result"
    assert_eq(length(proof.credentialSubject.inputs), 3)
    assert_eq(proof.credentialSubject.inputs[0].sha256, lineage()[0].sha256)
    assert_eq(proof.credentialSubject.declared_result_sha256, slice(hex(sha256(canonical_json(ranked))), 2))
    assert_eq(proof.issuer, did)
    assert_eq(receipt_verify(proof, did).verified, true)
    let forged be merge(proof, {"credentialSubject": merge(proof.credentialSubject, {"inputs": []})})
    assert_eq(receipt_verify(forged, did), nothing)                        -- drop an input and the proof breaks

test "keys group by ==: value and type kept, first-appearance order"
    assert_eq(count_by([1, 1.0, true, "1"]), [{"key": 1, "count": 3}, {"key": "1", "count": 1}])
    assert_eq(count_by([{"a": 1, "b": 2}, {"b": 2, "a": 1}])[0].count, 2)     -- same entries, another order
    assert_eq(count_by([datetime("2026-01-01T12:00:00Z"), datetime("2026-01-01T09:00:00-03:00")])[0].count, 2)   -- same instant
    assert_eq(count_by([nan, nan, 1])[0].count, 2)                            -- every NaN in one group
    assert_error(() => count_by([1.5d, 1.5]))                                 -- decimal vs float: convert on purpose
    let span be summarize([{"g": "a", "d": date(2026, 1, 2)}, {"g": "a", "d": date(2026, 1, 1)}], "g", {"first": min_of("d"), "last": max_of("d")})
    assert_eq(span, [{"g": "a", "first": date(2026, 1, 1), "last": date(2026, 1, 2)}])
    assert_error(() => summarize([{"g": "a", "v": "x"}], "g", {"s": sum_of("v")}))   -- text in a numeric aggregate
    let by_pair be group_by([{"a": 1, "b": 2, "v": 1}, {"a": 1, "b": 2, "v": 2}], ["a", "b"])
    assert_eq(by_pair[0].key, {"a": 1, "b": 2})                               -- a list of columns: the key is a map

test "rows are values: each gives a copy, so build the new table"
    let rows be [{"x": 1}, {"x": 2}]
    each r in rows
        set r["x"] to 0
    assert_eq(rows, [{"x": 1}, {"x": 2}])                                     -- unchanged
    set rows to apply(rows, (r) => merge(r, {"y": r.x * 10}))
    assert_eq(rows, [{"x": 1, "y": 10}, {"x": 2, "y": 20}])
    assert_eq(where(rows, (r) => r.x > 1), [{"x": 2, "y": 20}])
    assert_eq(collect(rows, "y"), [10, 20])
    assert_eq(sort_by(rows, (r) => r.y, desc = true)[0].x, 2)
```

The pandas/polars workflow, without a DataFrame: a **table is a list of maps**. That is exactly what `sql()`, `mongo_find`, `csv_parse`, `parquet_read`, `jsonl_decode` and `json_decode` give you, so a query result and a file go through the same pipeline. Every step takes rows and returns rows, which go straight to the next step, to a chart, to `csv_encode`/`parquet_write`, or out of a route. There is no index and no `loc`.

All of it is **pure** (no capability): only reading and writing the files needs `file.read` / `file.write`. It works under `run`, `test`, `serve` and inside `sandbox`.

## A pipeline, end to end

```synsema
require file.read("./data/*")
require file.write("./out/*")

-- 1. Load with types; an empty field (and the NA mark) is nothing = missing
let sales be csv_parse(read_file("./data/sales.csv"), {"types": {"day": "date", "amount": "decimal"}, "missing": ["NA"]})
let regions be parquet_read(read_file_bytes("./data/regions.parquet"))

-- 2. Decide what missing means: here, an order without an amount is dropped
let clean be drop_missing(sales, "amount")

-- 3. Join, then one row per region
let joined be join(clean, regions, "region", "left")
let by_region be summarize(joined, "region", {"total": sum_of("amount"), "orders": count(), "avg": mean_of("amount")})
let ranked be sort_by(by_region, (r) => r.total, desc = true)

-- 4. By month: truncate the date and use it as the key
let monthly be summarize(clean, (r) => truncate(r.day, "month"), {"total": sum_of("amount")})

-- 5. Deliver
write_file("./out/by_region.csv", csv_encode(ranked))
write_file("./out/by_region.parquet", parquet_write(ranked))
write_file("./out/by_region.svg", chart_svg("bar", ranked, {"x": "region", "y": "total"}))
```

Loading, the file formats and their options are on [Files & I/O](/en/0.6.x/30-io-files); dates and `truncate` on [Time & random](/en/0.6.x/31-time-random); charts on [CSV, stats & charts](/en/0.6.x/37-dataviz).

## Keys and columns

`group_by`, `summarize` and `count_by` take a **key**: a column name, a list of column names (the key becomes a map `{col: value, …}`), or a function `(row) => …`.

- A key keeps its **value and type** and groups by `==`: `1`, `1.0` and `true` are one group, the text `"1"` another; maps with the same entries in another order are one group; the same instant in two zones is one group; every NaN falls in one group.
- Groups come in **first-appearance order**.
- A column name that **no row** has is an error (`group_by: no row has a column "regoin" (columns: …)`) — the same in `sum_of("…")` and the other aggregates, `join` and `pivot`. A misspelling never sums to 0. A column present in only some rows is `nothing` where it is absent.
- A decimal and a float that land on the same key are an error (`cannot mix decimal and float`): convert one on purpose.

## `summarize` — one row per group

`summarize(rows, by, aggs)` → rows: the key column(s) plus one column per aggregate. `aggs` is a map `name → aggregate`.

```synsema
let rows be [{"r": "n", "m": 10}, {"r": "s", "m": 5}, {"r": "n", "m": 1}, {"r": "s", "m": nothing}]
summarize(rows, "r", {"total": sum_of("m"), "n": count(), "avg": mean_of("m"), "top": max_of("m")})
-- [{r: "n", total: 11, n: 2, avg: 5.5, top: 10}, {r: "s", total: 5, n: 2, avg: 5.0, top: 5}]
```

| Aggregate | Gives |
|---|---|
| `sum_of(col)`, `mean_of(col)`, `min_of(col)`, `max_of(col)`, `median_of(col)` | the reduction over the group's column (numeric; `min_of`/`max_of` also take dates) |
| `quantile_of(col, q)` | the quantile, `q` in 0–1 |
| `first_of(col)` | the first row's value, `nothing` included |
| `n_unique_of(col)` | how many distinct present values |
| `count()` | how many rows the group has, missing or not |
| any `(group) => …` | whatever you compute from the group's rows: `"span": (g) => max(collect(g, "m")) - min(collect(g, "m"))` |

- The numeric aggregates follow [the rules of every reduction](/en/0.6.x/35-bytes-math-arrays): they **skip `nothing`**, **propagate NaN** and keep decimals exact. A group whose values are all missing gives `0` for `sum_of` and `nothing` for the others.
- With `by` = a column, that column keeps its name; with a list, each key column does; with a function, the key goes in a column named `key`.
- An aggregate named like a key column is an error (it would overwrite the key).
- The aggregate's column is checked against the **whole table**, so ragged rows are fine.

`group_by(rows, key)` → `[{key, items}]` in first-appearance order, when you need the rows themselves: `each g in group_by(rows, "region")` … `g.key`, `g.items`. For figures per group, `summarize` is shorter.

`count_by(rows, key)` → `[{key, count}]`, **most frequent first** (ties in appearance order) — pandas' `value_counts`. With one argument it counts the values of a list: `count_by(["x", "y", "x"])` → `[{key: "x", count: 2}, {key: "y", count: 1}]`.

## `join` — combine two tables

`join(left, right, on, how?)` — a hash join, linear time. `on` is a column or a list of columns present on both sides.

```synsema
let L be [{"id": 1, "a": "x"}, {"id": 2, "a": "y"}]
let R be [{"id": 1, "b": "p"}, {"id": 3, "b": "q"}]
join(L, R, "id")               -- inner (default): [{id: 1, a: "x", b: "p"}]
join(L, R, "id", "left")       -- + {id: 2, a: "y", b: nothing}
join(L, R, "id", "right")      -- [{id: 1, a: "x", b: "p"}, {id: 3, a: nothing, b: "q"}]
join(L, R, "id", "outer")      -- all three ids
join(L, R, "id", "semi")       -- the left rows WITH a match, unchanged: [{id: 1, a: "x"}]
join(L, R, "id", "anti")       -- the left rows WITHOUT a match: [{id: 2, a: "y"}]
```

- **Every output row has every column**: an unmatched side gets `nothing`. Order: the left rows, then (for `right`/`outer`) the unmatched right rows.
- A `nothing` key never matches, as in SQL.
- A non-key column present on both sides: the right one gets the suffix **`_right`** (as polars does). If that name is already taken, it is an error, never an overwrite. `semi` and `anti` add no right columns.
- With **two** arguments, `join(items, sep)` is still the text join: `join(["a", "b"], "-")` → `"a-b"`.

## `pivot` — long to wide

`pivot(rows, index, columns, values, agg?)` → one row per value of `index`, one column per distinct value of `columns` (in first-appearance order), each cell from `values`.

```synsema
let P be [{"d": "mon", "p": "a", "v": 1}, {"d": "mon", "p": "b", "v": 2}, {"d": "tue", "p": "a", "v": 3}]
pivot(P, "d", "p", "v")                -- [{d: "mon", a: 1, b: 2}, {d: "tue", a: 3, b: nothing}]
pivot(P, "d", "p", "v", sum_of("v"))   -- agg: when several rows fall in one cell
```

A missing cell is `nothing`. Several rows in one cell without `agg` is an **error** (never a silent "first"). Two values that would print as the same column name (`1` and `"1"`), or one named like the `index` column, are an error: convert the column first.

## Missing data: `nothing` vs NaN

Two different things, kept apart as in polars and SQL:

- **`nothing` is a missing value.** It comes from an empty CSV field, a mark listed in `{"missing": [...]}`, a Parquet or SQL null, or a cell with no match in `join`/`pivot`. Reductions and aggregates **skip** it.
- **NaN is an invalid number** (`0.0 / 0.0`, a failed computation). It **propagates**: `mean([1, nan])` is `nan`, so a bad value is never averaged away in silence.

| Tool | Does |
|---|---|
| `is_missing(x)` | `x == nothing` |
| `count(xs)` / `count_missing(xs)` | how many values are present / how many are `nothing` (lists) |
| `drop_missing(xs)` | the list without its `nothing`s |
| `drop_missing(rows)` · `drop_missing(rows, "col")` · `drop_missing(rows, ["a", "b"])` | drop rows with a `nothing` in any column / in those columns (a column absent from a row counts as missing) |
| `fill_missing(xs, v)` · `fill_missing(rows, v)` · `fill_missing(rows, {"col": v})` | replace each `nothing` in a list / in every cell / only in those columns |
| `fill_nan(xs, number)` | replace each NaN (lists and arrays) |

Choose on purpose — dropping and filling give different answers. `count()` inside `summarize` counts rows; `count(collect(g, "col"))` counts the values present.

## pandas / polars → Synsema

| pandas / polars | Synsema |
|---|---|
| `df.groupby("r").agg(...)` | `summarize(rows, "r", {...})` |
| `df.merge(o, on="id", how="left")` | `join(rows, o, "id", "left")` |
| `pivot_table(...)` | `pivot(rows, i, c, v, sum_of(v))` |
| `value_counts()` | `count_by(rows, "col")` |
| `dropna()` / `fillna(0)` | `drop_missing(rows)` / `fill_missing(rows, 0)` |
| `sort_values("t", ascending=False)` | `sort_by(rows, (r) => r.t, desc = true)` |
| `df["col"]` | `collect(rows, "col")` |
| `df[df.x > 2]` | `where(rows, (r) => r.x > 2)` |
| `df.assign(y=...)` | `apply(rows, (r) => merge(r, {"y": ...}))` |
| `pd.to_datetime` / `dt.to_period("M")` | `date(x)`, `datetime(x)` / `truncate(d, "month")` |
| `read_parquet` / `to_parquet` | `parquet_read` / `parquet_write` |
| `read_json(lines=True)` | `jsonl_decode` |

Rows are values: `each r in rows` gives a copy of each row, so `set r["x"] to 1` inside the loop does not change `rows`. Build the new table instead — `set rows to apply(rows, (r) => merge(r, {"x": 1}))`.

## Prove which data produced the result — `lineage()` and `receipt()`

The **engine**, not your program, records every piece of data the program reads from outside, in order: files (`read_file`, `read_file_bytes`, `parquet_read`, `list_dir`, `grep`), HTTP answers, SQL/Mongo/Redis reads, chain RPC reads, messages from sockets and processes, model answers, stdin, and what `run`/`run_program` returned. You cannot add, edit or drop an entry.

```synsema
require file.read("./data/*")
let t be read_file("./data/sales.csv")
let e be lineage()[0]
print(e.source, e.what, "0x" + e.sha256 == hex(sha256(t)))   -- read_file ./data/sales.csv true
```

Each entry of `lineage()` is `{source, what, sha256, bytes, encoding}`:

- `source` — the builtin that read it. `what` — a path the program passed as text, a host, a size or a salted commitment, **never the data**: a file passed as bytes (`parquet_read(b)`) records `bytes <n>`; an HTTP read records the host only (no credentials, path or query); a SQL query, a Mongo filter or a model prompt records `query sha256-salted:<hex>`, so nobody can recover it by hashing guesses.
- `sha256` — hex, without `0x`, of what the program received; `bytes` — its length.
- `encoding` — which bytes the hash covers, so anyone can recompute it: `"text"` (the UTF-8 text: `hex(sha256(t))`), `"bytes"` (the raw bytes), `"jcs"` (`canonical_json(x)` of a value, e.g. the rows `parquet_read` returned), or `"json"` (`json_encode(x)`, when the value holds an integer beyond 2^53).

`receipt(opts?)` publishes the same list as `credentialSubject.inputs`, next to `program_sha`, `engine`, the capability audit and `declared_result_sha256` (the sha256 of `canonical_json` of the value you pass as `result`). **Signed**, it proves *which inputs, which program and which output*:

```synsema
require sign("ANALYST")
let key be as_secret(env("ANALYST_KEY"), "ANALYST")
let did be did_key_encode(ed25519_pubkey(key))
let proof be receipt({"sign": key, "verification_method": did + "#" + did_key_decode(did).multibase, "result": ranked})
receipt_verify(proof, did).verified      -- true; change any input and it is nothing
```

Anyone holding the same files recomputes their sha256 and compares them with `inputs`; `receipt_verify` checks the signature. A receipt never carries the data itself. Keys, DIDs and the rest of the receipt are on [Agent identity](/en/0.6.x/46-agent-identity).
