1"""Runtime assumptions registry — make every silent fallback VISIBLE (UNIFICATION_PLAN Phase 2c).
3Motivation: the hardcoded-Cd incident. A value nobody remembers choosing (injector Cd_inf=0.4) sat in
4a config default and silently wrecked optimizer runs. The fix pattern is not "no defaults" — solvers
5need to keep running when a property is missing — it is **no INVISIBLE defaults**: every time the code
6substitutes an assumed value for missing config data, that substitution is recorded here and surfaced
7(rich stability report `assumptions.fallbacks_used`, logs, future /api/assumptions endpoint).
9Usage (call-site pattern)::
11 from engine.pipeline.assumptions import assume
12 rho = cfg_rho if cfg_rho is not None else assume(
13 "stability.rho_oxidizer", 1140.0, unit="kg/m^3",
14 reason="fluids.oxidizer.density missing from config")
16Registry is per-process (optimizer workers each carry their own — diagnostic, not authoritative).
17``clear()`` at the start of an evaluation scope if per-run isolation matters.
20from __future__
import annotations
25from typing
import Any, Dict, Iterator, List, Optional
27_log = logging.getLogger(__name__)
28_lock = threading.Lock()
29_registry: Dict[str, Dict[str, Any]] = {}
37_local = threading.local()
41 scopes = getattr(_local,
"scopes",
None)
44 _local.scopes = scopes
48def assume(name: str, value: Any, *, unit: str =
"", reason: str =
"") -> Any:
49 """Record that ``value`` is being ASSUMED (config did not provide it) and return it.
51 Re-recording the same name updates the count rather than spamming the log — fallbacks inside
52 optimizer loops fire thousands of times.
55 entry = _registry.get(name)
57 _registry[name] = {
"value": value,
"unit": unit,
"reason": reason,
"count": 1}
58 _log.warning(
"ASSUMED %s = %r %s (%s)", name, value, unit, reason)
61 entry[
"value"] = value
65 scoped = collected.get(name)
67 collected[name] = {
"value": value,
"unit": unit,
"reason": reason,
"count": 1}
70 scoped[
"value"] = value
74@contextlib.contextmanager
75def scope() -> Iterator[Dict[str, Dict[str, Any]]]:
76 """Collect the assumptions recorded inside this block, leaving the global registry alone.
78 Use it around one evaluation whose report must say what *that* evaluation assumed::
80 with assumptions.scope() as used:
82 payload["fallbacks_used"] = assumptions.as_list(used)
84 Thread-local and re-entrant. It does NOT suppress the global record -- `get_assumptions()` still
85 returns everything the process has assumed, which is what the logs and the future
86 /api/assumptions endpoint want.
88 collected: Dict[str, Dict[str, Any]] = {}
90 scopes.append(collected)
94 scopes.remove(collected)
97def as_list(registry: Dict[str, Dict[str, Any]]) -> List[Dict[str, Any]]:
98 """Compact list form of a scope's collection, matching ``fallbacks_used()``."""
99 return [{
"name": k, **v}
for k, v
in sorted(registry.items())]
103 """Snapshot of all assumptions used so far in this process."""
105 return {k: dict(v)
for k, v
in _registry.items()}
109 """Compact list form for report payloads: [{name, value, unit, reason, count}, ...]."""
111 return [{
"name": k, **v}
for k, v
in sorted(_registry.items())]
List[Dict[str, Any]] as_list(Dict[str, Dict[str, Any]] registry)
Any assume(str name, Any value, *, str unit="", str reason="")
List[Dict[str, Dict[str, Any]]] _active_scopes()
Dict[str, Dict[str, Any]] get_assumptions()
Iterator[Dict[str, Dict[str, Any]]] scope()
List[Dict[str, Any]] fallbacks_used()