data-morph-gemma 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. data_morph_gemma-0.1.0.dist-info/METADATA +177 -0
  2. data_morph_gemma-0.1.0.dist-info/RECORD +39 -0
  3. data_morph_gemma-0.1.0.dist-info/WHEEL +4 -0
  4. data_morph_gemma-0.1.0.dist-info/entry_points.txt +2 -0
  5. data_morph_gemma-0.1.0.dist-info/licenses/LICENSE +25 -0
  6. datamorph/__init__.py +19 -0
  7. datamorph/cli.py +84 -0
  8. datamorph/convert.py +146 -0
  9. datamorph/data/__init__.py +1 -0
  10. datamorph/data/collect.py +221 -0
  11. datamorph/data/envelope.py +20 -0
  12. datamorph/data/generators/__init__.py +1 -0
  13. datamorph/data/generators/base.py +48 -0
  14. datamorph/data/generators/uc1_csv_to_json.py +64 -0
  15. datamorph/data/generators/uc2_json_to_csv.py +59 -0
  16. datamorph/data/generators/uc3_txt_log_to_csv.py +64 -0
  17. datamorph/data/generators/uc4_csv_to_txt_report.py +62 -0
  18. datamorph/data/generators/uc5_schema_migration.py +49 -0
  19. datamorph/data/sandbox.py +95 -0
  20. datamorph/data/teacher_script.py +114 -0
  21. datamorph/evaluation/__init__.py +0 -0
  22. datamorph/evaluation/metrics.py +264 -0
  23. datamorph/evaluation/output_cleanup.py +116 -0
  24. datamorph/evaluation/runner.py +218 -0
  25. datamorph/evaluation/teacher.py +193 -0
  26. datamorph/extractor/__init__.py +15 -0
  27. datamorph/extractor/base.py +26 -0
  28. datamorph/extractor/csv_extractor.py +515 -0
  29. datamorph/extractor/json_extractor.py +447 -0
  30. datamorph/extractor/json_walker.py +217 -0
  31. datamorph/extractor/sampler.py +68 -0
  32. datamorph/extractor/txt_extractor.py +199 -0
  33. datamorph/extractor/warning_rules.py +473 -0
  34. datamorph/features/__init__.py +1 -0
  35. datamorph/features/format_pairs.py +57 -0
  36. datamorph/model.py +63 -0
  37. datamorph/models/__init__.py +0 -0
  38. datamorph/models/gemma_mlx.py +163 -0
  39. datamorph/models/gemma_script_teacher.py +100 -0
