Skip to content

Commit d86c43a

Browse files
quic-boyucclaude
andcommitted
observatory: introduce Region/Session model with enter_context API
Lays the data model and runtime state machine for the worldview adopted in RFC §4.4-4.5: a lightweight Region (named scope from enter_context; nests freely; pure labelling) and a heavyweight Session (the outermost Region; owns lens lifecycle hooks). interfaces.py - Add Session dataclass: id, name, start_ts, end_ts, start_data, end_data. Holds per-session lens hook payloads. - RecordDigest gains session_id and region_stack (snapshot of the active region path at collect() time, outermost first). Both default to "" / [] so existing record reload paths and tests keep working. - SessionResult now carries an ordered `sessions: List[Session]` list in addition to the legacy flat start_data/end_data dicts; the flat views remain a union across sessions for transitional consumers. - Module docstring spells out the Worldview block (Region/Session/ Record/Archive/Report) so anyone opening the file lands on the same vocabulary as the RFC. observatory.py - Add enter_context(region_name=None, config=None): * outermost (region_stack empty): pushes a Region; if region_name is omitted, auto-generates "default" / "default-2" / ... ; opens a Session and fires on_session_start. * inner (region_stack non-empty): with a name, pushes a labelled Region (no Session change); without a name, pure config-only override (no Region pushed). - Add _open_session / _close_session helpers; on_session_end fires cleanly even on exception inside the with-block. - Reuse path: enable_context becomes a thin alias of enter_context() (no name) so existing call sites keep working. - collect() now stamps each Record with the active session_id and a copy of the active region_stack. - clear() resets _sessions, _active_session_id, _region_stack, and _config_stack alongside the legacy state. tests/test_region_session.py (new) Twelve focused unit tests covering: outermost with/without name, inner without name = config-only, inner with name = labelled Region inside the same Session, sibling outermost = multiple Sessions, default-name auto-increment, on_session_start/end fire exactly once per Session boundary (not per inner Region), collect outside any context is a no-op, time-ordered records, name uniqueness enforcement, on_session_end on exception, and the enable_context alias still working. Verification PYTHONPATH=~ python -m pytest \ ~/executorch/devtools/observatory/tests/ -v → 41 passed, 1 pre-existing unrelated failure (test_per_layer_accuracy_lens, "psnr" key, untouched here). Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent 80a25ee commit d86c43a

3 files changed

Lines changed: 488 additions & 37 deletions

File tree

devtools/observatory/interfaces.py

Lines changed: 72 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,30 @@
88
99
This module is the source-of-truth contract for:
1010
1. Frontend view composition (`ViewList` + typed blocks/specs).
11-
2. Runtime record/session objects (`ObservationContext`, `RecordDigest`, etc.).
11+
2. Runtime record/session objects (`ObservationContext`, `RecordDigest`,
12+
`Session`).
1213
3. Analyze-phase graph layer contribution via fx_viewer payload types.
1314
14-
Architecture model:
15-
1. Runtime phase (`observe`/`digest`) captures raw record data.
16-
2. Analyze phase (`analyze`) computes global and per-record derived data.
17-
3. Frontend phase (`Frontend.*`) maps typed data into renderable view blocks.
15+
Worldview (cf. RFC §4.4 / §4.5):
16+
17+
Region lightweight named scope from `Observatory.enter_context(name, ...)`;
18+
can nest; pure labelling (no lens hooks fire at region boundaries).
19+
Session the heavyweight scope; emerges automatically from the *outermost*
20+
Region; lens `on_session_start` / `on_session_end` fire at Session
21+
boundaries.
22+
Record one `Observatory.collect(name, artifact)` item; carries `session_id`
23+
+ `region_stack` (snapshot at collect time); stored time-ordered.
24+
Archive one Observatory invocation: `sessions[]` + `records[]`; the only
25+
thing persisted raw (`--output-archive`).
26+
Report derived output: `analyze` runs once per archive, then per-session
27+
`Frontend.dashboard` renders Report (HTML) and Report (JSON).
1828
29+
Architecture model:
30+
1. Runtime phase (`observe` then `digest`): produce one Record per
31+
`collect()` call, tagged with the active session_id and region_stack.
32+
2. Analyze phase (`analyze`): pure-data, runs once per archive over all
33+
records + sessions.
34+
3. Frontend phase (`Frontend.*`): per-session dashboard + per-record views.
1935
"""
2036

2137
from __future__ import annotations
@@ -565,20 +581,68 @@ class ObservationContext:
565581

566582
@dataclass
567583
class RecordDigest:
568-
"""Persistent observation item.
584+
"""One Record. The canonical persisted unit produced by `collect()`.
569585
570-
This is the canonical persisted unit produced by runtime capture.
586+
Fields:
587+
name: Display name (deduplicated to "name #2", "name #3" on collision).
588+
timestamp: Seconds since epoch at the moment `collect()` fires.
589+
session_id: Active session at collect time. Equals the outermost
590+
region_name on the runtime stack.
591+
region_stack: Snapshot of the active region path at collect time;
592+
outermost first. Used by the left-panel tree-view to group
593+
records under collapsible region nodes.
594+
data: Per-lens digest map keyed by `lens.get_name()`.
571595
"""
572596

