Results & artifacts#
Marking the headline result of a command and declaring the files it produces as exportable artifacts.
Mark an output as the result#
Flag the output that is what the experiment was for with –result, so knit describe highlights it.
APIs: knit_with_output, knit_output
A command may record several outputs, but usually one of them is the result —
what the experiment was for. Append --result to its knit_with_output
declaration to say so:
@command "measure" "Square a value and mark the result."
@with_required "x:integer" "The value to square."
@with_output "square:integer" "0" "The squared value (the result)." --result
@with_output "note:string" "" "An intermediate note (not a result)."
@with_table
_measure() {
local x
x="$(knit_get_parameter "x" "$@")"
knit_output "square" "$(( x * x ))" # the headline value result
knit_output "note" "squared x=${x}" # recorded, but not a result
printf 'square=%s\n' "$(( x * x ))"
}
@done
knit_register "measure" _measure "Square a value and mark the result."
knit_with_required "x:integer" "The value to square."
knit_with_output "square:integer" "0" "The squared value (the result)." --result
knit_with_output "note:string" "" "An intermediate note (not a result)."
knit_with_table
_measure() {
local x
x="$(knit_get_parameter "x" "$@")"
knit_output "square" "$(( x * x ))" # the headline value result
knit_output "note" "squared x=${x}" # recorded, but not a result
printf 'square=%s\n' "$(( x * x ))"
}
knit_done
--result is orthogonal to the output’s type and to recording: it is valid on
any output (a scalar such as square here, or a file / directory),
and it changes no value and moves no file. It is a declaration-time marker only,
so knit describe can surface it: the flagged output carries a result tag
in the human and Markdown views and a "result": true field in the JSON and
YAML views (see Describe the command tree). An unflagged output such as
note reports result false.
Set the value from the body with knit_output exactly as for any output (see
Emit outputs from a command); the flag lives on the declaration, not the
emission. For a file or directory you also want packaged for export, declare it
with knit_with_output_artifact ... --result instead (see Declare and bind an
artifact).
Declare and bind an artifact#
Declare a produced file with knit_with_output_artifact, write it under knit_artifact_dir, then bind it with knit_artifact.
APIs: knit_with_output_artifact, knit_artifact_dir, knit_artifact
An artifact is a file or directory a command produces that you want kept for
export — a dataset, a plot, a captured config. Declare it with
knit_with_output_artifact "name:type" "description" (the type must be file or
directory), write it under the artifacts root reported by
knit_artifact_dir, then bind it from the body with knit_artifact:
@command "tabulate" "Write a data table as an artifact."
@with_output "rows:integer" "0" "How many rows were written." --result
@with_output_artifact "table:file" "The data table (CSV)." --result
@with_table
_tabulate() {
# knit_artifact_dir is the artifacts/ root: write into it, then declare.
local out
out="$(knit_artifact_dir)"
mkdir -p "${out}"
printf 'i,i2\n1,1\n2,4\n3,9\n' > "${out}/table.csv"
knit_artifact "table" "table.csv" # already inside artifacts/
knit_output "rows" "3"
printf 'wrote %s\n' "${out}/table.csv"
}
@done
knit_register "tabulate" _tabulate "Write a data table as an artifact."
knit_with_output "rows:integer" "0" "How many rows were written." --result
knit_with_output_artifact "table:file" "The data table (CSV)." --result
knit_with_table
_tabulate() {
# knit_artifact_dir is the artifacts/ root: write into it, then declare.
local out
out="$(knit_artifact_dir)"
mkdir -p "${out}"
printf 'i,i2\n1,1\n2,4\n3,9\n' > "${out}/table.csv"
knit_artifact "table" "table.csv" # already inside artifacts/
knit_output "rows" "3"
printf 'wrote %s\n' "${out}/table.csv"
}
knit_done
The <linked-path> you bind is always inside the artifacts root, given
relative to it (table.csv) or as an absolute path within it; a path outside
the root is fatal. knit records the artifact’s value as that
artifacts-relative path, so the database holds no absolute machine path and
the record stays relocatable. Add --result (as on table here) to mark the
artifact as the headline result too (see Mark an output as the result). To
give the artifact a semantic kind — so a consumer can require a CSV table
rather than merely a file — register the kind first and name it here (see
Declare an artifact kind).
knit records each artifact as one row in the framework-owned artifacts table
— its path, name, type, content checksum, and result flag
— not as a column of the producing command’s own table, and links that row
to the producing invocation with a produced provenance edge. The content
digest is always recorded (there is no --no-checksum opt-out for an
artifact). Because each artifact is its own node, which invocation produced this
file? is a reverse walk of the produced edge (see Trace an artifact back to
its producer), and knit describe lists artifacts in a dedicated Artifacts
section, separate from the value outputs. The entry must exist and match its
declared type when you bind it. Artifacts are write-once: binding the same
path twice is fatal, so a command re-run that produces a fixed-named artifact
needs a distinct <linked-path> per run. To create the entry from a file that
lives elsewhere, use the --link-from / --copy-from shortcuts (see Link
or copy an artifact into place).
Fan out a variadic output#
Declare a *-quantified output artifact and bind the same name many times, so one command produces a whole collection.
APIs: knit_with_output_artifact, knit_artifact_dir, knit_artifact
A scalar artifact binds once. Add a * (zero or more) or + (one or more)
to the kind and the name becomes a collection: the body may bind it any
number of times, and each binding is its own artifacts row with its own
produced edge. Use it when one run scatters a whole set of files — shards,
frames, per-seed outputs — that you do not know the count of up front:
# A "*" (zero or more) output is a COLLECTION: the body may bind the same name
# any number of times, and each binding is its own artifacts row with its own
# "produced" edge. (Use "+" for one-or-more, which is fatal if the body binds
# nothing.) Here one run scatters `n` CSV shards under the artifacts root.
@command "shard" "Fan out a range into several CSV shards."
@with_optional "n:integer" "3" "How many shards to write."
@with_output_artifact "shards:csvfile*" "The CSV shards (zero or more)."
@with_table
_shard() {
local n out i
n="$(knit_get_parameter "n" "$@")"
out="$(knit_artifact_dir)"
mkdir -p "${out}"
for (( i = 1; i <= n; i++ )); do
printf 'id,sq\n%d,%d\n' "${i}" "$(( i * i ))" > "${out}/shard-${i}.csv"
knit_artifact "shards" "shard-${i}.csv" # bind one member of the collection
done
printf 'wrote %s shard(s)\n' "${n}"
}
@done
# A "*" (zero or more) output is a COLLECTION: the body may bind the same name
# any number of times, and each binding is its own artifacts row with its own
# "produced" edge. (Use "+" for one-or-more, which is fatal if the body binds
# nothing.) Here one run scatters `n` CSV shards under the artifacts root.
knit_register "shard" _shard "Fan out a range into several CSV shards."
knit_with_optional "n:integer" "3" "How many shards to write."
knit_with_output_artifact "shards:csvfile*" "The CSV shards (zero or more)."
knit_with_table
_shard() {
local n out i
n="$(knit_get_parameter "n" "$@")"
out="$(knit_artifact_dir)"
mkdir -p "${out}"
for (( i = 1; i <= n; i++ )); do
printf 'id,sq\n%d,%d\n' "${i}" "$(( i * i ))" > "${out}/shard-${i}.csv"
knit_artifact "shards" "shard-${i}.csv" # bind one member of the collection
done
printf 'wrote %s shard(s)\n' "${n}"
}
knit_done
The quantifier is the only difference from a scalar declaration. * accepts an
empty fan-out (a run that produced nothing is not an error); + requires at
least one binding and is fatal after the body if none was made, so it states at
least one of these must exist. The write-once rule is unchanged: each member
still needs a distinct artifacts-relative path (here shard-${i}.csv), so a
loop that reuses one path is rejected. Every member is recorded exactly as a
scalar artifact is — same path/name/kind/checksum columns, same
produced edge — so the collection needs no special handling to trace,
remove, or query; it is simply several artifact rows sharing one declared name.
Consume the whole set at once with a variadic input (see Consume many artifacts
with a glob).
Link or copy an artifact into place#
Create an artifact from a file elsewhere with knit_artifact –copy-from (snapshot) or –link-from (reference in place).
APIs: knit_artifact
When the bytes you want as an artifact already live somewhere else, let
knit_artifact place the entry for you instead of writing it under the
artifacts root by hand. --copy-from snapshots the source into artifacts/;
--link-from references it in place through an absolute-target symlink:
@command "collect" "Bind artifacts by copy and by reference."
@with_output_artifact "figure:file" "A small file, copied in for durability."
@with_output_artifact "dataset:file" "A large file, referenced in place." --result
@with_table
_collect() {
# --copy-from: snapshot a file into artifacts/ (missing parents are created).
printf 'a small figure\n' > figure.svg
knit_artifact "figure" "figure.svg" --copy-from figure.svg
# --link-from: a big file on a fast filesystem is referenced by an
# absolute-target symlink (no copy); its content is still checksummed.
printf 'big dataset bytes\n' > "${PWD}/big.dat"
knit_artifact "dataset" "dataset.dat" --link-from "${PWD}/big.dat"
printf 'collected\n'
}
@done
knit_register "collect" _collect "Bind artifacts by copy and by reference."
knit_with_output_artifact "figure:file" "A small file, copied in for durability."
knit_with_output_artifact "dataset:file" "A large file, referenced in place." --result
knit_with_table
_collect() {
# --copy-from: snapshot a file into artifacts/ (missing parents are created).
printf 'a small figure\n' > figure.svg
knit_artifact "figure" "figure.svg" --copy-from figure.svg
# --link-from: a big file on a fast filesystem is referenced by an
# absolute-target symlink (no copy); its content is still checksummed.
printf 'big dataset bytes\n' > "${PWD}/big.dat"
knit_artifact "dataset" "dataset.dat" --link-from "${PWD}/big.dat"
printf 'collected\n'
}
knit_done
Choose by cost and durability. --copy-from does cp -r, so the artifact is
a self-contained copy — right for a small file you want to keep even if the
original moves. --link-from makes a symlink whose target is resolved to an
absolute path, so a large file on a fast filesystem is referenced at zero copy
cost and stays where it is; a later export dereferences the link and streams the
real content into the archive.
Both shortcuts create any missing parent directories inside artifacts/ and
never overwrite an existing on-disk entry (artifacts are write-once); the
source <real-path> must exist, and the two shortcuts are mutually exclusive.
Either way the entry then goes through the same existence, type, and checksum path
as a hand-placed one: the digest is taken from the resolved target (following
the symlink, recursing into a directory), so a linked artifact is checksummed as
if it were physically present. It is recorded exactly like a hand-placed artifact — an artifacts row whose
path is the artifacts-relative <linked-path> (see Declare and bind an
artifact).
Trace an artifact back to its producer#
Recover which invocation produced a file by walking the produced edge from its artifacts row back to the producing command.
APIs: query:graph, query:sql, knit_with_output_artifact
Each artifact is recorded as one row in the artifacts table (its path,
name, type, checksum, and result), linked to the invocation that
made it by a produced provenance edge — not as a column of the
producing command’s own table (see Declare and bind an artifact). So which
invocation produced this file? is a reverse walk of that edge, keyed on the
artifacts-relative path.
With query graph the walk is a Cypher match. The producing node needs no
label — read the producer off the edge, so the same query works no matter which
command made the file:
$ ./exp.sh query graph --format column --header --exec \
"MATCH (t)-[e:produced]->(a:artifacts)
WHERE a.path = 'table.csv'
RETURN e.source_name, e.source_id"
e.source_name is the producing command and e.source_id is its invocation
id, which joins back to that command’s own recorded row for the rest of the
provenance (its parameters, its call edge to a parent, and so on).
The same lookup in query sql joins the artifacts row to the
__provenance__ edge on the artifact id:
$ ./exp.sh query sql --format column --header --exec \
"SELECT p.source_name, p.source_id
FROM artifacts a
JOIN __provenance__ p ON p.target_id = a.id AND p.edge_type = 'produced'
WHERE a.path = 'table.csv'"
Both directions are open: forward (RETURN a.path for a given producer, or a
join the other way) lists every file an invocation produced. See Query the
provenance graph for the Cypher subset and Run raw SQL for the shared
--format / --header options.
Walk an artifact’s full lineage#
Walk producer –produced–> artifact –used_by–> consumer in one query by joining the two provenance edges on the artifact’s row.
APIs: query:graph, query:sql, knit_with_input_artifact, knit_with_output_artifact
An artifact sits between the command that made it and the commands that read it.
The producer leaves a produced edge into the artifact’s row (see Trace an
artifact back to its producer); each consumer leaves a used_by edge out of
it (see Consume an artifact by kind). Because both edges meet at the same
artifacts node, one query walks the whole chain producer --produced-->
artifact --used_by--> consumer.
With query graph the chain is a single Cypher path through the artifact node,
keyed on its artifacts-relative path:
$ ./exp.sh query graph --format column --header --exec \
"MATCH (p)-[pr:produced]->(a:artifacts)-[ub:used_by]->(c)
WHERE a.path = 'table.csv'
RETURN pr.source_name, a.kind, ub.target_name"
The producing and consuming nodes need no label — name them off the edges
(pr.source_name, ub.target_name), so the same query works whatever
commands sit at the ends. To list everything that read a given artifact, keep
only the used_by half (RETURN ub.target_name); to list every artifact a
command consumed, match the used_by edge into it.
The same walk in query sql joins the two __provenance__ edges to the
artifacts row on the artifact id:
$ ./exp.sh query sql --format column --header --exec \
"SELECT pr.source_name AS producer, ub.target_name AS consumer
FROM artifacts a
JOIN __provenance__ pr ON pr.target_id = a.id AND pr.edge_type = 'produced'
JOIN __provenance__ ub ON ub.source_id = a.id AND ub.edge_type = 'used_by'
WHERE a.path = 'table.csv'"
See Query the provenance graph for the Cypher subset and Run raw SQL for the
shared --format / --header options.
Declare an artifact kind#
Give an artifact a semantic kind with knit_register_artifact, then produce it by naming the kind in knit_with_output_artifact.
APIs: knit_register_artifact, knit_with_output_artifact, knit_artifact
An artifact’s type is its physical form — file or directory. Its
kind is what it means: a CSV table, a checkpoint, a mesh. A kind is a
semantic label backed by exactly one physical type, so a consumer can require a
CSV table rather than merely a file. Declare a kind once at the top level with
knit_register_artifact "kind:type" "description", then a producer names the
kind in place of the bare physical type:
# A kind is a semantic label for an artifact, backed by exactly one physical
# type (file or directory). Declare it once at the top level; a producer then
# names the kind in place of the bare physical type.
@artifact "csvfile:file" "A tabulated result in CSV format."
@command "tabulate" "Write a data table as a csvfile artifact."
@with_output_artifact "table:csvfile" "The data table (CSV)." --result
@with_table
_tabulate() {
# knit_artifact_dir is the artifacts/ root: write into it, then bind.
local out
out="$(knit_artifact_dir)"
mkdir -p "${out}"
printf 'i,i2\n1,1\n2,4\n3,9\n' > "${out}/table.csv"
knit_artifact "table" "table.csv" # recorded with kind=csvfile
printf 'wrote %s\n' "${out}/table.csv"
}
@done
# A kind is a semantic label for an artifact, backed by exactly one physical
# type (file or directory). Declare it once at the top level; a producer then
# names the kind in place of the bare physical type.
knit_register_artifact "csvfile:file" "A tabulated result in CSV format."
knit_register "tabulate" _tabulate "Write a data table as a csvfile artifact."
knit_with_output_artifact "table:csvfile" "The data table (CSV)." --result
knit_with_table
_tabulate() {
# knit_artifact_dir is the artifacts/ root: write into it, then bind.
local out
out="$(knit_artifact_dir)"
mkdir -p "${out}"
printf 'i,i2\n1,1\n2,4\n3,9\n' > "${out}/table.csv"
knit_artifact "table" "table.csv" # recorded with kind=csvfile
printf 'wrote %s\n' "${out}/table.csv"
}
knit_done
file, directory, and the dir alias are builtin kinds — they are
their own physical type and need no declaration, so "table:file" keeps
working unchanged (see Declare and bind an artifact). A named kind is declared
once; redefining any kind (a builtin included) is fatal, and the physical
type must itself be file or directory.
The kind is recorded in the kind column of the framework-owned artifacts
table, next to the physical type; a bare table:file records
type=file, kind=file, while table:csvfile records type=file,
kind=csvfile. knit describe shows the kind (not the bare type) for a
declared output artifact. The kind is the contract a consumer checks against —
see Consume an artifact by kind.
Consume an artifact by kind#
Require a produced artifact of a given kind with knit_with_input_artifact, resolve it with knit_input_artifact_path, and record a used_by edge.
APIs: knit_with_input_artifact, knit_input_artifact_path
A command consumes an artifact another command produced by declaring
knit_with_input_artifact "name:kind" "description". This registers a required
string parameter whose value is the artifacts-relative path of a recorded
artifact of that kind. In the body, knit_input_artifact_path resolves that
path to the on-disk file:
# A consumer requires an artifact of a given kind. The parameter value is the
# artifacts-relative path of a recorded artifact; Knit resolves it, refuses a
# missing artifact or a kind mismatch, and --- with --verify-checksum ---
# re-hashes the bytes and refuses a table that changed since it was produced.
@command "summarize" "Consume a csvfile artifact and count its data rows."
@with_input_artifact "table:csvfile" "Artifacts-relative path of the CSV to read." --verify-checksum
@with_output "rows:integer" "0" "Number of data rows in the consumed table." --result
@with_table
_summarize() {
# Resolve the recorded artifacts-relative path to the on-disk file.
local csv rows
csv="$(knit_input_artifact_path "$(knit_get_parameter table "$@")")"
rows=$(( $(wc -l < "${csv}") - 1 )) # one header line; the rest are data
knit_output "rows" "${rows}"
printf 'summarize: %s data rows\n' "${rows}"
}
@done
# A consumer requires an artifact of a given kind. The parameter value is the
# artifacts-relative path of a recorded artifact; Knit resolves it, refuses a
# missing artifact or a kind mismatch, and --- with --verify-checksum ---
# re-hashes the bytes and refuses a table that changed since it was produced.
knit_register "summarize" _summarize "Consume a csvfile artifact and count its data rows."
knit_with_input_artifact "table:csvfile" "Artifacts-relative path of the CSV to read." --verify-checksum
knit_with_output "rows:integer" "0" "Number of data rows in the consumed table." --result
knit_with_table
_summarize() {
# Resolve the recorded artifacts-relative path to the on-disk file.
local csv rows
csv="$(knit_input_artifact_path "$(knit_get_parameter table "$@")")"
rows=$(( $(wc -l < "${csv}") - 1 )) # one header line; the rest are data
knit_output "rows" "${rows}"
printf 'summarize: %s data rows\n' "${rows}"
}
knit_done
Before the body runs, Knit resolves the path to its artifacts row and refuses
the run when the value is empty, when no artifact is recorded at that path, or
when the recorded artifact’s kind is not the required one — so the body never
sees the wrong kind of input. Add --verify-checksum (as here) to also re-hash
the bytes and refuse a run when the artifact changed since it was produced; the
digest recorded at production time is the reference. The kind must be registered
(builtin or via Declare an artifact kind).
Consuming an artifact records a used_by provenance edge from the artifact’s
row to the consuming invocation, the mirror of the produced edge the
producer left. Together they complete the lineage producer --produced--> artifact
--used_by--> consumer, which a single query walks end to end (see Walk an
artifact’s full lineage). A plain file input that is not a recorded artifact
takes a bare path parameter instead and leaves no edge — reach for
knit_with_input_artifact only when the input is a tracked artifact whose
lineage you want.
Consume many artifacts with a glob#
Declare a +/ input artifact and read every member with knit_input_artifact_paths, passing a comma list or a glob.*
APIs: knit_with_input_artifact, knit_input_artifact_paths
A * (zero or more) or + (one or more) on an input artifact lets one
parameter stand for a whole set. Its argument is a comma-separated list of
artifacts-relative paths, and any element holding a glob metacharacter
(*, ?, [) is expanded against the artifacts root — so a single
--shards 'shard-*.csv' gathers a whole fan-out. knit_input_artifact_paths
fills a bash array with the resolved on-disk paths, in order, de-duplicated:
# A "+" (one or more) input requires at least one member. Its argument is a
# comma-separated list of artifacts-relative paths, and any element holding a
# glob metacharacter (*, ?, [) is expanded against the artifacts root --- so one
# `--shards 'shard-*.csv'` gathers the whole fan-out. knit_input_artifact_paths
# fills a bash array with the resolved on-disk paths, in order, de-duplicated.
@command "merge" "Merge every shard matched by a glob into one row count."
@with_input_artifact "shards:csvfile+" "Artifacts-relative glob of the shards to merge (one or more)."
@with_output "rows:integer" "0" "Total data rows across the merged shards." --result
@with_table
_merge() {
local -a paths=()
knit_input_artifact_paths paths "$(knit_get_parameter "shards" "$@")"
local total=0 p
for p in "${paths[@]}"; do
total=$(( total + $(wc -l < "${p}") - 1 )) # drop each shard's header line
done
knit_output "rows" "${total}"
printf 'merged %s shard(s), %s data row(s)\n' "${#paths[@]}" "${total}"
}
@done
# A "+" (one or more) input requires at least one member. Its argument is a
# comma-separated list of artifacts-relative paths, and any element holding a
# glob metacharacter (*, ?, [) is expanded against the artifacts root --- so one
# `--shards 'shard-*.csv'` gathers the whole fan-out. knit_input_artifact_paths
# fills a bash array with the resolved on-disk paths, in order, de-duplicated.
knit_register "merge" _merge "Merge every shard matched by a glob into one row count."
knit_with_input_artifact "shards:csvfile+" "Artifacts-relative glob of the shards to merge (one or more)."
knit_with_output "rows:integer" "0" "Total data rows across the merged shards." --result
knit_with_table
_merge() {
local -a paths=()
knit_input_artifact_paths paths "$(knit_get_parameter "shards" "$@")"
local total=0 p
for p in "${paths[@]}"; do
total=$(( total + $(wc -l < "${p}") - 1 )) # drop each shard's header line
done
knit_output "rows" "${total}"
printf 'merged %s shard(s), %s data row(s)\n' "${#paths[@]}" "${total}"
}
knit_done
Every resolved member is validated before the body runs — containment,
existence, and recorded kind (plus the checksum with --verify-checksum) —
and the first bad element is fatal and names itself, so the body never sees a
partial or wrong-kind set. + refuses an empty result (the parameter is
required, and a glob matching nothing is fatal); * accepts it (the parameter
is optional and defaults to empty, and the array simply comes back empty). Quote
the glob so your shell does not expand it before Knit does. The consumer’s own
row stores the raw argument you passed (the pattern), while one used_by
edge is recorded per resolved member — so the lineage carries the concrete set,
not the pattern. For a single artifact, use the scalar knit_input_artifact_path
instead (see Consume an artifact by kind).
Discover and merge a fan-out#
Pair a *-output producer with a +/ -input consumer so one command scatters many artifacts and the next gathers them by glob.*
APIs: knit_with_output_artifact, knit_artifact, knit_with_input_artifact, knit_input_artifact_paths
Variadic artifacts compose into a discover-and-merge pattern across two
commands: a producer fans out a * collection, and a consumer gathers the
whole set through a + input with one glob argument — without either side
hard-coding how many members there are. The producer binds one collection name
per member:
# A "*" (zero or more) output is a COLLECTION: the body may bind the same name
# any number of times, and each binding is its own artifacts row with its own
# "produced" edge. (Use "+" for one-or-more, which is fatal if the body binds
# nothing.) Here one run scatters `n` CSV shards under the artifacts root.
@command "shard" "Fan out a range into several CSV shards."
@with_optional "n:integer" "3" "How many shards to write."
@with_output_artifact "shards:csvfile*" "The CSV shards (zero or more)."
@with_table
_shard() {
local n out i
n="$(knit_get_parameter "n" "$@")"
out="$(knit_artifact_dir)"
mkdir -p "${out}"
for (( i = 1; i <= n; i++ )); do
printf 'id,sq\n%d,%d\n' "${i}" "$(( i * i ))" > "${out}/shard-${i}.csv"
knit_artifact "shards" "shard-${i}.csv" # bind one member of the collection
done
printf 'wrote %s shard(s)\n' "${n}"
}
@done
# A "*" (zero or more) output is a COLLECTION: the body may bind the same name
# any number of times, and each binding is its own artifacts row with its own
# "produced" edge. (Use "+" for one-or-more, which is fatal if the body binds
# nothing.) Here one run scatters `n` CSV shards under the artifacts root.
knit_register "shard" _shard "Fan out a range into several CSV shards."
knit_with_optional "n:integer" "3" "How many shards to write."
knit_with_output_artifact "shards:csvfile*" "The CSV shards (zero or more)."
knit_with_table
_shard() {
local n out i
n="$(knit_get_parameter "n" "$@")"
out="$(knit_artifact_dir)"
mkdir -p "${out}"
for (( i = 1; i <= n; i++ )); do
printf 'id,sq\n%d,%d\n' "${i}" "$(( i * i ))" > "${out}/shard-${i}.csv"
knit_artifact "shards" "shard-${i}.csv" # bind one member of the collection
done
printf 'wrote %s shard(s)\n' "${n}"
}
knit_done
The consumer discovers them with a glob and merges what it finds:
# A "+" (one or more) input requires at least one member. Its argument is a
# comma-separated list of artifacts-relative paths, and any element holding a
# glob metacharacter (*, ?, [) is expanded against the artifacts root --- so one
# `--shards 'shard-*.csv'` gathers the whole fan-out. knit_input_artifact_paths
# fills a bash array with the resolved on-disk paths, in order, de-duplicated.
@command "merge" "Merge every shard matched by a glob into one row count."
@with_input_artifact "shards:csvfile+" "Artifacts-relative glob of the shards to merge (one or more)."
@with_output "rows:integer" "0" "Total data rows across the merged shards." --result
@with_table
_merge() {
local -a paths=()
knit_input_artifact_paths paths "$(knit_get_parameter "shards" "$@")"
local total=0 p
for p in "${paths[@]}"; do
total=$(( total + $(wc -l < "${p}") - 1 )) # drop each shard's header line
done
knit_output "rows" "${total}"
printf 'merged %s shard(s), %s data row(s)\n' "${#paths[@]}" "${total}"
}
@done
# A "+" (one or more) input requires at least one member. Its argument is a
# comma-separated list of artifacts-relative paths, and any element holding a
# glob metacharacter (*, ?, [) is expanded against the artifacts root --- so one
# `--shards 'shard-*.csv'` gathers the whole fan-out. knit_input_artifact_paths
# fills a bash array with the resolved on-disk paths, in order, de-duplicated.
knit_register "merge" _merge "Merge every shard matched by a glob into one row count."
knit_with_input_artifact "shards:csvfile+" "Artifacts-relative glob of the shards to merge (one or more)."
knit_with_output "rows:integer" "0" "Total data rows across the merged shards." --result
knit_with_table
_merge() {
local -a paths=()
knit_input_artifact_paths paths "$(knit_get_parameter "shards" "$@")"
local total=0 p
for p in "${paths[@]}"; do
total=$(( total + $(wc -l < "${p}") - 1 )) # drop each shard's header line
done
knit_output "rows" "${total}"
printf 'merged %s shard(s), %s data row(s)\n' "${#paths[@]}" "${total}"
}
knit_done
Run them in sequence — shard --n 3 then merge --shards 'shard-*.csv'
— and the glob resolves to whatever the fan-out produced. The provenance keeps
each member distinct on both sides: the producer leaves one produced edge per
bound member, and the consume leaves one used_by edge per resolved member, so
the lineage producer --produced--> member --used_by--> consumer holds for
every file in the set. One join over the two edge kinds recovers the whole group,
and what merged this shard? or which shards fed this merge? are the two ends
of that same walk (see Walk an artifact’s full lineage). This scales a sweep
without a fixed member count: add more shards and the same glob gathers them.