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 +237 -0
- fresh_cbm/benchmark.py +176 -0
- fresh_cbm/build.py +203 -0
- fresh_cbm/effects.py +177 -0
- fresh_cbm/family.py +163 -0
- fresh_cbm/manifest.schema.json +135 -0
- fresh_cbm/release.py +79 -0
- fresh_cbm/schema.py +55 -0
- fresh_cbm/validate.py +79 -0
- fresh_cbm/verify.py +125 -0
- fresh_cbm-0.2.0.dist-info/METADATA +117 -0
- fresh_cbm-0.2.0.dist-info/RECORD +16 -0
- fresh_cbm-0.2.0.dist-info/WHEEL +5 -0
- fresh_cbm-0.2.0.dist-info/entry_points.txt +6 -0
- fresh_cbm-0.2.0.dist-info/licenses/LICENSE +201 -0
- fresh_cbm-0.2.0.dist-info/top_level.txt +1 -0
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
|