Skip to content

Reading a directory of runs

How to read many archives at once: which runs terminated how, what each cost, and which input changed between them. One archive is archiving a solve; this is the directory they pile up in.

The four tables

An archive is a tree of parquet files, so a directory of them is a table per glob. Nothing is loaded and no schema is maintained. The same globs work in any tool that reads a folder of parquet files, such as DuckDB, Spark or Power BI's "Combine files":

import polars as pl

answers = pl.read_parquet('runs/*/answer/record.parquet')
metrics = pl.read_parquet('runs/*/answer/metrics.parquet')
inputs = pl.read_parquet('runs/*/sources.parquet')
catalog = pl.read_parquet('runs/*/catalog.parquet')
glob one row per says
answer/record.parquet solve, or sweep slice how it terminated, what it reached, when, under what name, and on which solver and package versions
answer/metrics.parquet the same what the build and its solves spent, and how big the model was
sources.parquet source per archive what each input's table digests to
catalog.parquet dimension column of each file what the file holds (what a file holds)

Every row says which archive it came from. specsolve_run is the archive's own name: runs/nightly-2026-09-10.zip writes nightly-2026-09-10. Every table in an archive carries it, so nothing has to read the paths.

Every file has the same columns, whoever wrote it. A single solve, an EachCoordinate sweep and an EachWindow sweep write one schema, so a directory that holds all three globs as one table. A sweep writes one row per slice and names it in two text columns. slice_axis is the key name, such as scenario or snapshot_start, and slice is the key as text. Both are null for a single solve:

answers.select('specsolve_run', 'slice_axis', 'slice')
# specsolve_run  slice_axis      slice
# base           null            null
# scenarios      scenario        high
# scenarios      scenario        low
# rolling        snapshot_start  0
# rolling        snapshot_start  4

Use load_archive and scan_archive for one archive, not for many. They give back a spec, its sources and an answer, which is what re-running a case needs. A warehouse question is a query over the parquet.

The run on a value frame

answer/primal/p.parquet holds the model's own dimension columns, value, and specsolve_run. A sweep archive writes the same file. A tool that combines files and drops their paths, such as Power BI's "Combine files", still gets the run:

sps.solve('dispatch.yaml', sources, archive='runs/nightly-2026-09-10/')

pl.read_parquet('runs/*/answer/primal/p.parquet')
# snapshot  generator  value  specsolve_run
# 0         wind       90.5   nightly-2026-09-10
# 0         solar      0.0    nightly-2026-09-10

A scenario sweep's frame carries its key, so a mix with solves does not glob. EachCoordinate('scenario') writes a scenario column that a solve does not write. polars refuses the glob, as it refuses the record:

SchemaError: extra column in file outside of expected schema: scenario

Union by name, as for the record. scenario is null on the rows of a solve:

pl.concat([pl.read_parquet(file) for file in sorted(glob('runs/*/answer/primal/p.parquet'))], how='diagonal')

The column is the archive's, not the model's. The specsolve_ prefix is reserved for the columns specsolve adds, and a spec that declares a name with that prefix, in any letter case, is refused. Both readers drop the column from the answer's frames, so a frame read back is the frame the solve returned. load_archive drops it from the sources too. The sources scan_archive gives back are the archive's own files, and reading one gives the column back.

What a file holds

catalog.parquet says what each file in the archive holds, so a reader needs no spec.yaml. It has one row per dimension column of each file under sources/ and answer/:

column holds
specsolve_run the archive's name, as on every other table it holds
path the file's path inside the archive, as sources/load.parquet or answer/dual/load.parquet
name the name the spec declares
kind dimension, relation, parameter, variable, constraint or expression
description the spec's description:, or null
dtype the declared type of a dimension's labels or a parameter's value, else null
column the column that holds dim's labels: a relation's role, else the dimension itself
dim the dimension, or null for a file over no dimension
dim_position the 0-based place of column in the order the spec declares the name's dimensions, which can differ from the order of the file's columns

