Skip to content

Versioned result bundles

save_result_bundle writes an opt-in version-1 scientific result bundle to a new destination. load_result_bundle returns its ResultsBundle: a scored ResultsTable, ordered Observable definitions, provenance and schema version. load_results returns only the scored table from either this format or a consistent legacy export.

import numpy as np
from trade_study import (
    Direction, Observable, ResultsTable, load_result_bundle, save_result_bundle,
)

results = ResultsTable(
    configs=[{"design": "a", "replicate": 0}],
    scores=np.array([[0.25]]),
    observable_names=["error"],
    metadata=[{"stream_id": [0, 7, 2], "root_seed": 2026}],
)
save_result_bundle(
    results, "new-result-bundle",
    observables=[Observable("error", Direction.MINIMIZE)],
    provenance={
        "model_id": "synthetic-decay-v1",
        "scorer_id": "absolute-error-v1",
        "score_transforms": {"error": "identity"},
        "environment_id": "record-your-environment-revision",
    },
)
bundle = load_result_bundle("new-result-bundle")
assert bundle.schema_version == 1
assert bundle.observables[0].direction == Direction.MINIMIZE

Observable definitions must match score column names in order. Directions and finite nonnegative weights are preserved independently of raw score values. The table itself does not carry scorer/model identities, learned transforms, external data revisions or stream-allocation policies: supply those through the primitive JSON provenance mapping. Available per-row replicate/seed metadata stays in ResultsTable.metadata. The bundle records trade-study and NumPy versions in provenance["runtime"], with caller information separately in provenance["caller"].

Finite ResultsTable.aggregate_replicates() summaries can be saved directly: their generated score_std metadata uses primitive Python floats. Caller configs and provenance still need supported primitive JSON values. Non-finite metadata remains unsupported even though score arrays may contain NaNs.

Version-1 schema and validation

Required files are scores.npz and meta.json; annotations.npz is required exactly when the manifest declares an annotation array. Each NPZ contains only its named array (scores or annotations). The metadata has exactly:

Field Contract
schema_version Integer 1, with unknown versions rejected.
encoding primitive-json-v1.
configs One string-keyed mapping per score row.
observable_names Unique nonempty score column names, in order.
annotation_names Unique nonempty annotation column names, in order.
metadata Empty, or one string-keyed mapping per score row.
observables Ordered names, minimize/maximize directions and weights.
provenance Runtime version strings and caller-managed primitive JSON identities.
arrays Score and optional annotation file descriptions.

An array description records its fixed filename, SHA-256 digest, two-dimensional shape and NumPy dtype string. Readers hash the same file bytes they decode, load without pickled objects, and check the decoded shape/dtype against the description. Scores and annotations must be real numeric matrices matching the row counts and column names. Non-finite score values can represent existing failed/undefined scores and remain in the numeric arrays; provenance/config JSON must be finite. Empty tables with correctly shaped matrices are supported.

Hashes detect inconsistent/replaced array payloads. They are not signatures or independent verification of model identities, provenance or scientific claims.

Supported input values are Python None, strings, booleans, integers, finite floats, lists and dictionaries with string keys, nested in any combination. These values round-trip without type conversion. Tuples, Paths, NumPy scalar objects/arrays, enums, callables, non-finite JSON numbers and other opaque objects are rejected before destination creation. Convert them to an explicit portable representation under your own input schema when needed.

Preserving inputs and scientific interpretation does not reconstruct executable simulators/scorers or certify a full replay. Record source/environment versions, external inputs, model/scorer definitions, transforms and random-stream allocation alongside the bundle. This format is separate from the transactional study checkpoint identity and recovery mechanism.

Initial publication and legacy compatibility

The first writer validates inputs and snapshots arrays before reserving a new directory. It writes array files, then publishes meta.json with one atomic same-filesystem replacement. Before metadata is committed, a reader cannot load the destination as a complete bundle. An interrupted save may leave an incomplete directory, but a retry refuses to overwrite it. Existing directories, including empty or incomplete ones, raise FileExistsError and retain their contents. Callers must keep inputs stable during serialization and exclusively own the new destination. Atomic rename visibility is assumed; power-failure durability, concurrent mutation of bundle files and distributed/object-store publication are outside this first increment.

