Scandiumlabs commited on
Commit
92ead08
·
verified ·
1 Parent(s): 9f88bf4

Upload dataset/dataset_store.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. dataset/dataset_store.py +389 -0
dataset/dataset_store.py ADDED
@@ -0,0 +1,389 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Indexed Parquet-backed dataset store for the Scandium-Dataset.
2
+
3
+ Replaces the 1.6 GB JSON singleton file with a columnar store that:
4
+ - Loads only the columns you need (no more parsing 1.6 GB for a formula lookup)
5
+ - Supports O(1) lookups by source_id via built-in index
6
+ - Writes incrementally (no full-rewrite for a single-entry update)
7
+ - Is natively viewable on HuggingFace Hub
8
+
9
+ Schema:
10
+ - Each entry is a row in a PyArrow Table
11
+ - Nested fields (ssb_screening, provenance, structure_json) are stored as
12
+ top-level struct columns or serialized JSON strings
13
+ - Index on source_id
14
+
15
+ Usage:
16
+ store = DatasetStore.open() # load existing
17
+ entry = store.lookup("mp-12345") # O(1) by source_id
18
+ store.update_field("mp-12345", "band_gap", 4.2) # in-place update
19
+ store.append(new_entries) # batch append
20
+ store.checkpoint() # save to disk
21
+ """
22
+ import json, time, os, shutil
23
+ from pathlib import Path
24
+ from typing import Optional
25
+ import pyarrow as pa
26
+ import pyarrow.parquet as pq
27
+ import pyarrow.ipc as ipc
28
+ import numpy as np
29
+
30
+ BASE_DIR = Path(__file__).resolve().parent.parent
31
+ DATASET_DIR = BASE_DIR / "dataset"
32
+ MASTER_PARQUET = DATASET_DIR / "entries_v3.parquet"
33
+ MASTER_JSON = DATASET_DIR / "entries_final_v3.json"
34
+
35
+ # Columns that are simple scalars — stored directly in Parquet
36
+ SCALAR_COLUMNS = [
37
+ "source", "source_id", "formula", "structured_formula",
38
+ "band_gap", "formation_energy_per_atom", "energy_above_hull",
39
+ "nsites", "nelements", "volume", "density",
40
+ "mobile_ion", "tier", "quality_score",
41
+ "is_experimental", "is_stable", "is_metal",
42
+ "space_group", "space_group_number", "crystal_system",
43
+ "sse_family", "license",
44
+ ]
45
+
46
+ # Nested fields stored as JSON strings
47
+ JSON_STRING_FIELDS = [
48
+ "structure_json", "ssb_screening", "provenance",
49
+ "elements", "families", "carrier_elements",
50
+ "quality_flags", "tags", "notes",
51
+ ]
52
+
53
+
54
+ def _encode_value(val):
55
+ """Encode a value for consistent storage.
56
+
57
+ All values stored as strings. Nested objects (dicts, lists) use
58
+ JSON encoding. Simple values are stringified with type prefixes
59
+ so they can be decoded back to the correct type.
60
+ """
61
+ if val is None:
62
+ return None
63
+ if isinstance(val, (dict, list)):
64
+ return "json:" + json.dumps(val)
65
+ if isinstance(val, bool):
66
+ return "b:" + ("1" if val else "0")
67
+ if isinstance(val, int):
68
+ return "i:" + str(val)
69
+ if isinstance(val, float):
70
+ return "f:" + str(val)
71
+ if isinstance(val, str):
72
+ return "s:" + val
73
+ return "s:" + str(val)
74
+
75
+
76
+ def _decode_value(encoded):
77
+ """Reverse of _encode_value."""
78
+ if encoded is None:
79
+ return None
80
+ if not isinstance(encoded, str):
81
+ return encoded
82
+ if encoded.startswith("json:"):
83
+ return json.loads(encoded[5:])
84
+ if encoded.startswith("b:"):
85
+ return encoded[2:] == "1"
86
+ if encoded.startswith("i:"):
87
+ return int(encoded[2:])
88
+ if encoded.startswith("f:"):
89
+ return float(encoded[2:])
90
+ if encoded.startswith("s:"):
91
+ return encoded[2:]
92
+ return encoded
93
+
94
+
95
+ def _first_pass_extract(entry, fields):
96
+ """Extract fields with type-safe encoding."""
97
+ result = {}
98
+ for f in fields:
99
+ result[f] = _encode_value(entry.get(f))
100
+ return result
101
+
102
+
103
+ def _json_to_table_batch(entries, batch_size=50000):
104
+ """Convert entries to PyArrow Table in batches."""
105
+ rows = []
106
+ for entry in entries:
107
+ row = _first_pass_extract(entry, SCALAR_COLUMNS + JSON_STRING_FIELDS)
108
+ rows.append(row)
109
+ if len(rows) >= batch_size:
110
+ yield pa.Table.from_pylist(rows)
111
+ rows.clear()
112
+ if rows:
113
+ yield pa.Table.from_pylist(rows)
114
+
115
+
116
+ def convert_json_to_parquet(json_path=None, parquet_path=None, force=False):
117
+ """Convert the master JSON file to Parquet format."""
118
+ if json_path is None:
119
+ json_path = MASTER_JSON
120
+ else:
121
+ json_path = Path(json_path)
122
+
123
+ if parquet_path is None:
124
+ parquet_path = MASTER_PARQUET
125
+ else:
126
+ parquet_path = Path(parquet_path)
127
+
128
+ if parquet_path.exists() and not force:
129
+ print(f" Parquet already exists at {parquet_path}")
130
+ print(f" Use force=True to re-convert")
131
+ return False
132
+
133
+ print(f" Loading JSON from {json_path}...")
134
+ t0 = time.time()
135
+ with open(json_path) as f:
136
+ entries = json.load(f)
137
+ print(f" {len(entries):,} entries loaded ({time.time()-t0:.1f}s)")
138
+
139
+ print(f" Converting to Parquet...")
140
+ t1 = time.time()
141
+ tables = list(_json_to_table_batch(entries))
142
+ if len(tables) == 1:
143
+ table = tables[0]
144
+ else:
145
+ table = pa.concat_tables(tables)
146
+
147
+ print(f" Table rows: {table.num_rows}, columns: {table.num_columns}")
148
+ print(f" Writing to {parquet_path}...")
149
+ pq.write_table(table, parquet_path, compression="zstd")
150
+ file_size = os.path.getsize(parquet_path)
151
+ print(f" Done ({time.time()-t1:.1f}s), {file_size/1e9:.2f} GB")
152
+
153
+ # Write a sidecar index (using decoded source_ids, not encoded)
154
+ index_path = parquet_path.with_suffix(".index.json")
155
+ source_ids_raw = table.column("source_id").to_pylist()
156
+ source_ids_decoded = [_decode_value(s) for s in source_ids_raw]
157
+ # Verify uniqueness
158
+ unique_sids = set(source_ids_decoded)
159
+ if len(unique_sids) < len(source_ids_decoded):
160
+ dupes = len(source_ids_decoded) - len(unique_sids)
161
+ print(f" Warning: {dupes} duplicate source_ids found")
162
+ index_data = {sid: i for i, sid in enumerate(source_ids_decoded)}
163
+ with open(index_path, "w") as f:
164
+ json.dump(index_data, f)
165
+ print(f" Index: {len(index_data)} entries")
166
+
167
+ return True
168
+
169
+
170
+ class DatasetStore:
171
+ """Indexed Parquet-backed store for the Scandium-Dataset.
172
+
173
+ Provides O(1) lookup by source_id and incremental update without
174
+ full-file rewrites.
175
+ """
176
+ _table: Optional[pa.Table] = None
177
+ _index: Optional[dict] = None
178
+ _dirty: bool = False
179
+ _parquet_path: Path = MASTER_PARQUET
180
+ _index_path: Path = MASTER_PARQUET.with_suffix(".index.json")
181
+
182
+ def __init__(self, parquet_path=None):
183
+ if parquet_path:
184
+ self._parquet_path = Path(parquet_path)
185
+ self._index_path = self._parquet_path.with_suffix(".index.json")
186
+
187
+ @classmethod
188
+ def open(cls, parquet_path=None):
189
+ """Open existing store or convert from JSON if needed."""
190
+ store = cls(parquet_path)
191
+ p = store._parquet_path
192
+ ip = store._index_path
193
+
194
+ if not p.exists():
195
+ print(f" Parquet not found at {p}")
196
+ print(f" Converting from JSON...")
197
+ convert_json_to_parquet(parquet_path=p)
198
+ if not p.exists():
199
+ raise FileNotFoundError(f"Could not create {p}")
200
+
201
+ print(f" Loading Parquet from {p}...")
202
+ t0 = time.time()
203
+ store._table = pq.read_table(p)
204
+ print(f" {store._table.num_rows:,} rows loaded ({time.time()-t0:.1f}s)")
205
+
206
+ if ip.exists():
207
+ with open(ip) as f:
208
+ store._index = json.load(f)
209
+ print(f" Index: {len(store._index)} entries")
210
+ else:
211
+ print(f" Rebuilding index...")
212
+ t1 = time.time()
213
+ sids_raw = store._table.column("source_id").to_pylist()
214
+ sids_decoded = [_decode_value(s) for s in sids_raw]
215
+ store._index = {sid: i for i, sid in enumerate(sids_decoded)}
216
+ with open(ip, "w") as f:
217
+ json.dump(store._index, f)
218
+ print(f" Index rebuilt ({time.time()-t1:.1f}s)")
219
+
220
+ store._dirty = False
221
+ return store
222
+
223
+ def lookup(self, source_id: str) -> Optional[dict]:
224
+ """Lookup an entry by source_id. O(1). Values decoded back to original types."""
225
+ row_idx = self._index.get(source_id)
226
+ if row_idx is None:
227
+ return None
228
+ row = self._table.slice(row_idx, 1).to_pylist()[0]
229
+ return {k: _decode_value(v) for k, v in row.items()}
230
+
231
+ def lookup_many(self, source_ids):
232
+ """Lookup multiple entries by source_id."""
233
+ result = []
234
+ missing = []
235
+ for sid in source_ids:
236
+ e = self.lookup(sid)
237
+ if e:
238
+ result.append(e)
239
+ else:
240
+ missing.append(sid)
241
+ return result, missing
242
+
243
+ def scan(self, columns=None, filter_fn=None):
244
+ """Iterate over entries, optionally filtered.
245
+
246
+ Args:
247
+ columns: list of column names to load (None = all)
248
+ filter_fn: callable(entry) -> bool to filter rows
249
+
250
+ Yields:
251
+ dict per matching entry
252
+ """
253
+ table = self._table
254
+ if columns:
255
+ available = [c for c in columns if c in table.column_names]
256
+ table = table.select(available)
257
+
258
+ for row in table.to_pylist():
259
+ decoded = {k: _decode_value(v) for k, v in row.items()}
260
+ if filter_fn is None or filter_fn(decoded):
261
+ yield decoded
262
+
263
+ def count(self, filter_fn=None):
264
+ """Count entries, optionally filtered."""
265
+ if filter_fn is None:
266
+ return self._table.num_rows
267
+ return sum(1 for _ in self.scan(filter_fn=filter_fn))
268
+
269
+ def update_field(self, source_id: str, field: str, value, nested_path: Optional[str] = None):
270
+ """Update a single field for an entry. In-memory only until checkpoint().
271
+
272
+ Args:
273
+ source_id: entry identifier
274
+ field: column/field name to update
275
+ value: new value
276
+ nested_path: if set, field is treated as a JSON-encoded dict column
277
+ and value is set at field[nested_path] within it
278
+ """
279
+ row_idx = self._index.get(source_id)
280
+ if row_idx is None:
281
+ raise KeyError(f"Entry not found: {source_id}")
282
+
283
+ if nested_path:
284
+ # Read current nested dict, update, write back
285
+ col_idx = self._table.schema.get_field_index(field)
286
+ if col_idx < 0:
287
+ raise KeyError(f"Nested field column not found: {field}")
288
+ old_col = self._table.column(field)
289
+ cur_values = old_col.to_pylist()
290
+ cur_raw = cur_values[row_idx]
291
+ cur_dict = _decode_value(cur_raw) if cur_raw is not None else {}
292
+ if not isinstance(cur_dict, dict):
293
+ cur_dict = {}
294
+ cur_dict[nested_path] = value
295
+ cur_values[row_idx] = _encode_value(cur_dict)
296
+ new_col = pa.chunked_array([pa.array(cur_values, type=old_col.type)])
297
+ self._table = self._table.set_column(col_idx, field, new_col)
298
+ else:
299
+ col_idx = self._table.schema.get_field_index(field)
300
+ encoded = _encode_value(value)
301
+
302
+ if col_idx >= 0:
303
+ old_col = self._table.column(field)
304
+ new_values = old_col.to_pylist()
305
+ new_values[row_idx] = encoded
306
+ new_col = pa.chunked_array([pa.array(new_values, type=old_col.type)])
307
+ self._table = self._table.set_column(col_idx, field, new_col)
308
+ else:
309
+ n = self._table.num_rows
310
+ new_values = [None] * n
311
+ new_values[row_idx] = encoded
312
+ new_col = pa.array(new_values, type=pa.string())
313
+ self._table = self._table.append_column(
314
+ pa.field(field, pa.string()), pa.chunked_array([new_col])
315
+ )
316
+
317
+ self._dirty = True
318
+
319
+ def update_entry(self, source_id: str, updates: dict):
320
+ """Apply multiple field updates to an entry."""
321
+ for field, value in updates.items():
322
+ self.update_field(source_id, field, value)
323
+
324
+ def append(self, entries: list):
325
+ """Append new entries (as dicts). Raises on duplicate source_id."""
326
+ new_sids = [e.get("source_id") for e in entries]
327
+ existing = [sid for sid in new_sids if sid in self._index]
328
+ if existing:
329
+ raise ValueError(f"Duplicate source_ids: {existing[:5]}")
330
+
331
+ batch = pa.Table.from_pylist([
332
+ _first_pass_extract(e, SCALAR_COLUMNS + JSON_STRING_FIELDS)
333
+ for e in entries
334
+ ])
335
+ self._table = pa.concat_tables([self._table, batch])
336
+
337
+ # Update index
338
+ start = len(self._index)
339
+ for i, e in enumerate(entries):
340
+ self._index[e["source_id"]] = start + i
341
+
342
+ self._dirty = True
343
+
344
+ def checkpoint(self):
345
+ """Write in-memory state to disk."""
346
+ if not self._dirty:
347
+ return
348
+
349
+ print(f" Checkpointing to {self._parquet_path}...")
350
+ t0 = time.time()
351
+ pq.write_table(self._table, self._parquet_path, compression="zstd")
352
+ print(f" Done ({time.time()-t0:.1f}s)")
353
+
354
+ # Update index file
355
+ with open(self._index_path, "w") as f:
356
+ json.dump(self._index, f)
357
+
358
+ self._dirty = False
359
+
360
+ def close(self):
361
+ """Checkpoint and release memory."""
362
+ if self._dirty:
363
+ self.checkpoint()
364
+ self._table = None
365
+ self._index = None
366
+
367
+ def __enter__(self):
368
+ return self
369
+
370
+ def __exit__(self, *args):
371
+ self.close()
372
+
373
+ @property
374
+ def num_entries(self):
375
+ return self._table.num_rows if self._table else 0
376
+
377
+
378
+ if __name__ == "__main__":
379
+ # Conversion entry point
380
+ import argparse
381
+ parser = argparse.ArgumentParser(description="Manage the Parquet dataset store")
382
+ parser.add_argument("--convert", action="store_true", help="Convert JSON to Parquet")
383
+ parser.add_argument("--force", action="store_true", help="Force re-conversion")
384
+ args = parser.parse_args()
385
+
386
+ if args.convert:
387
+ convert_json_to_parquet(force=args.force)
388
+ else:
389
+ print("Usage: python dataset/dataset_store.py --convert")