cvprofiles 2.0.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.
cvprofiles/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """cvprofiles — construct-validity profiles (score-agnostic, model-free engine)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "2.0.0"
6
+ __all__ = ["__version__"]
cvprofiles/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow ``python -m cvprofiles`` / ``python -m cvprofiles.cli``."""
2
+
3
+ from cvprofiles.cli import app
4
+
5
+ app()
@@ -0,0 +1,27 @@
1
+ """θ-anchor documentation discipline (v2.0 thread c).
2
+
3
+ Pre-data anchors as a first-class schema'd artifact: one anchor per
4
+ restriction id, hashed for provenance (anchors_hash), excluded from the
5
+ freeze preimage. "Pre-data" is a process commitment — the engine enforces
6
+ completeness + hash, never timing.
7
+ """
8
+
9
+ from cvprofiles.anchors.pipeline import (
10
+ AnchorError,
11
+ AnchorsConfig,
12
+ ThetaAnchor,
13
+ anchors_hash,
14
+ anchors_payload,
15
+ parse_anchors,
16
+ validate_completeness,
17
+ )
18
+
19
+ __all__ = [
20
+ "AnchorError",
21
+ "AnchorsConfig",
22
+ "ThetaAnchor",
23
+ "anchors_hash",
24
+ "anchors_payload",
25
+ "parse_anchors",
26
+ "validate_completeness",
27
+ ]
@@ -0,0 +1,136 @@
1
+ """θ-anchor parsing, completeness validation, and provenance hashing.
2
+
3
+ v2.0 thread c (docs/12 2026-08-05 D4/D6): anchors.yaml is a schema'd
4
+ pre-data artifact. One anchor per restriction id; completeness is enforced
5
+ against the pinned network. anchors_hash is SHA-256 of canonical JSON and is
6
+ **excluded from the freeze preimage** — it is documentation provenance, not
7
+ an engine input. "Pre-data" is a process commitment; the engine cannot
8
+ verify when a file was written.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+ from typing import Any, Literal
15
+
16
+ import yaml
17
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
18
+
19
+ from cvprofiles.freeze import hash_canonical_json
20
+ from cvprofiles.schemas.network import NetworkConfig
21
+
22
+ AnchorKind = Literal["literature", "derived", "author"]
23
+
24
+
25
+ class AnchorError(ValueError):
26
+ """Loud anchor failure (parse / schema / completeness)."""
27
+
28
+
29
+ class ThetaAnchor(BaseModel):
30
+ """One pre-data anchor for one restriction r."""
31
+
32
+ model_config = ConfigDict(extra="forbid")
33
+
34
+ restriction_id: str = Field(..., min_length=1)
35
+ citation_key: str = Field(..., min_length=1)
36
+ source_phrase: str = Field(..., min_length=1)
37
+ anchor_kind: AnchorKind
38
+ pre_data: bool = Field(
39
+ default=True,
40
+ description=(
41
+ "Process commitment flag: anchor declared before data analysis. "
42
+ "The engine cannot verify timing; this is the researcher's record."
43
+ ),
44
+ )
45
+
46
+ @field_validator("restriction_id")
47
+ @classmethod
48
+ def _id_strip(cls, v: str) -> str:
49
+ s = v.strip()
50
+ if not s:
51
+ raise ValueError("restriction_id must be non-empty")
52
+ return s
53
+
54
+
55
+ class AnchorsConfig(BaseModel):
56
+ """The full anchor set for one network."""
57
+
58
+ model_config = ConfigDict(extra="forbid")
59
+
60
+ schema_version: str = "1"
61
+ anchors: list[ThetaAnchor] = Field(..., min_length=1)
62
+
63
+ @model_validator(mode="after")
64
+ def _unique_ids(self) -> AnchorsConfig:
65
+ ids = [a.restriction_id for a in self.anchors]
66
+ if len(ids) != len(set(ids)):
67
+ raise ValueError("anchor restriction_ids must be unique")
68
+ return self
69
+
70
+
71
+ def parse_anchors(source: Path | str | dict[str, Any]) -> AnchorsConfig:
72
+ """Load and validate anchors from YAML path or mapping.
73
+
74
+ All invalid inputs raise ``AnchorError`` (never raw pydantic at the IO
75
+ boundary), mirroring the RESTRICT pattern.
76
+ """
77
+ if isinstance(source, dict):
78
+ try:
79
+ return AnchorsConfig.model_validate(source)
80
+ except Exception as exc: # pydantic ValidationError
81
+ raise AnchorError(f"invalid anchors schema: {exc}") from exc
82
+ p = Path(source)
83
+ if not p.is_file():
84
+ raise AnchorError(f"anchors file not found: {p}")
85
+ try:
86
+ raw = yaml.safe_load(p.read_text())
87
+ except yaml.YAMLError as exc:
88
+ raise AnchorError(f"invalid anchors YAML: {exc}") from exc
89
+ if not isinstance(raw, dict):
90
+ raise AnchorError("anchors YAML must be a mapping at top level")
91
+ try:
92
+ return AnchorsConfig.model_validate(raw)
93
+ except Exception as exc: # pydantic ValidationError
94
+ raise AnchorError(f"invalid anchors schema: {exc}") from exc
95
+
96
+
97
+ def validate_completeness(
98
+ config: AnchorsConfig, network: NetworkConfig
99
+ ) -> AnchorsConfig:
100
+ """Fail loud unless every restriction id has exactly one anchor.
101
+
102
+ Unknown anchor ids (not in the network) also fail: an anchor must bind
103
+ to a real restriction.
104
+ """
105
+ net_ids = {r.id for r in network.restrictions}
106
+ anchor_ids = {a.restriction_id for a in config.anchors}
107
+ missing = net_ids - anchor_ids
108
+ if missing:
109
+ raise AnchorError(
110
+ f"missing anchors for restriction(s): {sorted(missing)}"
111
+ )
112
+ unknown = anchor_ids - net_ids
113
+ if unknown:
114
+ raise AnchorError(
115
+ f"anchor restriction_id(s) not in the network: {sorted(unknown)}"
116
+ )
117
+ return config
118
+
119
+
120
+ def anchors_hash(config: AnchorsConfig) -> str:
121
+ """SHA-256 hex of canonical JSON for the validated anchor set.
122
+
123
+ Excluded from the freeze preimage by design (docs/12 D4): anchors are
124
+ documentation provenance, not engine inputs.
125
+ """
126
+ return hash_canonical_json(config.model_dump(mode="json"))
127
+
128
+
129
+ def anchors_payload(config: AnchorsConfig) -> dict[str, Any]:
130
+ """JSON-serializable audit payload (anchors.json + report panel)."""
131
+ return {
132
+ "schema_version": "1",
133
+ "anchors_hash": anchors_hash(config),
134
+ "n_anchors": len(config.anchors),
135
+ "anchors": [a.model_dump(mode="json") for a in config.anchors],
136
+ }
cvprofiles/cli.py ADDED
@@ -0,0 +1,213 @@
1
+ """Thin Typer CLI — state helpers + full SCORE→REPORT run."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ from pathlib import Path
8
+ from typing import Annotated
9
+
10
+ import typer
11
+
12
+ from cvprofiles import __version__
13
+
14
+ app = typer.Typer(
15
+ name="cvprofiles",
16
+ help="Construct-validity profiles: admissible sets and construct-identified ranges.",
17
+ no_args_is_help=True,
18
+ add_completion=False,
19
+ )
20
+
21
+
22
+ def _parse_theta_grid(raw: str | None) -> list[float] | None:
23
+ """Parse a comma-separated positive finite λ grid, fail-loudly."""
24
+ if raw is None:
25
+ return None
26
+ tokens = raw.split(",")
27
+ if not raw.strip() or any(not token.strip() for token in tokens):
28
+ raise ValueError("must be a non-empty comma-separated list of positive numbers")
29
+ values: list[float] = []
30
+ for token in tokens:
31
+ try:
32
+ value = float(token.strip())
33
+ except ValueError as exc:
34
+ raise ValueError(f"invalid lambda {token.strip()!r}") from exc
35
+ if not math.isfinite(value) or value <= 0.0:
36
+ raise ValueError(f"lambda {token.strip()!r} must be finite and > 0")
37
+ if value in values:
38
+ raise ValueError(f"duplicate lambda {token.strip()!r}")
39
+ values.append(value)
40
+ return values
41
+
42
+
43
+ def _parse_delta_grid(raw: str | None) -> list[float] | None:
44
+ """Parse a comma-separated non-negative finite δ grid, fail-loudly."""
45
+ if raw is None:
46
+ return None
47
+ tokens = raw.split(",")
48
+ if not raw.strip() or any(not token.strip() for token in tokens):
49
+ raise ValueError("must be a non-empty comma-separated list of non-negative numbers")
50
+ values: list[float] = []
51
+ for token in tokens:
52
+ try:
53
+ value = float(token.strip())
54
+ except ValueError as exc:
55
+ raise ValueError(f"invalid delta {token.strip()!r}") from exc
56
+ if not math.isfinite(value) or value < 0.0:
57
+ raise ValueError(f"delta {token.strip()!r} must be finite and >= 0")
58
+ if value in values:
59
+ raise ValueError(f"duplicate delta {token.strip()!r}")
60
+ values.append(value)
61
+ return values
62
+
63
+
64
+ def _version_callback(value: bool) -> None:
65
+ if value:
66
+ typer.echo(f"cvprofiles {__version__}")
67
+ raise typer.Exit()
68
+
69
+
70
+ @app.callback()
71
+ def main(
72
+ version: bool = typer.Option(
73
+ False,
74
+ "--version",
75
+ "-V",
76
+ help="Show package version and exit.",
77
+ callback=_version_callback,
78
+ is_eager=True,
79
+ ),
80
+ ) -> None:
81
+ """cvprofiles CLI (thin v1.0 spine)."""
82
+
83
+
84
+ @app.command("run")
85
+ def run_cmd(
86
+ scores: Annotated[
87
+ Path,
88
+ typer.Option("--scores", exists=True, dir_okay=False, help="Scores CSV/parquet"),
89
+ ],
90
+ roles: Annotated[
91
+ Path,
92
+ typer.Option("--roles", exists=True, dir_okay=False, help="roles.json"),
93
+ ],
94
+ network: Annotated[
95
+ Path,
96
+ typer.Option("--network", exists=True, dir_okay=False, help="network.yaml"),
97
+ ],
98
+ beta: Annotated[
99
+ Path,
100
+ typer.Option("--beta", exists=True, dir_okay=False, help="beta.yaml"),
101
+ ],
102
+ out: Annotated[
103
+ Path | None,
104
+ typer.Option("--out", help="Output dir (default: reports/runs/<run_id>/)"),
105
+ ] = None,
106
+ policy: Annotated[
107
+ str,
108
+ typer.Option("--policy", help="SCORE normalization: none | zscore_measures"),
109
+ ] = "none",
110
+ seed: Annotated[int, typer.Option("--seed", min=0)] = 0,
111
+ n_boot: Annotated[
112
+ int,
113
+ typer.Option(
114
+ "--n-boot",
115
+ min=0,
116
+ help="Bootstrap replicates over units; 0 disables bootstrap.",
117
+ ),
118
+ ] = 0,
119
+ theta_grid: Annotated[
120
+ str | None,
121
+ typer.Option(
122
+ "--theta-grid",
123
+ help="Comma-separated positive threshold scale multipliers.",
124
+ ),
125
+ ] = None,
126
+ delta_grid: Annotated[
127
+ str | None,
128
+ typer.Option(
129
+ "--delta-grid",
130
+ help="Comma-separated non-negative tolerance values (absolute δ).",
131
+ ),
132
+ ] = None,
133
+ anchors: Annotated[
134
+ Path | None,
135
+ typer.Option(
136
+ "--anchors",
137
+ exists=True,
138
+ dir_okay=False,
139
+ help="Pre-data θ-anchor YAML (documentation; excluded from run_id).",
140
+ ),
141
+ ] = None,
142
+ title: Annotated[
143
+ str,
144
+ typer.Option("--title"),
145
+ ] = "Construct-validity profile",
146
+ ) -> None:
147
+ """SCORE → RESTRICT → IDENTIFY → REPORT. Empty M* exits 0.
148
+
149
+ stdout is always a single JSON summary (machine-clean).
150
+ Human status crumbs go to stderr only.
151
+ """
152
+ from cvprofiles.identify.pipeline import IdentifyError
153
+ from cvprofiles.pipeline import run_profile, summary_dict
154
+ from cvprofiles.report.pipeline import ReportError
155
+ from cvprofiles.restrict.pipeline import RestrictError
156
+ from cvprofiles.score.pipeline import ScoreError
157
+
158
+ if policy not in ("none", "zscore_measures"):
159
+ typer.secho(f"error: unknown policy: {policy}", fg=typer.colors.RED, err=True)
160
+ raise typer.Exit(code=2)
161
+
162
+ try:
163
+ theta_grid_lambdas = _parse_theta_grid(theta_grid)
164
+ except ValueError as exc:
165
+ typer.secho(f"error: theta-grid: {exc}", fg=typer.colors.RED, err=True)
166
+ raise typer.Exit(code=2) from exc
167
+
168
+ try:
169
+ delta_grid_deltas = _parse_delta_grid(delta_grid)
170
+ except ValueError as exc:
171
+ typer.secho(f"error: delta-grid: {exc}", fg=typer.colors.RED, err=True)
172
+ raise typer.Exit(code=2) from exc
173
+
174
+ try:
175
+ result = run_profile(
176
+ scores=scores,
177
+ roles=roles,
178
+ network=network,
179
+ beta=beta,
180
+ out_dir=out,
181
+ policy=policy, # type: ignore[arg-type]
182
+ seed=seed,
183
+ title=title,
184
+ n_boot=n_boot,
185
+ theta_grid_lambdas=theta_grid_lambdas,
186
+ delta_grid_deltas=delta_grid_deltas,
187
+ anchors=anchors,
188
+ )
189
+ except (ScoreError, RestrictError, IdentifyError, ReportError, ValueError) as exc:
190
+ typer.secho(f"error: {exc}", fg=typer.colors.RED, err=True)
191
+ raise typer.Exit(code=1) from exc
192
+
193
+ summary = summary_dict(result)
194
+ # stdout = JSON only (tests + scripts parse this)
195
+ typer.echo(json.dumps(summary, indent=2))
196
+ # human crumbs → stderr only
197
+ if result.identify.empty:
198
+ typer.secho(
199
+ "empty M* — clean success; see report.html for binding restrictions",
200
+ fg=typer.colors.YELLOW,
201
+ err=True,
202
+ )
203
+ else:
204
+ typer.secho(
205
+ f"M*={result.identify.admissible} "
206
+ f"[L,U]=[{result.identify.range_L}, {result.identify.range_U}]",
207
+ fg=typer.colors.GREEN,
208
+ err=True,
209
+ )
210
+
211
+
212
+ if __name__ == "__main__":
213
+ app()
cvprofiles/freeze.py ADDED
@@ -0,0 +1,279 @@
1
+ """Freeze hashing and run_id construction (M1 lock).
2
+
3
+ Algorithm (LOCKED 2026-08-01 — see docs/12):
4
+
5
+ 1. Piece hashes are bare lowercase SHA-256 hex (64 chars), no ``sha256:`` prefix.
6
+ 2. Canonical JSON: UTF-8, ``sort_keys=True``, separators ``(",", ":")``,
7
+ ``allow_nan=False``. Non-JSON types raise.
8
+ 3. ``network_hash`` / ``beta_hash``: hash canonical JSON of
9
+ ``model_dump(mode="json")`` for validated NetworkConfig / BetaSpec.
10
+ 4. ``scores_hash``: hash canonical CSV text of the score table:
11
+ - columns in declared order (caller supplies column list);
12
+ - rows sorted by ``unit_id`` ascending (string sort);
13
+ - header line = comma-joined column names;
14
+ - floats formatted with 17 significant digits (``format(x, ".17g")``);
15
+ - integers without decimal; bool as ``0``/``1``; null as empty field;
16
+ - UTF-8 bytes of the full CSV (including trailing newline after last row).
17
+ 5. ``run_id``: SHA-256 of canonical JSON of the freeze preimage dict with keys
18
+ (JSON is sort_keys; documentation order is not the hash order):
19
+ ``beta_hash``, ``config``, ``delta``, ``n_boot``, ``network_hash``,
20
+ ``package_version``, ``schema_version``, ``scores_hash``, ``seed``.
21
+ ``n_boot`` is JSON ``null`` in v1.0.
22
+ 6. **Excluded from run_id preimage:** ``created_at``, wall clock, absolute paths,
23
+ hostnames, artifact path maps, report HTML.
24
+
25
+ Bit-stability: same validated scores + network + beta + package_version +
26
+ seed + delta + config ⇒ identical scores/network/beta hashes and run_id.
27
+
28
+ Engine freeze column list should be unit_id + measures + aux + outcome only;
29
+ diagnostic columns (e.g. ``V_star``) stay out of ``scores_hash`` unless the
30
+ caller intentionally versions them.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import hashlib
36
+ import json
37
+ import math
38
+ import re
39
+ from collections.abc import Mapping, Sequence
40
+ from typing import Any
41
+
42
+ import numpy as np
43
+ import pandas as pd
44
+
45
+ from cvprofiles.schemas.beta import BetaSpec
46
+ from cvprofiles.schemas.network import NetworkConfig
47
+ from cvprofiles.schemas.run import FreezeBundle
48
+
49
+ CANONICAL_JSON_SEPARATORS = (",", ":")
50
+ _HEX64 = re.compile(r"^[0-9a-f]{64}$")
51
+
52
+
53
+ def canonical_json_bytes(obj: Any) -> bytes:
54
+ """Serialize ``obj`` to canonical UTF-8 JSON bytes (sorted keys, compact)."""
55
+ try:
56
+ text = json.dumps(
57
+ obj,
58
+ sort_keys=True,
59
+ separators=CANONICAL_JSON_SEPARATORS,
60
+ ensure_ascii=False,
61
+ allow_nan=False,
62
+ )
63
+ except (TypeError, ValueError) as exc:
64
+ raise ValueError(f"object is not JSON-canonicalizable: {exc}") from exc
65
+ return text.encode("utf-8")
66
+
67
+
68
+ def sha256_hex(data: bytes) -> str:
69
+ """Bare lowercase SHA-256 hex digest (64 chars)."""
70
+ return hashlib.sha256(data).hexdigest()
71
+
72
+
73
+ def hash_canonical_json(obj: Any) -> str:
74
+ """SHA-256 hex of canonical JSON bytes."""
75
+ return sha256_hex(canonical_json_bytes(obj))
76
+
77
+
78
+ def _format_cell(value: Any) -> str:
79
+ if value is None:
80
+ return ""
81
+
82
+ # Numpy scalars → Python before NA / bool / int branching
83
+ if isinstance(value, np.generic):
84
+ value = value.item()
85
+
86
+ # Non-finite floats must fail loud (never coerce NaN/Inf to empty).
87
+ if isinstance(value, float):
88
+ if math.isnan(value) or math.isinf(value):
89
+ raise ValueError("scores table must not contain NaN or Inf for freeze hash")
90
+ return format(value, ".17g")
91
+
92
+ # Remaining missing values (e.g. pd.NA on non-float columns) → empty field
93
+ try:
94
+ if pd.isna(value):
95
+ return ""
96
+ except (TypeError, ValueError):
97
+ pass
98
+
99
+ if isinstance(value, bool):
100
+ return "1" if value else "0"
101
+ if isinstance(value, int) and not isinstance(value, bool):
102
+ return str(value)
103
+ if isinstance(value, str):
104
+ if any(c in value for c in (",", '"', "\n", "\r")):
105
+ escaped = value.replace('"', '""')
106
+ return f'"{escaped}"'
107
+ return value
108
+ raise ValueError(f"unsupported cell type for scores hash: {type(value)!r}")
109
+
110
+
111
+ def scores_canonical_csv_bytes(
112
+ df: pd.DataFrame,
113
+ columns: Sequence[str],
114
+ *,
115
+ unit_id_col: str = "unit_id",
116
+ ) -> bytes:
117
+ """Build canonical CSV bytes for score-table hashing.
118
+
119
+ Rows are sorted by ``unit_id_col`` (as string). Column order is exactly
120
+ ``columns`` (typically unit_id + measures + aux + outcome; diagnostics out).
121
+ """
122
+ missing = [c for c in columns if c not in df.columns]
123
+ if missing:
124
+ raise ValueError(f"scores frame missing columns for hash: {missing}")
125
+ if unit_id_col not in columns:
126
+ raise ValueError(f"unit_id column {unit_id_col!r} must be included in columns")
127
+
128
+ work = df.loc[:, list(columns)].copy()
129
+ work["_sort_key"] = work[unit_id_col].astype(str)
130
+ work = work.sort_values("_sort_key", kind="mergesort").drop(columns=["_sort_key"])
131
+
132
+ lines: list[str] = [",".join(columns)]
133
+ for row in work.itertuples(index=False, name=None):
134
+ lines.append(",".join(_format_cell(v) for v in row))
135
+ text = "\n".join(lines) + "\n"
136
+ return text.encode("utf-8")
137
+
138
+
139
+ def hash_scores_frame(
140
+ df: pd.DataFrame,
141
+ columns: Sequence[str],
142
+ *,
143
+ unit_id_col: str = "unit_id",
144
+ ) -> str:
145
+ """SHA-256 hex of canonical score CSV bytes."""
146
+ return sha256_hex(scores_canonical_csv_bytes(df, columns, unit_id_col=unit_id_col))
147
+
148
+
149
+ def hash_network(network: NetworkConfig | Mapping[str, Any]) -> str:
150
+ """SHA-256 of canonical JSON for a validated network (or raw mapping)."""
151
+ if isinstance(network, NetworkConfig):
152
+ payload = network.model_dump(mode="json")
153
+ else:
154
+ payload = NetworkConfig.model_validate(dict(network)).model_dump(mode="json")
155
+ return hash_canonical_json(payload)
156
+
157
+
158
+ def hash_beta(beta: BetaSpec | Mapping[str, Any]) -> str:
159
+ """SHA-256 of canonical JSON for a validated beta spec (or raw mapping)."""
160
+ if isinstance(beta, BetaSpec):
161
+ payload = beta.model_dump(mode="json")
162
+ else:
163
+ payload = BetaSpec.model_validate(dict(beta)).model_dump(mode="json")
164
+ return hash_canonical_json(payload)
165
+
166
+
167
+ def _validate_hex64(name: str, value: str) -> None:
168
+ if not isinstance(value, str) or not _HEX64.match(value):
169
+ raise ValueError(f"{name} must be 64-char lowercase hex string")
170
+
171
+
172
+ def normalize_n_boot(n_boot: int | None) -> int | None:
173
+ """Normalize ``n_boot`` for the freeze preimage (docs/12 v1.1 lock).
174
+
175
+ ``< 1`` (or None) ⇒ JSON ``null`` so v1.0-era run_ids stay bit-stable
176
+ when bootstrap is off; ``>= 1`` ⇒ int. Every preimage producer must call
177
+ this before ``compute_run_id`` / ``build_freeze_bundle``.
178
+ """
179
+ if n_boot is None or n_boot < 1:
180
+ return None
181
+ return int(n_boot)
182
+
183
+
184
+ def freeze_preimage(
185
+ *,
186
+ scores_hash: str,
187
+ network_hash: str,
188
+ beta_hash: str,
189
+ package_version: str,
190
+ seed: int = 0,
191
+ delta: float = 0.0,
192
+ schema_version: str = "1",
193
+ n_boot: int | None = None,
194
+ config: Mapping[str, Any] | None = None,
195
+ ) -> dict[str, Any]:
196
+ """Build the run_id preimage dict (hashed by ``compute_run_id``)."""
197
+ return {
198
+ "beta_hash": beta_hash,
199
+ "config": dict(config or {}),
200
+ "delta": float(delta),
201
+ "n_boot": n_boot,
202
+ "network_hash": network_hash,
203
+ "package_version": package_version,
204
+ "schema_version": schema_version,
205
+ "scores_hash": scores_hash,
206
+ "seed": int(seed),
207
+ }
208
+
209
+
210
+ def compute_run_id(
211
+ *,
212
+ scores_hash: str,
213
+ network_hash: str,
214
+ beta_hash: str,
215
+ package_version: str,
216
+ seed: int = 0,
217
+ delta: float = 0.0,
218
+ schema_version: str = "1",
219
+ n_boot: int | None = None,
220
+ config: Mapping[str, Any] | None = None,
221
+ ) -> str:
222
+ """Compute ``run_id`` = SHA-256 hex of canonical freeze preimage JSON."""
223
+ _validate_hex64("scores_hash", scores_hash)
224
+ _validate_hex64("network_hash", network_hash)
225
+ _validate_hex64("beta_hash", beta_hash)
226
+
227
+ preimage = freeze_preimage(
228
+ scores_hash=scores_hash,
229
+ network_hash=network_hash,
230
+ beta_hash=beta_hash,
231
+ package_version=package_version,
232
+ seed=seed,
233
+ delta=delta,
234
+ schema_version=schema_version,
235
+ n_boot=n_boot,
236
+ config=config,
237
+ )
238
+ return hash_canonical_json(preimage)
239
+
240
+
241
+ def build_freeze_bundle(
242
+ *,
243
+ scores_hash: str,
244
+ network_hash: str,
245
+ beta_hash: str,
246
+ package_version: str,
247
+ seed: int = 0,
248
+ delta: float = 0.0,
249
+ schema_version: str = "1",
250
+ n_boot: int | None = None,
251
+ config: Mapping[str, Any] | None = None,
252
+ ) -> FreezeBundle:
253
+ """Construct a validated FreezeBundle (does not compute run_id)."""
254
+ return FreezeBundle(
255
+ scores_hash=scores_hash,
256
+ network_hash=network_hash,
257
+ beta_hash=beta_hash,
258
+ package_version=package_version,
259
+ schema_version=schema_version,
260
+ seed=seed,
261
+ delta=delta,
262
+ n_boot=n_boot,
263
+ config=dict(config or {}),
264
+ )
265
+
266
+
267
+ def run_id_from_bundle(bundle: FreezeBundle) -> str:
268
+ """Compute run_id from a FreezeBundle (created_at is not on the bundle)."""
269
+ return compute_run_id(
270
+ scores_hash=bundle.scores_hash,
271
+ network_hash=bundle.network_hash,
272
+ beta_hash=bundle.beta_hash,
273
+ package_version=bundle.package_version,
274
+ seed=bundle.seed,
275
+ delta=bundle.delta,
276
+ schema_version=bundle.schema_version,
277
+ n_boot=bundle.n_boot,
278
+ config=bundle.config,
279
+ )
@@ -0,0 +1,17 @@
1
+ """IDENTIFY state — slacks, M*, β image, and headline range."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from cvprofiles.identify.pipeline import (
6
+ IdentifyError,
7
+ IdentifyResult,
8
+ run_identify,
9
+ write_identify_artifacts,
10
+ )
11
+
12
+ __all__ = [
13
+ "IdentifyError",
14
+ "IdentifyResult",
15
+ "run_identify",
16
+ "write_identify_artifacts",
17
+ ]