@@ -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+
185283def _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 :
0 commit comments