Biblioteca estándar
Análisis de datos (tablas)
-- 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§
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; las fechas y truncate en Tiempo y random; los gráficos en CSV, estadística y gráficos.
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.0ytrueson 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 ensum_of("…")y los demás agregados,joinypivot. Un error de tipeo nunca suma 0. Una columna presente solo en algunas filas esnothingdonde 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.
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: saltean
nothing, propagan NaN y mantienen exactos los decimales. Un grupo con todos los valores faltantes da0ensum_ofynothingen 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 llamadakey. - 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.
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 (enright/outer) las filas de la derecha sin match. - Una clave
nothingnunca 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.semiyantino 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.
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:
nothinges 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 enjoin/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])esnan, 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.
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)) registrabytes <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 registraquery sha256-salted:<hex>, así nadie puede recuperarlo hasheando intentos.sha256— hex, sin0x, 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:
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.