TimeFReader¶
TimeFReader opens a committed TimeF version and reconstructs a
TimeFDataset. The reader never imports or runs connector code.
There are two everyday ways to get a reader.
TimeNet.open() opens a dataset by its ID, and
TimeFReader.open() opens a version directory:
from timenet.client import TimeNet
from timenet.reader import TimeFReader
# By dataset ID, through the client.
with TimeNet().open("timenet/hello-world") as reader:
dataset = reader.read()
values = dataset.records[0].signals[0].to_arrow()
# By version directory.
with TimeFReader.open(version_dir) as reader:
dataset = reader.read()
TimeNet.load() opens a reader, reads the dataset with every value loaded, and closes the reader.
Opening a version describes the low-level way in.
Use the reader as a context manager when possible. close() releases open value files and decoded
chunk caches. For a remote release, the reader fetches and verifies control.duckdb once, and after
close() its next read reopens the verified local copy. If that copy has been removed in the
meantime, the reader fetches and verifies the catalog again.
A reader is not safe to share between threads. Open one reader per thread.
Catalog connections close before the process forks, because DuckDB cannot share a database across a
fork. Each one opens again on its next query, in the parent and in a forked data-loader worker
alike, so a task stream that was in progress when the process forked continues. Forking while other
threads use readers is not supported: a read that the fork's closing cuts short in another thread
raises TimeFReaderError. Use the spawn or forkserver start method there.
What loads when¶
A child dataset can read some of its values from the files of another release, for example a
parent. The client finds these releases before it creates the reader, as
Opening a version describes. Creating the reader does not open the control
database. The first request for records, tasks, or annotations opens control.duckdb in read-only
mode.
Records load with their Sources, Signals, axes, and annotations. Signal values stay lazy unless the
read's load plan asks for them, as Loading values at read time
describes. A call to to_arrow(), to_numpy(), or read_steps() on a lazy Signal reads only the
required chunks from Parquet or Zarr.
Task prompts, rationales, and text targets use TextHandle, which holds one string. Annotation
values use TextHandle for a string and TextListHandle for a list of strings; isinstance tells
the two apart. Call read() on a handle to obtain its native string or list. Optional prompts and
rationales remain None when absent; numeric and boolean values remain native Python scalars. Use
read_value(value) from timenet.types when a value may be a text handle or a native scalar.
read_values(values) does the same for each item, such as a task's targets, and returns a tuple, or
None for None.
Text read from a release uses the same handle types. Unless the read loads text, packed text
stays in its payload file until read() or a comparison of content needs it, and its handle keeps
nothing: is_loaded is false, and each read goes to the payload again. Schema derivation and
modality inference do not read packed text. The Arrow read_task_table(), read_target_table(),
and read_annotation_table() APIs return native values. The reader keeps up to 64 MiB of decoded
payload files, the newest one even when it is larger, and rejects a locator whose payload file the
manifest does not list. A release whose manifest lists no payload files is read without any locator
lookups.
Empty text handles are false in truth tests. An empty prompt remains a handle whose read()
returns "". An absent prompt remains None. Truth tests use the handle's length without reading
its value.
Text handles compare by content, with handles of the same kind and with native values of their
type: a TextHandle with strings, a TextListHandle with lists. A task therefore equals its
read-back copy regardless of where its text is stored. A TextHandle never equals a
TextListHandle or a list. A handle equals itself, and handles of different lengths compare
unequal, without a read. Two handles for the same row of one payload file are also equal without
a read, even from different readers, since the manifest's checksum identifies the file. Comparing
other handles of equal length reads both values. Text handles are not hashable.
Irregular time offsets are packed as well, one array per axis. The first time_offsets_us() call
on a Signal reads its axis's array and checks it against the axis and against the digest its
locator records. Within one read, the Signals on one axis share the checked array, so later calls
on any of them read nothing.
iter_records() yields Records in bounded batches. It does not create a complete TimeFDataset.
Without record_ids=, it yields every Record in the order of record_ids(). With record_ids=,
it yields the Records with these int IDs, in the order that you give. It makes sure that every
requested ID exists before it yields the first Record.
Tasks have four useful read paths:
read_tasks(inputs=records)builds the tasks that use one of the given Records as input, or every task, in one pass. Itssplit,required_modalities, andsupported_modalitiesarguments filter in DuckDB before any task is built, as they do onread_task_table()andread_target_table(). Every argument is keyword-only.iter_tasks(inputs=records)takes the same arguments and yields the same tasks in bounded batches.get(Task, id)andget_many(Task, ids)read tasks by ID, as Reading objects by ID describes.read_task_table(),read_target_table(), andread_annotation_table()return Arrow tables without creating Python task objects. Their ID columns areint64. The Record, Signal, and Task columns hold the ids that the dataset assigned, for exampletask_id,input_record_ids,from_task_ids, andspan_signal_ids.occurrence_idandcontent_idare storage row numbers of this version. The next write numbers them again. Inread_annotation_table(),object_idis a Record, Signal, or Task id, a storage row number for a Source, orNULLfor the dataset.
One call returns one consistent set of objects. A Record that two tasks use is one object, and the
Signals on one irregular axis share one handle to its time offsets. read_tasks(inputs=records)
and iter_tasks(inputs=records) reuse the Record objects you pass: a task input is the same
Record instance that iter_records() returned. Separate calls build separate objects, because
the reader keeps no object caches; keep a result if you need it again.
iter_tasks() builds each batch on its own. The tasks of one batch share the objects the batch
builds, as one call does, and batches share nothing but the Records you pass. A Record that tasks
of two batches use arrives as two separate objects with the same ID. So does a parent task that
one batch builds for a task's from_tasks and a later batch yields. In return, the stream keeps
none of a batch's objects once it has yielded the batch: it holds one batch of objects and that
batch's task IDs at a time, and selects each next batch after the last task ID it yielded. The
reader itself keeps compact chunk locators for the Signals it has resolved until it closes. By
default, a batch holds up to 5,000 tasks (TASK_BATCH_SIZE). Pass iter_tasks(batch_size=...) for
larger or smaller batches. With an eager plan the stream holds every value one batch loaded, so a
smaller batch bounds the memory its arrays take.
iter_records() builds each batch on its own in the same way. The Records of one batch share the
objects that the batch builds. So the Signals on one irregular axis share one handle to its time
offsets. Batches share nothing. The stream holds one batch at a time. A Record ID that you request
in two batches arrives as two separate objects.
By default, a batch of iter_records() holds up to 1,000 Records (RECORD_BATCH_SIZE). Pass
iter_records(batch_size=...) for larger or smaller batches.
Reading objects by ID¶
get(kind, id) reads one object by its ID, and get_many(kind, ids) reads several of one kind in
one batch. The kind is Record, Signal, or a task class, and the result has that type:
get(AnswerTask, 7) returns an AnswerTask. A read builds the object and what it references,
nothing else: a task brings the Records it uses and the tasks it derives from, and a Signal comes
inside its Record. Sources and annotations are not read by ID. Reach them through the object that
holds them, such as record.sources or signal.annotations.
An ID is an opaque handle, a positive int64 that the dataset numbers per kind. It stays the same
across edits of one dataset, so an ID you read from record.id or record_ids() finds the same
object in a later version of that dataset. It means something only together with that dataset's
reference: another dataset can use the same number for another object, which is why an object
imported from a parent is looked up by the parent's ID together with the parent's alias. An ID is
not a lookup key for content. To find an object by what it is, such as the number its original
source gave a recording, read the objects with iter_records() or read_tasks() and check their
metadata.
from timenet.client import TimeNet
from timenet.dataset import Signal
from timenet.types import AnswerTask, read_value
with TimeNet().open("timenet/hello-world") as reader:
task = reader.get(AnswerTask, task_id)
# Reads this one text and keeps nothing; None without a prompt.
prompt = read_value(task.prompt)
# Reads only the chunks that cover steps 0 to 500.
window = reader.get(Signal, signal_id).read_steps(0, 500)
get_many returns one object per ID, in the order of the IDs, and a repeated ID returns the same
object. Its objects form one graph: a Record that several of them use is one object, and a Record
whose Signals you ask for is built once. IDs are ints: a string, even one that spells a stored ID,
is rejected, and so is a single string passed as the collection. Integral NumPy or Arrow values
work as plain ints.
A child dataset gives the objects it imports from a parent IDs of its own. To read such a copy by
the ID the parent gives it, name the parent with parent=: its alias as a ParentAlias, or its
exact release as a DatasetRef when only one alias imports that release. A plain string could be
either, so parent= rejects it:
from timenet.types.reference_keys import ParentAlias
from timenet.types.references import DatasetRef
lead = reader.get(Signal, parent_signal_id, parent=ParentAlias("ptbxl"))
same = reader.get(
Signal,
parent_signal_id,
parent=DatasetRef.parse("physionet/ptb-xl@1.0.0"),
)
The reader translates the parent's ID to the local copy through the catalog, then reads the values from whichever release stores them.
An ID that names nothing, an unknown alias or parent, and a stored object of another kind, such as
get(AnswerTask, ...) on a classification task, raise TimeFNotFoundError, which subclasses both
TimeFValidationError and LookupError. An edit such as remove_records can remove the copy of a
parent object. The parent ID of that object then names nothing, because the edit drops that import.
get_many raises it, naming every missing ID, before it returns anything. Source, Annotation, and any other kind not listed above, an ID
that is not a positive int64, a single string passed to get_many, and a parent that is neither a
ParentAlias nor a DatasetRef raise TimeFValidationError.
Loading values at read time¶
read(), iter_records(), read_tasks(), iter_tasks(), get(), and get_many() take a
load= plan that says which kinds of stored values the read loads into the objects it builds. A
LoadPlan has one field per kind of value: signal_values, time_offsets for irregular axes, and
text for packed prompts, rationales, text targets, and annotation values. Each field is
Loading.LAZY, the default, or Loading.EAGER.
from timenet.reader import Loading, LoadPlan, TimeFReader
with TimeFReader.open(version_dir) as reader:
# Every value loads; the dataset needs no storage once the reader closes.
dataset = reader.read(load=LoadPlan.ALL_EAGER)
# Prompts and targets load; signal arrays stay lazy.
tasks = reader.read_tasks(load=LoadPlan(text=Loading.EAGER))
The read loads the values it needs in one batch before it returns. Arrays find their chunk
locations a batch of Signals at a time and load grouped by source release and file. The time
offsets of an axis load once. A loaded handle has is_loaded set and keeps its value in a compact
copy, so the decoded files and chunks it came from are not kept alive with it.
A plan applies only to the objects the read builds and to what they reference directly. A task's
plan reaches its text and annotations, the records it uses as inputs, candidates, or targets, the
Signals it targets, and the annotations it references. It does not reach tasks the read builds only
as another task's from_tasks, nor the Records you pass to read_tasks(inputs=records) or
iter_tasks(inputs=records), which stay as they are. For get() and get_many(), the plan
reaches the objects they return: a Record with its Signals, time offsets, and annotations, a Signal
with its own, and a task as above. A read never changes objects you already hold. To load more of
them, read again with a larger plan; the new read returns new objects. A failed read returns
nothing.
iter_tasks() loads each batch before it yields the batch, as far as the plan reaches from the
batch's tasks. What a batch reaches only through its tasks' from_tasks parents, which is the
parents, their records, and their annotations, it builds but does not load, unless a task of the
batch uses it too. A later batch that yields such a parent, or uses such a record or annotation,
builds its own copy and loads it.
iter_records() loads each batch before it yields the batch. For the Records of the batch, it
loads the Signal arrays, time offsets, and annotation values that the plan names. A batch never
loads the values of another batch. With LoadPlan.ALL_EAGER, the Records of a batch need no
storage after the reader closes. The stream holds the values of one batch at a time.
Values on lazy handles stay readable. to_numpy(), read_steps(), and a text handle's read()
read them through the reader, which reopens its files on demand after close().
Writing a read dataset reuses its stored payloads, whether they were loaded or not: array chunks, time offsets, and packed text keep their files in the release that stores them. The new release therefore depends on its source even when every value was loaded, and its readers must be able to find the source release.
Type reconstruction¶
The manifest describes the dataset schema and its files. The DuckDB control database stores the object hierarchy and relationships. The reader combines both sources to rebuild standard TimeNet types.
Reading an annotation text handle returns its stored string or string list. Scalar annotation
values retain their native types. Spans return as TimePoint, TimeInterval, StepPoint, or
StepInterval. The task type selects a built-in task class, and from_tasks
contains resolved Task objects.
Value reads¶
Each Signal has ordered chunk locations in control.duckdb. For Parquet, a location identifies a
shard, row group, and row. For Zarr, it identifies an array path and a range. Range reads select only
the chunks that intersect the requested range.
Scalar values use Arrow arrays. Multidimensional values use pa.FixedShapeTensorArray.
to_numpy() preserves the declared dtype and trailing shape. The reader caches open Parquet files
and decoded Zarr chunks until it closes.
API¶
| Member | Description |
|---|---|
read(load=...) |
Create the complete TimeFDataset, loading the values the plan names. |
iter_records(record_ids=..., load=..., batch_size=...) |
Yield Records in bounded batches. Load the values that the plan names. |
read_tasks(inputs=..., split=..., load=...) |
Build the matching tasks, or every task, in one pass. |
iter_tasks(inputs=..., split=..., load=..., batch_size=...) |
Yield the matching tasks in bounded batches. |
get(kind, id, parent=..., load=...) |
Read one Record, Signal, or task by ID. |
get_many(kind, ids, parent=..., load=...) |
Read objects of one kind by ID, in order, in one batch. |
read_task_table() |
Return one row per task. |
read_target_table() |
Return the ordered task targets. |
read_annotation_table() |
Return one row per annotation occurrence. |
verify() |
Hash every file the release reads, its own and its pinned payload source files. |
metadata |
Return the dataset metadata. |
schema |
Return the dataset schema. |
values_backend |
Return the selected values backend. |
Opening a version¶
To read a version without the client, pass a DatasetVersion to
TimeFReader(version). open_local_version() and the open_version() method of a registry
return one. A DatasetVersion holds the parsed manifest and the local or remote file systems of
the version and its payload sources. A reader that you open this way skips three parts of the
client: the download cache, the access check, and auto_build. The two examples open a version
from a registry and from a directory:
from timenet.reader import TimeFReader
from timenet.registry import open_local_version, open_registry
registry = open_registry("~/timenet/registry")
with TimeFReader(registry.open_version("timenet/hello-world")) as reader:
dataset = reader.read()
with TimeFReader(open_local_version(version_dir)) as reader:
dataset = reader.read()
open_local_version() and the open_version() method of a registry open a version at once, but
they read only metadata. Opening makes sure that:
manifest.jsonis valid and has a supported format version.- Each file that the manifest lists,
control.duckdbincluded, is present with its recorded size. - Each payload source is present, and its manifest still records each pinned file as the version pins it. Another release, for example a parent, stores some values of a child. That release is a payload source of the child.
- Each pinned file of a local payload source is present with its recorded size.
Opening hashes nothing and reads no values. A remote version's file sizes come from its manifest, and its reads and downloads check them.
A version lists every payload source it reads from, so opening resolves only those. A source's own sources are not opened: a version that reads only files its source stores opens without the releases that source reuses.
| Problem | Error | The message names |
|---|---|---|
| A payload source is missing | TimeNetDatasetNotFoundError |
The source and the root searched |
| A payload source differs from its pins | TimeFFormatError |
The source and the first differing file |
| A file is missing or has another size | TimeNetFileError |
The file, its expected size, and its actual size |
An invalid or unsupported manifest raises a validation error, and a missing one raises
FileNotFoundError.
Where payload sources are found¶
Payload sources resolve relative to a registry root. By default, open_local_version() takes the
root that holds the version as <root>/<org>/<name>/<version>, so a version and its sources keep
working when they move to another root together. Pass sources_root= to find them under another
root. TimeFReader.open() takes sources_root= too. A registry's open_version() finds them in
that registry.
A version without payload sources opens from any directory. A version with payload sources that is
not stored as <root>/<org>/<name>/<version> needs sources_root=, and opening it without one
raises TimeFValidationError.
Errors and integrity¶
Values remain lazy. A values file that changes after opening fails when a Signal reads the affected
chunk. Opening does not look for remote files. A missing remote control.duckdb fails on the first
read of Records or tasks. A missing remote values file fails when a Signal reads its chunk. Errors
include the relevant file or table context.
A catalog query raises TimeFFormatError when the catalog lacks a table, column, or type that the
read expects, and TimeFReaderError when the reader's connection closed while the read ran, through
close() or a fork in another thread. Other DuckDB errors, such as I/O failures, running out of
memory, or an interrupt, propagate unchanged.
A request for an object, ID, or parent alias that the dataset does not have raises
TimeFNotFoundError, a TimeFValidationError. A catalog that disagrees with its manifest raises
TimeFFormatError. For example, the catalog names no parent release for a parent alias of the
manifest, or it names another release.
Call verify() to hash every file before reading data: the version's own files, and the files it
pins on each payload source. If a file is missing or does not match its manifest record or pin,
verify() raises TimeNetFileError, a TimeFFormatError.
Inherited storage¶
A child dataset holds a complete catalog of its own. Its chunk and external-value locators name the
payload source that stores each file. A read never opens the catalog of a parent and never follows
a chain of parents. TimeNet.open(), TimeNet.open_torch(), and TimeFReader.open() resolve the
payload sources before the first read.
The writer stores a read order for the Records, from the locations of their values. read() and
iter_records() without IDs follow this order. Imported Records get IDs in the order of the
imports, so the order of their IDs can differ from the read order. Requested Record IDs keep the
order that the caller gives.
Round-trip guarantee¶
For valid writer input, the reader restores Records, Sources, Signals, annotations, and tasks. It
also restores task relationships and Signal values with their original dtype and shape.
Relationships return as object references: task inputs are Records, spans hold Signals, and
from_tasks holds Tasks. Records, Signals, and Tasks keep the id that the dataset assigned.
Sources, axes, and annotations have no id. The reader does not keep the Python object identity
from the writer process.
See the API reference for timenet.reader for all members.