fresh-cbm 0.2.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.
fresh_cbm/__init__.py ADDED
@@ -0,0 +1,237 @@
1
+ """Exact libcbm stand-state cache client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any, cast
9
+
10
+ import numpy as np
11
+ import pandas as pd
12
+
13
+ from .family import ChainFamily
14
+ from .schema import load_manifest
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class StateCache:
19
+ """A loaded exact stand-state table.
20
+
21
+ Parameters
22
+ ----------
23
+ root : pathlib.Path
24
+ Cache directory containing ``manifest.json``, ``index.parquet``, and
25
+ ``values/``.
26
+ manifest : dict
27
+ Parsed manifest.
28
+ index : pandas.DataFrame
29
+ State index in row order.
30
+ """
31
+
32
+ root: Path
33
+ manifest: dict[str, Any]
34
+ index: pd.DataFrame
35
+
36
+ @classmethod
37
+ def load(cls, root: str | Path, *, validate: bool = True) -> StateCache:
38
+ """Load a cache directory.
39
+
40
+ Parameters
41
+ ----------
42
+ root : path-like
43
+ Cache directory.
44
+ validate : bool, optional
45
+ Check the index, file set, and array shapes. Defaults to True.
46
+
47
+ Returns
48
+ -------
49
+ StateCache
50
+ The loaded cache.
51
+ """
52
+ path = Path(root)
53
+ manifest = load_manifest(path / "manifest.json")
54
+ index = pd.read_parquet(path / "index.parquet")
55
+ cache = cls(root=path, manifest=manifest, index=index)
56
+ if validate:
57
+ cache.validate()
58
+ return cache
59
+
60
+ def validate(self) -> None:
61
+ """Check the index, file set, offsets, and array shapes.
62
+
63
+ Raises
64
+ ------
65
+ ValueError
66
+ When the cache is incomplete or inconsistent with its manifest.
67
+ """
68
+ required = {"state_id", "track_id", "track_name", "age"}
69
+ missing = required - set(self.index.columns)
70
+ if missing:
71
+ raise ValueError(f"cache index is missing columns: {sorted(missing)}")
72
+ states = int(self.manifest["states"])
73
+ if len(self.index) != states:
74
+ raise ValueError(f"cache index has {len(self.index)} rows but manifest says {states}")
75
+ years = self.years
76
+ accounts = len(self.accounts)
77
+ expected = (states, years + 1, accounts)
78
+ family = self.chain_family
79
+ offsets = self.manifest["offsets"]
80
+ if not isinstance(offsets, list):
81
+ raise ValueError("manifest offsets must be a list")
82
+ if family is not None:
83
+ if tuple(int(value) for value in offsets) != family.offsets:
84
+ raise ValueError("chain_family offsets do not match manifest offsets")
85
+ if bool(self.manifest["empty"]) != family.allow_empty:
86
+ raise ValueError("chain_family empty policy does not match manifest")
87
+ files = self.manifest["files"]
88
+ if not isinstance(files, list):
89
+ raise ValueError("manifest files must be a list")
90
+ for name in files:
91
+ if not isinstance(name, str):
92
+ raise ValueError("manifest file names must be strings")
93
+ path = self.root / name
94
+ if not path.is_file():
95
+ raise ValueError(f"cache file is missing: {name}")
96
+ array = np.load(path, mmap_mode="r")
97
+ if array.shape != expected:
98
+ raise ValueError(f"{name} has shape {array.shape}; expected {expected}")
99
+
100
+ def verify_checksums(self, path: str | Path | None = None) -> None:
101
+ """Verify files against a SHA256SUMS manifest.
102
+
103
+ Parameters
104
+ ----------
105
+ path : path-like or None, optional
106
+ Checksum file. Defaults to ``SHA256SUMS`` in the cache root.
107
+
108
+ Raises
109
+ ------
110
+ ValueError
111
+ When a file is missing or a digest does not match.
112
+ """
113
+ source = Path(path) if path is not None else self.root / "SHA256SUMS"
114
+ if not source.is_file():
115
+ raise ValueError(f"checksum file is missing: {source}")
116
+ for line in source.read_text(encoding="utf-8").splitlines():
117
+ if not line.strip():
118
+ continue
119
+ digest, name = line.split(" ", 1)
120
+ target = self.root / name
121
+ if not target.is_file():
122
+ raise ValueError(f"checksum target is missing: {name}")
123
+ actual = hashlib.sha256(target.read_bytes()).hexdigest()
124
+ if actual != digest:
125
+ raise ValueError(f"checksum mismatch: {name}")
126
+
127
+ @property
128
+ def chain_family(self) -> ChainFamily | None:
129
+ """Return the declared chain family, if the manifest has one.
130
+
131
+ Returns
132
+ -------
133
+ ChainFamily or None
134
+ The family record, or None for a legacy manifest without one.
135
+ """
136
+ raw = self.manifest.get("chain_family")
137
+ return None if raw is None else ChainFamily.from_manifest(raw)
138
+
139
+ @property
140
+ def accounts(self) -> tuple[str, ...]:
141
+ """Return the cached account names."""
142
+ values = self.manifest["accounts"]
143
+ if not isinstance(values, list):
144
+ raise ValueError("manifest accounts must be a list")
145
+ return tuple(str(value) for value in values)
146
+
147
+ @property
148
+ def years(self) -> int:
149
+ """Return the stored horizon length in years."""
150
+ return int(self.manifest["years"])
151
+
152
+ def _row(self, track_id: int, age: int) -> int:
153
+ """Return the index row for a track and age."""
154
+ rows = self.index
155
+ match = rows[
156
+ (rows["track_id"].astype(int) == int(track_id)) & (rows["age"].astype(int) == int(age))
157
+ ]
158
+ if len(match) != 1:
159
+ raise KeyError(f"state not in cache: track={track_id} age={age}")
160
+ return int(match.iloc[0]["state_id"])
161
+
162
+ def array(self, chain_offset: int = -1) -> np.ndarray:
163
+ """Return the memmapped values for one chain offset.
164
+
165
+ Parameters
166
+ ----------
167
+ chain_offset : int, optional
168
+ Single ``CC`` event offset; ``-1`` is untreated.
169
+
170
+ Returns
171
+ -------
172
+ numpy.ndarray
173
+ The cached values with shape ``(states, years + 1, accounts)``.
174
+ """
175
+ name = "empty.npy" if chain_offset < 0 else f"offset_{chain_offset:02d}.npy"
176
+ return cast(np.ndarray, np.load(self.root / "values" / name, mmap_mode="r"))
177
+
178
+ def lookup_state(self, state_id: int, chain_offset: int = -1) -> np.ndarray:
179
+ """Return one state's ``(years + 1, accounts)`` array.
180
+
181
+ Parameters
182
+ ----------
183
+ state_id : int
184
+ Row in ``index.parquet``.
185
+ chain_offset : int, optional
186
+ Single ``CC`` event offset; ``-1`` is untreated.
187
+
188
+ Returns
189
+ -------
190
+ numpy.ndarray
191
+ The cached accounts.
192
+ """
193
+ return np.asarray(self.array(chain_offset)[int(state_id)])
194
+
195
+ def lookup(self, track_id: int, age: int, chain_offset: int = -1) -> np.ndarray:
196
+ """Return one state's ``(years + 1, accounts)`` array.
197
+
198
+ Parameters
199
+ ----------
200
+ track_id : int
201
+ Dataset track id.
202
+ age : int
203
+ Initial stand age.
204
+ chain_offset : int, optional
205
+ Single ``CC`` event offset; ``-1`` is untreated.
206
+
207
+ Returns
208
+ -------
209
+ numpy.ndarray
210
+ The cached accounts.
211
+ """
212
+ return self.lookup_state(self._row(track_id, age), chain_offset)
213
+
214
+ def account(self, track_id: int, age: int, chain_offset: int, account: str) -> np.ndarray:
215
+ """Return one account series for a state.
216
+
217
+ Parameters
218
+ ----------
219
+ track_id : int
220
+ Dataset track id.
221
+ age : int
222
+ Initial stand age.
223
+ chain_offset : int
224
+ Single ``CC`` event offset; ``-1`` is untreated.
225
+ account : str
226
+ Account name.
227
+
228
+ Returns
229
+ -------
230
+ numpy.ndarray
231
+ The account values over all stored years.
232
+ """
233
+ try:
234
+ column = self.accounts.index(account)
235
+ except ValueError as exc:
236
+ raise KeyError(f"account not in cache: {account}") from exc
237
+ return self.lookup(track_id, age, chain_offset)[:, column]
fresh_cbm/benchmark.py ADDED
@@ -0,0 +1,176 @@
1
+ """Repeatable cache and consumer-projection benchmarks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import random
8
+ import statistics
9
+ import time
10
+ from collections.abc import Mapping, Sequence
11
+ from dataclasses import asdict, dataclass
12
+ from pathlib import Path
13
+
14
+ import pandas as pd
15
+
16
+ from . import StateCache
17
+ from .effects import Cell, Chain, build_block_effects
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class CacheBenchmark:
22
+ """Measured cache scale and consumer cost.
23
+
24
+ Parameters
25
+ ----------
26
+ cache : str
27
+ Cache directory.
28
+ states : int
29
+ Number of cached stand states.
30
+ accounts : int
31
+ Account count.
32
+ offsets : int
33
+ Chain-offset count.
34
+ dtype : str
35
+ Stored dtype.
36
+ bytes : int
37
+ Total cache size on disk.
38
+ load_seconds : float
39
+ Time to load and validate the cache.
40
+ lookup_seconds : float
41
+ Median random state lookup time.
42
+ lookup_repeats : int
43
+ Lookup repetitions.
44
+ blocks : int
45
+ Blocks in the effect projection.
46
+ cells : int
47
+ Target cells in the projection.
48
+ effect_seconds : float
49
+ Time to project the blocks onto the cells.
50
+ """
51
+
52
+ cache: str
53
+ states: int
54
+ accounts: int
55
+ offsets: int
56
+ dtype: str
57
+ bytes: int
58
+ load_seconds: float
59
+ lookup_seconds: float
60
+ lookup_repeats: int
61
+ blocks: int
62
+ cells: int
63
+ effect_seconds: float
64
+
65
+
66
+ def _cache_bytes(root: Path) -> int:
67
+ """Return the total size of every file under a cache root."""
68
+ return sum(path.stat().st_size for path in root.rglob("*") if path.is_file())
69
+
70
+
71
+ def benchmark_cache(
72
+ cache_path: str | Path,
73
+ fragments: pd.DataFrame,
74
+ chains: Mapping[int, Chain],
75
+ cells: Sequence[Cell],
76
+ *,
77
+ repeats: int = 100,
78
+ seed: int = 7,
79
+ ) -> CacheBenchmark:
80
+ """Measure load, lookup, projection, and storage.
81
+
82
+ Parameters
83
+ ----------
84
+ cache_path : path-like
85
+ Cache directory.
86
+ fragments : pandas.DataFrame
87
+ Block/fragment rows for the projection.
88
+ chains : mapping of int to tuple
89
+ Current chain per block.
90
+ cells : sequence of (int, str)
91
+ Target cells.
92
+ repeats : int, optional
93
+ Random lookup repetitions. Defaults to 100.
94
+ seed : int, optional
95
+ Random seed. Defaults to seven.
96
+
97
+ Returns
98
+ -------
99
+ CacheBenchmark
100
+ The measured result.
101
+ """
102
+ if repeats < 1:
103
+ raise ValueError("repeats must be positive")
104
+ started = time.perf_counter()
105
+ cache = StateCache.load(cache_path)
106
+ load_seconds = time.perf_counter() - started
107
+ rows = list(range(len(cache.index)))
108
+ random.Random(seed).shuffle(rows)
109
+ offsets = [int(value) for value in cache.manifest["offsets"]]
110
+ samples = rows[:repeats]
111
+ timings: list[float] = []
112
+ for row in samples:
113
+ state = cache.index.iloc[row]
114
+ offset = random.Random(row).choice(offsets)
115
+ started = time.perf_counter()
116
+ cache.lookup(int(state.track_id), int(state.age), offset)
117
+ timings.append(time.perf_counter() - started)
118
+ started = time.perf_counter()
119
+ effects = build_block_effects(cache, fragments, chains, cells)
120
+ effect_seconds = time.perf_counter() - started
121
+ return CacheBenchmark(
122
+ cache=str(Path(cache_path)),
123
+ states=len(cache.index),
124
+ accounts=len(cache.accounts),
125
+ offsets=len(offsets),
126
+ dtype=str(cache.manifest["dtype"]),
127
+ bytes=_cache_bytes(Path(cache_path)),
128
+ load_seconds=load_seconds,
129
+ lookup_seconds=statistics.median(timings),
130
+ lookup_repeats=len(timings),
131
+ blocks=len(effects.blocks),
132
+ cells=len(effects.cells),
133
+ effect_seconds=effect_seconds,
134
+ )
135
+
136
+
137
+ def main(argv: Sequence[str] | None = None) -> int:
138
+ """Run the cache benchmark from the command line.
139
+
140
+ Parameters
141
+ ----------
142
+ argv : sequence of str or None, optional
143
+ Command-line arguments. None reads ``sys.argv``.
144
+
145
+ Returns
146
+ -------
147
+ int
148
+ Process exit status.
149
+ """
150
+ parser = argparse.ArgumentParser(prog="fresh-cbm-bench")
151
+ parser.add_argument("--cache", required=True)
152
+ parser.add_argument("--dataset", required=True, help="Dataset directory with fragments.parquet")
153
+ parser.add_argument("--account", default="carbon.system.total")
154
+ parser.add_argument("--year", type=int, default=49)
155
+ parser.add_argument("--offset", type=int, default=0)
156
+ parser.add_argument("--repeats", type=int, default=100)
157
+ parser.add_argument("--seed", type=int, default=7)
158
+ parser.add_argument("--out", type=Path)
159
+ args = parser.parse_args(argv)
160
+ fragments = pd.read_parquet(Path(args.dataset) / "fragments.parquet")
161
+ blocks = sorted({int(value) for value in fragments["block_id"]})
162
+ chains = dict.fromkeys(blocks, (("CC", args.offset),))
163
+ result = benchmark_cache(
164
+ args.cache,
165
+ fragments,
166
+ chains,
167
+ [(args.year, args.account)],
168
+ repeats=args.repeats,
169
+ seed=args.seed,
170
+ )
171
+ payload = asdict(result)
172
+ print(json.dumps(payload, indent=2))
173
+ if args.out is not None:
174
+ args.out.parent.mkdir(parents=True, exist_ok=True)
175
+ args.out.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
176
+ return 0
fresh_cbm/build.py ADDED
@@ -0,0 +1,203 @@
1
+ """Build an exact libcbm stand-state cache for one chain family.
2
+
3
+ The builder intentionally lives in fresh-cbm, not in the WS4 runtime. It imports
4
+ WS4's CarbonLedger so the cached rows are produced by the same exact path the
5
+ optimizer uses.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import sys
13
+ import time
14
+ from collections.abc import Sequence
15
+ from datetime import UTC, datetime
16
+ from pathlib import Path
17
+
18
+ import numpy as np
19
+ import pandas as pd
20
+
21
+ from .family import ChainFamily
22
+
23
+
24
+ def parse_offsets(text: str) -> list[int]:
25
+ """Parse ``0-49`` or ``0,1,2`` into offsets.
26
+
27
+ Parameters
28
+ ----------
29
+ text : str
30
+ Comma-separated offsets or ranges.
31
+
32
+ Returns
33
+ -------
34
+ list of int
35
+ Sorted unique offsets.
36
+ """
37
+ values: list[int] = []
38
+ for part in text.split(","):
39
+ part = part.strip()
40
+ if not part:
41
+ continue
42
+ if "-" in part:
43
+ start, end = part.split("-", 1)
44
+ values.extend(range(int(start), int(end) + 1))
45
+ else:
46
+ values.append(int(part))
47
+ if not values:
48
+ raise ValueError("no offsets selected")
49
+ return sorted(dict.fromkeys(values))
50
+
51
+
52
+ def build(args: argparse.Namespace) -> None:
53
+ """Build the cache.
54
+
55
+ Parameters
56
+ ----------
57
+ args : argparse.Namespace
58
+ Parsed builder arguments.
59
+
60
+ Raises
61
+ ------
62
+ RuntimeError
63
+ When WS4 is not importable on the current path.
64
+ """
65
+ ws4_path = getattr(args, "ws4_path", None)
66
+ if ws4_path:
67
+ sys.path.insert(0, str(ws4_path))
68
+ try:
69
+ from ws4.carbon.hwp import load_hwp_config
70
+ from ws4.carbon.ledger import CarbonLedger
71
+ from ws4.carbon.libcbm import cbm_defaults_reference, installed_libcbm_version
72
+ from ws4.carbon.package import load_carbon_package, package_fingerprint
73
+ except ImportError as exc: # pragma: no cover - depends on optional installation
74
+ raise RuntimeError(
75
+ "fresh-cbm-build needs WS4 with the carbon extra; pass --ws4-path "
76
+ "or set PYTHONPATH to the WS4 source root"
77
+ ) from exc
78
+
79
+ out = Path(args.out)
80
+ (out / "values").mkdir(parents=True, exist_ok=True)
81
+ package = load_carbon_package(Path(args.package))
82
+ hwp = load_hwp_config(Path(args.hwp_config)) if args.hwp_config else None
83
+ ledger = CarbonLedger(
84
+ Path(args.dataset),
85
+ package,
86
+ years=args.years,
87
+ hwp=hwp,
88
+ base_workers=args.workers,
89
+ )
90
+ fragments = ledger.fragments
91
+ states = (
92
+ fragments.loc[:, ["track_id", "age"]]
93
+ .drop_duplicates()
94
+ .sort_values(["track_id", "age"], kind="stable")
95
+ .reset_index(drop=True)
96
+ )
97
+ if args.limit is not None:
98
+ states = states.head(args.limit).reset_index(drop=True)
99
+ track_names = {
100
+ int(str(row.track_id)): str(row.name) for row in ledger.tracks.itertuples(index=False)
101
+ }
102
+ index = pd.DataFrame(
103
+ {
104
+ "state_id": np.arange(len(states), dtype=np.int64),
105
+ "track_id": states["track_id"].astype(int),
106
+ "track_name": [track_names.get(int(value), "") for value in states["track_id"]],
107
+ "age": states["age"].astype(int),
108
+ }
109
+ )
110
+ index.to_parquet(out / "index.parquet", index=False)
111
+ family = ChainFamily(
112
+ treatment=args.treatment,
113
+ offsets=tuple(parse_offsets(args.offsets)),
114
+ events=args.events,
115
+ allow_empty=not args.no_empty,
116
+ )
117
+ offsets = family.offsets
118
+ build_offsets = [-1, *offsets] if family.allow_empty else list(offsets)
119
+ started = time.perf_counter()
120
+ accounts: tuple[str, ...] | None = None
121
+ for offset in build_offsets:
122
+ chain = () if offset < 0 else family.chain(offset)
123
+ keys = {(int(row.track_id), int(row.age), chain) for row in states.itertuples(index=False)}
124
+ step = time.perf_counter()
125
+ ledger._prepare_units(keys)
126
+ if accounts is None:
127
+ first = ledger._unit_cache[next(iter(keys))]
128
+ accounts = tuple(sorted({account for _offset, account in first}))
129
+ values = np.zeros((len(states), args.years + 1, len(accounts)), dtype=args.dtype)
130
+ for row, state in enumerate(states.itertuples(index=False)):
131
+ unit = ledger._unit_cache[(int(state.track_id), int(state.age), chain)]
132
+ for offset_index in range(args.years + 1):
133
+ for account_index, account in enumerate(accounts):
134
+ values[row, offset_index, account_index] = unit.get(
135
+ (offset_index, account), 0.0
136
+ )
137
+ name = "empty.npy" if offset < 0 else f"offset_{offset:02d}.npy"
138
+ np.save(out / "values" / name, values, allow_pickle=False)
139
+ ledger._unit_cache.clear()
140
+ ledger._cache.clear()
141
+ print(f"offset={offset:>2} states={len(states)} seconds={time.perf_counter() - step:.1f}")
142
+ manifest = {
143
+ "schema": "fresh-cbm-state-v1",
144
+ "created": datetime.now(UTC).isoformat(),
145
+ "dataset": Path(args.dataset).name,
146
+ "package": Path(args.package).name,
147
+ "package_fingerprint": package_fingerprint(package),
148
+ "libcbm_version": installed_libcbm_version(),
149
+ "cbm_defaults_sha256": cbm_defaults_reference().sha256,
150
+ "years": args.years,
151
+ "dtype": str(np.dtype(args.dtype)),
152
+ "accounts": list(accounts or ()),
153
+ "offsets": list(offsets),
154
+ "empty": family.allow_empty,
155
+ "chain_family": family.as_manifest(),
156
+ "states": len(states),
157
+ "files": [
158
+ *(["values/empty.npy"] if family.allow_empty else []),
159
+ *[f"values/offset_{offset:02d}.npy" for offset in offsets],
160
+ ],
161
+ "build_seconds": time.perf_counter() - started,
162
+ "hwp_config": Path(args.hwp_config).name if args.hwp_config else None,
163
+ }
164
+ (out / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
165
+ print(f"wrote {out} in {time.perf_counter() - started:.1f}s")
166
+
167
+
168
+ def main(argv: Sequence[str] | None = None) -> int:
169
+ """Parse arguments and build the cache.
170
+
171
+ Parameters
172
+ ----------
173
+ argv : sequence of str or None, optional
174
+ Command-line arguments. None reads ``sys.argv``.
175
+
176
+ Returns
177
+ -------
178
+ int
179
+ Process exit status.
180
+ """
181
+ parser = argparse.ArgumentParser(prog="fresh-cbm-build")
182
+ parser.add_argument("--dataset", required=True)
183
+ parser.add_argument("--package", required=True)
184
+ parser.add_argument("--hwp-config")
185
+ parser.add_argument("--years", type=int, default=50)
186
+ parser.add_argument("--offsets", default="0-49")
187
+ parser.add_argument("--treatment", default="CC")
188
+ parser.add_argument("--events", type=int, default=1)
189
+ parser.add_argument("--no-empty", action="store_true")
190
+ parser.add_argument(
191
+ "--ws4-path",
192
+ help="WS4 source root to add to sys.path when running the builder",
193
+ )
194
+ parser.add_argument("--workers", type=int, default=32)
195
+ parser.add_argument("--dtype", choices=["float32", "float64"], default="float64")
196
+ parser.add_argument("--limit", type=int)
197
+ parser.add_argument("--out", required=True)
198
+ try:
199
+ build(parser.parse_args(argv))
200
+ except RuntimeError as exc:
201
+ print(f"error: {exc}", file=sys.stderr)
202
+ return 1
203
+ return 0