Repository navigation
Add build_basic_tomes: the header and a tome per channel in one walk - #40
Conversation
TomeCuratorFs.build_basic_tomes(channels) (and build_basic_tomes_from_fs)
builds the header tome and one tome per channel, each match's rows tagged
match_key: what create_header_tome, a has_<channel> subheader and a
make_tome per channel build, but each match is visited once, for its
manifest, its header row and every channel, read with pyarrow by 4 threads.
- Channel tomes are staged under tome/<ds_type>/.building and moved to
their names (basic_{channel}.{dates} by default) at the end, once the
header's dates are known, each with its header copy: the header rows of
the matches that have the channel.
- Pages are joined with pa.concat_tables(promote_options="permissive") and
carry pandas metadata naming the dtypes pd.concat gives the same frames,
worked out from two-row stand-ins per distinct file schema and checked on
the joined page; a page that doesn't match is built with pandas.
- Pages are cut on Arrow bytes; zstd by default.
- A key listed twice is built once, and the count is logged.
- Complete tomes are kept; an interrupted build leaves no partial channel
tome and starts over.
- TomeWriterFs.write_page takes a pyarrow.Table too.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The drift fixture's stored index was a range, which pandas keeps as metadata, so no __index_level_0__ column reached the builder. It is now a stepped index, stored as a column, and the test checks the file has it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Publishing can corrupt an overwritten tome after interruption, and channel names can collide with the header tome.
Review effort: Balanced
Findings: 1
Open (2)
What changed in this PR
Adds a one-pass builder for header and per-channel tomes while preserving existing make_tome behavior.
Changes:
- Adds threaded Arrow-based tome construction, staging, paging, and resume behavior.
- Allows
TomeWriterFsto write Arrow tables. - Adds comprehensive tests and documentation.
| File | Description |
|---|---|
pureskillgg_dsdk/tome/builder.py |
Implements one-pass tome building. |
pureskillgg_dsdk/tome/builder_test.py |
Tests equivalence, paging, threading, and recovery. |
pureskillgg_dsdk/tome/curator.py |
Exposes the new curator API. |
pureskillgg_dsdk/tome/writer_fs.py |
Adds Arrow table writing. |
pureskillgg_dsdk/tome/writer_fs_test.py |
Tests Arrow page serialization. |
docs/tome-data-model.md |
Documents the builder. |
CHANGELOG.md |
Records the new functionality. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| os.replace( | ||
| get_page_path_fs(self._tome_root, subtype, page), destination | ||
| ) |
There was a problem hiding this comment.
Agreed, and fixed in 0b6a037. _publish now writes the destination's manifest as partial, with no pages, before it moves any page file. Only then does it move the pages, write the header copy, and write the complete manifest. An interrupted publish leaves a partial tome, and the next build rebuilds it by default (behavior_if_partial="overwrite"). It never leaves a complete manifest over half-replaced pages. I didn't swap a sibling directory in atomically. On Windows a directory rename can't replace an existing folder, and the swap would still need a delete of the old tome and a manifest rewrite. Invalidating first closes the gap without deleting anything. test_an_interrupted_publish_leaves_the_tome_partial rebuilds a complete multi-page tome with overwrite and stops it after one page file has moved. The tome then reads partial with no pages, and the next default build equals the original. The test fails on the previous commit.
| def _build_tome(self, name) -> bool: | ||
| warn_if_invalid_tome_name(name) |
There was a problem hiding this comment.
Agreed, and fixed in 0b6a037. tome_names now raises ValueError when a channel's tome name would equal the header tome's name. When tome_name doesn't use {dates}, the check runs in the constructor, before anything is read. Otherwise it runs once the header gives the dates. test_a_channel_tome_may_not_be_the_header covers both cases, and it fails on the previous commit.
… the header's name
- Publishing a staged tome over an existing one first writes the
destination's manifest as partial with no pages, then moves the page
files. An interrupted publish now leaves a partial tome, which the next
build builds again, never a complete manifest over half-replaced pages.
- A tome_name that would give a channel's tome the header tome's name
raises ValueError: before reading when the name doesn't use {dates},
otherwise once the header gives the dates.
From the Copilot review.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Conflicts resolved keeping both sides: - curator.py: build_basic_tomes, then #39's get_dataframe overloads with columns= and library=, and TomeCuratorFs.scan(). - CHANGELOG.md: #39's lines stay under 3.3.0; 3.3.0 is tagged, so this branch's lines move to a new Unreleased section. - docs/tome-data-model.md merged without conflict. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Merged
The builder's behaviour is unchanged. Its tests compare frames after The full suite passes locally in three environments, and pylint and black are clean:
CI is green on every job, |


