---
slug: 37a-data-analysis
title: Análisis de datos (tablas)
description: Análisis de datos al estilo pandas en Synsema sin DataFrame — tablas como listas de maps, summarize con sum_of/mean_of/count(), join (inner, left, right, outer, semi, anti), pivot, count_by, datos faltantes (nothing vs NaN), un pipeline completo CSV/Parquet, y lineage() / receipt() para probar qué datos produjeron un resultado.
example_ids: [data-analysis]
---

# Análisis de datos (tablas)

```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)
```

El flujo de pandas/polars, sin DataFrame: una **tabla es una lista de maps**. Eso es exactamente lo que te dan `sql()`, `mongo_find`, `csv_parse`, `parquet_read`, `jsonl_decode` y `json_decode`, así que el resultado de una consulta y un archivo pasan por el mismo pipeline. Cada paso toma filas y devuelve filas, que van directo al paso siguiente, a un gráfico, a `csv_encode`/`parquet_write`, o afuera por una ruta. No hay índice ni `loc`.

Todo es **puro** (sin capacidad): solo leer y escribir los archivos necesita `file.read` / `file.write`. Funciona bajo `run`, `test`, `serve` y dentro de `sandbox`.

## Un pipeline, de punta a punta

```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"}))
```

La carga, los formatos de archivo y sus opciones están en [Archivos e I/O](/es/0.6.x/30-io-files); las fechas y `truncate` en [Tiempo y random](/es/0.6.x/31-time-random); los gráficos en [CSV, estadística y gráficos](/es/0.6.x/37-dataviz).

## Claves y columnas

`group_by`, `summarize` y `count_by` toman una **clave**: un nombre de columna, una lista de nombres de columna (la clave pasa a ser un map `{col: valor, …}`), o una función `(row) => …`.

- Una clave conserva su **valor y su tipo** y agrupa por `==`: `1`, `1.0` y `true` son un grupo, el texto `"1"` otro; maps con las mismas entradas en otro orden son un grupo; el mismo instante en dos zonas es un grupo; todo NaN cae en un grupo.
- Los grupos salen en **orden de primera aparición**.
- Un nombre de columna que **ninguna fila** tiene es un error (`group_by: no row has a column "regoin" (columns: …)`) — lo mismo en `sum_of("…")` y los demás agregados, `join` y `pivot`. Un error de tipeo nunca suma 0. Una columna presente solo en algunas filas es `nothing` donde falta.
- Un decimal y un float que caen en la misma clave son un error (`cannot mix decimal and float`): convertí uno a propósito.

## `summarize` — una fila por grupo

`summarize(rows, by, aggs)` → filas: la(s) columna(s) clave más una columna por agregado. `aggs` es un map `nombre → agregado`.

```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}]
```

| Agregado | Da |
|---|---|
| `sum_of(col)`, `mean_of(col)`, `min_of(col)`, `max_of(col)`, `median_of(col)` | la reducción sobre la columna del grupo (numérica; `min_of`/`max_of` también toman fechas) |
| `quantile_of(col, q)` | el cuantil, `q` en 0–1 |
| `first_of(col)` | el valor de la primera fila, `nothing` incluido |
| `n_unique_of(col)` | cuántos valores presentes distintos |
| `count()` | cuántas filas tiene el grupo, falten datos o no |
| cualquier `(group) => …` | lo que calcules con las filas del grupo: `"span": (g) => max(collect(g, "m")) - min(collect(g, "m"))` |

- Los agregados numéricos siguen [las reglas de toda reducción](/es/0.6.x/35-bytes-math-arrays): **saltean `nothing`**, **propagan NaN** y mantienen exactos los decimales. Un grupo con todos los valores faltantes da `0` en `sum_of` y `nothing` en los demás.
- Con `by` = una columna, esa columna conserva su nombre; con una lista, cada columna clave; con una función, la clave va a una columna llamada `key`.
- Un agregado con el nombre de una columna clave es un error (pisaría la clave).
- La columna del agregado se chequea contra la **tabla entera**, así que las filas desparejas están bien.

`group_by(rows, clave)` → `[{key, items}]` en orden de primera aparición, para cuando necesitás las filas mismas: `each g in group_by(rows, "region")` … `g.key`, `g.items`. Para cifras por grupo, `summarize` es más corto.

`count_by(rows, clave)` → `[{key, count}]`, **el más frecuente primero** (empates en orden de aparición) — el `value_counts` de pandas. Con un argumento cuenta los valores de una lista: `count_by(["x", "y", "x"])` → `[{key: "x", count: 2}, {key: "y", count: 1}]`.

## `join` — combinar dos tablas

`join(left, right, on, how?)` — un hash join, tiempo lineal. `on` es una columna o una lista de columnas presentes en los dos lados.

```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"}]
```

