featm 0.5.7__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 (83) hide show
  1. featm/__init__.py +86 -0
  2. featm/cache.py +190 -0
  3. featm/checkpoint.py +231 -0
  4. featm/cli.py +141 -0
  5. featm/contracts.py +93 -0
  6. featm/distributed/__init__.py +25 -0
  7. featm/distributed/checkpoint.py +541 -0
  8. featm/distributed/contracts.py +99 -0
  9. featm/distributed/dask_runtime.py +142 -0
  10. featm/distributed/execution.py +1118 -0
  11. featm/distributed/local.py +83 -0
  12. featm/distributed/partition_provider.py +245 -0
  13. featm/distributed/planner.py +80 -0
  14. featm/distributed/runtime.py +61 -0
  15. featm/engine.py +497 -0
  16. featm/execution.py +1994 -0
  17. featm/executor.py +145 -0
  18. featm/extractor.py +353 -0
  19. featm/features/__init__.py +27 -0
  20. featm/features/_utils.py +498 -0
  21. featm/features/audio/__init__.py +0 -0
  22. featm/features/audio/full.py +302 -0
  23. featm/features/event/__init__.py +0 -0
  24. featm/features/event/full.py +96 -0
  25. featm/features/geometry/triangulation.py +283 -0
  26. featm/features/graph/__init__.py +0 -0
  27. featm/features/graph/full.py +379 -0
  28. featm/features/image/basic.py +66 -0
  29. featm/features/image/full.py +394 -0
  30. featm/features/information/__init__.py +0 -0
  31. featm/features/information/full.py +150 -0
  32. featm/features/manifold/__init__.py +0 -0
  33. featm/features/manifold/full.py +63 -0
  34. featm/features/matrix/__init__.py +0 -0
  35. featm/features/matrix/full.py +56 -0
  36. featm/features/multimodal/__init__.py +0 -0
  37. featm/features/multimodal/full.py +60 -0
  38. featm/features/pointcloud/__init__.py +0 -0
  39. featm/features/pointcloud/full.py +168 -0
  40. featm/features/sequence/__init__.py +0 -0
  41. featm/features/sequence/full.py +128 -0
  42. featm/features/signal/__init__.py +0 -0
  43. featm/features/signal/full.py +249 -0
  44. featm/features/spatial/__init__.py +0 -0
  45. featm/features/spatial/full.py +109 -0
  46. featm/features/tabular/categorical.py +27 -0
  47. featm/features/tabular/datetime.py +66 -0
  48. featm/features/tabular/full.py +404 -0
  49. featm/features/tabular/interactions.py +41 -0
  50. featm/features/tabular/numeric.py +70 -0
  51. featm/features/text/__init__.py +0 -0
  52. featm/features/text/full.py +317 -0
  53. featm/features/timeseries/basic.py +110 -0
  54. featm/features/timeseries/full.py +676 -0
  55. featm/features/topology/__init__.py +0 -0
  56. featm/features/topology/full.py +129 -0
  57. featm/features/video/__init__.py +0 -0
  58. featm/features/video/full.py +117 -0
  59. featm/gpu/__init__.py +50 -0
  60. featm/gpu/backend.py +248 -0
  61. featm/gpu/cache.py +184 -0
  62. featm/gpu/dispatch.py +596 -0
  63. featm/gpu/exceptions.py +79 -0
  64. featm/gpu/kernels/__init__.py +1 -0
  65. featm/gpu/kernels/reductions.py +112 -0
  66. featm/gpu/kernels/spectral.py +207 -0
  67. featm/gpu/memory.py +211 -0
  68. featm/gpu/registration.py +88 -0
  69. featm/matrix.py +553 -0
  70. featm/planner.py +99 -0
  71. featm/registry.py +590 -0
  72. featm/report.py +232 -0
  73. featm/resources.py +412 -0
  74. featm/sampling.py +146 -0
  75. featm/schema.py +131 -0
  76. featm/sources.py +736 -0
  77. featm/state.py +749 -0
  78. featm-0.5.7.dist-info/METADATA +299 -0
  79. featm-0.5.7.dist-info/RECORD +83 -0
  80. featm-0.5.7.dist-info/WHEEL +5 -0
  81. featm-0.5.7.dist-info/entry_points.txt +2 -0
  82. featm-0.5.7.dist-info/licenses/LICENSE +21 -0
  83. featm-0.5.7.dist-info/top_level.txt +1 -0
