invarlock 0.3.1__py3-none-any.whl → 0.3.3__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 (40) hide show
  1. invarlock/__init__.py +1 -1
  2. invarlock/_data/runtime/tiers.yaml +61 -0
  3. invarlock/adapters/hf_loading.py +97 -0
  4. invarlock/calibration/__init__.py +6 -0
  5. invarlock/calibration/spectral_null.py +301 -0
  6. invarlock/calibration/variance_ve.py +154 -0
  7. invarlock/cli/app.py +15 -0
  8. invarlock/cli/commands/calibrate.py +576 -0
  9. invarlock/cli/commands/doctor.py +9 -3
  10. invarlock/cli/commands/explain_gates.py +53 -9
  11. invarlock/cli/commands/plugins.py +12 -2
  12. invarlock/cli/commands/run.py +181 -79
  13. invarlock/cli/commands/verify.py +40 -0
  14. invarlock/cli/config.py +11 -1
  15. invarlock/cli/determinism.py +252 -0
  16. invarlock/core/auto_tuning.py +215 -17
  17. invarlock/core/bootstrap.py +137 -5
  18. invarlock/core/registry.py +9 -4
  19. invarlock/core/runner.py +305 -35
  20. invarlock/eval/bench.py +467 -141
  21. invarlock/eval/bench_regression.py +12 -0
  22. invarlock/eval/bootstrap.py +3 -1
  23. invarlock/eval/data.py +29 -7
  24. invarlock/eval/primary_metric.py +20 -5
  25. invarlock/guards/rmt.py +536 -46
  26. invarlock/guards/spectral.py +217 -10
  27. invarlock/guards/variance.py +124 -42
  28. invarlock/reporting/certificate.py +476 -45
  29. invarlock/reporting/certificate_schema.py +4 -1
  30. invarlock/reporting/guards_analysis.py +108 -10
  31. invarlock/reporting/normalizer.py +24 -1
  32. invarlock/reporting/policy_utils.py +97 -15
  33. invarlock/reporting/primary_metric_utils.py +17 -0
  34. invarlock/reporting/validate.py +10 -10
  35. {invarlock-0.3.1.dist-info → invarlock-0.3.3.dist-info}/METADATA +12 -10
  36. {invarlock-0.3.1.dist-info → invarlock-0.3.3.dist-info}/RECORD +40 -33
  37. {invarlock-0.3.1.dist-info → invarlock-0.3.3.dist-info}/WHEEL +0 -0
  38. {invarlock-0.3.1.dist-info → invarlock-0.3.3.dist-info}/entry_points.txt +0 -0
  39. {invarlock-0.3.1.dist-info → invarlock-0.3.3.dist-info}/licenses/LICENSE +0 -0
  40. {invarlock-0.3.1.dist-info → invarlock-0.3.3.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,252 @@
1
+ """Determinism presets for CI/release runs.
2
+
3
+ Centralizes:
4
+ - Seeds (python/numpy/torch)
5
+ - Thread caps (OMP/MKL/etc + torch threads)
6
+ - TF32 policy
7
+ - torch deterministic algorithms
8
+ - A structured "determinism level" for certificate provenance
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import random
15
+ from typing import Any
16
+
17
+ import numpy as np
18
+
19
+ from invarlock.model_utils import set_seed
20
+
21
+ try: # optional torch
22
+ import torch
23
+ except Exception: # pragma: no cover
24
+ torch = None # type: ignore[assignment]
25
+
26
+
27
+ _THREAD_ENV_VARS: tuple[str, ...] = (
28
+ "OMP_NUM_THREADS",
29
+ "MKL_NUM_THREADS",
30
+ "OPENBLAS_NUM_THREADS",
31
+ "NUMEXPR_NUM_THREADS",
32
+ "VECLIB_MAXIMUM_THREADS",
33
+ )
34
+
35
+
36
+ def _coerce_int(value: Any, default: int) -> int:
37
+ try:
38
+ return int(value)
39
+ except Exception:
40
+ return int(default)
41
+
42
+
43
+ def _coerce_profile(profile: str | None) -> str:
44
+ try:
45
+ return (profile or "").strip().lower()
46
+ except Exception:
47
+ return ""
48
+
49
+
50
+ def _coerce_device(device: str | None) -> str:
51
+ try:
52
+ return (device or "").strip().lower()
53
+ except Exception:
54
+ return "cpu"
55
+
56
+
57
+ def apply_determinism_preset(
58
+ *,
59
+ profile: str | None,
60
+ device: str | None,
61
+ seed: int,
62
+ threads: int = 1,
63
+ ) -> dict[str, Any]:
64
+ """Apply a determinism preset and return a provenance payload."""
65
+
66
+ prof = _coerce_profile(profile)
67
+ dev = _coerce_device(device)
68
+ threads_i = max(1, _coerce_int(threads, 1))
69
+
70
+ requested = "off"
71
+ if prof in {"ci", "release"}:
72
+ requested = "strict"
73
+
74
+ env_set: dict[str, Any] = {}
75
+ torch_flags: dict[str, Any] = {}
76
+ notes: list[str] = []
77
+
78
+ # Thread caps (best-effort): make CPU determinism explicit and reduce drift.
79
+ if requested == "strict":
80
+ for var in _THREAD_ENV_VARS:
81
+ os.environ[var] = str(threads_i)
82
+ env_set[var] = os.environ.get(var)
83
+
84
+ # CUDA determinism: cuBLAS workspace config.
85
+ if requested == "strict" and dev.startswith("cuda"):
86
+ preferred = ":4096:8"
87
+ fallback = ":16:8"
88
+ if "CUBLAS_WORKSPACE_CONFIG" not in os.environ:
89
+ selected = preferred
90
+ if torch is not None:
91
+ try:
92
+ mem_bytes = int(torch.cuda.get_device_properties(0).total_memory)
93
+ if mem_bytes and mem_bytes < 8 * 1024**3:
94
+ selected = fallback
95
+ except Exception:
96
+ selected = preferred
97
+ os.environ["CUBLAS_WORKSPACE_CONFIG"] = selected
98
+ env_set["CUBLAS_WORKSPACE_CONFIG"] = os.environ.get("CUBLAS_WORKSPACE_CONFIG")
99
+
100
+ if requested == "strict":
101
+ os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
102
+ env_set["TOKENIZERS_PARALLELISM"] = os.environ.get("TOKENIZERS_PARALLELISM")
103
+
104
+ # Seed all RNGs (python/numpy/torch) using the existing helper for parity.
105
+ set_seed(int(seed))
106
+
107
+ # Derive a stable seed bundle for provenance.
108
+ seed_bundle = {
109
+ "python": int(seed),
110
+ "numpy": int(seed),
111
+ "torch": None,
112
+ }
113
+ try:
114
+ numpy_seed = int(np.random.get_state()[1][0])
115
+ seed_bundle["numpy"] = int(numpy_seed)
116
+ except Exception:
117
+ pass
118
+ if torch is not None:
119
+ try:
120
+ seed_bundle["torch"] = int(torch.initial_seed())
121
+ except Exception:
122
+ seed_bundle["torch"] = int(seed)
123
+
124
+ # Torch-specific controls.
125
+ level = "off" if requested == "off" else "strict"
126
+ if requested == "strict":
127
+ if torch is None:
128
+ level = "tolerance"
129
+ notes.append("torch_unavailable")
130
+ else:
131
+ # Thread caps.
132
+ try:
133
+ if hasattr(torch, "set_num_threads"):
134
+ torch.set_num_threads(threads_i)
135
+ if hasattr(torch, "set_num_interop_threads"):
136
+ torch.set_num_interop_threads(threads_i)
137
+ torch_flags["torch_threads"] = threads_i
138
+ except Exception:
139
+ level = "tolerance"
140
+ notes.append("torch_thread_caps_failed")
141
+
142
+ # Disable TF32 for determinism.
143
+ try:
144
+ matmul = getattr(
145
+ getattr(torch.backends, "cuda", object()), "matmul", None
146
+ )
147
+ if matmul is not None and hasattr(matmul, "allow_tf32"):
148
+ matmul.allow_tf32 = False
149
+ cudnn_mod = getattr(torch.backends, "cudnn", None)
150
+ if cudnn_mod is not None and hasattr(cudnn_mod, "allow_tf32"):
151
+ cudnn_mod.allow_tf32 = False
152
+ except Exception:
153
+ level = "tolerance"
154
+ notes.append("tf32_policy_failed")
155
+
156
+ # Deterministic algorithms.
157
+ try:
158
+ if hasattr(torch, "use_deterministic_algorithms"):
159
+ torch.use_deterministic_algorithms(True, warn_only=False)
160
+ except Exception:
161
+ # Downgrade to tolerance-based determinism rather than crashing.
162
+ level = "tolerance"
163
+ notes.append("deterministic_algorithms_unavailable")
164
+ try:
165
+ if hasattr(torch, "use_deterministic_algorithms"):
166
+ torch.use_deterministic_algorithms(True, warn_only=True)
167
+ except Exception:
168
+ pass
169
+
170
+ # cuDNN knobs.
171
+ try:
172
+ cudnn_mod = getattr(torch.backends, "cudnn", None)
173
+ if cudnn_mod is not None:
174
+ cudnn_mod.benchmark = False
175
+ if hasattr(cudnn_mod, "deterministic"):
176
+ cudnn_mod.deterministic = True
177
+ except Exception:
178
+ level = "tolerance"
179
+ notes.append("cudnn_determinism_failed")
180
+
181
+ # Snapshot applied flags for provenance.
182
+ try:
183
+ det_enabled = getattr(
184
+ torch, "are_deterministic_algorithms_enabled", None
185
+ )
186
+ if callable(det_enabled):
187
+ torch_flags["deterministic_algorithms"] = bool(det_enabled())
188
+ except Exception:
189
+ pass
190
+ try:
191
+ cudnn_mod = getattr(torch.backends, "cudnn", None)
192
+ if cudnn_mod is not None:
193
+ torch_flags["cudnn_deterministic"] = bool(
194
+ getattr(cudnn_mod, "deterministic", False)
195
+ )
196
+ torch_flags["cudnn_benchmark"] = bool(
197
+ getattr(cudnn_mod, "benchmark", False)
198
+ )
199
+ if hasattr(cudnn_mod, "allow_tf32"):
200
+ torch_flags["cudnn_allow_tf32"] = bool(
201
+ getattr(cudnn_mod, "allow_tf32", False)
202
+ )
203
+ except Exception:
204
+ pass
205
+ try:
206
+ matmul = getattr(
207
+ getattr(torch.backends, "cuda", object()), "matmul", None
208
+ )
209
+ if matmul is not None and hasattr(matmul, "allow_tf32"):
210
+ torch_flags["cuda_matmul_allow_tf32"] = bool(matmul.allow_tf32)
211
+ except Exception:
212
+ pass
213
+
214
+ # Normalized level is always one of these.
215
+ if level not in {"off", "strict", "tolerance"}:
216
+ level = "tolerance" if requested == "strict" else "off"
217
+
218
+ # Extra breadcrumb: random module state is not easily serializable; include a coarse marker.
219
+ try:
220
+ torch_flags["python_random"] = isinstance(random.random(), float)
221
+ except Exception:
222
+ pass
223
+
224
+ payload = {
225
+ "requested": requested,
226
+ "level": level,
227
+ "profile": prof or None,
228
+ "device": dev,
229
+ "threads": threads_i if requested == "strict" else None,
230
+ "seed": int(seed),
231
+ "seeds": seed_bundle,
232
+ "env": env_set,
233
+ "torch": torch_flags,
234
+ "notes": notes,
235
+ }
236
+
237
+ # Remove empty sections for stable artifacts.
238
+ if not payload["env"]:
239
+ payload.pop("env", None)
240
+ if not payload["torch"]:
241
+ payload.pop("torch", None)
242
+ if not payload["notes"]:
243
+ payload.pop("notes", None)
244
+ if payload.get("threads") is None:
245
+ payload.pop("threads", None)
246
+ if payload.get("profile") is None:
247
+ payload.pop("profile", None)
248
+
249
+ return payload
250
+
251
+
252
+ __all__ = ["apply_determinism_preset"]
@@ -7,9 +7,21 @@ Maps tier settings (conservative/balanced/aggressive) to specific guard paramete
7
7
  """
8
8
 
9
9
  import copy
10
+ import os
11
+ from functools import lru_cache
12
+ from importlib import resources as _ires
13
+ from pathlib import Path
10
14
  from typing import Any
11
15
 
12
- __all__ = ["resolve_tier_policies", "TIER_POLICIES", "EDIT_ADJUSTMENTS"]
16
+ import yaml
17
+
18
+ __all__ = [
19
+ "clear_tier_policies_cache",
20
+ "get_tier_policies",
21
+ "resolve_tier_policies",
22
+ "TIER_POLICIES",
23
+ "EDIT_ADJUSTMENTS",
24
+ ]
13
25
 
14
26
 
15
27
  # Base tier policy mappings
@@ -198,10 +210,183 @@ EDIT_ADJUSTMENTS: dict[str, dict[str, dict[str, Any]]] = {
198
210
  }
199
211
 
200
212
 
213
+ def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
214
+ out = copy.deepcopy(base)
215
+ for key, value in override.items():
216
+ if isinstance(value, dict) and isinstance(out.get(key), dict):
217
+ out[key] = _deep_merge(out[key], value)
218
+ else:
219
+ out[key] = copy.deepcopy(value)
220
+ return out
221
+
222
+
223
+ def _load_runtime_yaml(
224
+ config_root: str | None, *rel_parts: str
225
+ ) -> dict[str, Any] | None:
226
+ """Load YAML from runtime config locations.
227
+
228
+ Search order:
229
+ 1) $INVARLOCK_CONFIG_ROOT/runtime/...
230
+ 2) invarlock._data.runtime package resources
231
+ """
232
+ if config_root:
233
+ p = Path(config_root) / "runtime"
234
+ for part in rel_parts:
235
+ p = p / part
236
+ if p.exists():
237
+ data = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
238
+ if not isinstance(data, dict):
239
+ raise ValueError("Runtime YAML must be a mapping")
240
+ return data
241
+
242
+ try:
243
+ base = _ires.files("invarlock._data.runtime")
244
+ res = base
245
+ for part in rel_parts:
246
+ res = res.joinpath(part)
247
+ if getattr(res, "is_file", None) and res.is_file(): # type: ignore[attr-defined]
248
+ text = res.read_text(encoding="utf-8") # type: ignore[assignment]
249
+ data = yaml.safe_load(text) or {}
250
+ if not isinstance(data, dict):
251
+ raise ValueError("Runtime YAML must be a mapping")
252
+ return data
253
+ except Exception:
254
+ return None
255
+
256
+ return None
257
+
258
+
259
+ def _normalize_family_caps(caps: Any) -> dict[str, dict[str, float]]:
260
+ normalized: dict[str, dict[str, float]] = {}
261
+ if not isinstance(caps, dict):
262
+ return normalized
263
+ for family, value in caps.items():
264
+ family_key = str(family)
265
+ if isinstance(value, dict):
266
+ kappa = value.get("kappa")
267
+ if isinstance(kappa, int | float):
268
+ normalized[family_key] = {"kappa": float(kappa)}
269
+ elif isinstance(value, int | float):
270
+ normalized[family_key] = {"kappa": float(value)}
271
+ return normalized
272
+
273
+
274
+ def _normalize_multiple_testing(mt: Any) -> dict[str, Any]:
275
+ if not isinstance(mt, dict):
276
+ return {}
277
+ out: dict[str, Any] = {}
278
+ method = mt.get("method")
279
+ if method is not None:
280
+ out["method"] = str(method).lower()
281
+ alpha = mt.get("alpha")
282
+ try:
283
+ if alpha is not None:
284
+ out["alpha"] = float(alpha)
285
+ except Exception:
286
+ pass
287
+ m_val = mt.get("m")
288
+ try:
289
+ if m_val is not None:
290
+ out["m"] = int(m_val)
291
+ except Exception:
292
+ pass
293
+ return out
294
+
295
+
296
+ def _tier_entry_to_policy(tier_entry: dict[str, Any]) -> dict[str, dict[str, Any]]:
297
+ """Map a tiers.yaml entry to the canonical policy shape."""
298
+ out: dict[str, dict[str, Any]] = {}
299
+
300
+ metrics = tier_entry.get("metrics")
301
+ if isinstance(metrics, dict):
302
+ out["metrics"] = copy.deepcopy(metrics)
303
+
304
+ spectral_src = tier_entry.get("spectral") or tier_entry.get("spectral_guard")
305
+ if isinstance(spectral_src, dict):
306
+ spectral = copy.deepcopy(spectral_src)
307
+ if "family_caps" in spectral:
308
+ spectral["family_caps"] = _normalize_family_caps(
309
+ spectral.get("family_caps")
310
+ )
311
+ if "multiple_testing" in spectral:
312
+ spectral["multiple_testing"] = _normalize_multiple_testing(
313
+ spectral.get("multiple_testing")
314
+ )
315
+ out["spectral"] = spectral
316
+
317
+ rmt_src = tier_entry.get("rmt") or tier_entry.get("rmt_guard")
318
+ if isinstance(rmt_src, dict):
319
+ rmt = copy.deepcopy(rmt_src)
320
+ eps = rmt.get("epsilon_by_family")
321
+ if isinstance(eps, dict):
322
+ rmt["epsilon_by_family"] = {
323
+ str(k): float(v) for k, v in eps.items() if isinstance(v, int | float)
324
+ }
325
+ # Backward-compat: keep epsilon alias
326
+ rmt["epsilon"] = dict(rmt["epsilon_by_family"])
327
+ out["rmt"] = rmt
328
+
329
+ variance_src = tier_entry.get("variance") or tier_entry.get("variance_guard")
330
+ if isinstance(variance_src, dict):
331
+ out["variance"] = copy.deepcopy(variance_src)
332
+
333
+ return out
334
+
335
+
336
+ @lru_cache(maxsize=8)
337
+ def _load_tier_policies_cached(config_root: str | None) -> dict[str, dict[str, Any]]:
338
+ tiers = _load_runtime_yaml(config_root, "tiers.yaml") or {}
339
+ merged: dict[str, dict[str, Any]] = {}
340
+
341
+ # Start from defaults, then overlay tiers.yaml per-tier.
342
+ for tier_name, defaults in TIER_POLICIES.items():
343
+ merged[str(tier_name).lower()] = copy.deepcopy(defaults)
344
+
345
+ for tier_name, entry in tiers.items():
346
+ if not isinstance(entry, dict):
347
+ continue
348
+ tier_key = str(tier_name).lower()
349
+ resolved_entry = _tier_entry_to_policy(entry)
350
+ if tier_key not in merged:
351
+ merged[tier_key] = {}
352
+ merged[tier_key] = _deep_merge(merged[tier_key], resolved_entry)
353
+
354
+ return merged
355
+
356
+
357
+ def get_tier_policies(*, config_root: str | None = None) -> dict[str, dict[str, Any]]:
358
+ """Return tier policies loaded from runtime tiers.yaml (with safe defaults)."""
359
+ root = config_root
360
+ if root is None:
361
+ root = os.getenv("INVARLOCK_CONFIG_ROOT") or None
362
+ return _load_tier_policies_cached(root)
363
+
364
+
365
+ def clear_tier_policies_cache() -> None:
366
+ _load_tier_policies_cached.cache_clear()
367
+
368
+
369
+ def _load_profile_overrides(
370
+ profile: str | None, *, config_root: str | None
371
+ ) -> dict[str, Any]:
372
+ if not profile:
373
+ return {}
374
+ prof = str(profile).strip().lower()
375
+ candidate = _load_runtime_yaml(config_root, "profiles", f"{prof}.yaml")
376
+ if candidate is None and prof == "ci":
377
+ candidate = _load_runtime_yaml(config_root, "profiles", "ci_cpu.yaml") or {}
378
+ if not isinstance(candidate, dict):
379
+ return {}
380
+ return candidate
381
+
382
+
201
383
  def resolve_tier_policies(
202
384
  tier: str,
203
385
  edit_name: str | None = None,
204
386
  explicit_overrides: dict[str, dict[str, Any]] | None = None,
387
+ *,
388
+ profile: str | None = None,
389
+ config_root: str | None = None,
205
390
  ) -> dict[str, dict[str, Any]]:
206
391
  """
207
392
  Resolve tier-based guard policies with edit-specific adjustments and explicit overrides.
@@ -217,33 +402,45 @@ def resolve_tier_policies(
217
402
  Raises:
218
403
  ValueError: If tier is not recognized
219
404
  """
220
- if tier not in TIER_POLICIES:
405
+ tier_key = str(tier).lower()
406
+ tier_policies = get_tier_policies(config_root=config_root)
407
+ if tier_key not in tier_policies:
221
408
  raise ValueError(
222
- f"Unknown tier '{tier}'. Valid tiers: {list(TIER_POLICIES.keys())}"
409
+ f"Unknown tier '{tier}'. Valid tiers: {list(tier_policies.keys())}"
223
410
  )
224
411
 
225
412
  # Start with base tier policies
226
- policies: dict[str, dict[str, Any]] = copy.deepcopy(TIER_POLICIES[tier])
413
+ policies: dict[str, dict[str, Any]] = copy.deepcopy(tier_policies[tier_key])
414
+
415
+ # Apply profile overrides (when available)
416
+ overrides = _load_profile_overrides(profile, config_root=config_root)
417
+ guards = overrides.get("guards") if isinstance(overrides, dict) else None
418
+ if isinstance(guards, dict):
419
+ for guard_name, guard_overrides in guards.items():
420
+ key = str(guard_name).lower()
421
+ if not isinstance(guard_overrides, dict):
422
+ continue
423
+ if key in policies and isinstance(policies[key], dict):
424
+ policies[key] = _deep_merge(policies[key], guard_overrides)
425
+ else:
426
+ policies[key] = copy.deepcopy(guard_overrides)
227
427
 
228
428
  # Apply edit-specific adjustments
229
429
  if edit_name and edit_name in EDIT_ADJUSTMENTS:
230
430
  edit_adjustments = EDIT_ADJUSTMENTS[edit_name]
231
431
  for guard_name, adjustments in edit_adjustments.items():
232
- if guard_name in policies:
233
- guard_policy = policies[guard_name]
234
- assert isinstance(guard_policy, dict)
235
- guard_policy.update(adjustments)
432
+ if guard_name in policies and isinstance(policies.get(guard_name), dict):
433
+ policies[guard_name] = _deep_merge(policies[guard_name], adjustments)
236
434
 
237
435
  # Apply explicit overrides (highest precedence)
238
436
  if explicit_overrides:
239
437
  for guard_name, overrides in explicit_overrides.items():
240
- if guard_name in policies:
241
- guard_policy = policies[guard_name]
242
- assert isinstance(guard_policy, dict)
243
- guard_policy.update(overrides)
244
- else:
438
+ if guard_name in policies and isinstance(policies.get(guard_name), dict):
439
+ if isinstance(overrides, dict):
440
+ policies[guard_name] = _deep_merge(policies[guard_name], overrides)
441
+ elif isinstance(overrides, dict):
245
442
  # Create new guard policy if not in base tier
246
- policies[guard_name] = overrides.copy()
443
+ policies[guard_name] = copy.deepcopy(overrides)
247
444
 
248
445
  return policies
249
446
 
@@ -273,7 +470,7 @@ def get_tier_summary(tier: str, edit_name: str | None = None) -> dict[str, Any]:
273
470
  "tier": tier,
274
471
  "edit_name": edit_name,
275
472
  "error": str(e),
276
- "valid_tiers": list(TIER_POLICIES.keys()),
473
+ "valid_tiers": list(get_tier_policies().keys()),
277
474
  }
278
475
 
279
476
 
@@ -304,8 +501,9 @@ def validate_tier_config(config: Any) -> tuple[bool, str | None]:
304
501
  return False, "Missing 'tier' in auto configuration"
305
502
 
306
503
  tier = config["tier"]
307
- if tier not in TIER_POLICIES:
308
- valid_options = list(TIER_POLICIES.keys())
504
+ tier_policies = get_tier_policies()
505
+ if tier not in tier_policies:
506
+ valid_options = list(tier_policies.keys())
309
507
  return False, f"Invalid tier '{tier}'. Valid options: {valid_options}"
310
508
 
311
509
  if "enabled" in config and not isinstance(config["enabled"], bool):