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.
ecgbench.metadata.mcp_server exposes the same store to agents as five
Model Context Protocol tools (ecgbench mcp); the tool functions are plain
Python on ToolFunctions, the SDK is needed only to serve them.
ecgbench.metadata.records is the one module here that returns records
rather than dataset descriptions: load_records joins a dataset's fold table
with its labels, query_records runs DuckDB SQL over the result. It needs
pandas and is not imported with the package; reach it as
ecgbench.load_records / ecgbench.query_records or ecgbench records.
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
|
|
ArtefactMeta
dataclass
¶
ArtefactMeta(kind: str, version: str | None, sha256: str | None, n_records: int | None, ecgbench_version: str | None, created: str | None, name: str | None = None, file_sha256: str | None = None)
One artefact a split run produced, as recorded in the dataset's snapshot.
Attributes:
| Name | Type | Description |
|---|---|---|
kind |
str
|
|
version |
str | None
|
|
sha256 |
str | None
|
For |
n_records |
int | None
|
Records in that version; |
ecgbench_version |
str | None
|
The ECGBench that produced it. |
created |
str | None
|
ISO-8601 timestamp of the run (the report's |
name |
str | None
|
The file, relative to the output tree ( |
file_sha256 |
str | None
|
For |
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, ...] = (), artefacts: tuple[ArtefactMeta, ...] = (), 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 from the winning |
patients |
int | None
|
Same for patients (no snapshot source yet, so always catalogue). |
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. |
artefacts |
tuple[ArtefactMeta, ...]
|
What the canonical split run produced, from the dataset's snapshot; empty when no snapshot is committed. |
facts |
tuple[Fact, ...]
|
Every sourced value with provenance, duplicates included. |
prose |
str
|
Concatenated page text and config prose, for free-text search only. |
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
476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 | |
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
874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 | |
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, the label modules whose presence sets
implementation_state (and whose FIELDS are read) and the committed
snapshots. 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.
Source code in ecgbench/metadata/build.py
Artefact snapshots¶
snapshot
¶
Artefact snapshots: the recomputed numbers of a split run, without the record ids.
ecgbench splits leaves two summaries in output/<slug>/: manifest.json
(fold count, seed, a SHA-256 per input file, record counts and a fold_digest
over the whole record-to-fold mapping) and validation_report.json (record
counts before and after validation and the per-check failure counts, followed by
the list of excluded record ids). A snapshot is the id-free subset of both,
committed as ecgbench/data/snapshots/<slug>.json so the metadata build can
turn it into facts with manifest and validation_report provenance - the
sources that outrank the catalogue and the config in SOURCE_PRECEDENCE.
Two guarantees hold for every snapshot, and :func:check_id_free enforces the
first at write time and in the tests:
- No record identifiers. The validation report's
excluded_recordsblock is the one part of either file that names records, and it is never copied; a snapshot carries counts and digests only, so it can ship for a dataset whose fold CSVs may not (publish_fold_csvs: false). - One run, not a mixture. The manifest and the report must agree on the
record counts, or :func:
build_snapshotrefuses: a tree holding a report from one run and a manifest from another would produce a snapshot describing neither.
This module is standard-library only, because the metadata build runs inside the
packaging hook with pyyaml alone; ecgbench.manifest imports pandas and is
therefore never imported here.
SnapshotError
¶
Bases: ValueError
A snapshot could not be built, or does not hold up to its own guarantees.
build_snapshot
¶
Derive a snapshot from an output/<slug>/ tree.
validation_report.json is required; manifest.json is used when present
(runs before ECGBench 0.20 wrote none), and without it the split half -
fold_digest, n_folds, random_state, inputs - is None or
empty and sources says so. files holds the SHA-256 of each
<version>/folds.csv present, the bytes a Croissant FileObject can
name; the fold_digest is a hash of the partition, not of any file.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
output_dir
|
Path | str
|
The directory |
required |
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
The snapshot as a JSON-ready mapping, already checked to be id-free. |
Raises:
| Type | Description |
|---|---|
SnapshotError
|
the report is missing, the two files name different datasets or disagree on the record counts, or the result would hold a record identifier. |
Source code in ecgbench/metadata/snapshot.py
80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 | |
check_id_free
¶
Raise :class:SnapshotError if snapshot looks like it names records.
A record list can hide under a familiar key (excluded_records), as any
long list, or as a list of objects - the shape a per-record table takes in
JSON. All three are refused, at every depth.
Source code in ecgbench/metadata/snapshot.py
reference_manifest_path
¶
Path of the shipped reference manifest for dataset (which may not exist).
write_snapshot
¶
Build the snapshot for output_dir and write it as JSON.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
output_dir
|
Path | str
|
The |
required |
dest
|
Path | str | None
|
Target file; defaults to |
None
|
Returns:
| Type | Description |
|---|---|
Path
|
The path written. |
Raises:
| Type | Description |
|---|---|
SnapshotError
|
see :func: |
Source code in ecgbench/metadata/snapshot.py
load_snapshot
¶
Read one snapshot file, checking its version and its id-free guarantee.
Source code in ecgbench/metadata/snapshot.py
load_snapshots
¶
Every snapshot under directory, keyed by dataset slug; empty if none.
Source code in ecgbench/metadata/snapshot.py
Exports¶
export
¶
Exports of the metadata model: Croissant 1.1, schema.org JSON-LD and the website's JSON.
Three views over DatasetMeta, all built from the model alone and all
deterministic (no timestamps but the snapshot's own created), so a committed
copy can be checked against a fresh export the way metadata.json is:
- :func:
to_schema_org- oneschema.org/Datasetper dataset, the block a dataset page embeds as<script type="application/ld+json">so search engines index the catalogue as datasets. - :func:
to_croissant/ :func:to_croissant_collection- the same record as an MLCommons Croissant 1.1Dataset(aDataCatalogof them for the collection), withdistributionnaming the published fold CSVs on the Hub and their SHA-256 from the snapshot, aRecordSetper version over the fold table's guaranteed columns, PROV-OwasDerivedFromover the run's input files, and an ODRL prohibition on redistribution for a dataset whose fold CSVs are withheld.mlcroissantvalidates each dataset (:func:validate_croissant). - :func:
to_website-docs/_data/metadata.json, keyed by catalogue slug (whatpage.slugholds in the Jekyll layout), carrying the resolved counts, the declared field names for the search box, and the schema.org block.
Standard-library only, like the rest of the build: mlcroissant is imported
lazily by the validator alone.
catalogue_slug
¶
catalogue_slug(meta: DatasetMeta) -> str
page_url
¶
page_url(meta: DatasetMeta) -> str
to_schema_org
¶
to_schema_org(meta: DatasetMeta) -> dict[str, Any]
The schema.org/Dataset JSON-LD block for one dataset.
Only standard schema.org properties are used, so Google's Rich Results test accepts the block; the recomputed counts and the fold digests do not fit any of them and stay in the metadata export.
Source code in ecgbench/metadata/export.py
174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 | |
to_croissant
¶
to_croissant(meta: DatasetMeta, include_1_1: bool = True) -> dict[str, Any]
One dataset as a Croissant Dataset (JSON-LD, with the shared context).
distribution and recordSet are emitted only for a published dataset
with a snapshot; a withheld one gets conditionsOfAccess and, with
include_1_1, an ODRL policy prohibiting distribution instead. The
run's inputs become prov:wasDerivedFrom file objects.
Source code in ecgbench/metadata/export.py
to_croissant_collection
¶
to_croissant_collection(model: tuple[DatasetMeta, ...], include_1_1: bool = True) -> dict[str, Any]
Every configured dataset as one JSON-LD DataCatalog of Croissant datasets.
Catalogue-only entries have no config, no signals ECGBench reads and no
partition, so they are listed by page URL under hasPart rather than as
Croissant datasets.
Source code in ecgbench/metadata/export.py
validate_croissant
¶
Validate a Croissant document or collection with mlcroissant.
Returns the error messages, one per failing dataset, empty when valid. Warnings (recommended properties) are not errors.
Each member is loaded as an mlcroissant.Dataset rather than a bare
Metadata: only the Dataset constructor builds the structure graph
and raises ValidationError for a missing @type, a field whose
source names no FileObject, or a broken RecordSet -- Metadata
alone collects the recommended-property warnings and nothing else.
Raises:
| Type | Description |
|---|---|
ImportError
|
|
Source code in ecgbench/metadata/export.py
to_website
¶
to_website(model: tuple[DatasetMeta, ...]) -> dict[str, dict[str, Any]]
docs/_data/metadata.json: one compact entry per dataset, keyed by catalogue slug.
Source code in ecgbench/metadata/export.py
website_json
¶
website_json(model: tuple[DatasetMeta, ...]) -> str
The canonical text of the website copy (sorted keys, two-space indent).
write_website_json
¶
write_website_json(model: tuple[DatasetMeta, ...], path: Path | str = WEBSITE_JSON_PATH) -> bool
Write the website copy; returns True when the file changed.
Source code in ecgbench/metadata/export.py
website_json_current
¶
website_json_current(model: tuple[DatasetMeta, ...], path: Path | str = WEBSITE_JSON_PATH) -> bool
Whether the committed website copy matches a fresh export.
Source code in ecgbench/metadata/export.py
Records¶
records
¶
Record-level access: a dataset's fold table joined with its labels, queryable with SQL.
The metadata layer describes datasets; this module is the one place that
returns records. load_records reads the fold CSVs ecgbench splits
wrote (from the Hub or a local tree), joins load_labels() on the record id,
and returns one pandas DataFrame per request. query_records runs DuckDB SQL
over that frame with file-system access disabled, so a query can only ever see
the view it was given.
The fold-table readers here are also what ECGDataset uses: its Hub and
local paths were factored out so the two cannot disagree about the Hub layout,
the identifier dtypes or the fold-selection rules. Nothing in this module is
imported by ecgbench.metadata itself, because it needs pandas.
Hub layout, mirrored from output/<slug>/::
<slug>/<version>/folds.csv # every record + fold + default_split
<slug>/<version>/<split>/fold_<N>.csv # N is 1-indexed
Nothing per-record is bundled in the wheel or published for a dataset whose
publish_fold_csvs is false; those raise SplitsNotPublishedError before
any network call and are reproduced locally with ecgbench splits.
SplitsNotPublishedError
¶
Bases: RuntimeError
The dataset's splits are deliberately not on the Hub.
Raised instead of a bare 404 for credentialed or restricted sources, whose identifiers ECGBench will not republish. The message carries the command that regenerates the identical split locally.
RecordsUnavailableError
¶
Bases: RuntimeError
The dataset has no fold table at all: it is catalogue-only (no config).
RecordsQueryError
¶
Bases: ValueError
DuckDB rejected the SQL, or it tried to reach outside the records view.
not_published_error
¶
not_published_error(config: DatasetConfig) -> SplitsNotPublishedError
The error for a withheld dataset, quoting the config's regeneration recipe.
Source code in ecgbench/metadata/records.py
read_fold_csv
¶
read_fold_csv(path: str | Path, config: DatasetConfig) -> DataFrame
Read one fold CSV, keeping identifier columns as strings.
The one place every fold-CSV read goes through, so the Hub and local paths
cannot disagree about a record's id. Without the dtype, pandas turns a
zero-padded record id such as afdb's 00735 into 735 and a signal
read then looks for a record named "735".
Source code in ecgbench/metadata/records.py
filter_fold_table
¶
Filter a master folds.csv by split and/or fold.
With split=None the default_split filter is skipped, which is what
makes cross-split fold selection (custom cross-validation) possible.
Raises:
| Type | Description |
|---|---|
ValueError
|
a requested fold holds no record (in that split). |
Source code in ecgbench/metadata/records.py
read_split_folds
¶
read_split_folds(split_dir: Path, split: str, fold_numbers: list[int] | None, config: DatasetConfig) -> DataFrame
Concatenate fold_<N>.csv files from one split's directory.
Raises:
| Type | Description |
|---|---|
FileNotFoundError
|
a requested fold is not in this split, naming the
folds it does hold and the |
Source code in ecgbench/metadata/records.py
hub_fold_paths
¶
hub_fold_paths(config: DatasetConfig, version: str, split: str | None, fold_numbers: list[int] | None) -> list[str]
Repo-relative paths a request needs: the master table, or one file per fold.
split=None means "by fold, ignoring the default split", which only the
master folds.csv can answer; so does an unfiltered request.
Source code in ecgbench/metadata/records.py
fetch_hub_fold_table
¶
fetch_hub_fold_table(config: DatasetConfig, version: str = 'clean', split: str | None = None, fold_numbers: list[int] | None = None, repo_id: str = HF_REPO_ID) -> DataFrame
Download the fold CSVs a request needs from the Hub and return them as one table.
Raises:
| Type | Description |
|---|---|
SplitsNotPublishedError
|
the config withholds its fold CSVs (checked before any network call). |
ImportError
|
|
ValueError
|
a requested fold holds no record. |
Source code in ecgbench/metadata/records.py
load_local_fold_table
¶
load_local_fold_table(splits_dir: Path, config: DatasetConfig, version: str = 'clean', split: str | None = None, fold_numbers: list[int] | None = None) -> DataFrame
Read the fold CSVs from a local tree.
splits_dir is probed as <dir>/<version>/<split>/, <dir>/<split>/,
then <dir>/<version>/folds.csv and <dir>/folds.csv — so it may be
output/<slug>/ as ecgbench splits wrote it, or a dataset directory
the fold tree was copied into.
Raises:
| Type | Description |
|---|---|
FileNotFoundError
|
no fold CSVs under |
ValueError
|
a requested fold holds no record. |
Source code in ecgbench/metadata/records.py
load_fold_table
¶
load_fold_table(config: DatasetConfig, version: str = 'clean', split: str | None = None, fold_numbers: list[int] | None = None, source: str = 'hf', splits_dir: Path | str | None = None, repo_id: str = HF_REPO_ID) -> DataFrame
The fold table for a request, from the Hub (source="hf") or splits_dir.
Raises:
| Type | Description |
|---|---|
ValueError
|
unknown |
Source code in ecgbench/metadata/records.py
resolve_config
¶
resolve_config(dataset: str | DatasetConfig) -> DatasetConfig
The config behind any dataset id, alias or display name.
Raises:
| Type | Description |
|---|---|
UnknownDatasetError
|
no dataset by that name (the message names close matches). |
RecordsUnavailableError
|
the dataset is catalogue-only. |
Source code in ecgbench/metadata/records.py
join_labels
¶
join_labels(fold_table: DataFrame, labels: DataFrame, config: DatasetConfig) -> DataFrame
Left-join a label table (indexed by record id) onto a fold table.
Fold-table columns win: a label column with the same name (patient_id,
say) is dropped rather than suffixed, because the fold CSV already holds
it for every record in the partition. Row order is the fold table's.
Raises:
| Type | Description |
|---|---|
ValueError
|
no record matched a label row at all, which means the two tables key on different identifiers. |
Source code in ecgbench/metadata/records.py
load_records
¶
load_records(dataset: str | DatasetConfig, data_path: Path | str | None = None, version: str = 'clean', split: str | None = None, fold_numbers: list[int] | None = None, source: str = 'hf', splits_dir: Path | str | None = None, labels: bool = True) -> DataFrame
One row per record: the fold table joined with the dataset's labels.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
dataset
|
str | DatasetConfig
|
Config slug, catalogue slug, display name, or a |
required |
data_path
|
Path | str | None
|
Root of a local copy of the source dataset, where the labels
live. Resolved through |
None
|
version
|
str
|
|
'clean'
|
split
|
str | None
|
|
None
|
fold_numbers
|
list[int] | None
|
Restrict to these folds. With a named |
None
|
source
|
str
|
|
'hf'
|
splits_dir
|
Path | str | None
|
The fold tree for |
None
|
labels
|
bool
|
Join |
True
|
Returns:
| Type | Description |
|---|---|
DataFrame
|
The fold table's columns (record id, patient id, signal paths, |
DataFrame
|
|
DataFrame
|
followed by the label columns. |
Raises:
| Type | Description |
|---|---|
UnknownDatasetError
|
no such dataset. |
RecordsUnavailableError
|
catalogue-only dataset. |
SplitsNotPublishedError
|
|
LabelsUnavailableError
|
|
FileNotFoundError
|
no fold CSVs under |
ValueError
|
bad |
Source code in ecgbench/metadata/records.py
query_records
¶
Run DuckDB SQL over a DataFrame registered as the view records.
The connection is in-memory with external access disabled and the configuration locked, so the query can read the view and nothing else: no files, no URLs, no extension loads.
Raises:
| Type | Description |
|---|---|
ImportError
|
|
RecordsQueryError
|
the SQL failed, including any attempt to reach a file. |
Source code in ecgbench/metadata/records.py
hub_fold_url
¶
hub_fold_url(config: DatasetConfig, version: str = 'clean', repo_id: str = HF_REPO_ID) -> str
The hf:// URL DuckDB's httpfs extension reads a published master table from.
Source code in ecgbench/metadata/records.py
hub_view_sql
¶
hub_view_sql(config: DatasetConfig, url: str, view: str = SQL_VIEW) -> str
The CREATE VIEW statement over a remote fold CSV.
Identifier columns are declared VARCHAR for a config with
zero_padded_identifiers so afdb's 00735 survives, exactly as
:func:read_fold_csv keeps them; every other config declares nothing, and
DuckDB infers the rest.
Source code in ecgbench/metadata/records.py
query_hub
¶
query_hub(dataset: str | DatasetConfig, sql: str = f'SELECT * FROM {SQL_VIEW}', version: str = 'clean', repo_id: str = HF_REPO_ID) -> DataFrame
Run SQL directly over a published folds.csv on the Hub, without downloading it first.
DuckDB's httpfs extension streams the CSV (identifiers only — labels
never leave the source dataset, so none are joined here). The extension is
installed on first use, which needs network access.
Raises:
| Type | Description |
|---|---|
SplitsNotPublishedError
|
the dataset's fold CSVs are withheld. |
ImportError
|
|
RecordsQueryError
|
the SQL failed or the extension could not be loaded. |
Source code in ecgbench/metadata/records.py
write_parquet
¶
Write a DataFrame as Parquet through DuckDB, or pandas when DuckDB is absent.
Raises:
| Type | Description |
|---|---|
ImportError
|
neither |
Source code in ecgbench/metadata/records.py
MCP server¶
mcp_server
¶
An MCP server exposing the metadata layer to agents (ecgbench mcp).
Five tools, each a thin wrapper on MetadataStore returning JSON-ready
dicts: search_datasets (FTS5 query plus the structured filters of
ecgbench search), list_datasets, get_dataset (the full record),
list_fields (the declared label columns) and related_datasets (the
leakage edges). Nothing here touches records, signals or the network: the
server reads the bundled metadata.json / metadata.sqlite only, so it
can be given to any agent without a data path.
The mcp SDK (the mcp extra) is imported only in :func:build_mcp_server;
the tool functions themselves are plain Python so they can be called, and
tested, without it. SDK 1.x names the server class FastMCP, 2.x renamed it
MCPServer with the same tool decorator; both are supported.
Register it with Claude Code as::
claude mcp add ecgbench -- ecgbench mcp
or in any MCP client config as the command ecgbench with arguments
["mcp"] over stdio.
ToolFunctions
¶
ToolFunctions(store: MetadataStore | None = None)
The five tools over one store, as plain callables.
Kept on a class rather than as module-level closures so a test can build
them over an in-memory MetadataStore and so the SDK's decorator sees
functions with complete type hints and docstrings (which become the tool
schema and description).
Source code in ecgbench/metadata/mcp_server.py
search_datasets
¶
search_datasets(query: str | None = None, limit: int = DEFAULT_LIMIT, 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[dict[str, Any]]
Search the catalogue by free text and structured filters, best match first.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
query
|
str | None
|
FTS5 query over name, keywords, description and field names:
|
None
|
limit
|
int
|
Maximum number of hits. |
DEFAULT_LIMIT
|
leads
|
int | None
|
Exact lead count (2 for two-lead Holter sets, 12 for standard ECG). |
None
|
fs
|
int | None
|
A sampling rate the dataset ships, in Hz. |
None
|
signal_format
|
str | None
|
|
None
|
access
|
str | None
|
|
None
|
license
|
str | None
|
Substring of the licence text ( |
None
|
category
|
str | None
|
Catalogue category id ( |
None
|
state
|
str | None
|
|
None
|
min_records
|
int | None
|
Lower bound on the record count. |
None
|
max_records
|
int | None
|
Upper bound on the record count. |
None
|
has_labels
|
bool | None
|
Only datasets whose labels ECGBench can load. |
None
|
has_patient_id
|
bool | None
|
Only datasets with a patient identifier for grouped splits. |
None
|
published
|
bool | None
|
Only datasets whose fold CSVs are on the Hub. |
None
|
Source code in ecgbench/metadata/mcp_server.py
list_datasets
¶
Every dataset ECGBench knows, optionally filtered by implementation state or category.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
state
|
str | None
|
|
None
|
category
|
str | None
|
Catalogue category id. |
None
|
Source code in ecgbench/metadata/mcp_server.py
get_dataset
¶
The full record for one dataset: identity, signal, access, split, relations, declared label fields, artefact snapshots and every sourced fact with provenance.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
dataset
|
str
|
Dataset id, catalogue slug or display name, case-insensitive
( |
required |
Source code in ecgbench/metadata/mcp_server.py
list_fields
¶
The label columns load_labels() returns for a dataset, with type, unit,
vocabulary and description. Empty for a dataset whose labels are unavailable.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
dataset
|
str
|
Dataset id, catalogue slug or display name. |
required |
Source code in ecgbench/metadata/mcp_server.py
related_datasets
¶
Datasets related to this one, both directions, with shares_records:
true means the two hold the same recordings, so training on one and
evaluating on the other contaminates the test set.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
dataset
|
str
|
Dataset id, catalogue slug or display name. |
required |
Source code in ecgbench/metadata/mcp_server.py
summary
¶
summary(meta: DatasetMeta) -> dict[str, Any]
The compact row search_datasets and list_datasets return.
Source code in ecgbench/metadata/mcp_server.py
build_mcp_server
¶
build_mcp_server(store: MetadataStore | None = None)
An MCP server with the five tools registered over store (default: the bundled index).
Raises:
| Type | Description |
|---|---|
ImportError
|
the |
Source code in ecgbench/metadata/mcp_server.py
run_server
¶
Serve the tools until the client disconnects (ecgbench mcp).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
transport
|
str
|
|
'stdio'
|