featm/__init__.py ADDED
@@ -0,0 +1,86 @@
1
+ from . import features as _features # register built-ins
2
+ from . import state as _state # register Step 2 stateful two-pass encoders
3
+ from . import gpu # Step 3: registers no CUDA import at module scope -- see featm.gpu.backend
4
+ from .engine import (
5
+ AutoFeatures,
6
+ create_feature,
7
+ extract,
8
+ feature_info,
9
+ list_families,
10
+ list_features,
11
+ list_modalities,
12
+ search,
13
+ )
14
+ from .extractor import FeatureExtractor, FittedFeatureExtractor, fit, load_fitted
15
+ from .distributed import distributed_extract
16
+ from .matrix import FeatureMatrix, MaterializationGuardError
17
+ from .registry import (
18
+ BatchSemantics,
19
+ Complexity,
20
+ Cost,
21
+ FeatureOutput,
22
+ FeatureSpec,
23
+ MemoryProfile,
24
+ REGISTRY,
25
+ RegistryValidationError,
26
+ register_feature,
27
+ )
28
+ from .report import ExecutionReport
29
+ from .resources import ResourceBudget, parse_size
30
+ from .schema import DatasetProfile, SemanticType, infer_schema
31
+ from .sources import (
32
+ ArrowSource,
33
+ CSVSource,
34
+ IteratorSource,
35
+ JSONLSource,
36
+ ParquetSource,
37
+ PandasSource,
38
+ Source,
39
+ SourceNotReplayableError,
40
+ resolve_source,
41
+ )
42
+
43
+ __all__ = [
44
+ "ArrowSource",
45
+ "AutoFeatures",
46
+ "BatchSemantics",
47
+ "CSVSource",
48
+ "Complexity",
49
+ "Cost",
50
+ "DatasetProfile",
51
+ "ExecutionReport",
52
+ "FeatureExtractor",
53
+ "FeatureMatrix",
54
+ "FeatureOutput",
55
+ "FeatureSpec",
56
+ "FittedFeatureExtractor",
57
+ "IteratorSource",
58
+ "JSONLSource",
59
+ "MaterializationGuardError",
60
+ "MemoryProfile",
61
+ "PandasSource",
62
+ "ParquetSource",
63
+ "REGISTRY",
64
+ "RegistryValidationError",
65
+ "ResourceBudget",
66
+ "SemanticType",
67
+ "Source",
68
+ "SourceNotReplayableError",
69
+ "create_feature",
70
+ "distributed_extract",
71
+ "extract",
72
+ "feature_info",
73
+ "fit",
74
+ "gpu",
75
+ "infer_schema",
76
+ "list_families",
77
+ "list_features",
78
+ "list_modalities",
79
+ "load_fitted",
80
+ "parse_size",
81
+ "register_feature",
82
+ "resolve_source",
83
+ "search",
84
+ ]
85
+
86
+ __version__ = "0.5.7"
featm/cache.py ADDED
@@ -0,0 +1,190 @@
1
+ """Shared intermediate computation cache.
2
+
3
+ Selected features frequently depend on the same expensive intermediate (one
4
+ FFT feeding several spectral descriptors, one Delaunay triangulation feeding
5
+ several simplex statistics, one graph Laplacian eigendecomposition feeding
6
+ several spectral graph features, and so on). This module gives feature
7
+ implementations a way to memoize such intermediates *within one execution
8
+ scope* (one batch of one :func:`featm.extract` / engine run) so that
9
+ selecting several related features does not recompute shared work.
10
+
11
+ Design:
12
+
13
+ - A cache is explicitly scoped via :func:`scoped_cache`, a context manager
14
+ entered once per batch/call. Each scope gets its own cache instance, so
15
+ data from different batches or different datasets can never leak into one
16
+ another -- the cache simply does not outlive its scope.
17
+ - Within a scope, callers look up the *current* cache with
18
+ :func:`current_cache` (backed by a ``contextvars.ContextVar`` so it is
19
+ correct across threads) and call :meth:`SharedIntermediateCache.get_or_compute`.
20
+ - The cache obeys a byte budget with LRU eviction so a scope cannot grow
21
+ without bound; evicted entries are simply recomputed on next use.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import contextvars
26
+ import hashlib
27
+ import threading
28
+ from collections import OrderedDict
29
+ from contextlib import contextmanager
30
+ from typing import Any, Callable, Iterator
31
+
32
+ import numpy as np
33
+
34
+ _DEFAULT_BUDGET_BYTES = 256 * 1024 * 1024 # 256MB per execution scope by default
35
+
36
+ _CURRENT: contextvars.ContextVar["SharedIntermediateCache | None"] = contextvars.ContextVar(
37
+ "featm_current_cache", default=None
38
+ )
39
+
40
+
41
+ def _default_size_estimate(value: Any) -> int:
42
+ if isinstance(value, np.ndarray):
43
+ return int(value.nbytes)
44
+ if isinstance(value, (list, tuple)):
45
+ return sum(_default_size_estimate(v) for v in value) or 64
46
+ if isinstance(value, dict):
47
+ return sum(_default_size_estimate(v) for v in value.values()) or 64
48
+ if isinstance(value, (bytes, bytearray, str)):
49
+ return len(value)
50
+ return 64 # crude fallback for scalars/small objects
51
+
52
+
53
+ class SharedIntermediateCache:
54
+ """A bounded, LRU-evicting cache for one execution scope."""
55
+
56
+ def __init__(self, max_bytes: int = _DEFAULT_BUDGET_BYTES) -> None:
57
+ self.max_bytes = max_bytes
58
+ self._store: "OrderedDict[tuple, tuple[Any, int]]" = OrderedDict()
59
+ self._bytes = 0
60
+ self.hits = 0
61
+ self.misses = 0
62
+ self.evictions = 0
63
+ self.oversized_skips = 0
64
+ self._lock = threading.Lock()
65
+
66
+ def get_or_compute(
67
+ self,
68
+ key: tuple,
69
+ compute_fn: Callable[[], Any],
70
+ *,
71
+ size_estimator: Callable[[Any], int] | None = None,
72
+ ) -> Any:
73
+ with self._lock:
74
+ hit = self._store.get(key)
75
+ if hit is not None:
76
+ self.hits += 1
77
+ self._store.move_to_end(key)
78
+ return hit[0]
79
+
80
+ # Compute outside the lock: recomputation for an already-cached key by a
81
+ # concurrent caller is wasted work, not a correctness problem, and this
82
+ # avoids holding the lock across a potentially expensive computation.
83
+ value = compute_fn()
84
+ estimator = size_estimator or _default_size_estimate
85
+ nbytes = max(int(estimator(value)), 1)
86
+
87
+ with self._lock:
88
+ self.misses += 1
89
+ if nbytes > self.max_bytes:
90
+ # A single value larger than the whole budget must never be
91
+ # retained -- caching it would permanently keep the cache over
92
+ # budget (nothing left to evict it in favor of). Serve it to
93
+ # the caller without storing it; it will simply be recomputed
94
+ # next time. This is the correct trade for a hard budget.
95
+ self.oversized_skips += 1
96
+ return value
97
+ self._store[key] = (value, nbytes)
98
+ self._store.move_to_end(key)
99
+ self._bytes += nbytes
100
+ self._evict_if_needed()
101
+ return value
102
+
103
+ def _evict_if_needed(self) -> None:
104
+ # No "leave at least one entry" exception: an entry only ever reaches
105
+ # this point after already passing the oversized-value check above, so
106
+ # evicting everything else is always sufficient to get back under
107
+ # budget without having to evict the entry that was just inserted.
108
+ while self._bytes > self.max_bytes and self._store:
109
+ _, (_, nbytes) = self._store.popitem(last=False)
110
+ self._bytes -= nbytes
111
+ self.evictions += 1
112
+
113
+ def clear(self) -> None:
114
+ with self._lock:
115
+ self._store.clear()
116
+ self._bytes = 0
117
+
118
+ @property
119
+ def size_bytes(self) -> int:
120
+ return self._bytes
121
+
122
+ @property
123
+ def entry_count(self) -> int:
124
+ return len(self._store)
125
+
126
+ def stats(self) -> dict[str, int]:
127
+ return {
128
+ "hits": self.hits,
129
+ "misses": self.misses,
130
+ "evictions": self.evictions,
131
+ "oversized_skips": self.oversized_skips,
132
+ "entries": self.entry_count,
133
+ "bytes": self._bytes,
134
+ }
135
+
136
+
137
+ def current_cache() -> SharedIntermediateCache | None:
138
+ """Return the cache for the current execution scope, or ``None`` outside one."""
139
+ return _CURRENT.get()
140
+
141
+
142
+ @contextmanager
143
+ def scoped_cache(
144
+ cache: SharedIntermediateCache | None = None,
145
+ *,
146
+ max_bytes: int = _DEFAULT_BUDGET_BYTES,
147
+ ) -> Iterator[SharedIntermediateCache]:
148
+ """Open one fresh cache scope (one batch, or one whole-array ``extract`` call).
149
+
150
+ A new :class:`SharedIntermediateCache` is created per scope unless one is
151
+ passed explicitly, guaranteeing intermediates never leak across scopes.
152
+ """
153
+ c = cache if cache is not None else SharedIntermediateCache(max_bytes=max_bytes)
154
+ token = _CURRENT.set(c)
155
+ try:
156
+ yield c
157
+ finally:
158
+ _CURRENT.reset(token)
159
+
160
+
161
+ def array_cache_key(namespace: str, arr: Any, **params: Any) -> tuple:
162
+ """Build a content-addressed cache key for a numpy-convertible array.
163
+
164
+ Content hashing (rather than object identity) is used here because arrays
165
+ are frequently re-wrapped/copied between the caller and the feature
166
+ implementation; hashing the actual bytes still gives a correct cache hit
167
+ without relying on the same Python object surviving the whole call.
168
+
169
+ ``np.ascontiguousarray(a).tobytes()`` always makes a full copy, which is
170
+ wasteful for a large array whose only purpose here is to be hashed. The
171
+ common case -- an already C-contiguous array -- can be hashed straight
172
+ from its existing buffer via a ``memoryview`` with no copy at all; a copy
173
+ is made only for the genuinely non-contiguous case, where one is
174
+ unavoidable to get a well-defined byte order to hash.
175
+ """
176
+ a = np.asarray(arr)
177
+ buf = a.data if a.flags["C_CONTIGUOUS"] else np.ascontiguousarray(a).tobytes()
178
+ digest = hashlib.blake2b(buf, digest_size=16).hexdigest()
179
+ return (namespace, digest, a.shape, str(a.dtype), tuple(sorted(params.items())))
180
+
181
+
182
+ def identity_cache_key(namespace: str, obj: Any, **params: Any) -> tuple:
183
+ """Build an object-identity cache key for objects too expensive to hash by content.
184
+
185
+ Valid only *within one execution scope*: Python guarantees ``id()`` is
186
+ unique among objects alive at the same time, and a scope's cache never
187
+ outlives the batch whose objects it was built from, so this cannot
188
+ collide across batches or datasets.
189
+ """
190
+ return (namespace, id(obj), tuple(sorted(params.items())))
featm/checkpoint.py ADDED
@@ -0,0 +1,231 @@
1
+ """Checkpoint / resume for long out-of-core extraction runs.
2
+
3
+ A checkpoint directory holds one small JSON manifest (written atomically —
4
+ temp file + ``os.replace``, so a crash mid-write cannot leave a corrupt
5
+ manifest that looks valid) plus any fitted-state artifacts and the partial
6
+ disk-backed output written so far.
7
+
8
+ Granularity: checkpointing is at the *batch* level for one execution plan
9
+ (the whole set of selected features run together), not at the sub-batch
10
+ level and not independently per feature. A run has up to two phases:
11
+
12
+ - ``fit`` -- only present if at least one selected feature is stateful;
13
+ scans the source once, merging each stateful feature's state,
14
+ checkpointing the running accumulator after every batch so an
15
+ interrupted fit resumes mid-pass rather than from scratch.
16
+ - ``transform`` -- scans the source (again, if there was a fit phase) computing
17
+ every selected feature per batch and writing one output shard
18
+ per batch, checkpointing after each shard.
19
+
20
+ On resume, already-completed batches for the active phase are skipped by
21
+ re-iterating the source and discarding them (cheap: no feature computation
22
+ runs against a skipped batch) rather than recomputing anything. Compatibility
23
+ is verified against a fingerprint of the source, schema, selected features
24
+ and parameters, and the relevant execution settings before any work is
25
+ reused; an incompatible checkpoint is rejected with a clear explanation
26
+ rather than silently reused.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import os
32
+ import pickle
33
+ from dataclasses import dataclass, field
34
+ from pathlib import Path
35
+ from typing import Any
36
+
37
+ CHECKPOINT_MANIFEST_VERSION = 3
38
+ MANIFEST_FILENAME = "checkpoint.json"
39
+
40
+
41
+ class CheckpointIncompatibleError(RuntimeError):
42
+ """Raised when an existing checkpoint does not match the requested run."""
43
+
44
+
45
+ @dataclass
46
+ class CheckpointIdentity:
47
+ """Everything that must match between a checkpoint and the run resuming it."""
48
+
49
+ featm_version: str
50
+ source_fingerprint: str
51
+ schema_fingerprint: str
52
+ features: list[str]
53
+ params: dict[str, dict[str, Any]]
54
+ settings: dict[str, Any]
55
+
56
+ def to_dict(self) -> dict[str, Any]:
57
+ return {
58
+ "featm_version": self.featm_version,
59
+ "source_fingerprint": self.source_fingerprint,
60
+ "schema_fingerprint": self.schema_fingerprint,
61
+ "features": list(self.features),
62
+ "params": self.params,
63
+ "settings": self.settings,
64
+ }
65
+
66
+
67
+ def _atomic_write_json(path: Path, payload: dict) -> None:
68
+ tmp = path.with_suffix(path.suffix + f".tmp{os.getpid()}")
69
+ tmp.write_text(json.dumps(payload, indent=2, default=str), encoding="utf-8")
70
+ os.replace(tmp, path)
71
+
72
+
73
+ def _safe_filename(name: str) -> str:
74
+ return "".join(c if c.isalnum() or c in "._-" else "_" for c in name)
75
+
76
+
77
+ class CheckpointManager:
78
+ def __init__(self, directory: str | Path) -> None:
79
+ self.directory = Path(directory)
80
+ self.directory.mkdir(parents=True, exist_ok=True)
81
+ self.output_dir = self.directory / "output"
82
+ self.state_dir = self.directory / "state"
83
+ self.manifest_path = self.directory / MANIFEST_FILENAME
84
+
85
+ # --- manifest lifecycle -------------------------------------------------
86
+ def load(self) -> dict[str, Any] | None:
87
+ if not self.manifest_path.exists():
88
+ return None
89
+ manifest = json.loads(self.manifest_path.read_text(encoding="utf-8"))
90
+ if not isinstance(manifest, dict):
91
+ raise CheckpointIncompatibleError(
92
+ f"Existing checkpoint manifest at {self.manifest_path} is invalid: "
93
+ "its JSON root must be an object. Use a fresh checkpoint directory."
94
+ )
95
+ return manifest
96
+
97
+ def _check_compatible(self, existing: dict[str, Any], identity: CheckpointIdentity) -> None:
98
+ # manifest_version is compared explicitly (local checkpoint
99
+ # replay-history closure pass, v0.4.3 -> v0.4.4): a checkpoint
100
+ # written under an older manifest schema (v1, before failure/
101
+ # degradation history was persisted at all) must be rejected as
102
+ # incompatible on resume, not silently reused -- an old manifest
103
+ # simply has no `fit_failed_features`/`transform_priming_failed_
104
+ # features`/`degraded_batches_by_feature` keys to restore, which
105
+ # would silently reintroduce the exact history-loss bug this pass
106
+ # fixes for any pre-existing checkpoint. A versioned package string
107
+ # (`featm_version`) is not a substitute for this -- the schema, not
108
+ # just the code, changed.
109
+ if existing.get("manifest_version") != CHECKPOINT_MANIFEST_VERSION:
110
+ raise CheckpointIncompatibleError(
111
+ f"Checkpoint at {self.directory} was written under manifest schema version "
112
+ f"{existing.get('manifest_version')!r}, but this version of featm requires "
113
+ f"{CHECKPOINT_MANIFEST_VERSION!r} (older checkpoints do not persist enough failure/degradation "
114
+ "history to resume with a replay-equivalent report). Use a fresh checkpoint directory."
115
+ )
116
+ wanted = identity.to_dict()
117
+ mismatches = []
118
+ for key, want in wanted.items():
119
+ got = existing.get(key)
120
+ if got != want:
121
+ mismatches.append(f"{key}: checkpoint has {got!r}, requested run has {want!r}")
122
+ if mismatches:
123
+ raise CheckpointIncompatibleError(
124
+ f"Checkpoint at {self.directory} is not compatible with this run:\n "
125
+ + "\n ".join(mismatches)
126
+ + "\nUse a fresh checkpoint directory, or match the source/features/params/settings exactly."
127
+ )
128
+
129
+ def initialize_or_resume(self, identity: CheckpointIdentity, *, has_fit_phase: bool) -> dict[str, Any]:
130
+ """Load a compatible existing manifest, or create a fresh one.
131
+
132
+ Raises :class:`CheckpointIncompatibleError` if a checkpoint exists at
133
+ this directory but does not match ``identity``.
134
+ """
135
+ existing = self.load()
136
+ if existing is not None:
137
+ self._check_compatible(existing, identity)
138
+ return existing
139
+ manifest = identity.to_dict()
140
+ manifest.update(
141
+ {
142
+ "manifest_version": CHECKPOINT_MANIFEST_VERSION,
143
+ "phase": "fit" if has_fit_phase else "transform",
144
+ "fit_batches_completed": 0,
145
+ "fit_accumulator_file": None,
146
+ "fit_state_files": {},
147
+ # Local checkpoint replay-history closure pass (v0.4.3 ->
148
+ # v0.4.4): durable failure/degradation metadata, so a resumed
149
+ # (partial or zero-read "done") run's report is
150
+ # replay-equivalent to what an uninterrupted run would have
151
+ # reported -- not silently clean merely because the feature
152
+ # that failed left no successfully-fitted-state file behind
153
+ # to key a restore off of. Each `..._failed_features` value
154
+ # is ``{"kind": "failed" | "optional_dependency", "message": str}``
155
+ # so a restore can call the matching ExecutionReport method.
156
+ "fit_failed_features": {},
157
+ "transform_priming_failed_features": {},
158
+ "degraded_batches_by_feature": {},
159
+ "transform_batches_completed": 0,
160
+ "transform_shards_written": 0,
161
+ "done": False,
162
+ }
163
+ )
164
+ self.save(manifest)
165
+ return manifest
166
+
167
+ def save(self, manifest: dict[str, Any]) -> None:
168
+ _atomic_write_json(self.manifest_path, manifest)
169
+
170
+ # --- fitted state artifacts ----------------------------------------------
171
+ def save_fit_state(self, feature_name: str, state: Any) -> str:
172
+ self.state_dir.mkdir(parents=True, exist_ok=True)
173
+ filename = f"{_safe_filename(feature_name)}.pkl"
174
+ path = self.state_dir / filename
175
+ tmp = path.with_suffix(path.suffix + f".tmp{os.getpid()}")
176
+ with open(tmp, "wb") as fh:
177
+ pickle.dump(state, fh)
178
+ os.replace(tmp, path)
179
+ return filename
180
+
181
+ def load_fit_state(self, filename: str) -> Any:
182
+ with open(self.state_dir / filename, "rb") as fh:
183
+ return pickle.load(fh)
184
+
185
+ def save_fit_accumulators(self, accumulators: dict[str, Any], batch_index: int) -> str:
186
+ """Persist in-progress fit accumulators as a *versioned* snapshot, named by batch index.
187
+
188
+ This does NOT update the manifest -- the caller must separately call
189
+ :meth:`save` with the manifest referencing this filename once it wants
190
+ to commit to this snapshot as the new resume point. Two files being
191
+ each individually atomic (this one, and the manifest) does not make
192
+ writing both together atomic: a crash between the two would otherwise
193
+ leave the manifest pointing at (and reprocessing) a batch whose
194
+ accumulator snapshot has already moved past it, double-counting that
195
+ batch's contribution to the fitted state. Versioning by batch index
196
+ means an orphaned snapshot from a crash before the manifest commit is
197
+ simply never referenced and is harmless (cleaned up opportunistically
198
+ by :meth:`cleanup_orphan_accumulator_snapshots`).
199
+ """
200
+ self.state_dir.mkdir(parents=True, exist_ok=True)
201
+ filename = f"_fit_accumulators-{batch_index:010d}.pkl"
202
+ path = self.state_dir / filename
203
+ tmp = path.with_suffix(path.suffix + f".tmp{os.getpid()}")
204
+ with open(tmp, "wb") as fh:
205
+ pickle.dump(accumulators, fh)
206
+ os.replace(tmp, path)
207
+ return filename
208
+
209
+ def load_fit_accumulators(self, filename: str) -> dict[str, Any] | None:
210
+ """Load exactly the snapshot the manifest committed to -- never "the latest on disk"."""
211
+ path = self.state_dir / filename
212
+ if not path.exists():
213
+ from .matrix import CheckpointCorruptionError
214
+ raise CheckpointCorruptionError(f"Committed fit snapshot is missing: {path}")
215
+ try:
216
+ with open(path, "rb") as fh:
217
+ return pickle.load(fh)
218
+ except (OSError, pickle.UnpicklingError, EOFError) as exc:
219
+ from .matrix import CheckpointCorruptionError
220
+ raise CheckpointCorruptionError(f"Committed fit snapshot is unreadable: {path}") from exc
221
+
222
+ def cleanup_orphan_accumulator_snapshots(self, keep_filename: str | None) -> None:
223
+ """Remove old/orphaned accumulator snapshots, keeping only the manifest-committed one."""
224
+ if not self.state_dir.exists():
225
+ return
226
+ for p in self.state_dir.glob("_fit_accumulators-*.pkl"):
227
+ if keep_filename is None or p.name != keep_filename:
228
+ try:
229
+ p.unlink()
230
+ except OSError:
231
+ pass
featm/cli.py ADDED
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from dataclasses import asdict
6
+
7
+ from .engine import feature_info, list_families, list_features, list_modalities, search
8
+
9
+
10
+ def _spec_dict(spec):
11
+ data = asdict(spec)
12
+ data.pop("implementation", None)
13
+ cost = data.get("cost")
14
+ if hasattr(cost, "value"):
15
+ data["cost"] = cost.value
16
+ return data
17
+
18
+
19
+ def _print_specs(specs) -> None:
20
+ for spec in specs:
21
+ suffix = f" [{', '.join(spec.requires)}]" if spec.requires else ""
22
+ print(f"{spec.name}{suffix}")
23
+
24
+
25
+ def build_parser() -> argparse.ArgumentParser:
26
+ parser = argparse.ArgumentParser(
27
+ prog="featm",
28
+ description="Browse the featm feature registry.",
29
+ )
30
+ sub = parser.add_subparsers(dest="command", required=True)
31
+
32
+ p_list = sub.add_parser("list", help="List registered feature names")
33
+ p_list.add_argument("pattern", nargs="?", default=None)
34
+ p_list.add_argument("--modality", default=None)
35
+ p_list.add_argument("--family", default=None)
36
+
37
+ p_search = sub.add_parser("search", help="Search feature names/descriptions/tags")
38
+ p_search.add_argument("query")
39
+
40
+ p_info = sub.add_parser("info", help="Show metadata for one feature")
41
+ p_info.add_argument("name")
42
+ p_info.add_argument("--json", action="store_true")
43
+
44
+ sub.add_parser("modalities", help="List available modalities")
45
+
46
+ p_families = sub.add_parser("families", help="List feature families")
47
+ p_families.add_argument("--modality", default=None)
48
+
49
+ p_run = sub.add_parser("run", help="Run an out-of-core extraction against a file source")
50
+ p_run.add_argument("source", help="Path to a CSV/TSV/JSONL/Parquet/Arrow file")
51
+ p_run.add_argument("--features", nargs="+", required=True, help="Explicit feature names to extract")
52
+ p_run.add_argument(
53
+ "--column", action="append", default=[], metavar="FEATURE=COLUMN",
54
+ help="Explicit input column for one tabular feature, e.g. tabular.scaling.zscore=price. Repeatable.",
55
+ )
56
+ p_run.add_argument("--batch-rows", type=int, default=None)
57
+ p_run.add_argument("--memory-budget", default=None, help="e.g. 2GB")
58
+ p_run.add_argument("--disk-budget", default=None, help="e.g. 50GB")
59
+ p_run.add_argument("--output", choices=["auto", "memory", "disk"], default="auto")
60
+ p_run.add_argument("--output-path", default=None, help="Write the result here (.parquet or .csv)")
61
+ p_run.add_argument("--mode", choices=["exact", "approximate", "auto"], default="exact")
62
+ p_run.add_argument("--errors", choices=["raise", "warn", "skip"], default="skip")
63
+ p_run.add_argument("--checkpoint", default=None, help="Checkpoint directory for resumable runs")
64
+ p_run.add_argument("--json", action="store_true", help="Print the execution report as JSON")
65
+
66
+ return parser
67
+
68
+
69
+ def main(argv: list[str] | None = None) -> int:
70
+ args = build_parser().parse_args(argv)
71
+
72
+ if args.command == "list":
73
+ for name in list_features(args.pattern, modality=args.modality, family=args.family):
74
+ print(name)
75
+ return 0
76
+
77
+ if args.command == "search":
78
+ _print_specs(search(args.query))
79
+ return 0
80
+
81
+ if args.command == "info":
82
+ spec = feature_info(args.name)
83
+ data = _spec_dict(spec)
84
+ if args.json:
85
+ print(json.dumps(data, indent=2, default=str))
86
+ else:
87
+ for key, value in data.items():
88
+ print(f"{key}: {value}")
89
+ return 0
90
+
91
+ if args.command == "modalities":
92
+ for modality in list_modalities():
93
+ print(modality)
94
+ return 0
95
+
96
+ if args.command == "families":
97
+ for family in list_families(modality=args.modality):
98
+ print(family)
99
+ return 0
100
+
101
+ if args.command == "run":
102
+ from .engine import extract as _extract
103
+
104
+ columns = {}
105
+ for item in args.column:
106
+ key, _, value = item.partition("=")
107
+ if not _:
108
+ print(f"--column expects FEATURE=COLUMN, got: {item!r}")
109
+ return 2
110
+ columns[key] = value
111
+
112
+ result = _extract(
113
+ args.source,
114
+ features=args.features,
115
+ columns=columns or None,
116
+ batch_rows=args.batch_rows,
117
+ memory_budget=args.memory_budget,
118
+ disk_budget=args.disk_budget,
119
+ output=args.output,
120
+ mode=args.mode,
121
+ errors=args.errors,
122
+ checkpoint=args.checkpoint,
123
+ )
124
+ if args.json:
125
+ print(json.dumps(result.report.to_dict(), indent=2, default=str))
126
+ else:
127
+ print(f"shape: {result.shape} backend: {'disk' if result.is_disk_backed else 'memory'}")
128
+ print(result.report.summary())
129
+ if args.output_path:
130
+ if str(args.output_path).endswith(".csv"):
131
+ result.to_csv(args.output_path)
132
+ else:
133
+ result.to_parquet(args.output_path)
134
+ print(f"wrote {result.shape[0]} rows x {result.shape[1]} columns to {args.output_path}")
135
+ return 0 if not result.report.failed_features else 1
136
+
137
+ return 2
138
+
139
+
140
+ if __name__ == "__main__":
141
+ raise SystemExit(main())