573597
name: str
574598
timestamp: float
599+
session_id: str = ""
600+
region_stack: List[str] = field(default_factory=list)
575601
data: Dict[str, Serializable] = field(default_factory=dict)
576602

577603

604+
@dataclass
605+
class Session:
606+
"""Heavyweight scope opened by an outermost `enter_context` call.
607+
608+
A Session begins when an outermost `Observatory.enter_context(...)` is
609+
entered and ends when that outermost block exits. Lens `on_session_start`
610+
and `on_session_end` hooks fire at Session boundaries (not at inner
611+
Regions).
612+
613+
Fields:
614+
id: Unique session identifier within an archive. Equals `name` for
615+
single-archive runs; prefixed `<archive_label>:<name>` when
616+
loaded via `--compare`.
617+
name: Human-facing label (the outermost region_name, or an
618+
auto-generated default like "default" / "default-2").
619+
start_ts: Seconds since epoch when the Session opened.
620+
end_ts: Seconds since epoch when the Session closed (None while open).
621+
start_data: Per-lens payload from `on_session_start`, keyed by
622+
`lens.get_name()`.
623+
end_data: Per-lens payload from `on_session_end`.
624+
"""
625+
626+
id: str
627+
name: str
628+
start_ts: float
629+
end_ts: Optional[float] = None
630+
start_data: Dict[str, Serializable] = field(default_factory=dict)
631+
end_data: Dict[str, Serializable] = field(default_factory=dict)
632+
633+
578634
@dataclass
579635
class SessionResult:
580-
"""Session start/end data from lens hooks."""
636+
"""Aggregate of all Sessions in an Archive.
637+
638+
Wraps an ordered list of `Session` instances plus the legacy flat
639+
`start_data`/`end_data` views. The flat views are unions across all
640+
sessions (last-write-wins on collision) and exist for transitional
641+
compatibility with code paths that have not yet migrated to the
642+
per-Session model.
643+
"""
581644

645+
sessions: List[Session] = field(default_factory=list)
582646
start_data: Dict[str, Serializable] = field(default_factory=dict)
583647
end_data: Dict[str, Serializable] = field(default_factory=dict)
584648

devtools/observatory/observatory.py

Lines changed: 161 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,33 @@
66

77
"""Observatory runtime core.
88
9+
Worldview (cf. RFC §4.4 / §4.5):
10+
11+
Region lightweight named scope from `Observatory.enter_context(name, ...)`;
12+
can nest; pure labelling (no lens hooks at region boundaries).
13+
Session heavyweight scope; emerges automatically from the outermost
14+
Region. Lens `on_session_start` / `on_session_end` fire here.
15+
Record one `Observatory.collect(name, artifact)` item; carries
16+
`session_id` + `region_stack` snapshot.
17+
Archive one Observatory invocation: `sessions[]` + `records[]`.
18+
Report derived analysis (HTML / JSON) over the Archive.
19+
920
Lifecycle summary:
10-
1. Runtime capture: `observe -> digest`.
21+
1. Runtime: `observe` then `digest` per `collect()`.
1122
2. Analysis: per-lens `analyze(records, config)`.
1223
3. Assembly: merge frontend blocks + graph assets/layers.
1324
4. Rendering/export: JSON and HTML reports.
1425
1526
The runtime enforces strict typed interfaces from `interfaces.py`.
27+
28+
`enter_context(region_name=None, config=None)` is the primary entry API.
29+
- Outermost (region stack empty on entry): if `region_name` is omitted, an
30+
auto-generated default name (`"default"`, `"default-2"`, …) is used. Pushes
31+
a Region and opens a Session (fires `on_session_start`).
32+
- Inner (region stack non-empty on entry): if `region_name` is omitted, the
33+
call is a config-only override — the region stack and Session are unchanged.
34+
- `enable_context(...)` is preserved as a thin alias that opens an unnamed
35+
outermost / config-only inner block, matching the semantics above.
1636
"""
1737