@@ -0,0 +1,473 @@
1
+ """Metadata warnings + pure detection rules for the extractor pipeline.
2
+
3
+ This module is pure: no file I/O, no network, no global state. Each
4
+ rule function takes the bare minimum data and returns a MetadataWarning
5
+ or None.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Any, Literal
12
+
13
+ Severity = Literal["info", "warn", "error"]
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class MetadataWarning:
18
+ """A single warning emitted by a metadata extractor.
19
+
20
+ Attributes:
21
+ code: Stable identifier (e.g. "REPEATING_ENTITY").
22
+ severity: One of "info", "warn", "error".
23
+ message: Human-readable message for the model and reviewer.
24
+ context: Structured details for programmatic use.
25
+ """
26
+
27
+ code: str
28
+ severity: Severity
29
+ message: str
30
+ context: dict[str, Any]
31
+
32
+ def to_dict(self) -> dict[str, Any]:
33
+ return {
34
+ "code": self.code,
35
+ "severity": self.severity,
36
+ "message": self.message,
37
+ "context": dict(self.context),
38
+ }
39
+
40
+
41
+ def check_empty_file(*, row_count: int) -> MetadataWarning | None:
42
+ """Fire `EMPTY_FILE` (error) when the file has zero data rows."""
43
+ if row_count > 0:
44
+ return None
45
+ return MetadataWarning(
46
+ code="EMPTY_FILE",
47
+ severity="error",
48
+ message="File has zero data rows.",
49
+ context={"row_count": row_count},
50
+ )
51
+
52
+
53
+ def check_missing_header(*, has_header: bool) -> MetadataWarning | None:
54
+ """Fire `MISSING_HEADER` (warn) when csv.Sniffer detects no header row."""
55
+ if has_header:
56
+ return None
57
+ return MetadataWarning(
58
+ code="MISSING_HEADER",
59
+ severity="warn",
60
+ message=(
61
+ "csv.Sniffer reports no header row. Column names have been "
62
+ "synthesized as c0, c1, ... — verify before relying on them."
63
+ ),
64
+ context={},
65
+ )
66
+
67
+
68
+ def check_duplicate_column_name(*, raw_header: list[str]) -> MetadataWarning | None:
69
+ """Fire `DUPLICATE_COLUMN_NAME` (warn) when header has repeated column names."""
70
+ seen: set[str] = set()
71
+ dupes: list[str] = []
72
+ for name in raw_header:
73
+ if name in seen and name not in dupes:
74
+ dupes.append(name)
75
+ seen.add(name)
76
+ if not dupes:
77
+ return None
78
+ return MetadataWarning(
79
+ code="DUPLICATE_COLUMN_NAME",
80
+ severity="warn",
81
+ message=(
82
+ f"Header has duplicate column names: {dupes}. pandas auto-renames "
83
+ f"duplicates with a numeric suffix (e.g. 'name', 'name.1')."
84
+ ),
85
+ context={"duplicates": dupes},
86
+ )
87
+
88
+
89
+ def check_inconsistent_quoting(*, inconsistent: bool) -> MetadataWarning | None:
90
+ """Fire `INCONSISTENT_QUOTING` (warn) when csv.Sniffer detects mixed quoting styles."""
91
+ if not inconsistent:
92
+ return None
93
+ return MetadataWarning(
94
+ code="INCONSISTENT_QUOTING",
95
+ severity="warn",
96
+ message=(
97
+ "csv.Sniffer flagged inconsistent quoting in this file. The "
98
+ "generated script should be robust to mixed quoting."
99
+ ),
100
+ context={},
101
+ )
102
+
103
+
104
+ def check_latin1_fallback(
105
+ *, final_encoding: str, attempted: list[str]
106
+ ) -> MetadataWarning | None:
107
+ """Fire `LATIN1_FALLBACK` (warn) when UTF-8 decoding fails and latin-1 is used as fallback."""
108
+ if final_encoding != "latin-1":
109
+ return None
110
+ return MetadataWarning(
111
+ code="LATIN1_FALLBACK",
112
+ severity="warn",
113
+ message=(
114
+ "File could not be decoded as UTF-8. Fell back to latin-1, "
115
+ "which may produce mojibake for non-Western characters."
116
+ ),
117
+ context={
118
+ "attempted_encodings": list(attempted),
119
+ "final_encoding": "latin-1",
120
+ },
121
+ )
122
+
123
+
124
+ def check_repeating_entity(
125
+ *, column: dict[str, Any], row_count: int
126
+ ) -> MetadataWarning | None:
127
+ """Fire `REPEATING_ENTITY` (warn) when a string column has unique_count / row_count < 0.5."""
128
+ if column.get("dtype") != "string":
129
+ return None
130
+ if row_count <= 0:
131
+ return None
132
+ unique_count = column["unique_count"]
133
+ if unique_count == 0:
134
+ return None
135
+ ratio = unique_count / row_count
136
+ if ratio >= 0.5:
137
+ return None
138
+ rows_per_entity_avg = round(row_count / unique_count, 2)
139
+ return MetadataWarning(
140
+ code="REPEATING_ENTITY",
141
+ severity="warn",
142
+ message=(
143
+ f"Column '{column['name']}' has {unique_count} unique values "
144
+ f"across {row_count} rows ({rows_per_entity_avg} rows per entity "
145
+ f"avg). If converting to nested JSON, consider grouping rows by "
146
+ f"this column before serializing."
147
+ ),
148
+ context={
149
+ "column": column["name"],
150
+ "unique_count": unique_count,
151
+ "row_count": row_count,
152
+ "rows_per_entity_avg": rows_per_entity_avg,
153
+ },
154
+ )
155
+
156
+
157
+ def check_numeric_column_quote_risk(
158
+ *, column: dict[str, Any]
159
+ ) -> MetadataWarning | None:
160
+ """Fire `NUMERIC_COLUMN_QUOTE_RISK` (warn) when a numeric column may be incorrectly quoted."""
161
+ dtype = column.get("dtype")
162
+ if dtype not in ("integer", "float"):
163
+ return None
164
+ return MetadataWarning(
165
+ code="NUMERIC_COLUMN_QUOTE_RISK",
166
+ severity="warn",
167
+ message=(
168
+ f"Column '{column['name']}' is {dtype}. When serializing to "
169
+ f"JSON, do NOT cast to string — preserve as numeric type."
170
+ ),
171
+ context={"column": column["name"], "dtype": dtype},
172
+ )
173
+
174
+
175
+ def check_mixed_dtype_column(*, column: dict[str, Any]) -> MetadataWarning | None:
176
+ """Fire `MIXED_DTYPE_COLUMN` (error) when a column contains multiple data types."""
177
+ if column.get("dtype") != "mixed":
178
+ return None
179
+ return MetadataWarning(
180
+ code="MIXED_DTYPE_COLUMN",
181
+ severity="error",
182
+ message=(
183
+ f"Column '{column['name']}' contains values of multiple dtypes. "
184
+ f"The conversion script must perform explicit per-row casting."
185
+ ),
186
+ context={"column": column["name"]},
187
+ )
188
+
189
+
190
+ def check_high_null_rate(
191
+ *, column: dict[str, Any], row_count: int
192
+ ) -> MetadataWarning | None:
193
+ """Fire `HIGH_NULL_RATE` (warn) when a column has null_count / row_count > 0.1."""
194
+ if row_count <= 0:
195
+ return None
196
+ null_count = column.get("null_count", 0)
197
+ rate = null_count / row_count
198
+ if rate <= 0.1:
199
+ return None
200
+ return MetadataWarning(
201
+ code="HIGH_NULL_RATE",
202
+ severity="warn",
203
+ message=(
204
+ f"Column '{column['name']}' has {null_count} nulls in "
205
+ f"{row_count} rows ({rate:.1%}). Script must handle None/NaN."
206
+ ),
207
+ context={
208
+ "column": column["name"],
209
+ "null_count": null_count,
210
+ "row_count": row_count,
211
+ "null_rate": round(rate, 3),
212
+ },
213
+ )
214
+
215
+
216
+ def check_likely_date_column(*, column: dict[str, Any]) -> MetadataWarning | None:
217
+ """Fire `LIKELY_DATE_COLUMN` (info) when a column is detected as date or datetime type."""
218
+ dtype = column.get("dtype")
219
+ if dtype not in ("date", "datetime"):
220
+ return None
221
+ return MetadataWarning(
222
+ code="LIKELY_DATE_COLUMN",
223
+ severity="info",
224
+ message=(
225
+ f"Column '{column['name']}' is detected as {dtype}. Script "
226
+ f"should parse and re-serialize using a consistent format "
227
+ f"(ISO 8601 recommended)."
228
+ ),
229
+ context={"column": column["name"], "dtype": dtype},
230
+ )
231
+
232
+
233
+ # ---------------------------------------------------------------------------
234
+ # JSON-specific warning rules
235
+ # ---------------------------------------------------------------------------
236
+
237
+ import re as _re # noqa: E402 (intentionally aliased to avoid clashing with `re` elsewhere)
238
+
239
+ # Imported lazily inside type hints — avoids a runtime import cycle since
240
+ # json_walker.py does not need warning_rules.py.
241
+ from typing import TYPE_CHECKING # noqa: E402
242
+
243
+ if TYPE_CHECKING:
244
+ from .json_walker import PathStats
245
+
246
+
247
+ _ISO_DATE_RE = _re.compile(r"^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?$")
248
+ _US_DATE_RE = _re.compile(r"^\d{1,2}/\d{1,2}/\d{4}$")
249
+
250
+
251
+ def check_optional_key(*, stats: "PathStats") -> MetadataWarning | None:
252
+ """Fire `OPTIONAL_KEY` (warn) when a path has presence strictly between 0 and 1."""
253
+ if stats.denominator <= 0:
254
+ return None
255
+ presence = stats.occurrence_count / stats.denominator
256
+ if presence >= 1.0 or presence <= 0.0:
257
+ return None
258
+ return MetadataWarning(
259
+ code="OPTIONAL_KEY",
260
+ severity="warn",
261
+ message=(
262
+ f"Path '{stats.path}' is present in {stats.occurrence_count} of "
263
+ f"{stats.denominator} records ({presence:.1%}). Conversion code "
264
+ f"should treat it as optional and provide a default for missing values."
265
+ ),
266
+ context={
267
+ "path": stats.path,
268
+ "presence": round(presence, 4),
269
+ "occurrence_count": stats.occurrence_count,
270
+ "denominator": stats.denominator,
271
+ },
272
+ )
273
+
274
+
275
+ def check_mixed_type_path(*, stats: "PathStats") -> MetadataWarning | None:
276
+ """Fire `MIXED_TYPE_PATH` (error) when a path has multiple non-null real dtypes."""
277
+ real = set(stats.dtypes_seen) - {"null"}
278
+ if len(real) <= 1:
279
+ return None
280
+ return MetadataWarning(
281
+ code="MIXED_TYPE_PATH",
282
+ severity="error",
283
+ message=(
284
+ f"Path '{stats.path}' has values of multiple types: "
285
+ f"{sorted(real)}. Conversion code must explicitly coerce to a single type."
286
+ ),
287
+ context={"path": stats.path, "dtypes_seen": sorted(real)},
288
+ )
289
+
290
+
291
+ def check_deeply_nested(*, max_depth: int, threshold: int) -> MetadataWarning | None:
292
+ """Fire `DEEPLY_NESTED` (warn) when max_depth strictly exceeds threshold."""
293
+ if max_depth <= threshold:
294
+ return None
295
+ return MetadataWarning(
296
+ code="DEEPLY_NESTED",
297
+ severity="warn",
298
+ message=(
299
+ f"JSON nesting reaches depth {max_depth}, exceeding the threshold "
300
+ f"of {threshold}. Conversion code should use recursion or an explicit walker."
301
+ ),
302
+ context={"max_depth": max_depth, "threshold": threshold},
303
+ )
304
+
305
+
306
+ def check_large_array(*, stats: "PathStats", threshold: int) -> MetadataWarning | None:
307
+ """Fire `LARGE_ARRAY` (warn) when any observed array length strictly exceeds threshold."""
308
+ if not stats.array_lengths_seen:
309
+ return None
310
+ max_len = max(stats.array_lengths_seen)
311
+ if max_len <= threshold:
312
+ return None
313
+ return MetadataWarning(
314
+ code="LARGE_ARRAY",
315
+ severity="warn",
316
+ message=(
317
+ f"Array at '{stats.path}' has up to {max_len} elements (threshold "
318
+ f"{threshold}). Generated script should stream this array, not load it whole."
319
+ ),
320
+ context={
321
+ "path": stats.path,
322
+ "max_length": max_len,
323
+ "threshold": threshold,
324
+ },
325
+ )
326
+
327
+
328
+ def check_heterogeneous_array(
329
+ *,
330
+ stats: "PathStats",
331
+ child_key_sets: list[frozenset[str]],
332
+ mixed_element_types: bool = False,
333
+ ) -> MetadataWarning | None:
334
+ """Fire `HETEROGENEOUS_ARRAY` (warn) when array elements are non-uniform.
335
+
336
+ Two triggers:
337
+ 1. `mixed_element_types` is True (array contains both objects and scalars).
338
+ 2. Jaccard similarity across `child_key_sets` is < 0.6.
339
+ """
340
+ if mixed_element_types:
341
+ return MetadataWarning(
342
+ code="HETEROGENEOUS_ARRAY",
343
+ severity="warn",
344
+ message=(
345
+ f"Array at '{stats.path}' contains both object and non-object "
346
+ f"elements. Conversion code must branch on element type."
347
+ ),
348
+ context={"path": stats.path, "reason": "mixed_element_types"},
349
+ )
350
+ if len(child_key_sets) < 2:
351
+ return None
352
+ # Pairwise minimum Jaccard
353
+ min_jaccard = 1.0
354
+ for i in range(len(child_key_sets)):
355
+ for j in range(i + 1, len(child_key_sets)):
356
+ a, b = child_key_sets[i], child_key_sets[j]
357
+ union = a | b
358
+ if not union:
359
+ continue
360
+ jaccard = len(a & b) / len(union)
361
+ min_jaccard = min(min_jaccard, jaccard)
362
+ if min_jaccard >= 0.6:
363
+ return None
364
+ return MetadataWarning(
365
+ code="HETEROGENEOUS_ARRAY",
366
+ severity="warn",
367
+ message=(
368
+ f"Array at '{stats.path}' has elements with non-uniform key sets "
369
+ f"(min Jaccard {min_jaccard:.2f} < 0.60). Some keys exist only on some elements."
370
+ ),
371
+ context={
372
+ "path": stats.path,
373
+ "reason": "non_uniform_keys",
374
+ "min_jaccard": round(min_jaccard, 3),
375
+ },
376
+ )
377
+
378
+
379
+ def check_likely_date_value(*, stats: "PathStats") -> MetadataWarning | None:
380
+ """Fire `LIKELY_DATE_VALUE` (info) when ≥80% of sampled strings match a date pattern."""
381
+ if "string" not in stats.dtypes_seen or len(stats.dtypes_seen - {"null"}) != 1:
382
+ return None
383
+ if not stats.sample_values:
384
+ return None
385
+ matches = sum(
386
+ 1
387
+ for v in stats.sample_values
388
+ if isinstance(v, str) and (_ISO_DATE_RE.match(v) or _US_DATE_RE.match(v))
389
+ )
390
+ ratio = matches / len(stats.sample_values)
391
+ if ratio < 0.8:
392
+ return None
393
+ return MetadataWarning(
394
+ code="LIKELY_DATE_VALUE",
395
+ severity="info",
396
+ message=(
397
+ f"Path '{stats.path}' looks like a date column ({ratio:.0%} of "
398
+ f"sampled values parse as ISO 8601 or MM/DD/YYYY). Conversion "
399
+ f"script should parse + re-serialize consistently."
400
+ ),
401
+ context={"path": stats.path, "match_ratio": round(ratio, 2)},
402
+ )
403
+
404
+
405
+ # ---------------------------------------------------------------------------
406
+ # TXT-specific warning rules
407
+ # ---------------------------------------------------------------------------
408
+
409
+
410
+ def check_no_pattern_detected(*, record_pattern: str) -> MetadataWarning | None:
411
+ """Fire `NO_PATTERN_DETECTED` (warn) when no structured line pattern was found."""
412
+ if record_pattern != "freeform":
413
+ return None
414
+ return MetadataWarning(
415
+ code="NO_PATTERN_DETECTED",
416
+ severity="warn",
417
+ message=(
418
+ "No consistent line structure (log/delimited/key-value/fixed-width) "
419
+ "was detected. The conversion script must parse freeform text."
420
+ ),
421
+ context={"record_pattern": record_pattern},
422
+ )
423
+
424
+
425
+ def check_mixed_line_structure(
426
+ *, match_ratio: float, threshold: float = 0.8
427
+ ) -> MetadataWarning | None:
428
+ """Fire `MIXED_LINE_STRUCTURE` (warn) when only some lines match the dominant pattern."""
429
+ if match_ratio <= 0.0 or match_ratio >= threshold:
430
+ return None
431
+ return MetadataWarning(
432
+ code="MIXED_LINE_STRUCTURE",
433
+ severity="warn",
434
+ message=(
435
+ f"Only {match_ratio:.0%} of lines match the dominant pattern "
436
+ f"(threshold {threshold:.0%}). Some lines deviate — the script "
437
+ f"should skip or special-case non-conforming lines."
438
+ ),
439
+ context={"match_ratio": round(match_ratio, 3), "threshold": threshold},
440
+ )
441
+
442
+
443
+ def check_inconsistent_field_count(
444
+ *, field_counts: list[int]
445
+ ) -> MetadataWarning | None:
446
+ """Fire `INCONSISTENT_FIELD_COUNT` (warn) when delimited lines split into varying widths."""
447
+ if not field_counts or len(set(field_counts)) <= 1:
448
+ return None
449
+ return MetadataWarning(
450
+ code="INCONSISTENT_FIELD_COUNT",
451
+ severity="warn",
452
+ message=(
453
+ f"Delimited lines split into a varying number of fields "
454
+ f"(observed widths: {sorted(set(field_counts))}). The script must "
455
+ f"handle ragged rows."
456
+ ),
457
+ context={"observed_widths": sorted(set(field_counts))},
458
+ )
459
+
460
+
461
+ def check_likely_timestamp_prefix(*, record_pattern: str) -> MetadataWarning | None:
462
+ """Fire `LIKELY_TIMESTAMP_PREFIX` (info) when lines start with a timestamp."""
463
+ if record_pattern != "log_line":
464
+ return None
465
+ return MetadataWarning(
466
+ code="LIKELY_TIMESTAMP_PREFIX",
467
+ severity="info",
468
+ message=(
469
+ "Lines begin with a timestamp prefix. The script should parse the "
470
+ "leading timestamp and re-serialize it consistently (ISO 8601)."
471
+ ),
472
+ context={"record_pattern": record_pattern},
473
+ )
@@ -0,0 +1 @@
1
+ """data-morph feature formatting (verified pairs -> training-ready chat JSONL)."""
@@ -0,0 +1,57 @@
1
+ """Turn verified pair records into chat-format training examples + a disjoint split."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from typing import Any
8
+
9
+
10
+ def to_chat_record(record: dict[str, Any]) -> dict[str, Any]:
11
+ """Build the user/assistant chat turns the student is trained on.
12
+
13
+ user = the metadata envelope + the conversion instruction (what the model
14
+ sees at inference — never the full file).
15
+ assistant = the verified <analysis> + <script>.
16
+ """
17
+ env_json = json.dumps(record["envelope"], indent=2, default=str)
18
+ user = (
19
+ f"Metadata envelope:\n```json\n{env_json}\n```\n\n"
20
+ f"Task: {record['instruction']}\n"
21
+ f"Write a Python conversion script (reads sys.argv[1], writes sys.argv[2]; "
22
+ f"stdlib + pandas only)."
23
+ )
24
+ assistant = (
25
+ f"<analysis>{record['analysis']}</analysis>\n"
26
+ f"<script>\n{record['script']}\n</script>"
27
+ )
28
+ return {"messages": [
29
+ {"role": "user", "content": user},
30
+ {"role": "assistant", "content": assistant},
31
+ ]}
32
+
33
+
34
+ def _bucket(case_id: str, seed: int) -> float:
35
+ """Deterministic [0,1) hash of a case id (stable across runs/machines)."""
36
+ h = hashlib.md5(f"{seed}:{case_id}".encode("utf-8")).hexdigest()
37
+ return int(h[:8], 16) / 0xFFFFFFFF
38
+
39
+
40
+ def split_records(
41
+ records: list[dict[str, Any]],
42
+ *,
43
+ val_frac: float = 0.1,
44
+ test_frac: float = 0.1,
45
+ seed: int = 0,
46
+ ) -> dict[str, list[dict[str, Any]]]:
47
+ """Split records into train/val/test, disjoint by case_id, deterministically."""
48
+ out: dict[str, list[dict[str, Any]]] = {"train": [], "val": [], "test": []}
49
+ for rec in records:
50
+ b = _bucket(rec["case_id"], seed)
51
+ if b < test_frac:
52
+ out["test"].append(rec)
53
+ elif b < test_frac + val_frac:
54
+ out["val"].append(rec)
55
+ else:
56
+ out["train"].append(rec)
57
+ return out
datamorph/model.py ADDED
@@ -0,0 +1,63 @@
1
+ """Resolve which MLX model the conversion pipeline should load.
2
+
3
+ Resolution order (first hit wins):
4
+
5
+ 1. an explicit ``model`` argument, or ``$GEMMA_MLX_MODEL`` — a local directory
6
+ path, or a Hugging Face repo id;
7
+ 2. the default local model directory, if present (skips a download for
8
+ developers who already have the weights);
9
+ 3. the published model on the Hugging Face Hub, downloaded + cached.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import re
16
+ from pathlib import Path
17
+
18
+ ENV_VAR = "GEMMA_MLX_MODEL"
19
+ DEFAULT_MODEL_NAME = "gemma4-e2b-textonly-iter400-q8-vocab16k"
20
+ DEFAULT_HF_REPO = "Bunnana/data-morph-gemma-2b"
21
+ # datamorph/model.py -> repo root is one parent up; weights live in models/.
22
+ DEFAULT_MODEL_DIR = Path(__file__).resolve().parent.parent / "models" / DEFAULT_MODEL_NAME
23
+
24
+ # A Hugging Face repo id is "<owner>/<name>" — exactly one slash, no leading
25
+ # slash, and only id-safe characters. Anything else is treated as a local path.
26
+ _REPO_ID_RE = re.compile(r"[A-Za-z0-9][\w.-]*/[\w.-]+\Z")
27
+
28
+
29
+ def _looks_like_repo_id(value: str) -> bool:
30
+ return bool(_REPO_ID_RE.fullmatch(value))
31
+
32
+
33
+ def _download(repo_id: str) -> str:
34
+ try:
35
+ from huggingface_hub import snapshot_download
36
+ except ImportError as exc: # pragma: no cover - dependency is declared
37
+ raise RuntimeError(
38
+ "huggingface_hub is required to download the model. "
39
+ "Install it with `pip install huggingface_hub`."
40
+ ) from exc
41
+ return snapshot_download(repo_id)
42
+
43
+
44
+ def resolve_model(model: str | os.PathLike[str] | None = None) -> str:
45
+ """Return a local path to the MLX model to load (downloading it if needed)."""
46
+ candidate = model or os.environ.get(ENV_VAR)
47
+ if candidate:
48
+ candidate = str(candidate)
49
+ path = Path(candidate).expanduser()
50
+ if path.exists():
51
+ return str(path)
52
+ if _looks_like_repo_id(candidate):
53
+ return _download(candidate)
54
+ raise FileNotFoundError(
55
+ f"Model path not found: {path}. Pass a local directory, a Hugging Face "
56
+ f"repo id, or set ${ENV_VAR}."
57
+ )
58
+
59
+ if DEFAULT_MODEL_DIR.exists():
60
+ return str(DEFAULT_MODEL_DIR)
61
+
62
+ # Nothing local — fall back to the published model on the Hub.
63
+ return _download(DEFAULT_HF_REPO)
File without changes