What it builds
TomeCuratorFs.build_basic_tomes(channels)builds a data scientist's standard tome set in one walk over the matches: the header tome, and one tome per channel whose rows are taggedmatch_key. Those are the tomescreate_header_tome, ahas_<channel>subheader and amake_tomeper channel build today. The builder visits each match once and reads its manifest, its header row and every requested channel with pyarrow, 4 matches at a time. Today's script reads each manifest at least 23 times.has_<channel>subheader. A match whose file is empty is in the keyset with no rows, as withmake_tome. A channel that no match has gets no tome.all,map_*andplatform_*subheaders only filter the header, so the caller still makes them withcreate_subheader_tome, and the builder stays focused.TomeLoaderreads back the values and dtypes amake_tometome gives.make_tomeiterate/concat API is unchanged.TomeWriterFs.write_pagenow also takes apyarrow.Table.loader.pyandreader_fs.pyare untouched.API
build_basic_tomes_from_fs(...)next tocreate_header_tome_from_fsdoes the same without a curator. The full reference is in the docstring and in a new section ofdocs/tome-data-model.md.Names and staging.
{dates}is known only once every header row has been read, so when the builder builds the header it writes the channel tomes totome/<ds_type>/.building/<header>/<channel>. At the end it moves each one to its name, which is a rename on the same disk. Before it moves any page file, it marks the destination partial. It writes the tome's header copy before marking the manifest complete.Resuming. A complete tome is kept, so a rerun whose tomes are all complete reads no match. An interrupted build leaves no partial channel tome, only the staging folder and a partial header, and the next call starts over. By default a partial tome, such as one an interrupted
make_tomeleft, is built again. There is nocontinue, because the build is one walk: continuing one tome would mean reading every match again anyway.How the dtypes match
make_tomemake_tomereads each match with pandas, joins a page withpd.concat, and writes it with pandas. The builder instead joins each page's Arrow tables withpa.concat_tables(promote_options="permissive"):The page's pandas metadata names the dtype
pd.concatwould give the same matches. The builder gets it by runningpd.concaton two-row stand-ins, one per distinct file schema and null pattern, which carry each file's type and pandas metadata. That is howInt64wins over theint64andobjectsome csds versions declare for the id columns: the per-file pandas metadata is merged, not taken from the first file. The builder then checks that the joined page reads back with exactly those dtypes. If it doesn't, or pyarrow can't join the tables (differing categories, for example), that page is built withpd.concatasmake_tomewould build it, and the builder logs "Page built with pandas". No real-data page needed that.The stored pandas index (
__index_level_0__, or a named index) is dropped, asmake_tome'signore_indexconcat drops it. Tables are merged every 256 matches, so a page of many small matches doesn't hold tens of thousands of tables. Page cuts count Arrow bytes (Table.nbytes), which count strings by their bytes wheremake_tomecounts pandas' deep memory, so a page holds more rows for the samemax_page_size_mb.Measured
Setup. Every run was cold, on 200 matches never read before by this work or by the P1 benchmark. The matches were read from the USB drive and the tomes written to the NVMe. Each run built the 21 standard channels with 256 MB pages, in its own process, with no other busy python. The baseline is P1's one-pass pyarrow prototype, timed in the same session. It reads the manifest and the 21 channels but not the header channel.
Seconds per match:
read_threads=0)make_tome's per-channel loop took 20.4 s on the same files.{dates}names were known before any channel page was written, and then read the channels. Visiting every match folder twice cost 20 to 30% on the USB drive. So the builder now visits each match once, and stages the channel tomes until the dates are known.Equivalence on real data
These checks read the CSDS collection on
D:read-only and wrote output toC:\polars_bench\p4. The builder's tomes were compared with tomes thatmake_tomebuilt on dsdk master1ab01d1(zstd), followingmake_standard_tomes.py: a header, ahas_<channel>subheader per channel, andmake_tomewithmatch_key. Both were loaded throughTomeLoader. The sample was 200 random matches of the July header (seed 20260928), across the 21 standard channels.player_connect,player_fallandplayer_interactionwere left out because no manifest has them.fc24eb5."Equal" covers:
match_keysequence;assert_frame_equal);make_tome's copy of thehas_<channel>subheader.Every page was built by pyarrow; none needed the pandas fallback. The tomes had 0 to 107,472 rows.
bot_takeover,item_removeandplayer_nameare empty in every sampled match.De-duplication. The July header lists 10 matches twice. A 30-match set in the header's own order, listing 3 of those matches twice (33 entries), gave a logged "Duplicate match keys dropped" with count 3. All 21 tomes equal
make_tomeover the 30 matches once each.Tests
builder_test.pyhas 39 tests, andwriter_fs_test.pygains 2. With #39 merged in, the suite has 225 tests. They pass on the lock's pandas 3.0.6 and on pandas 2.3.3, as in thetest-pandas-2job. Without polars, 186 pass and #39's 39 polars tests skip. pylint gives 10.00, and black is clean.Equal to
make_tomeon the in-repo fixtures: 28 channels (all but tick, player_status and player_vector), inline and with 4 threads, with small pages. Compared: rows, keyset order,match_key, values, dtypes, the header againstcreate_header_tome, and the header copies.Schema drift on a synthetic collection written by pandas:
Int64,int64orobject;__index_level_0__;Int64column;Each case runs with one page and with a page per match, and with tables merged every 256, 2 or 1 matches.
The pandas fallback: differing categories match
make_tome, merged or not, and are logged.Duplicate keys in
keys=and in a kept header.The page split: the page keysets equal a simulation of the Arrow-byte rule.
Threads 0 against 4 give identical pages.
Resuming:
overwriterebuilds;failraises;make_tometome is rebuilt, or left withpass;Arguments: bad arguments fail before anything is read,
{dates}needs amatch_date, and a channel's tome may not take the header's name.Switching
make_standard_tomes.pyThat script isn't changed here. Its header block,
scan_channel_presenceand the per-channelmake_tomeloop come down to:has_<channel>subheaders are no longer written. Each basic tome's.headeris that subheader, and nothing else in the repo reads them.built.tomes.The CHANGELOG entry is under a new
## Unreleasedsection, because 3.3.0 is tagged. This PR has no version bump, tag or release.masterwith dsdk#39 is merged in, and the suite passes on the lock's pandas, on pandas 2.3.3, and without polars.🤖 Generated with Claude Code