Skip to content

Commit db0c338

Browse files
w4ffl35claude
andcommitted
Fix the plan's cross-tree links, which broke the docs build
build_docs.sh flattens plans/ into docs/ and strips the ../ prefix (scripts/build_docs.sh:59), so this plan's ../spikeforge_hub/CURATION.md and ../NOTICE.md resolved to paths that do not exist inside the generated tree and check_docs_links.py failed. The other plans avoid this only by accident: their cross-tree references carry a :line suffix, which stops the checker treating them as page links at all. Absolute repository URLs instead, which resolve identically in the repository, the wiki and the MkDocs site. This is the second time a document I added broke a docs gate I did not run before pushing -- the first needed a mkdocs.yml nav entry. Three gates guard the three renderings and passing two of them proves nothing about the third. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent e23701e commit db0c338

5 files changed

Lines changed: 144 additions & 6 deletions

File tree

‎plans/hub_expansion_plan.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ that demonstrates spiking networks on the workloads that justify them — event
99
vision first, then audio — without weakening a single honesty guarantee the
1010
project already makes.
1111

12-
**Read first:** [`spikeforge_hub/CURATION.md`](../spikeforge_hub/CURATION.md) is
12+
**Read first:** [`spikeforge_hub/CURATION.md`](https://github.com/Capsize-Games/spikeforge/blob/main/spikeforge_hub/CURATION.md) is
1313
the policy this plan operates under and does not amend. Where this plan and
1414
that document disagree, that document wins and this plan is wrong.
1515

@@ -30,7 +30,7 @@ the better answer. One DVS128 Gesture checkpoint changes what the hub
3030
demonstrates more than twenty more MNIST variants would.
3131

3232
Keep the reference-configuration philosophy exactly as
33-
[`CURATION.md`](../spikeforge_hub/CURATION.md) states it: stock
33+
[`CURATION.md`](https://github.com/Capsize-Games/spikeforge/blob/main/spikeforge_hub/CURATION.md) states it: stock
3434
hyperparameters, modest epochs, one seed, honest numbers, a reproduce command,
3535
and explicitly not a state-of-the-art claim.
3636

@@ -199,7 +199,7 @@ Then:
199199
[`scripts/build_hub_page.py`](../scripts/build_hub_page.py) (a Data column, or
200200
a line under the notes). The page is HTML-escaped already; keep it that way.
201201
- Backfill all six existing entries via `--publish`, not by hand.
202-
- Add the dataset attributions to [`NOTICE.md`](../NOTICE.md).
202+
- Add the dataset attributions to [`NOTICE.md`](https://github.com/Capsize-Games/spikeforge/blob/main/NOTICE.md).
203203

204204
### 2.4 Tests
205205

‎spikeforge/data/dataset_spec.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,9 @@ class DatasetSpec:
4747
kwargs: Dict[str, object] = field(default_factory=dict)
4848
tonic_class: Optional[str] = None
4949
splits: Dict[str, Dict[str, Any]] = field(default_factory=dict)
50+
#: Names a reader that decodes this dataset's samples itself instead of
51+
#: using tonic's ``__getitem__``. Tonic still downloads it and still
52+
#: owns the cache layout; only the per-sample decode is replaced. Set
53+
#: for the Heidelberg audio datasets, whose tonic decode destroys every
54+
#: timestamp -- see :mod:`spikeforge.events.hsd_reader`.
55+
native_reader: Optional[str] = None

‎spikeforge/data/datasets.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from spikeforge.config import DATA_DIR
1515
from spikeforge.data.dataset_spec import DatasetSpec
1616
from spikeforge.data.event_errors import EventSplitMissingError
17-
from spikeforge.events import tonic_api
17+
from spikeforge.events import hsd_reader, tonic_api
1818

1919
_REGISTRY: Dict[str, DatasetSpec] = {
2020
"mnist": DatasetSpec(
@@ -71,6 +71,7 @@
7171
"ssc", 35, "Spiking Speech Commands (35 classes)", "event",
7272
tonic_class="SSC",
7373
splits={"train": {"split": "train"}, "test": {"split": "test"}},
74+
native_reader=hsd_reader.HSD,
7475
),
7576
# Sequence modality: a fully synthetic token parity task, no loader.
7677
"sequence_toy": DatasetSpec(

‎spikeforge/data/event_loader.py‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
EventsExtraMissingError,
4646
EventTimestampError,
4747
)
48-
from spikeforge.events import tonic_api
48+
from spikeforge.events import hsd_reader, tonic_api
4949
from spikeforge.events.event_sample import EventSample
5050

5151
#: Default number of time bins a loaded sample is binned into.
@@ -84,7 +84,12 @@ def open_event_dataset(
8484
cls = tonic_api.dataset_class(spec.tonic_class or "")
8585
if cls is None:
8686
raise EventsExtraMissingError(spec.name)
87-
return cls(_root(spec, save_to), **kwargs)
87+
dataset = cls(_root(spec, save_to), **kwargs)
88+
if spec.native_reader == hsd_reader.HSD:
89+
# Tonic did the download and owns the cache layout; only its
90+
# per-sample decode is replaced. See `hsd_reader` for why.
91+
return hsd_reader.open_split(dataset)
92+
return dataset
8893

8994

9095
def sample_from(

‎spikeforge/events/hsd_reader.py‎

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"""Read Heidelberg spiking-audio splits (SHD, SSC) from their HDF5 directly.
2+
3+
Tonic downloads these datasets correctly and then decodes them wrongly. Its
4+
reader converts the file's timestamps from seconds to microseconds with
5+
``times * 1e6``, but the Heidelberg files store them as ``float16`` (maximum
6+
65504). Under NumPy 2's NEP 50 promotion a ``float16`` array times a Python
7+
float stays ``float16``, so the multiply overflows to ``inf``, becomes ``NaN``,
8+
and casts to ``INT64_MIN`` -- for every timestamp in every sample. Binning then
9+
sees a zero-width span and collapses the whole recording into one time step, so
10+
a 25-step spiking network trains on a single static frame and still reports a
11+
plausible accuracy.
12+
13+
So this module replaces that one conversion. It does **not** replace tonic:
14+
the download, extraction, cache layout, and sensor geometry all still come
15+
from the tonic dataset object, which is passed in. Only ``__getitem__`` is
16+
ours, and it scales in ``float64`` where the arithmetic is exact.
17+
18+
Two incidental wins. Tonic reopens the HDF5 file on every ``__getitem__``;
19+
this opens it once, which is what makes
20+
:class:`~spikeforge.events.event_source.EventSampleSource`'s open-once caching
21+
actually pay off here. And the emitted stream keeps tonic's own
22+
``(t, x, p)`` structured layout, so nothing downstream can tell the
23+
difference -- a cochlea has channels rather than pixel rows, and
24+
:func:`~spikeforge.data.event_loader.events_to_sample` already places a
25+
one-row sensor's events on row 0.
26+
27+
``h5py`` arrives with ``tonic`` itself, so the ``events`` extra covers both,
28+
but the import is defensive here exactly as it is in
29+
:mod:`spikeforge.events.tonic_api`: this is the only module in the project
30+
that imports ``h5py``.
31+
"""
32+
33+
import os
34+
from importlib import import_module
35+
from typing import Any, Optional, Tuple
36+
37+
import numpy as np
38+
39+
#: Name a registry entry sets in ``DatasetSpec.native_reader`` to route here.
40+
HSD = "hsd"
41+
#: The structured layout tonic's own HSD reader emits, kept verbatim so
42+
#: nothing downstream has to special-case this path.
43+
DTYPE = np.dtype([("t", int), ("x", int), ("p", int)])
44+
#: Seconds-to-microseconds scale, applied in float64 where it is exact.
45+
MICROSECONDS = 1e6
46+
47+
48+
def _h5py() -> Optional[Any]:
49+
"""Return the ``h5py`` module, or ``None`` when it is unavailable."""
50+
try:
51+
return import_module("h5py")
52+
except ImportError:
53+
return None
54+
55+
56+
def available() -> bool:
57+
"""Return True when the HDF5 reader can run in this environment."""
58+
return _h5py() is not None
59+
60+
61+
class HsdSplit:
62+
"""One SHD/SSC split, read from its HDF5 file.
63+
64+
Presents the same surface the rest of the event path uses of a tonic
65+
dataset -- ``len()``, ``[index]``, and ``sensor_size`` -- so it drops in
66+
wherever the tonic object went.
67+
"""
68+
69+
def __init__(self, dataset: Any) -> None:
70+
"""Open the split ``dataset`` points at, reading its layout from it.
71+
72+
``dataset`` is the constructed tonic dataset: it has already done the
73+
download and extraction, and it knows the cache layout and the sensor
74+
geometry. Nothing here second-guesses any of that.
75+
"""
76+
module = _h5py()
77+
if module is None:
78+
raise RuntimeError(
79+
"h5py is required to read SHD/SSC and is normally installed "
80+
'with tonic; reinstall the `events` extra: pip install -e '
81+
'".[events]"'
82+
)
83+
self._path = os.path.join(
84+
dataset.location_on_system, dataset.data_filename
85+
)
86+
self.sensor_size: Tuple[int, int, int] = dataset.sensor_size
87+
self._file = module.File(self._path, "r")
88+
self._times = self._file["spikes/times"]
89+
self._units = self._file["spikes/units"]
90+
self._labels = self._file["labels"]
91+
92+
def __len__(self) -> int:
93+
"""Return the split's sample count."""
94+
return int(len(self._labels))
95+
96+
def __getitem__(self, index: int) -> Tuple[np.ndarray, int]:
97+
"""Return one ``((t, x, p) array, label)`` pair.
98+
99+
The timestamps are widened to ``float64`` *before* being scaled, which
100+
is the whole point of this module: doing it in the file's own
101+
``float16`` overflows and loses every timestamp.
102+
"""
103+
position = int(index)
104+
seconds = np.asarray(self._times[position], dtype=np.float64)
105+
units = np.asarray(self._units[position], dtype=np.int64)
106+
events = np.empty(seconds.shape[0], dtype=DTYPE)
107+
events["t"] = np.rint(seconds * MICROSECONDS).astype(np.int64)
108+
events["x"] = units
109+
# The Heidelberg recordings carry no polarity; tonic supplies a
110+
# constant 1 and we match it rather than inventing a second channel.
111+
events["p"] = 1
112+
return events, int(np.asarray(self._labels[position]))
113+
114+
def close(self) -> None:
115+
"""Close the underlying HDF5 file."""
116+
self._file.close()
117+
118+
@property
119+
def path(self) -> str:
120+
"""Return the HDF5 file this split reads."""
121+
return self._path
122+
123+
124+
def open_split(dataset: Any) -> HsdSplit:
125+
"""Return an :class:`HsdSplit` reading the split ``dataset`` located."""
126+
return HsdSplit(dataset)

0 commit comments

Comments
 (0)