STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
assumptions.py
Go to the documentation of this file.
1"""Runtime assumptions registry — make every silent fallback VISIBLE (UNIFICATION_PLAN Phase 2c).
2
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).
8
9Usage (call-site pattern)::
10
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")
15
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.
18"""
19
20from __future__ import annotations
21
22import contextlib
23import logging
24import threading
25from typing import Any, Dict, Iterator, List, Optional
26
27_log = logging.getLogger(__name__)
28_lock = threading.Lock()
29_registry: Dict[str, Dict[str, Any]] = {}
30
31# Active `scope()` collectors, per thread. The registry itself is process-global and cumulative on
32# purpose -- it is the diagnostic record of everything this process has assumed. But a REPORT must
33# describe one evaluation, and the global registry cannot do that: after a methalox run recorded
34# "fluids.oxidizer.latent_heat missing", an ethalox run whose preset supplies every field still
35# printed "N physics input(s) fell back to recorded defaults", naming the previous propellant's
36# gaps. Scopes solve that without destroying the global record (which `clear()` would).
37_local = threading.local()
38
39
40def _active_scopes() -> List[Dict[str, Dict[str, Any]]]:
41 scopes = getattr(_local, "scopes", None)
42 if scopes is None:
43 scopes = []
44 _local.scopes = scopes
45 return scopes
46
47
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.
50
51 Re-recording the same name updates the count rather than spamming the log — fallbacks inside
52 optimizer loops fire thousands of times.
53 """
54 with _lock:
55 entry = _registry.get(name)
56 if entry is None:
57 _registry[name] = {"value": value, "unit": unit, "reason": reason, "count": 1}
58 _log.warning("ASSUMED %s = %r %s (%s)", name, value, unit, reason)
59 else:
60 entry["count"] += 1
61 entry["value"] = value
62 # Also record into every open scope, so a report can describe its own run. Nested scopes all
63 # see it: an outer scope must not miss what an inner one collected.
64 for collected in _active_scopes():
65 scoped = collected.get(name)
66 if scoped is None:
67 collected[name] = {"value": value, "unit": unit, "reason": reason, "count": 1}
68 else:
69 scoped["count"] += 1
70 scoped["value"] = value
71 return value
72
73
74@contextlib.contextmanager
75def scope() -> Iterator[Dict[str, Dict[str, Any]]]:
76 """Collect the assumptions recorded inside this block, leaving the global registry alone.
77
78 Use it around one evaluation whose report must say what *that* evaluation assumed::
79
80 with assumptions.scope() as used:
81 ...
82 payload["fallbacks_used"] = assumptions.as_list(used)
83
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.
87 """
88 collected: Dict[str, Dict[str, Any]] = {}
89 scopes = _active_scopes()
90 scopes.append(collected)
91 try:
92 yield collected
93 finally:
94 scopes.remove(collected)
95
96
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())]
100
101
102def get_assumptions() -> Dict[str, Dict[str, Any]]:
103 """Snapshot of all assumptions used so far in this process."""
104 with _lock:
105 return {k: dict(v) for k, v in _registry.items()}
106
107
108def fallbacks_used() -> List[Dict[str, Any]]:
109 """Compact list form for report payloads: [{name, value, unit, reason, count}, ...]."""
110 with _lock:
111 return [{"name": k, **v} for k, v in sorted(_registry.items())]
112
113
114def clear() -> None:
115 with _lock:
116 _registry.clear()
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()