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.
- data_morph_gemma-0.1.0.dist-info/METADATA +177 -0
- data_morph_gemma-0.1.0.dist-info/RECORD +39 -0
- data_morph_gemma-0.1.0.dist-info/WHEEL +4 -0
- data_morph_gemma-0.1.0.dist-info/entry_points.txt +2 -0
- data_morph_gemma-0.1.0.dist-info/licenses/LICENSE +25 -0
- datamorph/__init__.py +19 -0
- datamorph/cli.py +84 -0
- datamorph/convert.py +146 -0
- datamorph/data/__init__.py +1 -0
- datamorph/data/collect.py +221 -0
- datamorph/data/envelope.py +20 -0
- datamorph/data/generators/__init__.py +1 -0
- datamorph/data/generators/base.py +48 -0
- datamorph/data/generators/uc1_csv_to_json.py +64 -0
- datamorph/data/generators/uc2_json_to_csv.py +59 -0
- datamorph/data/generators/uc3_txt_log_to_csv.py +64 -0
- datamorph/data/generators/uc4_csv_to_txt_report.py +62 -0
- datamorph/data/generators/uc5_schema_migration.py +49 -0
- datamorph/data/sandbox.py +95 -0
- datamorph/data/teacher_script.py +114 -0
- datamorph/evaluation/__init__.py +0 -0
- datamorph/evaluation/metrics.py +264 -0
- datamorph/evaluation/output_cleanup.py +116 -0
- datamorph/evaluation/runner.py +218 -0
- datamorph/evaluation/teacher.py +193 -0
- datamorph/extractor/__init__.py +15 -0
- datamorph/extractor/base.py +26 -0
- datamorph/extractor/csv_extractor.py +515 -0
- datamorph/extractor/json_extractor.py +447 -0
- datamorph/extractor/json_walker.py +217 -0
- datamorph/extractor/sampler.py +68 -0
- datamorph/extractor/txt_extractor.py +199 -0
- datamorph/extractor/warning_rules.py +473 -0
- datamorph/features/__init__.py +1 -0
- datamorph/features/format_pairs.py +57 -0
- datamorph/model.py +63 -0
- datamorph/models/__init__.py +0 -0
- datamorph/models/gemma_mlx.py +163 -0
- 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
|