- **Toda fila de salida tiene todas las columnas**: un lado sin match lleva `nothing`. Orden: las filas de la izquierda, y después (en `right`/`outer`) las filas de la derecha sin match.
- Una clave `nothing` nunca matchea, como en SQL.
- Una columna no clave presente en los dos lados: la de la derecha lleva el sufijo **`_right`** (como hace polars). Si ese nombre ya está tomado, es un error, nunca una pisada. `semi` y `anti` no agregan columnas de la derecha.
- Con **dos** argumentos, `join(items, sep)` sigue siendo el join de texto: `join(["a", "b"], "-")` → `"a-b"`.

## `pivot` — de largo a ancho

`pivot(rows, index, columns, values, agg?)` → una fila por valor de `index`, una columna por valor distinto de `columns` (en orden de primera aparición), cada celda sale de `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
```

Una celda faltante es `nothing`. Varias filas en una celda sin `agg` es un **error** (nunca un "el primero" silencioso). Dos valores que se imprimirían como el mismo nombre de columna (`1` y `"1"`), o uno con el nombre de la columna `index`, son un error: convertí la columna antes.

## Datos faltantes: `nothing` vs NaN

Dos cosas distintas, separadas como en polars y SQL:

- **`nothing` es un valor faltante.** Sale de un campo CSV vacío, de una marca listada en `{"missing": [...]}`, de un null de Parquet o SQL, o de una celda sin match en `join`/`pivot`. Las reducciones y los agregados lo **saltean**.
- **NaN es un número inválido** (`0.0 / 0.0`, un cálculo que falló). Se **propaga**: `mean([1, nan])` es `nan`, así un valor malo nunca se promedia en silencio.

| Herramienta | Hace |
|---|---|
| `is_missing(x)` | `x == nothing` |
| `count(xs)` / `count_missing(xs)` | cuántos valores están presentes / cuántos son `nothing` (listas) |
| `drop_missing(xs)` | la lista sin sus `nothing` |
| `drop_missing(rows)` · `drop_missing(rows, "col")` · `drop_missing(rows, ["a", "b"])` | descarta filas con un `nothing` en cualquier columna / en esas columnas (una columna ausente en una fila cuenta como faltante) |
| `fill_missing(xs, v)` · `fill_missing(rows, v)` · `fill_missing(rows, {"col": v})` | reemplaza cada `nothing` en una lista / en toda celda / solo en esas columnas |
| `fill_nan(xs, número)` | reemplaza cada NaN (listas y arrays) |

Elegí a propósito — descartar y rellenar dan respuestas distintas. `count()` dentro de `summarize` cuenta filas; `count(collect(g, "col"))` cuenta los valores presentes.

## 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` |

Las filas son valores: `each r in rows` da una copia de cada fila, así que `set r["x"] to 1` dentro del loop no cambia `rows`. Construí la tabla nueva en su lugar — `set rows to apply(rows, (r) => merge(r, {"x": 1}))`.

## Probar qué datos produjeron el resultado — `lineage()` y `receipt()`

El **engine**, no tu programa, registra cada dato que el programa lee de afuera, en orden: archivos (`read_file`, `read_file_bytes`, `parquet_read`, `list_dir`, `grep`), respuestas HTTP, lecturas de SQL/Mongo/Redis, lecturas RPC de cadenas, mensajes de sockets y procesos, respuestas de modelos, stdin, y lo que devolvieron `run`/`run_program`. No podés agregar, editar ni borrar una entrada.

```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
```

Cada entrada de `lineage()` es `{source, what, sha256, bytes, encoding}`:

- `source` — el builtin que lo leyó. `what` — una ruta que el programa pasó como texto, un host, un tamaño o un compromiso con sal, **nunca los datos**: un archivo pasado como bytes (`parquet_read(b)`) registra `bytes <n>`; una lectura HTTP registra solo el host (sin credenciales, ruta ni query); una consulta SQL, un filtro de Mongo o un prompt de modelo registra `query sha256-salted:<hex>`, así nadie puede recuperarlo hasheando intentos.
- `sha256` — hex, sin `0x`, de lo que recibió el programa; `bytes` — su largo.
- `encoding` — qué bytes cubre el hash, para que cualquiera lo recalcule: `"text"` (el texto UTF-8: `hex(sha256(t))`), `"bytes"` (los bytes crudos), `"jcs"` (`canonical_json(x)` de un valor, p. ej. las filas que devolvió `parquet_read`), o `"json"` (`json_encode(x)`, cuando el valor tiene un entero más allá de 2^53).

`receipt(opts?)` publica la misma lista como `credentialSubject.inputs`, junto a `program_sha`, `engine`, la auditoría de capacidades y `declared_result_sha256` (el sha256 del `canonical_json` del valor que pasás como `result`). **Firmado**, prueba *qué entradas, qué programa y qué salida*:

```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
```

Cualquiera que tenga los mismos archivos recalcula su sha256 y lo compara con `inputs`; `receipt_verify` chequea la firma. Un receipt nunca lleva los datos mismos. Las claves, los DIDs y el resto del receipt están en [Identidad de agentes](/es/0.6.x/46-agent-identity).
