Skip to content

Commit ee24e08

Browse files
satraclaude
andcommitted
feat(040): ingest_source writes to ParquetStore, no individual YAML files
T010: ingest_source collects all entities in memory by type, writes via write_staged_batch at the end. New helpers: _collect_response_option_values and _resolve_response_option_uris_in_memory operate without file I/O. 22/33 tasks done. 28 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e678986 commit ee24e08

2 files changed

Lines changed: 122 additions & 24 deletions

File tree

library/src/undata_library/ingest.py

Lines changed: 121 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ def ingest_source(
4242
Accepts either library_path (Path) or backend (StorageBackend).
4343
If backend is provided and has a base_dir attribute, uses that as library_path.
4444
45+
All entities are collected in memory by type and written to ParquetStore
46+
via write_staged_batch() at the end.
47+
4548
Returns stats: {created, merged, total}.
4649
"""
4750
if library_path is None and backend is not None and hasattr(backend, "base_dir"):
@@ -50,6 +53,8 @@ def ingest_source(
5053
raise ValueError("Either library_path or backend must be provided")
5154
from datetime import datetime, timezone
5255

56+
from .staging import write_staged_batch
57+
5358
# Load extractor — returns (attribute_pairs, all_entities)
5459
pairs, all_entities = _extract(source_name, schema_path)
5560

@@ -68,9 +73,6 @@ def ingest_source(
6873
# This keeps ingestion pure (source data only) and tracks annotations
6974
# as separate PROV-O curation events.
7075

71-
# Route all entity types to their correct directories
72-
import uuid
73-
7476
from .models import EntityType
7577

7678
stats_by_type: dict[str, int] = {
@@ -88,16 +90,20 @@ def ingest_source(
8890
EntityType.VALUESET: "valuesets",
8991
}
9092

91-
# Create all directories
92-
for dirname in type_to_dir.values():
93-
(library_path / dirname).mkdir(parents=True, exist_ok=True)
93+
# Collect all entities in memory by type: dict[str, list[dict]]
94+
batches: dict[str, list[dict]] = {
95+
"elements": [],
96+
"schemas": [],
97+
"values": [],
98+
"valuesets": [],
99+
}
94100

95101
# Dedup by (entity_type, source, class, name)
96102
seen_keys: set[tuple[str, str, str, str]] = set()
97103
created = 0
98104
merged = 0
99105

100-
# Write ATTRIBUTE entities from pairs (legacy path — these have SemanticIdentity)
106+
# Collect ATTRIBUTE entities from pairs (these have SemanticIdentity)
101107
for sem, prov in pairs:
102108
dedup_key = ("attribute", prov.source, prov.class_, prov.name)
103109
if dedup_key in seen_keys:
@@ -106,12 +112,12 @@ def ingest_source(
106112
seen_keys.add(dedup_key)
107113

108114
record = ElementRecord(semantic=sem, provenance=[prov])
109-
filepath = library_path / "elements" / f"{uuid.uuid4()}.yaml"
110-
_write_element(filepath, record)
115+
data = record.model_dump(mode="json", exclude_none=True, by_alias=True)
116+
batches["elements"].append(data)
111117
created += 1
112118
stats_by_type["elements"] += 1
113119

114-
# Write CLASS, ENUM_VALUE, VALUESET entities from all_entities
120+
# Collect CLASS, ENUM_VALUE, VALUESET entities from all_entities
115121
for entity in all_entities:
116122
etype = entity.entity_type
117123
dirname = type_to_dir.get(etype)
@@ -133,21 +139,22 @@ def ingest_source(
133139
"semantic": entity.semantic if isinstance(entity.semantic, dict) else {},
134140
"provenance": [prov],
135141
}
136-
filepath = library_path / dirname / f"{uuid.uuid4()}.yaml"
137-
filepath.write_text(
138-
yaml.dump(data, default_flow_style=False, sort_keys=False),
139-
encoding="utf-8",
140-
)
142+
batches[dirname].append(data)
141143
stats_by_type[dirname] += 1
142144

143-
# Legacy: also extract values from response_options on elements
144-
registry_path = library_path / "hash-registry.yaml"
145-
registry = _load_registry(registry_path)
146-
value_stats = _extract_values(source_name, pairs, library_path, registry)
147-
stats_by_type["values"] += value_stats.get("created", 0)
145+
# Extract values from response_options on elements (in memory)
146+
value_mappings = _load_value_mappings(library_path)
147+
extra_values = _collect_response_option_values(source_name, pairs, value_mappings)
148+
batches["values"].extend(extra_values)
149+
stats_by_type["values"] += len(extra_values)
150+
151+
# Resolve response_option values to ValueConcept URIs (in memory)
152+
_resolve_response_option_uris_in_memory(batches["elements"], batches["values"])
148153

149-
# Resolve response_option values to ValueConcept URIs
150-
_resolve_response_option_uris(library_path)
154+
# Write all collected entities to ParquetStore via write_staged_batch
155+
for entity_type, entities in batches.items():
156+
if entities:
157+
write_staged_batch(library_path, entity_type, entities, source=source_name)
151158

152159
return {
153160
"created": created,
@@ -182,6 +189,97 @@ def _load_value_mappings(library_path: Path) -> dict[str, dict]:
182189
return lookup
183190

184191

192+
def _collect_response_option_values(
193+
source_name: str,
194+
pairs: list[tuple[SemanticIdentity, ProvenanceEntry]],
195+
value_mappings: dict[str, dict],
196+
) -> list[dict]:
197+
"""Extract enum values from element response_options and return as dicts.
198+
199+
Returns a list of entity dicts ready for write_staged_batch.
200+
"""
201+
# Collect all raw enum values with their source
202+
raw_values: list[tuple[str, str]] = [] # (raw_value, source_name)
203+
for sem, _prov in pairs:
204+
if sem.response_options:
205+
for opt in sem.response_options:
206+
raw_values.append((opt.value, source_name))
207+
208+
# Group by mapped identity
209+
value_groups: dict[str, tuple[ValueSemanticIdentity, list[ProvenanceEntry]]] = {}
210+
for raw_val, src in raw_values:
211+
mapping = value_mappings.get(raw_val.lower())
212+
if mapping:
213+
label = mapping["label"]
214+
else:
215+
label = raw_val.lower().replace(" ", "_")
216+
217+
sem_id = ValueSemanticIdentity(
218+
value_type="categorical",
219+
label=label,
220+
)
221+
sem_dict = sem_id.model_dump(exclude_none=True)
222+
sha = compute_sha256(canonical_json(sem_dict))
223+
224+
prov = ProvenanceEntry(source=src, **{"class": ""}, name=raw_val)
225+
226+
if sha not in value_groups:
227+
value_groups[sha] = (sem_id, [])
228+
# Avoid duplicate provenance
229+
existing_raw = {(p.source, p.name) for p in value_groups[sha][1]}
230+
if (src, raw_val) not in existing_raw:
231+
value_groups[sha][1].append(prov)
232+
233+
results: list[dict] = []
234+
for _sha, (sem_id, provs) in value_groups.items():
235+
record = ValueConcept(semantic=sem_id, provenance=provs)
236+
data = record.model_dump(mode="json", exclude_none=True)
237+
results.append(data)
238+
239+
return results
240+
241+
242+
def _resolve_response_option_uris_in_memory(
243+
elements: list[dict],
244+
values: list[dict],
245+
) -> int:
246+
"""Resolve response_option values to ValueConcept URIs in memory.
247+
248+
Mutates element dicts in place, linking response_option values to
249+
matching value labels. Returns number of elements modified.
250+
"""
251+
# Build lookup: raw_value (lowercased) → value label
252+
value_lookup: dict[str, str] = {}
253+
for val_entity in values:
254+
sem = val_entity.get("semantic", {})
255+
label = sem.get("label", "")
256+
if label:
257+
value_lookup[label.lower()] = label
258+
for p in val_entity.get("provenance", []):
259+
raw_name = p.get("name", "")
260+
if raw_name:
261+
value_lookup[raw_name.lower()] = label
262+
263+
resolved = 0
264+
for elem in elements:
265+
sem = elem.get("semantic", {})
266+
opts = sem.get("response_options")
267+
if not opts:
268+
continue
269+
changed = False
270+
for opt in opts:
271+
raw = opt.get("value", "")
272+
match_label = value_lookup.get(raw.lower())
273+
if match_label and not opt.get("ontology_term"):
274+
# Use label as URI placeholder — will be resolved to
275+
# content-addressed URI at commit time
276+
opt["ontology_term"] = f"{BASE_URI}/values/{match_label}"
277+
changed = True
278+
if changed:
279+
resolved += 1
280+
return resolved
281+
282+
185283
def _extract_values(
186284
source_name: str,
187285
pairs: list[tuple[SemanticIdentity, ProvenanceEntry]],
@@ -196,7 +294,7 @@ def _extract_values(
196294

197295
# Collect all raw enum values with their source
198296
raw_values: list[tuple[str, str]] = [] # (raw_value, source_name)
199-
for sem, prov in pairs:
297+
for sem, _prov in pairs:
200298
# Extract enum values from response_options
201299
if sem.response_options:
202300
for opt in sem.response_options:

specs/040-unified-embedding-storage/tasks.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040

4141
**Independent Test**: Pipeline run for BIDS → find /output -name "*.yaml" | grep -v runs → 0 results
4242

43-
- [ ] T010 [US1] Rewrite ingest_source to collect entities in memory and call ParquetStore.write_batch instead of writing individual YAML files in library/src/undata_library/ingest.py
43+
- [X] T010 [US1] Rewrite ingest_source to collect entities in memory and call ParquetStore.write_batch instead of writing individual YAML files in library/src/undata_library/ingest.py
4444
- [X] T011 [US1] Rewrite commit_staged to read from ParquetStore, compute sha256, write committed entities to ParquetStore (no YAML output) in library/src/undata_library/commit.py
4545
- [X] T012 [US1] Rewrite cross-reference resolution (_resolve_cross_references) to operate on DataFrames from ParquetStore in library/src/undata_library/commit.py
4646
- [X] T013 [US1] Remove yaml.dump calls from commit path and yaml_to_parquet conversion step in library/src/undata_library/commit.py and library/src/undata_library/staging.py

0 commit comments

Comments
 (0)