1838
from __future__ import annotations
@@ -42,6 +62,7 @@
4262
ObservationContext,
4363
RecordAnalysis,
4464
RecordDigest,
65+
Session,
4566
SessionResult,
4667
ViewList,
4768
validate_view_list,
@@ -81,14 +102,30 @@ def floatstr(
81102

82103

83104
class Observatory:
84-
"""Global registry for collecting and rendering observability artifacts."""
105+
"""Global registry: opens a Session, accumulates Records into an Archive,
106+
and emits a Report on export.
107+
108+
Class-level state (per-process; reset via `clear()`):
109+
_records: time-ordered Records keyed by deduplicated name.
110+
_ignored_graphs: substrings that suppress matching `collect()` calls.
111+
_session_result: legacy SessionResult plus the per-Session list.
112+
_sessions: ordered dict of Session by id (insertion = display order).
113+
_active_session_id: name of the currently-open Session (or None).
114+
_lens_registry: ordered list of registered Lens classes.
115+
_lenses_initialized: flag for one-time default-lens registration.
116+
_config_stack: Context frames (one per `enter_context` call).
117+
_region_stack: active Region names; outermost first, innermost last.
118+
"""
85119

86120
_records: Dict[str, RecordDigest] = {}
87121
_ignored_graphs: Set[str] = set()
88122
_session_result: SessionResult = SessionResult()
123+
_sessions: Dict[str, Session] = {}
124+
_active_session_id: Optional[str] = None
89125
_lens_registry: List[Type[Lens]] = []
90126
_lenses_initialized: bool = False
91127
_config_stack: List[Dict[str, Any]] = []
128+
_region_stack: List[str] = []
92129

93130
@classmethod
94131
def register_lens(cls, lens_cls: Type[Lens], append=True) -> None:
@@ -126,13 +163,33 @@ def _ensure_default_lenses(cls) -> None:
126163

127164
@classmethod
128165
@contextmanager
129-
def enable_context(cls, config: Optional[Dict[str, Any]] = None) -> ContextManager[None]:
130-
"""Enable observation context with nested config overrides.
166+
def enter_context(
167+
cls,
168+
region_name: Optional[str] = None,
169+
config: Optional[Dict[str, Any]] = None,
170+
) -> ContextManager[None]:
171+
"""Push a Region onto the runtime stack (and a Session at outermost).
172+
173+
Behaviour by call position:
174+
1. Outermost (region stack empty on entry):
175+
- With ``region_name``: pushes the named Region and opens a Session
176+
with the same ``id`` and ``name``.
177+
- Without ``region_name``: auto-generates a non-duplicating default
178+
("default", "default-2", ...), pushes it as a Region, and opens
179+
a Session with that name.
180+
2. Inner (region stack non-empty on entry):
181+
- With ``region_name``: pushes a labelled Region; the active
182+
Session is unchanged (regions do not fire lens session hooks).
183+
- Without ``region_name``: config-only override - the config frame
184+
is pushed but no Region is added to the stack.
185+
186+
Lens lifecycle: ``on_session_start`` / ``on_session_end`` fire only
187+
at Session boundaries (outermost-region entry/exit). Inner Regions
188+
are pure labelling.
131189
132-
Session hooks run once per outermost context:
133-
1. On first enter, auto-collection patches are installed and
134-
`on_session_start` hooks are called.
135-
2. On last exit, `on_session_end` hooks are called and patches removed.
190+
Notes:
191+
``region_name`` must be unique within an Archive. Re-using a name
192+
for a sibling outermost block raises ``RuntimeError``.
136193
"""
137194

138195
cls._ensure_default_lenses()
@@ -150,34 +207,100 @@ def merge_config_dict(base: Dict[str, Any], new: Dict[str, Any]) -> Dict[str, An
150207
parent_config = cls._config_stack[-1] if cls._config_stack else {}
151208
context_config = merge_config_dict(parent_config, config or {})
152209

153-
is_outermost_start = len(cls._config_stack) == 0
210+
is_outermost = len(cls._region_stack) == 0
211+
push_region = region_name is not None or is_outermost
212+
213+
if push_region:
214+
if region_name is None:
215+
# Outermost without name: auto-generate a non-duplicating default.
216+
effective_name = cls._next_default_session_name()
217+
else:
218+
effective_name = region_name
219+
if is_outermost and effective_name in cls._sessions:
220+
raise RuntimeError(
221+
f"Session name {effective_name!r} already used in this archive."
222+
)
223+
else:
224+
# Inner without name: config-only override.
225+
effective_name = None
226+
154227
cls._config_stack.append(context_config)
155-
hook_ctx = ObservationContext(config=context_config)
156228

157-
if is_outermost_start:
158-
for lens in cls._lens_registry:
159-
try:
160-
data = lens.on_session_start(hook_ctx)
161-
if data:
162-
cls._session_result.start_data[lens.get_name()] = data
163-
except Exception as exc:
164-
logging.error("[Observatory] Lens %s failed on_session_start: %s", lens, exc)
229+
if push_region and is_outermost:
230+
cls._open_session(effective_name)
231+
232+
if push_region:
233+
cls._region_stack.append(effective_name)
165234

166235
try:
167236
yield
168237
finally:
169-
is_outermost_end = len(cls._config_stack) == 1
238+
if push_region:
239+
cls._region_stack.pop()
240+
cls._config_stack.pop()
241+
if push_region and is_outermost:
242+
cls._close_session(effective_name)
170243

171-
if is_outermost_end:
172-
for lens in cls._lens_registry:
173-
try:
174-
data = lens.on_session_end(hook_ctx)
175-
if data:
176-
cls._session_result.end_data[lens.get_name()] = data
177-
except Exception as exc:
178-
logging.error("[Observatory] Lens %s failed on_session_end: %s", lens, exc)
244+
@classmethod
245+
@contextmanager
246+
def enable_context(cls, config: Optional[Dict[str, Any]] = None) -> ContextManager[None]:
247+
"""Backwards-compatible alias for ``enter_context`` without a name.
179248
180-
cls._config_stack.pop()
249+
Outermost call opens an auto-named Session; nested calls are
250+
config-only overrides. Prefer ``enter_context(region_name, config)``
251+
for new code so the tree view picks up labelled regions.
252+
"""
253+
254+
with cls.enter_context(region_name=None, config=config):
255+
yield
256+
257+
@classmethod
258+
def _next_default_session_name(cls) -> str:
259+
"""Return the next non-duplicating default Session name."""
260+
261+
if "default" not in cls._sessions:
262+
return "default"
263+
n = 2
264+
while f"default-{n}" in cls._sessions:
265+
n += 1
266+
return f"default-{n}"
267+
268+
@classmethod
269+
def _open_session(cls, name: str) -> None:
270+
"""Open a Session and fire `on_session_start` on every active lens."""
271+
272+
active_config = cls._config_stack[-1] if cls._config_stack else {}
273+
hook_ctx = ObservationContext(config=active_config)
274+
session = Session(id=name, name=name, start_ts=time.time())
275+
for lens in cls._lens_registry:
276+
try:
277+
data = lens.on_session_start(hook_ctx)
278+
if data:
279+
session.start_data[lens.get_name()] = data
280+
cls._session_result.start_data[lens.get_name()] = data
281+
except Exception as exc:
282+
logging.error("[Observatory] Lens %s failed on_session_start: %s", lens, exc)
283+
cls._sessions[name] = session
284+
cls._session_result.sessions.append(session)
285+
cls._active_session_id = name
286+
287+
@classmethod
288+
def _close_session(cls, name: str) -> None:
289+
"""Fire `on_session_end` on every active lens and close the Session."""
290+
291+
active_config = cls._config_stack[-1] if cls._config_stack else {}
292+
hook_ctx = ObservationContext(config=active_config)
293+
session = cls._sessions[name]
294+
for lens in cls._lens_registry:
295+
try:
296+
data = lens.on_session_end(hook_ctx)
297+
if data:
298+
session.end_data[lens.get_name()] = data
299+
cls._session_result.end_data[lens.get_name()] = data
300+
except Exception as exc:
301+
logging.error("[Observatory] Lens %s failed on_session_end: %s", lens, exc)
302+
session.end_ts = time.time()
303+
cls._active_session_id = None
181304

182305
@classmethod
183306
def _get_current_context(cls) -> Optional[ObservationContext]:
@@ -223,7 +346,12 @@ def collect(cls, name: str, artifact: Any) -> None:
223346
ctx = ObservationContext(config=active_config)
224347
ctx.shared_state["record_name"] = name
225348

226-
record = RecordDigest(name=name, timestamp=datetime.now().timestamp())
349+
record = RecordDigest(
350+
name=name,
351+
timestamp=datetime.now().timestamp(),
352+
session_id=cls._active_session_id or "",
353+
region_stack=list(cls._region_stack),
354+
)
227355
t_start = time.perf_counter()
228356

229357
for lens in cls._lens_registry:
@@ -256,6 +384,10 @@ def clear(cls) -> None:
256384

257385
cls._records.clear()
258386
cls._session_result = SessionResult()
387+
cls._sessions.clear()
388+
cls._active_session_id = None
389+
cls._region_stack.clear()
390+
cls._config_stack.clear()
259391

260392
for lens in cls._lens_registry:
261393
try:

0 commit comments

Comments
 (0)