Metadata¶
ecgbench.metadata — the catalogue and the configs merged into one typed record
per dataset, DatasetMeta, with every sourced value kept as a Fact carrying
its provenance. Any alias resolves: ptb-xl, ptbxl and PTB-XL are the same
record, and mit-bih-arrhythmia-database is mitdb.
The merged model is compiled by build from docs/_datasets/*.md and
ecgbench/data/configs/*.yaml, exported to ecgbench/data/metadata.json
(committed, and pinned to a fresh build by a test), and read back by
MetadataStore. Importing the package costs nothing beyond the standard
library; the JSON is parsed on first use.
from ecgbench import metadata
meta = metadata.get("mit-bih-arrhythmia-database") # same as get("mitdb")
meta.signal.leads, meta.access.license_text, meta.implementation_state
for m in metadata.search("holter", leads=2, access="open"):
print(m.dataset_id, m.records_display)
metadata.related("ptbxl") # leakage edges, both directions
The same views are available on the command line as ecgbench list,
ecgbench info and ecgbench related — see CLI.
Model¶
model
¶
Typed, source-agnostic description of one ECGBench dataset.
The catalogue front matter and the YAML configs describe the same datasets from
two angles, in two slug namespaces, with overlapping and occasionally
disagreeing values. This module defines the one record both are merged into:
DatasetMeta, composed of independent facets (signal, access, split,
relations) so a facet can be absent — a catalogue-only dataset has no signal
facet — without breaking the others.
Every value that came from a source file is also kept as a Fact carrying a
Provenance, including the duplicates. The top-level fields hold the winning
value under the precedence manifest > validation_report > config >
catalogue; facts keeps every candidate so a disagreement can be shown
rather than hidden.
Standard library only: this module is imported by ecgbench.metadata at
package import, so it must cost nothing.
Provenance
dataclass
¶
Where a fact came from.
Attributes:
| Name | Type | Description |
|---|---|---|
source |
str
|
One of |
source_path |
str
|
Repository-relative path of the file the value was read from. |
observed_at |
str | None
|
ISO-8601 timestamp for computed sources (manifests, reports);
|
Fact
dataclass
¶
Fact(key: str, value: object, provenance: Provenance)
One value for one key from one source.
value is restricted to what JSON can carry: str, int, float,
bool, None or a list of those.
SignalMeta
dataclass
¶
SignalMeta(format: str, leads: int, lead_names: tuple[str, ...] | None, alternate_lead_names: dict[int, tuple[str, ...]] | None, record_lead_layouts: tuple[tuple[str, ...], ...] | None, sampling_rates: tuple[int, ...], default_sampling_rate: int, duration_seconds: float, units: str, unit_scale: float, zero_padded_identifiers: bool)
What the waveform files hold — from the config only.
Attributes:
| Name | Type | Description |
|---|---|---|
format |
str
|
One of the |
leads |
int
|
Number of leads in the predominant layout. |
lead_names |
tuple[str, ...] | None
|
Lead names in file order for the predominant layout, or |
alternate_lead_names |
dict[int, tuple[str, ...]] | None
|
Layouts for records storing a different number of leads, keyed by that count. |
record_lead_layouts |
tuple[tuple[str, ...], ...] | None
|
Every layout used at the same lead count, for a
release whose records name their leads differently ( |
sampling_rates |
tuple[int, ...]
|
All rates the release ships. |
default_sampling_rate |
int
|
The rate |
duration_seconds |
float
|
Nominal record length. |
units |
str
|
Physical unit after |
unit_scale |
float
|
Multiplier from stored sample values to |
zero_padded_identifiers |
bool
|
Whether ids must be read as strings to survive. |
AccessMeta
dataclass
¶
AccessMeta(access: str, license_text: str | None, license_url: str | None, url: str, download_url: str | None, publish_fold_csvs: bool, no_publish_reason: str)
How the data can be obtained and what ECGBench may republish.
Attributes:
| Name | Type | Description |
|---|---|---|
access |
str
|
|
license_text |
str | None
|
Human-readable licence name, as the catalogue states it. |
license_url |
str | None
|
A licence URL when the config gives one, else |
url |
str
|
Landing page of the source release. |
download_url |
str | None
|
Direct archive URL when the config declares one. |
publish_fold_csvs |
bool
|
Whether fold CSVs go to the public Hub repo. Only
meaningful when a config exists; |
no_publish_reason |
str
|
The config's explanation, which includes the command to regenerate the split locally; empty when publishing is allowed. |
SplitMeta
dataclass
¶
SplitMeta(n_folds: int, predefined_column: str | None, has_patient_id: bool, record_id_column: str | None)
How ECGBench partitions the dataset — from the config only.
Attributes:
| Name | Type | Description |
|---|---|---|
n_folds |
int
|
Fold count |
predefined_column |
str | None
|
The source's own fold column when the release ships a
split ECGBench adopts ( |
has_patient_id |
bool
|
Whether folds are grouped by a patient column. |
record_id_column |
str | None
|
Column naming a record in the metadata CSV. |
FieldMeta
dataclass
¶
FieldMeta(name: str, type: str, description: str = '', unit: str | None = None, vocabulary: tuple[str, ...] | None = None, nullable: bool = True, example: str | None = None, source: str = 'labels')
One declared column of the dataset's label table.
The metadata-layer twin of ecgbench.labels._fields.Field (same shape),
read from the label module's FIELDS or the config's labels.fields.
Attributes:
| Name | Type | Description |
|---|---|---|
name |
str
|
Column name exactly as |
type |
str
|
Frictionless Table Schema type, or |
description |
str
|
What the value means, including sentinels and encodings. |
unit |
str | None
|
Physical unit for measurements, else |
vocabulary |
tuple[str, ...] | None
|
Closed set of values as strings, else |
nullable |
bool
|
Whether a record may lack a value. |
example |
str | None
|
One representative value, as text. |
source |
str
|
|
RelationMeta
dataclass
¶
RelationMeta(target: str, relation: str, shares_records: bool | None, verified: bool, note: str, derived: bool)
An edge to another dataset, mirrored from the catalogue's related block.
Attributes:
| Name | Type | Description |
|---|---|---|
target |
str
|
|
relation |
str
|
One of |
shares_records |
bool | None
|
|
verified |
bool
|
Whether the overlap was checked against the files. |
note |
str
|
Free text, mandatory when |
derived |
bool
|
|
DatasetMeta
dataclass
¶
DatasetMeta(dataset_id: str, aliases: tuple[str, ...], name: str, category: str, status: str, implementation_state: str, version: str | None, description: str, paper_title: str | None, paper_doi: str | None, citation: str, origin_institution: str, origin_country: str | None, search_keywords: str, records: int | None, patients: int | None, records_display: str, patients_display: str, signal: SignalMeta | None, access: AccessMeta, split: SplitMeta | None, relations: tuple[RelationMeta, ...] = (), fields: tuple[FieldMeta, ...] = (), facts: tuple[Fact, ...] = (), prose: str = '')
The unified record for one dataset.
Attributes:
| Name | Type | Description |
|---|---|---|
dataset_id |
str
|
Config slug where a config exists, else the catalogue slug. |
aliases |
tuple[str, ...]
|
Every name the dataset answers to — both slugs and both display names, first the catalogue slug. |
name |
str
|
Display name from the catalogue (the curated, website-facing one; a differing config name is kept as an alias and a fact). |
category |
str
|
Catalogue category ( |
status |
str
|
Catalogue |
implementation_state |
str
|
Derived: |
version |
str | None
|
Release version from the config, |
description |
str
|
Config description, falling back to the catalogue page's first overview section. |
paper_title |
str | None
|
Catalogue citation short form. |
paper_doi |
str | None
|
DOI URL, from the catalogue or derived from the config's DOI. |
citation |
str
|
Full citation from the config, empty without one. |
origin_institution |
str
|
Catalogue field. |
origin_country |
str | None
|
Catalogue field. |
search_keywords |
str
|
Catalogue keyword string. |
records |
int | None
|
Record count parsed from the winning |
patients |
int | None
|
Same for patients. |
records_display |
str
|
The catalogue's own string, kept verbatim. |
patients_display |
str
|
Same for patients. |
signal |
SignalMeta | None
|
Signal facet, |
access |
AccessMeta
|
Access facet, always present. |
split |
SplitMeta | None
|
Split facet, |
relations |
tuple[RelationMeta, ...]
|
Edges to other datasets, both directions materialised. |
fields |
tuple[FieldMeta, ...]
|
Declared columns of the label table, empty until declared. |
facts |
tuple[Fact, ...]
|
Every sourced value with provenance, duplicates included. |
prose |
str
|
Concatenated page text and config prose, for free-text search only. |
facts_for
¶
facts_for(key: str) -> tuple[Fact, ...]
Every fact recorded under key, most trustworthy source first.
disagreements
¶
disagreements() -> dict[str, tuple[Fact, ...]]
Keys whose sources give more than one distinct value.
Source code in ecgbench/metadata/model.py
to_dict
¶
JSON-ready mapping; from_dict inverts it exactly.
Source code in ecgbench/metadata/model.py
from_dict
classmethod
¶
from_dict(data: dict) -> DatasetMeta
Rebuild a record from to_dict() output (or the bundled JSON).
Source code in ecgbench/metadata/model.py
Store¶
store
¶
Read-side access to the compiled metadata: lookup, ranked search, relations.
open_store() loads the export bundled in the wheel
(ecgbench/data/metadata.json) into a MetadataStore, attaches the SQLite
index next to it (metadata.sqlite, opened read-only) for ranked full-text
search, and caches the result so a process does this once.
Two things happen on open that a caller does not see unless they go wrong:
- Staleness. In a source checkout, if any catalogue Markdown, config YAML or
label module has changed since the last build (mtime + size fingerprint in
metadata.sources.json), both files are rebuilt intoecgbench/data/and one log line says so. An installed wheel has no fingerprint file and no writable sources, so this branch is skipped there. - FTS5 fallback. If the index is missing and cannot be written, or was built without FTS5, or the runtime SQLite lacks FTS5, free-text search falls back to the case-insensitive substring match over the same fields and warns once per store. Structured filters are unaffected.
Free-text queries are FTS5 syntax passed through verbatim — "atrial fib*",
holter NOT paediatric, "long term" — and a query SQLite rejects raises
MetadataQueryError quoting its message. Results are ranked by bm25()
with the name weighted highest, then keywords and aliases, description,
institution, page prose, and (from Phase 3) field names — plus a small
implementation prior: a catalogue-only entry is pushed 0.5 bm25 units down
and a label-less config 0.25, so that at near-equal relevance the dataset a user
can actually load comes first (ptb gives PTB-XL before PTB-XL+, whose
shorter page would otherwise win on length normalisation alone).
MetadataQueryError
¶
Bases: ValueError
SQLite rejected a full-text query; the message quotes its reason.
SearchHit
dataclass
¶
SearchHit(meta: DatasetMeta, score: float | None)
One ranked search result.
Attributes:
| Name | Type | Description |
|---|---|---|
meta |
DatasetMeta
|
The dataset. |
score |
float | None
|
|
MetadataStore
¶
MetadataStore(model: tuple[DatasetMeta, ...], source: str = '<memory>')
An in-memory model with an alias index and an optional FTS5 index.
Construct one directly from a tuple of DatasetMeta for tests (substring
search, no warning), or call open_store() for the bundled data with its
index attached.
Source code in ecgbench/metadata/store.py
fts_enabled
property
¶
Whether free-text queries are ranked by FTS5 rather than substring-matched.
fts_fallback_reason
property
¶
Why ranked search is unavailable, or None when it is available.
attach_index
¶
Open the SQLite index at path read-only for ranked search.
Refuses — and remembers why, for the one-time warning — when the file is
missing, was built for a different model (meta.content_digest differs
from expected_digest), lacks the FTS5 table, or the runtime SQLite
cannot read FTS5 tables.
Returns:
| Type | Description |
|---|---|
bool
|
|
Source code in ecgbench/metadata/store.py
all
¶
all() -> list[DatasetMeta]
resolve
¶
get
¶
get(key: str) -> DatasetMeta
The record for key (catalogue slug, config slug or display name).
Raises:
| Type | Description |
|---|---|
UnknownDatasetError
|
no dataset answers to |
Source code in ecgbench/metadata/store.py
related
¶
related(key: str) -> list[RelationMeta]
search_ranked
¶
search_ranked(query: str | None = None, *, limit: int | None = None, leads: int | None = None, fs: int | None = None, signal_format: str | None = None, access: str | None = None, license: str | None = None, category: str | None = None, state: str | None = None, min_records: int | None = None, max_records: int | None = None, has_labels: bool | None = None, has_patient_id: bool | None = None, published: bool | None = None) -> list[SearchHit]
Like search but returns SearchHit with the bm25() score.
With a query and an attached index the order is by relevance; otherwise
by dataset_id. Structured filters apply after ranking and keep it.
Source code in ecgbench/metadata/store.py
search
¶
search(query: str | None = None, *, limit: int | None = None, leads: int | None = None, fs: int | None = None, signal_format: str | None = None, access: str | None = None, license: str | None = None, category: str | None = None, state: str | None = None, min_records: int | None = None, max_records: int | None = None, has_labels: bool | None = None, has_patient_id: bool | None = None, published: bool | None = None) -> list[DatasetMeta]
Filter datasets; every given criterion must hold (AND).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
query
|
str | None
|
FTS5 query when the index is attached ( |
None
|
limit
|
int | None
|
Keep at most this many results, after filtering. |
None
|
leads
|
int | None
|
Exact lead count. Uses the signal facet where a config exists,
else the catalogue's |
None
|
fs
|
int | None
|
A sampling rate the release ships (config datasets only). |
None
|
signal_format
|
str | None
|
|
None
|
access
|
str | None
|
|
None
|
license
|
str | None
|
Substring of the licence name or URL. |
None
|
category
|
str | None
|
Exact catalogue category. |
None
|
state
|
str | None
|
Exact |
None
|
min_records
|
int | None
|
Inclusive lower bound on the parsed record count; datasets whose count did not parse are excluded. |
None
|
max_records
|
int | None
|
Inclusive upper bound, same caveat. |
None
|
has_labels
|
bool | None
|
Whether a label loader or declarative columns exist. |
None
|
has_patient_id
|
bool | None
|
Whether folds are patient-grouped (config datasets only). |
None
|
published
|
bool | None
|
Whether fold CSVs may be fetched from the Hub. |
None
|
Raises:
| Type | Description |
|---|---|
ValueError
|
|
MetadataQueryError
|
the index rejected |
Source code in ecgbench/metadata/store.py
open_store
¶
open_store(path: Path | str | None = None) -> MetadataStore
Open the bundled metadata, or an export at path.
The bundled store is cached for the process. With path (a
metadata.json), the index is expected as metadata.sqlite beside it
and is built there when missing or stale, if the directory is writable.
Source code in ecgbench/metadata/store.py
Identity¶
identity
¶
Resolve any name a dataset goes by to its dataset_id.
A dataset has up to four names: the dashed catalogue slug (ptb-xl), the
underscored config slug (ptbxl), the catalogue display name (PTB-XL) and
the config's own name where it differs. DatasetMeta.aliases lists them; this
module turns that list into a case-insensitive lookup, with close-match hints on
a miss so a typo is answered with the intended id rather than a bare error.
UnknownDatasetError
¶
Bases: KeyError
Raised when a key matches no alias of any dataset.
close_matches holds up to three aliases that look like the key, so the
message — and a CLI exit — can name what was probably meant.
Source code in ecgbench/metadata/identity.py
AliasIndex
¶
AliasIndex(model: Iterable[DatasetMeta])
Case-insensitive alias → dataset_id table over a model.
Built once per model; resolve is a dict lookup. Two datasets claiming the
same alias (ignoring case) is a build error, reported with both ids.
Source code in ecgbench/metadata/identity.py
resolve
¶
Return the dataset_id for key or raise UnknownDatasetError.
Source code in ecgbench/metadata/identity.py
resolve
¶
resolve(key: str, model: Iterable[DatasetMeta]) -> str
One-shot AliasIndex(model).resolve(key).
Prefer holding an AliasIndex (or a MetadataStore, which owns one)
when resolving more than a single key.
Source code in ecgbench/metadata/identity.py
Build¶
build
¶
Compile the catalogue front matter and the YAML configs into DatasetMeta records.
The sources stay the truth — humans edit docs/_datasets/*.md and
ecgbench/data/configs/*.yaml — and this module derives from them. Nothing
here is hand-edited: ecgbench/data/metadata.json is regenerated by
write_json and the tests compare its digest against a fresh build so it
cannot drift silently.
Merging rules, in one place:
dataset_idis the config slug when the catalogue entry declaresconfig_slug, else the catalogue slug.- Every sourced value is kept as a
Fact; when two sources give the same key the top-level field takes the winner underSOURCE_PRECEDENCE(config beats catalogue). The exception isname, which is the catalogue's curated display name; a differing config name becomes an alias. recordsandpatientsare parsed from the catalogue display strings only when the whole string is a plain integer with optional thousands separators ("18,869"→ 18869)."~1,000","—","5,749 segments"and"25 (23 with signals)"giveNoneand keep their display string; each unparsed value is logged once.- Label availability is read from the presence of
ecgbench/labels/<slug>.pyor a declarativelabels:block naming a source CSV and join column, never by importing the loaders — the build must not pull in pandas. A test pins that file set to_custom_loaders().
Validation problems are collected and raised together as MetadataBuildError,
the same pattern catalogue._load uses for related blocks.
MetadataBuildError
¶
Bases: ValueError
The sources are inconsistent; every problem found is listed in the message.
BuildResult
dataclass
¶
What build_all produced.
Attributes:
| Name | Type | Description |
|---|---|---|
json_path |
Path
|
The JSON export. |
sqlite_path |
Path
|
The SQLite index. |
content_digest |
str
|
Digest of the model both files describe. |
json_written |
bool
|
|
fts |
str
|
|
ModelDiff
dataclass
¶
Dataset ids whose records differ between two exports.
parse_count
¶
Parse a catalogue count display string into an int, or None.
Only a bare integer, with or without thousands separators, is accepted. Anything qualified — an approximation, a unit word, a parenthetical, a range, a dash — is a statement the catalogue author chose to make, and flattening it to a number would lose the qualification.
Source code in ecgbench/metadata/build.py
build_model
¶
build_model() -> tuple[DatasetMeta, ...]
Merge every catalogue entry with its config into DatasetMeta records.
Returns:
| Type | Description |
|---|---|
tuple[DatasetMeta, ...]
|
One record per catalogue entry, sorted by |
Raises:
| Type | Description |
|---|---|
MetadataBuildError
|
a declared |
Source code in ecgbench/metadata/build.py
368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 | |
content_digest
¶
content_digest(model: tuple[DatasetMeta, ...]) -> str
sha256:<hex> over the canonical JSON of the records.
Two builds agree iff their sources describe the same datasets; the timestamp and schema version are deliberately outside the hash.
Source code in ecgbench/metadata/build.py
to_json
¶
to_json(model: tuple[DatasetMeta, ...], built_at: str | None = None) -> str
Serialise the model with its envelope (schema version, digest, timestamp).
Keys are sorted and non-ASCII is kept as-is, so the output is stable across
builds when the sources are; built_at defaults to now in UTC.
Source code in ecgbench/metadata/build.py
read_digest
¶
The content_digest recorded in an export, or None if unreadable.
Source code in ecgbench/metadata/build.py
write_json
¶
write_json(path: Path | str = DEFAULT_JSON_PATH, model: tuple[DatasetMeta, ...] | None = None) -> bool
Write the export to path, unless the file already has the same digest.
Leaving an unchanged file alone keeps the committed copy free of timestamp-only churn.
Returns:
| Type | Description |
|---|---|
bool
|
|
Source code in ecgbench/metadata/build.py
load_json
¶
load_json(path: Path | str = DEFAULT_JSON_PATH) -> tuple[DatasetMeta, ...]
Read an export back into DatasetMeta records.
Raises:
| Type | Description |
|---|---|
ValueError
|
the file's |
Source code in ecgbench/metadata/build.py
fts5_available
¶
Whether the runtime SQLite can create an FTS5 table.
Probed by doing it, in memory, rather than by reading PRAGMA
compile_options: a loadable-extension build lists nothing there.
Source code in ecgbench/metadata/build.py
write_sqlite
¶
write_sqlite(model: tuple[DatasetMeta, ...], path: Path | str = SQLITE_PATH, built_at: str | None = None) -> str
Write the SQLite index for model to path atomically.
The relational tables mirror the JSON export (dataset.document holds
each record whole); dataset_fts is the FTS5 index behind ranked search.
When the runtime SQLite lacks FTS5 everything but the virtual table is
written and meta.fts is "none", which the store reads as "use the
substring path".
The file is written to a sibling temp path and renamed into place, so a
reader holding the old file open with mode=ro never sees a partial one.
Returns:
| Type | Description |
|---|---|
str
|
The FTS state recorded in |
Source code in ecgbench/metadata/build.py
751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 | |
read_sqlite_meta
¶
The meta table of an index as a dict; empty if the file is unreadable.
Source code in ecgbench/metadata/build.py
is_source_checkout
¶
Whether the package runs from the repository rather than an installed wheel.
A wheel carries the catalogue at ecgbench/_datasets/ (hatch force-include);
a checkout has it at docs/_datasets/ next to pyproject.toml.
Source code in ecgbench/metadata/build.py
source_fingerprint
¶
{relative path: [mtime_ns, size]} for every file the build reads.
Catalogue Markdown, config YAML and the label modules whose presence sets
implementation_state. Cheap (one stat per file) and sufficient to
notice an edit; a rebuild it triggers is a no-op on the JSON when the
content digest has not changed.
Source code in ecgbench/metadata/build.py
read_sources
¶
The fingerprint recorded by the last build, or None when there is none.
Source code in ecgbench/metadata/build.py
write_sources
¶
Record the current source fingerprint next to the index.
Source code in ecgbench/metadata/build.py
sources_changed
¶
Whether the sources differ from the fingerprint at path (or none exists).
build_all
¶
build_all(output_dir: Path | str | None = None, model: tuple[DatasetMeta, ...] | None = None) -> BuildResult
Build the model and write every derived file: JSON, SQLite, fingerprint.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
output_dir
|
Path | str | None
|
Directory to write into; defaults to |
None
|
model
|
tuple[DatasetMeta, ...] | None
|
A pre-built model, to avoid building twice. |
None
|
Source code in ecgbench/metadata/build.py
diff_exports
¶
diff_exports(old: dict, new_model: tuple[DatasetMeta, ...]) -> ModelDiff
Compare a loaded export document against a freshly built model, by dataset.