Join it on the path, not the name. A constraint can have the name of a parameter, so name = 'load' can match the parameter's source and the constraint's dual. path and dim_position identify one row. In DuckDB:

select name, kind, description, column, dim
from 'runs/base/catalog.parquet'
where path = 'answer/primal/p.parquet'
order by dim_position;

The catalog lists the files the archive holds, and no other. A name the spec declares has no row where it has no file: every answer of a solve that left no values, the duals of a model that has none, and a named expression the data cannot evaluate. answer/record.parquet and answer/reasons.parquet say why.

The catalog has no units, because the spec declares none. It has no row for specsolve_run, which every file carries, or for a dimension's specsolve_position. Neither holds labels. In a sweep archive, an answer's path is a directory that holds one file per slice, and each frame also carries the column of the sweep key, which axis.json names and the catalog does not list.

Compare cases solved apart

solved_at is when the solver returned, so runs solved on different machines still order:

table = pl.read_parquet('runs/*/answer/record.parquet')
table.sort('solved_at').select('specsolve_run', 'status', 'objective')

Check the digests before you read the numbers. spec_digest is a digest of the spec an answer came back from. One distinct value across the table is the claim that every row answered the same document, and a null is a row that named none, so ask for both:

assert table['spec_digest'].n_unique() == 1, 'one spec, or this compares nothing'
assert table['spec_digest'].null_count() == 0, 'and every row named the document it answered'

Find which input changed between two runs

spec_digest says two runs answered the same document and nothing about the numbers, so two runs of one spec over different data carry the same one. sources.parquet separates them, and names the input that moved:

import specsolve as sps

base = sps.load_archive('runs/base/')
other = sps.load_archive('runs/halved/')

moved = base.source_digests.join(other.source_digests, on='source', suffix='_other').filter(
    pl.col('digest') != pl.col('digest_other')
)
moved['source'].to_list()  # ['load']

Across a whole directory it is a window rather than a join, specsolve_run being on every row:

inputs = pl.read_parquet('runs/*/sources.parquet')
inputs.sort('specsolve_run').with_columns(before=pl.col('digest').shift().over('source')).filter(
    pl.col('before').is_not_null() & (pl.col('before') != pl.col('digest'))
)

The digest is of the table the solve read, before the run is stamped on, so one table archived under two names digests alike. That table is the one under sources/, not the file you passed, and hashing the member does not give the row back, because the member also carries specsolve_run. Two archives of the same data written by different versions of polars can differ, and reading an archive does not verify the digests (the rule).

See what the runs cost

answer/metrics.parquet carries the model's size beside the clocks, so one query says which cases are growing and where the time goes:

metrics.select('specsolve_run', 'rows', 'nonzeros', 'build_seconds', 'solve_seconds').sort(
    pl.col('build_seconds') + pl.col('solve_seconds'), descending=True
)

The columns are the metrics. A sweep writes one row per slice, with the slice's own share of the clocks and solves of 1 (reading a sweep).

Query it from a database

A directory archive is parquet where it lies, so a query engine reads it without polars in the way. In DuckDB, the glob reads solves and sweeps together:

select specsolve_run, slice, rows, nonzeros, build_seconds, solve_seconds
from read_parquet('runs/*/answer/metrics.parquet')
order by build_seconds + solve_seconds desc;

A value frame carries specsolve_run too, so a query across runs reads it as any other column. union_by_name keeps the scenario of a sweep:

select *
from read_parquet('runs/*/answer/primal/p.parquet', union_by_name = true)
order by specsolve_run, snapshot;

Without union_by_name, DuckDB takes the columns of the first file it reads. When that file is a solve, the rows of a scenario sweep lose scenario and no error occurs.

Inside one archive every frame is tidy, so the values join to the sources they were solved from on the coordinates both carry:

select p.snapshot, p.generator, p.value, load.value as load
from 'runs/base/answer/primal/p.parquet' p
join 'runs/base/sources/load.parquet' load using (snapshot, specsolve_run);

A zip has to be unpacked first, because no query engine reads inside one. Give either reader an into= and query what lands there.