Existing save_results keeps its legacy multi-file export and default=str fallback for unsupported config/metadata objects. It can overwrite a directory and is not an atomic update. Legacy bundles remain readable when rows, names, array dimensions and metadata agree. They return schema version 0, empty observable definitions and empty provenance: missing interpretation is explicit. Malformed legacy arrays/metadata now raise ValueError rather than producing a mismatched result table. A missing version field in otherwise versioned metadata is rejected, without silently downgrading to a legacy interpretation.

Legacy save_results refuses to overwrite a committed versioned bundle, so a different export call cannot silently discard its interpretation and integrity metadata. Save legacy exports to their own destinations.

To migrate, load a consistent legacy table, explicitly recover any lossy input types, supply the scientific definitions/provenance and save to a new version-1 destination or publish a generation as described below.

Repeated publication with immutable generations

publish_result_bundle writes each update to a unique version-1 directory under generations/, then replaces one current.json pointer atomically. It returns the generation directory for an explicit snapshot read. A root can already contain a direct legacy or version-1 bundle; those original files are retained. load_result_bundle(root) and load_results(root) follow the current pointer when present. A published ResultsBundle.generation records the captured identifier; directly loaded generation directories and legacy files report generation=None.

from dataclasses import replace
from trade_study import publish_result_bundle, load_result_bundle

observables = [Observable("error", Direction.MINIMIZE)]
first_directory = publish_result_bundle(
    results, "study-publication", observables=observables,
    provenance={"model_id": "synthetic-v1", "revision": "first"},
)
# Later updates use the same root and a distinct generation directory.
updated_results = replace(results, scores=np.array([[0.125]]))
second_directory = publish_result_bundle(
    updated_results, "study-publication", observables=observables,
    provenance={"model_id": "synthetic-v1", "revision": "second"},
)
current = load_result_bundle("study-publication")
assert current.generation == second_directory.name
previous = load_result_bundle(first_directory)

The pointer has exactly schema_version=1, a 32-character lowercase hexadecimal generation identifier and manifest_sha256 for that generation's meta.json. Readers capture one pointer's bytes, check its selected metadata digest and use those same captured metadata bytes. The version-1 reader then checks each array payload and scientific/table metadata. It never falls back to old direct files when a pointer exists but is invalid or refers to missing/inconsistent data. Legacy save_results refuses a published namespace before replacing any files.

Before pointer replacement, failed array or metadata writes leave the old pointer untouched. A pointer-replacement failure also leaves the previous complete generation current. An interruption after a successful replacement may leave a new complete generation current even if the caller did not receive the return value; inspect the pointer to determine the committed generation. No partially written generation becomes current through this protocol.

Readers and writers can overlap on supported local filesystems: a reader sees the complete generation selected by its captured pointer, even when a later publication finishes during its read. Cooperating concurrent writers use unique directories; the last successful pointer replacement wins. This does not merge study results or choose which writer is scientifically preferable. Keep inputs stable while their snapshots are taken, and keep published generation files immutable. Existing invalid current.json files are rejected before publication changes that namespace.

Atomic replacement follows Python's os.replace contract on a filesystem providing that behavior. All staging and pointer files stay inside the publication root. There is no fsync durability guarantee across power loss, no object-store/distributed-filesystem protocol, and no protection against actors modifying generation files outside this API. Integrity checks detect encountered inconsistent payloads; they do not authenticate provenance.

Old, incomplete and no-longer-current generation directories are retained. The API performs no automatic garbage collection, so an overlapping reader or recorded snapshot reference cannot lose its selected data during publication. Any external retention policy must account for active readers and referenced snapshots. Software predating this pointer format reads only retained direct legacy files; update readers before using published roots. Checkpoint journals and executable scientific replay remain separate mechanisms.