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,447 @@
1
+ """JSON metadata extractor — file inspection + path walker + envelope.
2
+
3
+ CLI entry point at the bottom of the file. See the spec at
4
+ docs/superpowers/specs/2026-05-20-json-metadata-extractor-design.md.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from .base import MetadataExtractor
14
+ from .json_walker import PathStats, walk
15
+ from .warning_rules import (
16
+ MetadataWarning,
17
+ check_deeply_nested,
18
+ check_heterogeneous_array,
19
+ check_large_array,
20
+ check_likely_date_value,
21
+ check_mixed_type_path,
22
+ check_optional_key,
23
+ )
24
+
25
+ DEFAULT_MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
26
+ DEFAULT_MAX_DEPTH_WARN = 6
27
+ DEFAULT_MAX_ARRAY_LEN_WARN = 10_000
28
+ OBJECT_ELEMENT_THRESHOLD = 0.8
29
+
30
+
31
+ def _classify_root(root: Any) -> str:
32
+ """Return one of 'record_array', 'object', or 'unsupported' (spec §5.4)."""
33
+ if isinstance(root, dict):
34
+ return "object"
35
+ if isinstance(root, list):
36
+ if not root:
37
+ return "unsupported"
38
+ object_count = sum(1 for el in root if isinstance(el, dict))
39
+ if object_count / len(root) >= OBJECT_ELEMENT_THRESHOLD:
40
+ return "record_array"
41
+ return "unsupported"
42
+ return "unsupported"
43
+
44
+
45
+ def _resolve_dtype(dtypes_seen: frozenset[str]) -> str:
46
+ """Resolve a path's dtype per spec §5.5.
47
+
48
+ - All non-null contributions of one dtype → that dtype.
49
+ - Multiple non-null dtypes → 'mixed'.
50
+ - Only nulls → 'null'.
51
+ """
52
+ real = dtypes_seen - {"null"}
53
+ if not real:
54
+ return "null"
55
+ if len(real) == 1:
56
+ return next(iter(real))
57
+ return "mixed"
58
+
59
+
60
+ def _sample_record_array(
61
+ root: list[Any],
62
+ head_n: int,
63
+ middle_n: int,
64
+ tail_n: int,
65
+ ) -> dict[str, list[Any]]:
66
+ """Return {head, middle, tail} per spec §5.3. No element is duplicated."""
67
+ n = len(root)
68
+ if n < head_n + middle_n + tail_n:
69
+ return {"head": list(root), "middle": [], "tail": []}
70
+ head = root[:head_n]
71
+ tail = root[n - tail_n:] if tail_n > 0 else []
72
+ if middle_n > 0:
73
+ # Place middle in the center of the available space (between head and tail).
74
+ available_start = head_n
75
+ available_end = n - tail_n
76
+ mid_start = (available_start + available_end - middle_n) // 2
77
+ middle = root[mid_start : mid_start + middle_n]
78
+ else:
79
+ middle = []
80
+ return {"head": head, "middle": middle, "tail": tail}
81
+
82
+
83
+ def _render_path(stats: PathStats) -> dict[str, Any]:
84
+ """Render a PathStats into the envelope's per-path dict (spec §5.2)."""
85
+ dtype = _resolve_dtype(stats.dtypes_seen)
86
+ presence = (
87
+ stats.occurrence_count / stats.denominator if stats.denominator > 0 else 0.0
88
+ )
89
+ entry: dict[str, Any] = {
90
+ "path": stats.path,
91
+ "dtype": dtype,
92
+ "presence": round(presence, 4),
93
+ }
94
+ if dtype not in ("object", "array"):
95
+ entry["sample_values"] = list(stats.sample_values)
96
+ entry["unique_count"] = stats.unique_count
97
+ if dtype in ("integer", "float") and stats.min_value is not None:
98
+ entry["min"] = stats.min_value
99
+ entry["max"] = stats.max_value
100
+ if dtype == "string" and stats.max_length is not None:
101
+ entry["max_length"] = stats.max_length
102
+ return entry
103
+
104
+
105
+ def _minimal_envelope(
106
+ file_path: Path,
107
+ file_size: int,
108
+ warnings_: list[MetadataWarning],
109
+ ) -> dict[str, Any]:
110
+ """Return a well-formed but empty envelope used by every short-circuit code."""
111
+ return {
112
+ "format": "json",
113
+ "file_path": str(file_path),
114
+ "file_size_bytes": file_size,
115
+ "encoding": "utf-8",
116
+ "schema_version": MetadataExtractor.SCHEMA_VERSION,
117
+ "schema": {"root_shape": "unsupported", "paths": []},
118
+ "samples": {},
119
+ "warnings": [w.to_dict() for w in warnings_],
120
+ }
121
+
122
+
123
+ def _split_path(path: str) -> list[str]:
124
+ """Split a PathStats path string into a sequence of hops.
125
+
126
+ Examples:
127
+ "" -> []
128
+ "users" -> ["users"]
129
+ "[]" -> ["[]"]
130
+ "[].name" -> ["[]", "name"]
131
+ "users[].id" -> ["users", "[]", "id"]
132
+ "a.b.c" -> ["a", "b", "c"]
133
+ """
134
+ if not path:
135
+ return []
136
+ out: list[str] = []
137
+ buf = ""
138
+ i = 0
139
+ while i < len(path):
140
+ ch = path[i]
141
+ if ch == "[" and path[i : i + 2] == "[]":
142
+ if buf:
143
+ out.append(buf)
144
+ buf = ""
145
+ out.append("[]")
146
+ i += 2
147
+ if i < len(path) and path[i] == ".":
148
+ i += 1
149
+ elif ch == ".":
150
+ if buf:
151
+ out.append(buf)
152
+ buf = ""
153
+ i += 1
154
+ else:
155
+ buf += ch
156
+ i += 1
157
+ if buf:
158
+ out.append(buf)
159
+ return out
160
+
161
+
162
+ def _collect_array_element_signatures(
163
+ root: Any, target_path: str
164
+ ) -> tuple[list[frozenset[str]], bool]:
165
+ """Return (child_key_sets, mixed_element_types_flag) for one array path.
166
+
167
+ Walks `root` along `target_path` (using PathStats notation) and
168
+ collects element key sets at every visit of the array. Used only
169
+ by check_heterogeneous_array — too specialised to live in the walker.
170
+ """
171
+ parts = _split_path(target_path)
172
+
173
+ key_sets: list[frozenset[str]] = []
174
+ mixed_types = {"object": False, "non_object": False}
175
+
176
+ def descend(node: Any, idx: int) -> None:
177
+ if idx >= len(parts):
178
+ # node is the array itself.
179
+ if not isinstance(node, list):
180
+ return
181
+ for el in node:
182
+ if isinstance(el, dict):
183
+ key_sets.append(frozenset(el.keys()))
184
+ mixed_types["object"] = True
185
+ else:
186
+ mixed_types["non_object"] = True
187
+ return
188
+ part = parts[idx]
189
+ if part == "[]":
190
+ if isinstance(node, list):
191
+ for el in node:
192
+ descend(el, idx + 1)
193
+ else:
194
+ if isinstance(node, dict) and part in node:
195
+ descend(node[part], idx + 1)
196
+
197
+ descend(root, 0)
198
+ mixed = mixed_types["object"] and mixed_types["non_object"]
199
+ return key_sets, mixed
200
+
201
+
202
+ def _collect_warnings(
203
+ path_stats: list[PathStats],
204
+ max_depth: int,
205
+ max_depth_warn: int,
206
+ max_array_len_warn: int,
207
+ root: Any,
208
+ ) -> list[MetadataWarning]:
209
+ """Apply the six pure check_* rules over walker output + scalars."""
210
+ warnings_: list[MetadataWarning] = []
211
+
212
+ # Per-path rules.
213
+ for s in path_stats:
214
+ for fn in (
215
+ check_optional_key,
216
+ check_mixed_type_path,
217
+ check_likely_date_value,
218
+ ):
219
+ w = fn(stats=s)
220
+ if w is not None:
221
+ warnings_.append(w)
222
+ w = check_large_array(stats=s, threshold=max_array_len_warn)
223
+ if w is not None:
224
+ warnings_.append(w)
225
+
226
+ # Whole-envelope rule: deeply-nested.
227
+ w = check_deeply_nested(max_depth=max_depth, threshold=max_depth_warn)
228
+ if w is not None:
229
+ warnings_.append(w)
230
+
231
+ # Heterogeneous-array rule needs per-array child-key-sets.
232
+ for s in path_stats:
233
+ if "array" not in s.dtypes_seen:
234
+ continue
235
+ key_sets, mixed_types = _collect_array_element_signatures(root, s.path)
236
+ if not key_sets and not mixed_types:
237
+ continue
238
+ w = check_heterogeneous_array(
239
+ stats=s,
240
+ child_key_sets=key_sets,
241
+ mixed_element_types=mixed_types,
242
+ )
243
+ if w is not None:
244
+ warnings_.append(w)
245
+
246
+ return warnings_
247
+
248
+
249
+ class JSONExtractor(MetadataExtractor):
250
+ """Stage 1b — turns a JSON file into the shared metadata envelope.
251
+
252
+ See spec §4 for parameter semantics. All thresholds are configurable
253
+ via the constructor; the defaults match spec §6.2.
254
+ """
255
+
256
+ def __init__(
257
+ self,
258
+ head_n: int = 3,
259
+ middle_n: int = 1,
260
+ tail_n: int = 1,
261
+ sample_values_per_path: int = 3,
262
+ max_file_size_bytes: int = DEFAULT_MAX_FILE_SIZE,
263
+ max_depth_warn: int = DEFAULT_MAX_DEPTH_WARN,
264
+ max_array_len_warn: int = DEFAULT_MAX_ARRAY_LEN_WARN,
265
+ ) -> None:
266
+ self.head_n = head_n
267
+ self.middle_n = middle_n
268
+ self.tail_n = tail_n
269
+ self.sample_values_per_path = sample_values_per_path
270
+ self.max_file_size_bytes = max_file_size_bytes
271
+ self.max_depth_warn = max_depth_warn
272
+ self.max_array_len_warn = max_array_len_warn
273
+
274
+ def supports(self, file_path: Path) -> bool:
275
+ return file_path.suffix.lower() == ".json"
276
+
277
+ def extract(self, file_path: Path) -> dict[str, Any]:
278
+ file_size = file_path.stat().st_size
279
+
280
+ # 1. File-size guard (cheapest check, before any I/O beyond stat).
281
+ if file_size > self.max_file_size_bytes:
282
+ return _minimal_envelope(
283
+ file_path,
284
+ file_size,
285
+ [
286
+ MetadataWarning(
287
+ code="FILE_TOO_LARGE",
288
+ severity="error",
289
+ message=(
290
+ f"File size {file_size} bytes exceeds cap "
291
+ f"{self.max_file_size_bytes} bytes."
292
+ ),
293
+ context={
294
+ "file_size_bytes": file_size,
295
+ "max_file_size_bytes": self.max_file_size_bytes,
296
+ },
297
+ )
298
+ ],
299
+ )
300
+
301
+ # 2. Empty-file guard.
302
+ if file_size == 0:
303
+ return _minimal_envelope(
304
+ file_path,
305
+ file_size,
306
+ [
307
+ MetadataWarning(
308
+ code="EMPTY_FILE",
309
+ severity="error",
310
+ message="File is byte-empty.",
311
+ context={"file_size_bytes": 0},
312
+ )
313
+ ],
314
+ )
315
+
316
+ # 3. Decode (utf-8-sig handles BOM transparently).
317
+ try:
318
+ text = file_path.read_bytes().decode("utf-8-sig")
319
+ except UnicodeDecodeError as e:
320
+ return _minimal_envelope(
321
+ file_path,
322
+ file_size,
323
+ [
324
+ MetadataWarning(
325
+ code="MALFORMED_JSON",
326
+ severity="error",
327
+ message=f"File is not valid UTF-8: {e}",
328
+ context={"error": str(e)},
329
+ )
330
+ ],
331
+ )
332
+
333
+ # 4. Parse.
334
+ try:
335
+ root = json.loads(text)
336
+ except json.JSONDecodeError as e:
337
+ return _minimal_envelope(
338
+ file_path,
339
+ file_size,
340
+ [
341
+ MetadataWarning(
342
+ code="MALFORMED_JSON",
343
+ severity="error",
344
+ message=f"JSON parse failed: {e}",
345
+ context={
346
+ "error": str(e),
347
+ "line": e.lineno,
348
+ "column": e.colno,
349
+ },
350
+ )
351
+ ],
352
+ )
353
+
354
+ # 5. Empty-root guard (parsed [], {}, or null).
355
+ if root in (None, [], {}):
356
+ return _minimal_envelope(
357
+ file_path,
358
+ file_size,
359
+ [
360
+ MetadataWarning(
361
+ code="EMPTY_FILE",
362
+ severity="error",
363
+ message="Parsed root is empty / null.",
364
+ context={"parsed_type": type(root).__name__},
365
+ )
366
+ ],
367
+ )
368
+
369
+ # 6. Root-shape classification.
370
+ root_shape = _classify_root(root)
371
+ if root_shape == "unsupported":
372
+ return _minimal_envelope(
373
+ file_path,
374
+ file_size,
375
+ [
376
+ MetadataWarning(
377
+ code="ROOT_SHAPE_UNSUPPORTED",
378
+ severity="error",
379
+ message=(
380
+ f"JSON root is {type(root).__name__}; Phase 2 supports "
381
+ f"only object roots and record-array roots (≥80% object elements)."
382
+ ),
383
+ context={"observed_root_type": type(root).__name__},
384
+ )
385
+ ],
386
+ )
387
+
388
+ # 7. Walk the root and render schema.paths.
389
+ path_stats = walk(root, sample_values_per_path=self.sample_values_per_path)
390
+ max_depth = max((s.max_depth_seen for s in path_stats), default=0)
391
+ schema: dict[str, Any] = {
392
+ "root_shape": root_shape,
393
+ "max_depth": max_depth,
394
+ "paths": [_render_path(s) for s in path_stats],
395
+ }
396
+ if root_shape == "record_array":
397
+ schema["root_array_length"] = len(root)
398
+
399
+ # 8. Sample records if root is a record_array.
400
+ if root_shape == "record_array":
401
+ samples = _sample_record_array(
402
+ root, self.head_n, self.middle_n, self.tail_n
403
+ )
404
+ else:
405
+ samples = {}
406
+
407
+ # 9. Apply warning rules over walker output.
408
+ warnings_list = _collect_warnings(
409
+ path_stats=path_stats,
410
+ max_depth=max_depth,
411
+ max_depth_warn=self.max_depth_warn,
412
+ max_array_len_warn=self.max_array_len_warn,
413
+ root=root,
414
+ )
415
+
416
+ return {
417
+ "format": "json",
418
+ "file_path": str(file_path),
419
+ "file_size_bytes": file_size,
420
+ "encoding": "utf-8",
421
+ "schema_version": MetadataExtractor.SCHEMA_VERSION,
422
+ "schema": schema,
423
+ "samples": samples,
424
+ "warnings": [w.to_dict() for w in warnings_list],
425
+ }
426
+
427
+
428
+ def _main() -> int:
429
+ import argparse
430
+
431
+ parser = argparse.ArgumentParser(
432
+ prog="python -m datamorph.extractor.json_extractor",
433
+ description="Extract metadata envelope from a JSON file.",
434
+ )
435
+ parser.add_argument("file", help="Path to a .json file")
436
+ args = parser.parse_args()
437
+
438
+ env = JSONExtractor().extract(Path(args.file))
439
+ text = json.dumps(env, indent=2, default=str, ensure_ascii=False)
440
+ print(text)
441
+ token_estimate = len(text) // 4
442
+ print(f"# rough token estimate: ~{token_estimate} (chars / 4)")
443
+ return 0
444
+
445
+
446
+ if __name__ == "__main__":
447
+ raise SystemExit(_main())
@@ -0,0 +1,217 @@
1
+ """Pure recursive walker for JSON values.
2
+
3
+ Given a parsed JSON node (the output of json.loads), walks the structure
4
+ and returns a list of PathStats — one entry per unique path observed.
5
+
6
+ The walker is pure: no I/O, no warnings. The extractor consumes the
7
+ PathStats list and derives warnings as a second pass.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+ from typing import Any
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class PathStats:
18
+ """Aggregated facts about a single JSON path.
19
+
20
+ Path notation uses dotted keys with ``[]`` marking each array hop.
21
+ Examples: ``[].id`` (root-array element field), ``users[].name``
22
+ (key inside an object root), ``[].orders[].price`` (nested array).
23
+
24
+ Attributes:
25
+ path: Path string.
26
+ dtypes_seen: Set of dtype names observed at this path (e.g.
27
+ ``{"integer"}`` or ``{"integer", "string"}`` for mixed).
28
+ ``null`` is recorded separately and does NOT promote to mixed.
29
+ occurrence_count: Number of times this path appeared.
30
+ denominator: Number of times this path's parent existed
31
+ (used for presence = occurrence_count / denominator).
32
+ sample_values: First-seen-wins sample of values at this path,
33
+ capped at sample_values_per_path. Empty for container paths.
34
+ max_depth_seen: Depth at which this path lives. Root = 0.
35
+ array_lengths_seen: Lengths of arrays observed at this path.
36
+ Populated only when path ends in ``[]`` (i.e. the path itself
37
+ refers to an array). Empty tuple otherwise.
38
+ max_length: Max string length observed (string paths only).
39
+ min_value: Min numeric value observed (integer/float paths only).
40
+ max_value: Max numeric value observed (integer/float paths only).
41
+ unique_count: Number of distinct values within sample_values
42
+ (bounded by sample_values_per_path).
43
+ """
44
+
45
+ path: str
46
+ dtypes_seen: frozenset[str]
47
+ occurrence_count: int
48
+ denominator: int
49
+ sample_values: tuple[Any, ...]
50
+ max_depth_seen: int
51
+ array_lengths_seen: tuple[int, ...]
52
+ max_length: int | None
53
+ min_value: float | int | None
54
+ max_value: float | int | None
55
+ unique_count: int
56
+
57
+
58
+ @dataclass
59
+ class _Acc:
60
+ """Mutable accumulator used during a walk. Converted to PathStats at the end."""
61
+
62
+ path: str
63
+ dtypes_seen: set[str] = field(default_factory=set)
64
+ occurrence_count: int = 0
65
+ denominator: int = 0
66
+ sample_values: list[Any] = field(default_factory=list)
67
+ max_depth_seen: int = 0
68
+ array_lengths_seen: list[int] = field(default_factory=list)
69
+ max_length: int | None = None
70
+ min_value: float | int | None = None
71
+ max_value: float | int | None = None
72
+
73
+
74
+ def _dtype_of(value: Any) -> str:
75
+ """Map a JSON-decoded Python value to its dtype name (see spec §5.5)."""
76
+ if value is None:
77
+ return "null"
78
+ if isinstance(value, bool):
79
+ return "boolean"
80
+ if isinstance(value, int):
81
+ return "integer"
82
+ if isinstance(value, float):
83
+ return "float"
84
+ if isinstance(value, str):
85
+ return "string"
86
+ if isinstance(value, list):
87
+ return "array"
88
+ if isinstance(value, dict):
89
+ return "object"
90
+ return "unknown"
91
+
92
+
93
+ def _record_leaf(acc: _Acc, value: Any, depth: int, cap: int) -> None:
94
+ """Update an accumulator with a single observed leaf value."""
95
+ acc.occurrence_count += 1
96
+ acc.max_depth_seen = max(acc.max_depth_seen, depth)
97
+ acc.dtypes_seen.add(_dtype_of(value))
98
+ if value not in acc.sample_values and len(acc.sample_values) < cap:
99
+ acc.sample_values.append(value)
100
+
101
+ # Numeric extras — booleans are a subclass of int in Python, so guard.
102
+ if isinstance(value, bool):
103
+ return
104
+ if isinstance(value, (int, float)):
105
+ acc.min_value = value if acc.min_value is None else min(acc.min_value, value)
106
+ acc.max_value = value if acc.max_value is None else max(acc.max_value, value)
107
+ elif isinstance(value, str):
108
+ length = len(value)
109
+ acc.max_length = length if acc.max_length is None else max(acc.max_length, length)
110
+
111
+
112
+ def _finalize(acc: _Acc) -> PathStats:
113
+ """Freeze a mutable accumulator into an immutable PathStats."""
114
+ return PathStats(
115
+ path=acc.path,
116
+ dtypes_seen=frozenset(acc.dtypes_seen),
117
+ occurrence_count=acc.occurrence_count,
118
+ denominator=acc.denominator,
119
+ sample_values=tuple(acc.sample_values),
120
+ max_depth_seen=acc.max_depth_seen,
121
+ array_lengths_seen=tuple(acc.array_lengths_seen),
122
+ max_length=acc.max_length,
123
+ min_value=acc.min_value,
124
+ max_value=acc.max_value,
125
+ unique_count=len(set(acc.sample_values)),
126
+ )
127
+
128
+
129
+ def walk(root: Any, sample_values_per_path: int) -> list[PathStats]:
130
+ """Walk a parsed JSON value and return per-path statistics.
131
+
132
+ For scalar / heterogeneous roots, returns an empty list — the caller
133
+ (JSONExtractor) is responsible for short-circuiting with
134
+ ROOT_SHAPE_UNSUPPORTED.
135
+
136
+ Args:
137
+ root: A value as produced by json.loads (dict, list, str, int,
138
+ float, bool, or None).
139
+ sample_values_per_path: Cap on how many distinct values to keep
140
+ per path. Must be > 0.
141
+
142
+ Returns:
143
+ Ordered list of PathStats (one per unique path observed).
144
+ """
145
+ if not isinstance(root, (dict, list)):
146
+ return []
147
+
148
+ accs: dict[str, _Acc] = {}
149
+
150
+ def get(path: str) -> _Acc:
151
+ if path not in accs:
152
+ accs[path] = _Acc(path=path)
153
+ return accs[path]
154
+
155
+ def visit_value(value: Any, path: str, depth: int, denom_increment: int) -> None:
156
+ acc = get(path)
157
+ acc.denominator += denom_increment
158
+
159
+ # Only record scalar leaves in sample_values; containers update
160
+ # occurrence_count + dtype + max_depth manually so their dtype
161
+ # ("object"/"array") is recorded without polluting sample_values.
162
+ if not isinstance(value, (dict, list)):
163
+ _record_leaf(acc, value, depth=depth, cap=sample_values_per_path)
164
+ else:
165
+ acc.occurrence_count += 1
166
+ acc.max_depth_seen = max(acc.max_depth_seen, depth)
167
+ acc.dtypes_seen.add(_dtype_of(value))
168
+
169
+ if isinstance(value, dict):
170
+ for key, child in value.items():
171
+ child_path = f"{path}.{key}" if path else key
172
+ visit_value(child, child_path, depth + 1, denom_increment=1)
173
+ elif isinstance(value, list):
174
+ acc.array_lengths_seen.append(len(value))
175
+ child_path = f"{path}[]"
176
+ for element in value:
177
+ if isinstance(element, dict):
178
+ for key, sub in element.items():
179
+ leaf_path = f"{child_path}.{key}"
180
+ visit_value(sub, leaf_path, depth + 2, denom_increment=1)
181
+ else:
182
+ visit_value(element, child_path, depth + 1, denom_increment=1)
183
+
184
+ if isinstance(root, list):
185
+ # Collect all keys seen across all dict elements so that every
186
+ # key-path gets a denominator equal to the number of dict elements
187
+ # (not just the elements that happen to contain that key).
188
+ dict_element_count = sum(1 for el in root if isinstance(el, dict))
189
+ for element in root:
190
+ if isinstance(element, dict):
191
+ # First, bump the denominator for every key seen across ALL
192
+ # elements so optional keys get a correct presence fraction.
193
+ pass # handled below after collecting the full key universe
194
+ # Non-dict elements at array root contribute to HETEROGENEOUS_ARRAY
195
+ # detection in the extractor, not here.
196
+
197
+ # Two-pass approach: collect full key universe, then visit values.
198
+ all_keys: set[str] = set()
199
+ for element in root:
200
+ if isinstance(element, dict):
201
+ all_keys.update(element.keys())
202
+
203
+ # Prime denominators for all known keys with the total dict-element count.
204
+ for key in all_keys:
205
+ acc = get(f"[].{key}")
206
+ acc.denominator += dict_element_count
207
+
208
+ # Now visit only present values (denom_increment=0 since already set).
209
+ for element in root:
210
+ if isinstance(element, dict):
211
+ for key, value in element.items():
212
+ visit_value(value, f"[].{key}", depth=1, denom_increment=0)
213
+ elif isinstance(root, dict):
214
+ for key, value in root.items():
215
+ visit_value(value, key, depth=1, denom_increment=1)
216
+
217
+ return [_finalize(acc) for acc in accs.values()]