fieldwork 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.
fieldwork/__init__.py ADDED
@@ -0,0 +1,47 @@
1
+ """Explore unfamiliar data through patterns and inspectable evidence."""
2
+
3
+ from ._explore import (
4
+ ExplorerResult,
5
+ KeySpec,
6
+ SchemaProposal,
7
+ census,
8
+ grain,
9
+ infer_schema,
10
+ joint_counts,
11
+ levels,
12
+ )
13
+ from ._explore.relations import pairs
14
+ from .availability import missingness
15
+ from .discovery import discover_dependencies
16
+ from .evidence import InvestigationResult, Scope
17
+ from .navigation import PathResult, suggest_paths
18
+ from .patterns import value_patterns
19
+ from .presentation import render_html, render_plaintext, render_svg, visualization_data
20
+ from .workflow import Recipe, compare, explore
21
+
22
+ __version__ = "0.1.0"
23
+ __all__ = [
24
+ "ExplorerResult",
25
+ "InvestigationResult",
26
+ "KeySpec",
27
+ "PathResult",
28
+ "Recipe",
29
+ "SchemaProposal",
30
+ "Scope",
31
+ "census",
32
+ "compare",
33
+ "discover_dependencies",
34
+ "explore",
35
+ "grain",
36
+ "infer_schema",
37
+ "joint_counts",
38
+ "levels",
39
+ "missingness",
40
+ "pairs",
41
+ "render_html",
42
+ "render_plaintext",
43
+ "render_svg",
44
+ "suggest_paths",
45
+ "value_patterns",
46
+ "visualization_data",
47
+ ]
@@ -0,0 +1,27 @@
1
+ """Feature hierarchy explorer public implementation exports."""
2
+
3
+ from .census import census, levels
4
+ from .grain import grain
5
+ from .graphics import render_html, render_svg
6
+ from .orchestration import explore
7
+ from .relations import joint_counts
8
+ from .render import render_plaintext
9
+ from .result import ExplorerResult, KeySpec
10
+ from .roles import SchemaProposal, infer_schema
11
+ from .visual_data import visualization_data
12
+
13
+ __all__ = [
14
+ "ExplorerResult",
15
+ "KeySpec",
16
+ "SchemaProposal",
17
+ "census",
18
+ "explore",
19
+ "grain",
20
+ "infer_schema",
21
+ "joint_counts",
22
+ "levels",
23
+ "render_html",
24
+ "render_plaintext",
25
+ "render_svg",
26
+ "visualization_data",
27
+ ]
@@ -0,0 +1,64 @@
1
+ """Private exact grouping kernels used by the explorer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import Counter
6
+ from collections.abc import Iterable
7
+
8
+ import numpy as np
9
+
10
+
11
+ def dense_counts(codes: np.ndarray, rows: np.ndarray) -> dict[int, int]:
12
+ if rows.size == 0:
13
+ return {}
14
+ values, counts = np.unique(codes[rows], return_counts=True)
15
+ return {int(value): int(count) for value, count in zip(values, counts)}
16
+
17
+
18
+ def exact_pair_ids(
19
+ parents: np.ndarray, levels: np.ndarray, *, packing_limit: int | None = None
20
+ ) -> tuple[np.ndarray, list[tuple[int, int]]]:
21
+ """Dense IDs for exact integer pairs, with an overflow-safe tuple fallback."""
22
+
23
+ if parents.shape != levels.shape:
24
+ raise ValueError("parent and level arrays must have the same shape")
25
+ if parents.size == 0:
26
+ return np.empty(0, dtype=np.int64), []
27
+ width = int(levels.max()) + 1
28
+ max_parent = int(parents.max())
29
+ safe = np.iinfo(np.int64).max // max(width, 1)
30
+ if packing_limit is not None:
31
+ safe = min(safe, packing_limit)
32
+ if max_parent <= safe:
33
+ packed = parents.astype(np.int64) * width + levels.astype(np.int64)
34
+ unique, inverse = np.unique(packed, return_inverse=True)
35
+ pairs = [(int(item // width), int(item % width)) for item in unique]
36
+ return inverse.astype(np.int64, copy=False), pairs
37
+ lookup: dict[tuple[int, int], int] = {}
38
+ pair_list: list[tuple[int, int]] = []
39
+ inverse = np.empty(len(parents), dtype=np.int64)
40
+ for index, pair in enumerate(zip(parents.tolist(), levels.tolist())):
41
+ normalized = (int(pair[0]), int(pair[1]))
42
+ dense = lookup.get(normalized)
43
+ if dense is None:
44
+ dense = len(pair_list)
45
+ lookup[normalized] = dense
46
+ pair_list.append(normalized)
47
+ inverse[index] = dense
48
+ ordered = sorted(pair_list)
49
+ remap = np.empty(len(pair_list), dtype=np.int64)
50
+ for new_id, pair in enumerate(ordered):
51
+ remap[lookup[pair]] = new_id
52
+ return remap[inverse], ordered
53
+
54
+
55
+ def python_prefix_counts(rows: Iterable[tuple[object, ...]]) -> list[Counter]:
56
+ """Readable reference kernel used by differential tests."""
57
+
58
+ counters: list[Counter] = []
59
+ for row in rows:
60
+ for depth in range(1, len(row) + 1):
61
+ if len(counters) < depth:
62
+ counters.append(Counter())
63
+ counters[depth - 1][row[:depth]] += 1
64
+ return counters
@@ -0,0 +1,479 @@
1
+ """Independent level counts and bounded nested census."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import deque
6
+ from collections.abc import Iterable
7
+ from typing import Any
8
+
9
+ import numpy as np
10
+ import pandas as pd
11
+
12
+ from ._kernels import dense_counts
13
+ from .encoding import (
14
+ MISSING,
15
+ ScalarIdentity,
16
+ encode_series,
17
+ normalize_scalar,
18
+ resolve_columns,
19
+ validate_limit,
20
+ validate_schema,
21
+ )
22
+ from .result import ExplorerResult
23
+
24
+
25
+ def _source(df: pd.DataFrame) -> dict[str, Any]:
26
+ return {
27
+ "table_id": "table",
28
+ "rows": len(df),
29
+ "columns": len(df.columns),
30
+ "dtypes": [
31
+ {
32
+ "column": normalize_scalar(column, label=True).to_dict(),
33
+ "dtype": str(df[column].dtype),
34
+ }
35
+ for column in df.columns
36
+ ],
37
+ }
38
+
39
+
40
+ def _scope(
41
+ scope_id: str,
42
+ input_rows: int,
43
+ missing_excluded_rows: int,
44
+ restriction_excluded_rows: int,
45
+ conditional: bool,
46
+ lineage: list[str] | None = None,
47
+ *,
48
+ parent_scope: dict[str, Any] | None = None,
49
+ ) -> dict[str, Any]:
50
+ if parent_scope is not None:
51
+ input_rows = parent_scope["input_rows"]
52
+ missing_excluded_rows += parent_scope["missing_excluded_rows"]
53
+ restriction_excluded_rows += parent_scope["restriction_excluded_rows"]
54
+ conditional = conditional or parent_scope["conditional"]
55
+ lineage = [*parent_scope["lineage"], parent_scope["scope_id"], *(lineage or [])]
56
+ evaluated = input_rows - missing_excluded_rows - restriction_excluded_rows
57
+ return {
58
+ "scope_id": scope_id,
59
+ "input_rows": input_rows,
60
+ "missing_excluded_rows": missing_excluded_rows,
61
+ "restriction_excluded_rows": restriction_excluded_rows,
62
+ "evaluated_rows": evaluated,
63
+ "retained_rows": evaluated,
64
+ "conditional": conditional,
65
+ "lineage": lineage or [],
66
+ }
67
+
68
+
69
+ def _rank_counts(counts: dict[int, int], values: list[ScalarIdentity]) -> list[tuple[int, int]]:
70
+ return sorted(counts.items(), key=lambda item: (-item[1], values[item[0]].sort_key()))
71
+
72
+
73
+ def _mixed_warning(
74
+ feature_id: str, column: Any, values: Iterable[ScalarIdentity]
75
+ ) -> dict[str, Any] | None:
76
+ families = sorted({value.kind for value in values if value is not MISSING})
77
+ if len(families) > 1:
78
+ return {
79
+ "code": "MIXED_LEVEL_TYPES",
80
+ "feature_id": feature_id,
81
+ "column": normalize_scalar(column, label=True).to_dict(),
82
+ "families": families,
83
+ }
84
+ return None
85
+
86
+
87
+ def levels(
88
+ df: pd.DataFrame,
89
+ features: Iterable[Any] | None = None,
90
+ *,
91
+ top_n: int | None = None,
92
+ max_levels: int | None = 100,
93
+ min_count: int = 1,
94
+ dropna: bool = False,
95
+ schema: dict[Any, str] | None = None,
96
+ engine_metadata: bool = False,
97
+ scope_metadata: dict[str, Any] | None = None,
98
+ ) -> ExplorerResult:
99
+ """Count levels independently for every requested feature."""
100
+
101
+ selected = resolve_columns(df, features, argument="features", default_all=True)
102
+ validate_schema(df, schema)
103
+ validate_limit("top_n", top_n, zero=False)
104
+ validate_limit("max_levels", max_levels)
105
+ validate_limit("min_count", min_count)
106
+ records: list[dict[str, Any]] = []
107
+ warnings: list[dict[str, Any]] = []
108
+ scopes: list[dict[str, Any]] = []
109
+ for position, column in enumerate(selected):
110
+ feature_id = f"f{position}"
111
+ values, codes = encode_series(df[column])
112
+ missing_code = values.index(MISSING) if MISSING in values else None
113
+ eligible = np.arange(len(df), dtype=np.int64)
114
+ missing_excluded = 0
115
+ if dropna and missing_code is not None:
116
+ keep = codes != missing_code
117
+ missing_excluded = int((~keep).sum())
118
+ eligible = eligible[keep]
119
+ counts = dense_counts(codes, eligible)
120
+ ranked = _rank_counts(counts, values)
121
+ semantic = ranked[:top_n] if top_n is not None else ranked
122
+ semantic = [item for item in semantic if item[1] >= min_count]
123
+ reported = semantic[:max_levels] if max_levels is not None else semantic
124
+ evaluated_rows = len(eligible)
125
+ output_levels = [
126
+ {
127
+ "level_id": f"{feature_id}:l{code}",
128
+ "rank": rank,
129
+ "value": values[code].to_dict(),
130
+ "count": count,
131
+ "share_of_feature": count / evaluated_rows if evaluated_rows else None,
132
+ "share_reason": None if evaluated_rows else "empty_population",
133
+ }
134
+ for rank, (code, count) in enumerate(reported, 1)
135
+ ]
136
+ reported_rows = sum(item[1] for item in reported)
137
+ scope_id = f"s1:{feature_id}"
138
+ scopes.append(_scope(scope_id, len(df), missing_excluded, 0, False))
139
+ records.append(
140
+ {
141
+ "feature_id": feature_id,
142
+ "column": normalize_scalar(column, label=True).to_dict(),
143
+ "role": (schema or {}).get(column),
144
+ "scope_id": scope_id,
145
+ "status": "empty" if evaluated_rows == 0 else "computed",
146
+ "levels_total": len(ranked),
147
+ "levels_reported": len(reported),
148
+ "omitted_levels": len(ranked) - len(reported),
149
+ "reported_rows": reported_rows,
150
+ "unreported_rows": evaluated_rows - reported_rows,
151
+ "levels": output_levels,
152
+ }
153
+ )
154
+ warning = _mixed_warning(feature_id, column, values)
155
+ if warning:
156
+ warnings.append(warning)
157
+ role = (schema or {}).get(column)
158
+ if role in {"id", "continuous"}:
159
+ warnings.append(
160
+ {
161
+ "code": "EXPLICIT_ROLE_SELECTION",
162
+ "feature_id": feature_id,
163
+ "column": normalize_scalar(column, label=True).to_dict(),
164
+ "role": role,
165
+ }
166
+ )
167
+ payload: dict[str, Any] = {
168
+ "status": "empty" if len(df) == 0 else "computed",
169
+ "source": _source(df),
170
+ "scopes": scopes,
171
+ "effective_limits": {
172
+ "top_n": top_n,
173
+ "max_levels": max_levels,
174
+ "min_count": min_count,
175
+ "dropna": dropna,
176
+ },
177
+ "per_feature": records,
178
+ "warnings": warnings,
179
+ }
180
+ if scope_metadata:
181
+ payload["scope_metadata"] = scope_metadata
182
+ if engine_metadata:
183
+ payload["engine"] = {"name": "typed_dense_counts"}
184
+ return ExplorerResult("levels", payload)
185
+
186
+
187
+ def _pre_mask_per_parent(
188
+ codes: list[np.ndarray],
189
+ values: list[list[ScalarIdentity]],
190
+ eligible: np.ndarray,
191
+ top_n: int,
192
+ ) -> tuple[np.ndarray, list[dict[str, Any]]]:
193
+ surviving = np.zeros(codes[0].shape[0], dtype=bool)
194
+ retained: list[dict[str, Any]] = []
195
+ queue: deque[tuple[int, np.ndarray, tuple[int, ...]]] = deque([(0, eligible, ())])
196
+ while queue:
197
+ depth, rows, path = queue.popleft()
198
+ counts = dense_counts(codes[depth], rows)
199
+ chosen = _rank_counts(counts, values[depth])[:top_n]
200
+ retained.append(
201
+ {
202
+ "path": list(path),
203
+ "depth": depth + 1,
204
+ "level_codes": [code for code, _ in chosen],
205
+ }
206
+ )
207
+ for code, _ in chosen:
208
+ child_rows = rows[codes[depth][rows] == code]
209
+ child_path = (*path, code)
210
+ if depth + 1 == len(codes):
211
+ surviving[child_rows] = True
212
+ else:
213
+ queue.append((depth + 1, child_rows, child_path))
214
+ return surviving, retained
215
+
216
+
217
+ def _census(
218
+ df: pd.DataFrame,
219
+ dimensions: Iterable[Any],
220
+ *,
221
+ top_n: int | None = None,
222
+ top_n_mode: str = "post",
223
+ top_n_per_parent: bool = False,
224
+ min_retained_fraction: float = 0.01,
225
+ max_depth: int | None = None,
226
+ max_levels: int | None = 100,
227
+ max_nodes: int | None = 10000,
228
+ min_count: int = 1,
229
+ dropna: bool = False,
230
+ schema: dict[Any, str] | None = None,
231
+ engine_metadata: bool = False,
232
+ ) -> ExplorerResult:
233
+ """Build a deterministic, ancestor-closed observed-prefix census."""
234
+
235
+ selected = resolve_columns(df, dimensions, argument="dimensions")
236
+ validate_schema(df, schema)
237
+ validate_limit("top_n", top_n, zero=False)
238
+ validate_limit("max_depth", max_depth, zero=False)
239
+ validate_limit("max_levels", max_levels)
240
+ validate_limit("max_nodes", max_nodes)
241
+ validate_limit("min_count", min_count)
242
+ if top_n_mode not in {"pre", "post"}:
243
+ raise ValueError("top_n_mode must be 'pre' or 'post'")
244
+ if (
245
+ not isinstance(min_retained_fraction, (int, float))
246
+ or isinstance(min_retained_fraction, bool)
247
+ or not 0 <= min_retained_fraction <= 1
248
+ ):
249
+ raise ValueError("min_retained_fraction must be between 0 and 1")
250
+ active = selected[:max_depth] if max_depth is not None else selected
251
+ dictionaries: list[list[ScalarIdentity]] = []
252
+ code_arrays: list[np.ndarray] = []
253
+ missing_codes: list[int | None] = []
254
+ for column in active:
255
+ values, codes = encode_series(df[column])
256
+ dictionaries.append(values)
257
+ code_arrays.append(codes)
258
+ missing_codes.append(values.index(MISSING) if MISSING in values else None)
259
+ eligible_mask = np.ones(len(df), dtype=bool)
260
+ if dropna:
261
+ for codes, missing_code in zip(code_arrays, missing_codes):
262
+ if missing_code is not None:
263
+ eligible_mask &= codes != missing_code
264
+ eligible = np.flatnonzero(eligible_mask)
265
+ missing_excluded = len(df) - len(eligible)
266
+ retained_metadata: list[dict[str, Any]] = []
267
+ evaluated = eligible
268
+ warnings: list[dict[str, Any]] = []
269
+ if top_n_mode == "pre" and top_n is not None and len(eligible):
270
+ if top_n_per_parent:
271
+ final_mask, retained_metadata = _pre_mask_per_parent(
272
+ code_arrays, dictionaries, eligible, top_n
273
+ )
274
+ else:
275
+ final_mask = eligible_mask.copy()
276
+ for depth, (codes, values) in enumerate(zip(code_arrays, dictionaries)):
277
+ ranked = _rank_counts(dense_counts(codes, eligible), values)
278
+ chosen = {code for code, _ in ranked[:top_n]}
279
+ retained_metadata.append({"depth": depth + 1, "level_codes": sorted(chosen)})
280
+ final_mask &= np.isin(codes, list(chosen))
281
+ evaluated = np.flatnonzero(final_mask)
282
+ if not len(evaluated):
283
+ raise ValueError(
284
+ "DEGENERATE_TOP_N: pre selection removed every eligible row "
285
+ f"({len(eligible)} eligible rows)"
286
+ )
287
+ fraction = len(evaluated) / len(eligible)
288
+ if fraction < min_retained_fraction:
289
+ warnings.append(
290
+ {
291
+ "code": "LOW_RETAINED_FRACTION",
292
+ "retained_fraction": fraction,
293
+ "eligible_rows": len(eligible),
294
+ "retained_rows": len(evaluated),
295
+ }
296
+ )
297
+ restriction_excluded = len(eligible) - len(evaluated)
298
+ scope = _scope(
299
+ "s2",
300
+ len(df),
301
+ missing_excluded,
302
+ restriction_excluded,
303
+ top_n_mode == "pre" and restriction_excluded > 0,
304
+ ["input", "dropna" if dropna else "include_missing", top_n_mode],
305
+ )
306
+ features = [
307
+ {
308
+ "feature_id": f"f{index}",
309
+ "column": normalize_scalar(column, label=True).to_dict(),
310
+ "role": (schema or {}).get(column),
311
+ "dictionary_cardinality": len(dictionaries[index]),
312
+ }
313
+ for index, column in enumerate(active)
314
+ ]
315
+ for index, values in enumerate(dictionaries):
316
+ warning = _mixed_warning(f"f{index}", active[index], values)
317
+ if warning:
318
+ warnings.append(warning)
319
+ role = (schema or {}).get(active[index])
320
+ if role in {"id", "continuous"}:
321
+ warnings.append(
322
+ {
323
+ "code": "EXPLICIT_ROLE_SELECTION",
324
+ "feature_id": f"f{index}",
325
+ "column": normalize_scalar(active[index], label=True).to_dict(),
326
+ "role": role,
327
+ }
328
+ )
329
+ global_chosen: list[set[int] | None] = []
330
+ for codes, values in zip(code_arrays, dictionaries):
331
+ if top_n is not None and not top_n_per_parent and top_n_mode == "post":
332
+ global_chosen.append(
333
+ {code for code, _ in _rank_counts(dense_counts(codes, evaluated), values)[:top_n]}
334
+ )
335
+ else:
336
+ global_chosen.append(None)
337
+ nodes: list[dict[str, Any]] = []
338
+ emitted_levels: set[tuple[int, int]] = set()
339
+ queue: deque[tuple[str, int, np.ndarray, int]] = deque()
340
+ queue.append(("root", 0, evaluated, len(evaluated)))
341
+ root = {
342
+ "node_id": "root",
343
+ "parent_id": None,
344
+ "feature_id": None,
345
+ "level_id": None,
346
+ "depth": 0,
347
+ "count": len(evaluated),
348
+ "share_of_parent": None,
349
+ "share_of_total": 1.0 if len(evaluated) else None,
350
+ "share_reason": None if len(evaluated) else "empty_population",
351
+ "expansion_state": "unexpanded" if active else "complete",
352
+ "omitted_child_rows": 0,
353
+ "omitted_child_levels": 0,
354
+ "stop_reasons": [],
355
+ }
356
+ node_lookup: dict[str, dict[str, Any]] = {"root": root}
357
+ next_id = 0
358
+ while queue:
359
+ parent_id, depth, rows, parent_count = queue.popleft()
360
+ parent = node_lookup[parent_id]
361
+ if depth >= len(active):
362
+ parent["expansion_state"] = "complete"
363
+ continue
364
+ counts = dense_counts(code_arrays[depth], rows)
365
+ ranked = _rank_counts(counts, dictionaries[depth])
366
+ chosen = ranked
367
+ if top_n is not None:
368
+ if top_n_per_parent and top_n_mode == "post":
369
+ chosen = chosen[:top_n]
370
+ elif global_chosen[depth] is not None:
371
+ chosen = [item for item in chosen if item[0] in global_chosen[depth]]
372
+ chosen = [item for item in chosen if item[1] >= min_count]
373
+ if max_levels is not None:
374
+ chosen = chosen[:max_levels]
375
+ remaining_budget = None if max_nodes is None else max_nodes - len(nodes)
376
+ budget_truncated = remaining_budget is not None and len(chosen) > max(0, remaining_budget)
377
+ if remaining_budget is not None:
378
+ chosen = chosen[: max(0, remaining_budget)]
379
+ chosen_codes = {code for code, _ in chosen}
380
+ omitted_rows = sum(count for code, count in ranked if code not in chosen_codes)
381
+ parent["omitted_child_rows"] = omitted_rows
382
+ parent["omitted_child_levels"] = len(ranked) - len(chosen)
383
+ parent["expansion_state"] = "expanded"
384
+ reasons: list[str] = []
385
+ if len(chosen) < len(ranked):
386
+ if top_n is not None:
387
+ reasons.append("top_n")
388
+ if max_levels is not None and len(ranked) > max_levels:
389
+ reasons.append("max_levels")
390
+ if budget_truncated or (max_nodes is not None and len(nodes) >= max_nodes):
391
+ reasons.append("max_nodes")
392
+ if any(count < min_count for _, count in ranked):
393
+ reasons.append("min_count")
394
+ parent["stop_reasons"] = sorted(set(reasons))
395
+ for code, count in chosen:
396
+ node_id = f"n{next_id}"
397
+ next_id += 1
398
+ child_rows = rows[code_arrays[depth][rows] == code]
399
+ node = {
400
+ "node_id": node_id,
401
+ "parent_id": parent_id,
402
+ "feature_id": f"f{depth}",
403
+ "level_id": f"f{depth}:l{code}",
404
+ "depth": depth + 1,
405
+ "count": count,
406
+ "share_of_parent": count / parent_count if parent_count else None,
407
+ "share_of_total": count / len(evaluated) if len(evaluated) else None,
408
+ "share_reason": None if len(evaluated) else "empty_population",
409
+ "expansion_state": "complete" if depth + 1 == len(active) else "unexpanded",
410
+ "omitted_child_rows": 0,
411
+ "omitted_child_levels": 0,
412
+ "stop_reasons": [],
413
+ }
414
+ nodes.append(node)
415
+ node_lookup[node_id] = node
416
+ emitted_levels.add((depth, code))
417
+ if depth + 1 < len(active):
418
+ queue.append((node_id, depth + 1, child_rows, count))
419
+ # Pre-selection metadata must remain decodable even when no corresponding
420
+ # tree node survives the output budgets or the conjunctive pre filter.
421
+ referenced_levels = emitted_levels.copy()
422
+ for retained in retained_metadata:
423
+ referenced_levels.update((retained["depth"] - 1, code) for code in retained["level_codes"])
424
+ referenced_levels.update(enumerate(retained.get("path", [])))
425
+ level_dictionary = [
426
+ {
427
+ "level_id": f"f{depth}:l{code}",
428
+ "feature_id": f"f{depth}",
429
+ "value": dictionaries[depth][code].to_dict(),
430
+ }
431
+ for depth, code in sorted(
432
+ referenced_levels,
433
+ key=lambda item: (item[0], dictionaries[item[0]][item[1]].sort_key()),
434
+ )
435
+ ]
436
+ status = "empty" if not len(evaluated) else "computed"
437
+ if any(node["omitted_child_rows"] for node in [root, *nodes]):
438
+ status = "partial" if len(evaluated) else status
439
+ payload: dict[str, Any] = {
440
+ "status": status,
441
+ "source": _source(df),
442
+ "scopes": [scope],
443
+ "features": features,
444
+ "level_dictionary": level_dictionary,
445
+ "tree": {
446
+ "status": status,
447
+ "scope_id": "s2",
448
+ "dimensions": [f"f{i}" for i in range(len(active))],
449
+ "requested_depth": len(active),
450
+ "root": root,
451
+ "nodes": nodes,
452
+ "retained_sets": retained_metadata,
453
+ },
454
+ "effective_limits": {
455
+ "top_n": top_n,
456
+ "top_n_mode": top_n_mode,
457
+ "top_n_per_parent": top_n_per_parent,
458
+ "max_depth": max_depth,
459
+ "max_levels": max_levels,
460
+ "max_nodes": max_nodes,
461
+ "min_count": min_count,
462
+ "dropna": dropna,
463
+ },
464
+ "warnings": warnings,
465
+ }
466
+ if engine_metadata:
467
+ payload["engine"] = {"name": "encoded_observed_prefix_refinement"}
468
+ return ExplorerResult("census", payload)
469
+
470
+
471
+ def census(df, dimensions, *, scope=None, missing=None, table_id="table", **options):
472
+ """Build an observed-prefix census, optionally preserving a discovery scope and sentinels."""
473
+ if scope is None and missing is None and table_id == "table":
474
+ return _census(df, dimensions, **options)
475
+ from ..evidence import foundation_context
476
+
477
+ return foundation_context(
478
+ df, _census, dimensions, scope=scope, missing=missing, table_id=table_id, **options
479
+ )