Skip to content

Boyuc/observatory draft demo#19288

Draft
quic-boyuc wants to merge 96 commits into
pytorch:mainfrom
CodeLinaro:boyuc/observatory_draft_demo
Draft

Boyuc/observatory draft demo#19288
quic-boyuc wants to merge 96 commits into
pytorch:mainfrom
CodeLinaro:boyuc/observatory_draft_demo

Conversation

@quic-boyuc

@quic-boyuc quic-boyuc commented May 5, 2026

Copy link
Copy Markdown
Contributor

Observatory + fx_viewer — A Visualizer and Debugging Framework for ExecuTorch

Please read RFC first : #20618

This document covers the feature set, architecture, and API design with concrete examples and demos. For the higher-level motivation, the fair comparison against Model Explorer, and the open design questions, see the RFC at #20618

Motivation (brief)

ExecuTorch already has strong low-level capture tools (Inspector, ETRecord/ETDump), but no layer to coordinate them, and no embeddable, overlay-capable graph viewer for in-pipeline debugging. As a result each backend builds its own glue scripts, and debugging output ends up scattered across console prints, CSVs, and screenshots.

  • Observatory provides the missing coordination + synthesis layer: write one Lens once, get a shareable report.
  • fx_viewer provides the missing viewer: server-free, embeddable in one HTML file, with programmatic overlays and N-way compare.

The full motivation and the fair comparison against Model Explorer / Inspector live in the RFC ( #20618 §2). This document is about how it works.


What You Get

One command in, one HTML file out:

pip3 install 'fast-sugiyama[all]'   # requires python >= 3.11 — required by the graph lens

python -m executorch.backends.qualcomm.debugger.observatory \
    --output-html obs_report.html \
    --lens-recipe accuracy \
    examples/qualcomm/oss_scripts/mobilevit_v2.py \
    --backend htp --model SM8650 -d ./imagenet-mini-val/ \
    -b build-android/ --compile_only

Output: a self-contained HTML file — no server, no login, no external service. Attach it to an issue, PR, or email.

Features in the report:

  • Session dashboard — per-session metadata, lens-contributed sections.
  • Record tree — captured artifacts, time-ordered or grouped by region_stack.
  • Interactive FX graph — pan, zoom, minimap, fuzzy search, N-way synchronized compare.
  • Per-layer accuracy overlay — PSNR / cosine / MSE as a color gradient on graph nodes, from CPU simulation across intermediate snapshots at prepare_pt2e, convert_pt2e, and to_edge_transform_and_lower (stages not stored in ETRecord). Runtime/delegated accuracy via Inspector is a planned follow-up lens.

Two machine-readable outputs accompany the HTML:

  • Archive (JSON) — raw sessions[] + records[], no analysis baked in; the input for compare subcommand and late re-analysis.
  • Report (JSON) — analyzed summary for CI gates, dashboards, and LLM triage.

Architecture Overview

Observatory follows a three-layer design: Interface → Core → Lenses.

╔═════════════════════════════════════════════════════════════════════════════╗
║  INTERFACE LAYER                                                            ║
╠═════════════════════════════════════════════════════════════════════════════╣
║ ┌─ User interface ──────────┐ ┌─ Lens author ──────┐ ┌─ Artifacts ───────┐ ║
║ │ generic CLI               │ │ implements a Lens:  │ │ Report (HTML)     │ ║
║ │ backend CLI               │ │   on_session_start  │ │ Archive (JSON)    │ ║
║ │ `with` block (context)    │ │   on_session_end    │ │ Report (JSON)     │ ║
║ │ @observe_pass decorator   │ │   observe / digest  │ │                   │ ║
║ │ Observatory.collect(...)  │ │   analyze           │ │                   │ ║
║ │                           │ │   get_frontend_spec │ │                   │ ║
║ └───────────────────────────┘ └─────────────────────┘ └───────────────────┘ ║
╚═════════════════════════════════════════════════════════════════════════════╝
         │                              │                          ▲
         │ drives                       │ registers with           │ emits
         ▼                              ▼                          │
╔═════════════════════════════════════════════════════════════════════════════╗
║  OBSERVATORY CORE           (backend-agnostic)                              ║
╠═════════════════════════════════════════════════════════════════════════════╣
║ ┌─ Session manager ──────────┐   ┌─ Record store ─────────────────────┐    ║
║ │ region & config stack      │   │ per-collect fan-out to every lens  │    ║
║ │ Session open/close         │   │ time-ordered records, region_stack │    ║
║ │ lens registry              │   │ tagged with active session_id      │    ║
║ └────────────────────────────┘   └────────────────────────────────────┘    ║
║                                                                             ║
║ ┌─ Report assembly ────────────────────┐  ┌─ fx_viewer ──────────────────┐ ║
║ │ per-lens analyze over Archive        │  │ Python API (build-time)      │ ║
║ │ Frontend.dashboard / .record         │  │ JS API (browser run-time)    │ ║
║ │ base graph + extension overlays      │  │ Canvas renderer, no server   │ ║
║ └──────────────────────────────────────┘  └───────────────────────────────┘ ║
║                                                                             ║
║ ┌─ Export ─────────────────────────────────────────────────────────────────┐║
║ │ Report (HTML)   — self-contained, for human reviewers                    │║
║ │ Archive (JSON)  — raw sessions[] + records[]; CI input; reload/ --compare │║
║ │ Report (JSON)   — analyzed output for LLM triage / dashboards            │║
║ └──────────────────────────────────────────────────────────────────────────┘║
╚═════════════════════════════════════════════════════════════════════════════╝
         ▲
         │ registered at CLI entry; called by Core's lifecycle phases
         │
╔═════════════════════════════════════════════════════════════════════════════╗
║  LENSES                     (all implement the Lens protocol)               ║
╠═════════════════════════════════════════════════════════════════════════════╣
║ ┌─ Common lenses ─────────────────────┐ ┌─ Backend-specific lenses ──────┐ ║
║ │ graph, metadata, accuracy           │ │ qualcomm/ — QNN debug lenses   │ ║
║ │ per_layer_accuracy, stack_trace     │ │ xnnpack/  — XNNPACK lenses     │ ║
║ │ pipeline_graph_collector            │ │ arm/      — ARM lenses          │ ║
║ │ graph_color                         │ │                                 │ ║
║ └─────────────────────────────────────┘ └─────────────────────────────────┘ ║
╚═════════════════════════════════════════════════════════════════════════════╝

The Capture / Analysis Split

Observatory keeps capture (online, during the run) separate from analysis (offline, from the saved Archive). Only the Archive is raw and persisted; Reports are always derived from it.

  Run (live)                    Persist                    Derive
  ─────────                     ───────                    ──────
  Session hooks fire            Archive (JSON)             Report (HTML)
  collect() → Records           sessions[] + records[]     analyze + Frontend
  observe/digest per lens       no analysis baked in       per-lens derived views
        │                              │                          │
        └──────── written at ──────────┘                          │
                  end of run                                      │
                                       │                          │
                                       └─── reload path ──────────┘
                                            (re-analyze with new
                                             config, or combine
                                             via --compare)

Key property: the same Archive can be re-analyzed with different lens configs, or two archives compared via --compare, all without re-running the AOT compile.


Python vs. JavaScript API Boundaries

fx_viewer and Observatory meet at exactly two API surfaces — one in Python (build-time), one in JavaScript (run-time in the browser).

   BUILD TIME (Python API)
   ┌─ Observatory ──────────────────┐  calls  ┌─ fx_viewer Python API ────────┐
   │ graph / graph_color /          │ ──────► │ FXGraphExporter               │
   │ per_layer_accuracy lenses      │         │ GraphExtension / ColorRule    │
   │ Observatory core (emit)        │         └────────────────┬───────────────┘
   └────────────────┬───────────────┘                          │ produces
                    │ assembles + writes                        ▼
                    └──────────────► report.html ◄─────────────┘
                                     (embedded payload + JS runtime bundle)
                                     │ opened in browser
                                     ▼
   RUN TIME (JS API, all in browser)
   ┌─ Observatory report shell ─────┐  calls  ┌─ fx_viewer JS API ───────────┐
   │ 03_blocks.js                   │ ──────► │ FXGraphViewer.create({...})  │
   │   (mount viewer per Record)    │         │ FXGraphCompare.create({...}) │
   │ 04_actions.js                  │         │ setLayers / setColorBy /     │
   │   (theme + selection sync)     │         │  setTheme / selectNode       │
   └────────────────────────────────┘         └──────────────────────────────┘

Python API (Build-Time)

Component Role
FXGraphExporter(gm) Extract FX graph structure from a GraphModule, compute Sugiyama layout (x, y + edge routing), export to HTML or JSON
GraphExtension(id, name) Declare an overlay layer — per-node data, coloring rules, labels, tooltips
NumericColorRule(attribute, cmap) Map a continuous numeric field to a color gradient ("viridis", "reds", "blues", "greens")
CategoricalColorRule(attribute) Map discrete string values to deterministic hues

Standalone usage (no Observatory dependency):

from executorch.devtools.fx_viewer import FXGraphExporter
FXGraphExporter(graph_module).export_html("my_graph.html")

JavaScript API (Run-Time)

Component Role
FXGraphViewer.create(config) Mount a single graph viewer on a DOM element
FXGraphCompare.create({viewers, layout, sync}) Mount N-way compare with cross-graph selection sync
setLayers(ids[]) / setColorBy(id) / setTheme(name) Runtime layer/theme mutation
Selection sync via debug_handle or from_node Cross-graph node matching across AOT pipeline stages, even after fusion/decomposition

The Lens Protocol

A Lens is the single extension unit — one Python class that owns one debugging concern end-to-end. Implement only the methods you need; the framework ignores the rest.

Protocol Methods (fired in lifecycle order)

class Lens:
    @classmethod
    def get_name(cls) -> str: ...          # config key: config[get_name()]

    @classmethod
    def setup(cls) -> None: ...            # one-time init at registration

    @classmethod
    def on_session_start(cls, context: ObservationContext) -> None:
        """Install instrumentation (e.g. monkey-patches). Fires at outermost enter_context."""

    @classmethod
    def observe(cls, artifact: Any, context: ObservationContext) -> Any:
        """Filter + transform the artifact at each collect() call. Return None to skip."""

    @classmethod
    def digest(cls, observation: Any, context: ObservationContext) -> Serializable:
        """Convert observation into a JSON-serializable form for the Record."""

    @classmethod
    def on_session_end(cls, context: ObservationContext) -> None:
        """Restore instrumentation. Guaranteed to fire even on exception."""

    @classmethod
    def clear(cls) -> None: ...            # reset state between runs

    @staticmethod
    def analyze(records: List[RecordDigest], config: Dict[str, Any]) -> AnalysisResult:
        """Derive insights across all records at report time (offline)."""

    @staticmethod
    def get_frontend_spec() -> Frontend:
        """Return the Frontend strategy that renders this lens's report blocks."""

Lens Lifecycle Diagram

  on_session_start  ─► instrumentation installed
        │
        ▼
  for each Observatory.collect(name, artifact):
    each lens: observe → digest  ─► serialized into Record.digests[lens_name]
        │
        ▼
  on_session_end    ─► instrumentation restored (guaranteed on exception)
        │
        ▼
  ═══ ARCHIVE (raw: sessions[] + records[]) ═══
        │
        ▼
  analyze(records, config)  ─► AnalysisResult
        │
        ▼
  get_frontend_spec() → Frontend
        ├── dashboard(session, records, analysis) → ViewList | None
        │     (session-level blocks: TableBlock, HtmlBlock, ...)
        └── record(digest, analysis, context)     → ViewList | None
              (per-record blocks: TableBlock, GraphBlock, ...)

Frontend and Block Types

get_frontend_spec() returns a Frontend instance. The framework calls its methods at report-emit time:

Method When called Returns
dashboard(session, records, analysis) Once per (Session, lens) pair ViewList of session-level blocks, or None
record(digest, analysis, context) Once per (Record, lens) pair ViewList of per-record blocks, or None
json_report(session, records, analysis) Once per (Session, lens) pair Dict for Report (JSON), or None
resources() Once at report assembly {"js": ..., "css": ...} for shared assets

Available block types in a ViewList:

Block Purpose
TableBlock Key-value or row data; auto side-by-side diff in compare view
HtmlBlock Arbitrary HTML fragment
CustomBlock Custom JS-rendered widget
GraphBlock Interactive FX graph via fx_viewer; synchronized N-way compare

Shipped Common Lenses

Lens Purpose
graph Base FX graph extraction and layout via FXGraphExporter
metadata Run-wide info: CLI command, env, model
accuracy Graph-level accuracy metrics across lowering stages
per_layer_accuracy Per-operator PSNR / cosine / MSE color overlay
pipeline_graph_collector Auto-collect at prepare_pt2e, convert_pt2e, to_edge_transform_and_lower
stack_trace User-code call stack provenance at each collection point
graph_color Partition/delegation color overlay

Worked Example: AdbLogLens (Custom Lens)

This example shows the full extension surface — a lens that captures device-side logs from ADB without touching Observatory core:

from dataclasses import dataclass
from executorch.devtools.observatory import Observatory
from executorch.devtools.observatory.interfaces import (
    Lens, ObservationContext, Frontend, ViewList, TableBlock,
)
from executorch.backends.qualcomm.export_utils import SimpleADB

@dataclass
class AdbLogEntry:
    source: str        # "logcat" or "dmesg"
    cmd_label: str     # which on-device command produced this
    content: str

class AdbLogLens(Lens):
    _original = None

    @classmethod
    def get_name(cls) -> str:
        return "adb_log"

    @classmethod
    def on_session_start(cls, context: ObservationContext) -> None:
        """Patch SimpleADB.execute to capture logcat + dmesg after every call."""
        cls._original = SimpleADB.execute

        def patched(self, *args, **kw):
            cls._original(self, *args, **kw)
            cmd_label = kw.get("custom_runner_cmd") or "executor_runner"
            for source, shell_cmd in [
                ("logcat", "logcat -d"),
                ("dmesg",  "dmesg"),
            ]:
                Observatory.collect(
                    f"adb_log/{cmd_label}/{source}",
                    AdbLogEntry(source, cmd_label,
                                content=_adb_shell(self, shell_cmd)),
                )

        SimpleADB.execute = patched

    @classmethod
    def on_session_end(cls, context: ObservationContext) -> None:
        """Restore original — guaranteed even on exception."""
        SimpleADB.execute = cls._original
        cls._original = None

    @classmethod
    def observe(cls, artifact, context: ObservationContext):
        """Filter by type and live config — reads config per-call for nesting."""
        if not isinstance(artifact, AdbLogEntry):
            return None
        cfg = context.config.get("adb_log", {})
        if not cfg.get("enabled", True):
            return None
        if artifact.source not in cfg.get("sources", ["logcat"]):
            return None
        return {"source": artifact.source,
                "cmd":    artifact.cmd_label,
                "lines":  artifact.content.splitlines()}

    @classmethod
    def digest(cls, observation, context: ObservationContext):
        return observation   # already JSON-serializable

    @staticmethod
    def analyze(records, config):
        return AnalysisResult()   # no cross-record analysis needed

    @staticmethod
    def get_frontend_spec():
        return _AdbFrontend()

class _AdbFrontend(Frontend):
    def record(self, digest, analysis, context):
        if not digest:
            return None
        rows = [{"source": digest["source"], "cmd": digest["cmd"],
                 "lines": len(digest["lines"])}]
        return ViewList(blocks=[TableBlock(id="adb_log", rows=rows)])

Key design patterns demonstrated:

  • Session hooks install/restore instrumentation (monkey-patching).
  • observe() reads context.config at call time — nested enter_context overrides work correctly.
  • Type filtering: lens returns None for artifacts it doesn't own.
  • collect() is type-agnostic — multiple lenses coexist without knowing about each other.
  • get_frontend_spec() returns a Frontend; record() returns a ViewList with a TableBlock.

Nested Config Scoping in Practice

with Observatory.enter_context(config={
    "accuracy": {"evaluator": my_evaluator},
    "adb_log":  {"sources": ["logcat"]},          # lightweight default
}):
    run_on_device(probe_cmd)      # only logcat captured
    run_on_device(smoke_cmd)      # only logcat captured

    with Observatory.enter_context(config={
        "accuracy": {"enabled": False},
        "adb_log":  {"sources": ["logcat", "dmesg"]},  # broaden for target
    }):
        device_result = run_on_device(target_cmd)       # logcat + dmesg
        Observatory.collect("post_device", device_result)

    run_on_device(teardown_cmd)   # back to logcat only

Config overrides are pushed on enter_context entry and popped on exit — even on exception.


Invocation Surfaces

There are two real surfaces: the CLI and the context + collection points pair. @observe_pass is syntactic sugar over the second.

1. CLI (zero-code-change)

Observatory parses its own leading flags, then runs your script exactly as written via runpy, forwarding all remaining arguments verbatim:

# Generic (framework lenses only)
python -m executorch.devtools.observatory \
    --output-html run.html \
    your_script.py --your-args

# Backend-specific
python -m executorch.backends.xnnpack.debugger.observatory \
    --lens-recipe=accuracy \
    examples/xnnpack/aot_compiler.py --model_name=mv2 --delegate --quantize

# Archive for CI (no HTML needed at capture time)
python -m executorch.backends.xnnpack.debugger.observatory \
    --output-archive nightly/${DATE}/mv2.json \
    --lens-recipe=accuracy \
    examples/xnnpack/aot_compiler.py --model_name=mv2

1a. compare Subcommand (overlay multiple archives)

compare is a subcommand of any Observatory CLI (generic or backend-specific). It ingests two or more Archive JSONs and produces one HTML report where records with the same name (e.g. Annotated Model) are placed side-by-side and label-prefixed.

# Cross-backend compare — XNNPack mv2 vs Qualcomm mobilenet_v2
python -m executorch.backends.qualcomm.debugger.observatory compare \
    --input-archive xnn/mv2/archive.json          --label XNNPACK/mv2 \
    --input-archive qnn/mobilenet_v2/archive.json --label Qualcomm/mobilenet_v2 \
    --output-html comparison.html \
    --lens-recipe accuracy,adb \
    --title "MobileNetV2 — XNNPACK vs Qualcomm"

Which backend CLI to invoke compare from — this matters. Each backend CLI pre-registers its own lens frontends (HTML/CSS/JS assets baked into the report). The generic python -m executorch.devtools.observatory compare only registers core lens frontends (graph, metadata, accuracy). To render backend-specific overlays (e.g. Qualcomm's ADB log panel) in the compared report, run compare under the backend CLI that owns those lens frontends — even when the input archives came from a different backend. In the example above we invoke QNN's CLI so the ADB log frontend is registered; XNNPack's CLI would produce the same compare geometry but omit that panel.

--lens-recipe on compare. Same syntax as collection mode — repeatable (--lens-recipe accuracy --lens-recipe adb) or comma-separated (--lens-recipe accuracy,adb). It only pre-registers frontends for rendering; the archives themselves already contain the captured lens data, so recipes here control which panels are drawn, not what is analyzed.

1b. visualize Subcommand (Archive JSON → HTML re-render)

visualize re-renders a single Archive JSON to HTML without re-executing the compile pipeline — useful after a lens code update, or when the report was captured on a headless CI runner:

python -m executorch.backends.xnnpack.debugger.observatory visualize \
    --input-archive archive.json --output-html report_v2.html \
    [--lens-recipe accuracy]

Same backend-CLI-registers-lens-frontends rule as compare.

2. Context + Collection Points

Activate the lenses you want, open a session, and mark the points to capture. Lenses are turned on (and tuned) by the config dict, keyed by lens name:

from executorch.devtools.observatory import Observatory
from executorch.devtools.observatory.lenses import PerLayerAccuracyLens

Observatory.register_lens(PerLayerAccuracyLens)

with Observatory.enter_context("quantization",
                               config={"per_layer_accuracy": {"enabled": True}}):
    Observatory.collect("before_quantize", gm)           # capture point 1

    with Observatory.enter_context("fast_passes",
                                   config={"per_layer_accuracy": {"enabled": False}}):
        gm = run_cheap_passes(gm)                        # lens off for this sub-step

    quantized_gm = quantize_model(gm)
    Observatory.collect("after_quantize", quantized_gm)  # capture point 2

# Stage 1 — persist the raw Archive JSON
Observatory.export_json("archive.json")

# Stage 2 — render HTML from the archive, any time, without re-running
Observatory.generate_html_from_json("archive.json", "report.html")

3. @observe_pass Decorator (sugar over context + collect)

@observe_pass wraps a pass so its input and output graphs are collect()-ed automatically — no manual collection points needed:

from executorch.devtools.observatory import Observatory, observe_pass

@observe_pass
class MyPass(ExportPass):
    def call(self, gm): ...

# Or wrap existing pass instances without modifying their class:
observed_passes = [observe_pass(p) for p in [FoldQDQ(), LayoutTransform()]]

with Observatory.enter_context("pipeline"):
    PassManager(observed_passes)(graph_module)

Collection-Point Mechanisms

Three distinct mechanisms produce collection points — all flow into the same collect(name, artifact) path:

Mechanism How it works Patch targets / scope
pipeline_graph_collector lens patches Wraps standard pipeline functions at on_session_start; each patch calls collect() with the returned object, then restores at on_session_end prepare_pt2e, convert_pt2e, to_edge_transform_and_lower, ETRecord.add_*
@observe_pass decorator Tags any PassBase subclass/instance; wraps each pass call in its own Region and collects graph before + after Any pass: user-defined or framework-provided
Manual Observatory.collect(name, artifact) Direct call anywhere inside user code Any artifact type, any moment
# What a pipeline_graph_collector patch looks like (simplified):
import torchao.quantization.pt2e.quantize_pt2e as qt

_original = qt.convert_pt2e

def _patched(model, *args, **kwargs):
    Observatory.collect("Calibrated Model", model)        # capture input graph
    result = _original(model, *args, **kwargs)            # call the real function
    Observatory.collect("Quantized Model", result)        # capture output graph
    return result

qt.convert_pt2e = _patched
# on_session_end: qt.convert_pt2e = _original  (always restored, even on exception)

All patches are installed in on_session_start and removed in on_session_end — the user's script runs unchanged.


Key Vocabulary

Term Definition
Region Named scope from enter_context(name). Nests. Pure labelling — no lens hooks fire at region boundaries.
Session Outermost Region. Lens on_session_start/on_session_end fire here.
Record One collect(name, artifact) item. Tagged with session_id + region_stack.
Archive Raw state: sessions[] + records[]. The only thing persisted.
Report Derived output from an Archive via analyze + Frontend. HTML or JSON.
Lens One Python class that owns one debugging concern end-to-end.
Frontend The visualization strategy returned by get_frontend_spec(); contributes dashboard() and record() blocks.

POC Implementation Structure (~/executorch)

devtools/
├── observatory/
│   ├── __init__.py              # Public API: Observatory, observe_pass
│   ├── observatory.py           # Core: Session manager, Record store, export
│   ├── interfaces.py            # Lens protocol, Frontend, ViewList, block types
│   ├── observe_pass.py          # @observe_pass decorator
│   ├── cli.py                   # Generic CLI entry point
│   ├── html_template.py         # Report HTML assembly
│   ├── lenses/
│   │   ├── graph.py             # Base FX graph extraction + layout
│   │   ├── metadata.py          # Run-wide metadata
│   │   ├── accuracy.py          # Graph-level accuracy metrics
│   │   ├── per_layer_accuracy.py # Per-operator PSNR/cosine/MSE
│   │   ├── pipeline_graph_collector.py  # Auto-collection at pipeline points
│   │   ├── stack_trace.py       # Call stack provenance
│   │   └── graph_color.py       # Partition color overlay
│   ├── templates/js/
│   │   ├── 03_blocks.js         # Mount viewer per Record graph block
│   │   └── 04_actions.js        # Theme + selection sync logic
│   └── tests/
│
├── fx_viewer/
│   ├── __init__.py              # Public API: FXGraphExporter
│   ├── exporter.py              # Graph extraction, Sugiyama layout, HTML/JSON export
│   ├── extension.py             # GraphExtension: add_node_data, set_color_rule, set_sync_key
│   ├── color_rules.py           # NumericColorRule, CategoricalColorRule
│   ├── models.py                # Data models: GraphNode, GraphEdge, GraphPayload
│   ├── templates/               # JS canvas runtime bundle
│   └── examples/                # Standalone usage examples
│
backends/
├── qualcomm/debugger/observatory/
│   ├── cli.py                   # QNN backend CLI
│   ├── lenses/                  # QNN-specific lenses (AdbLens, etc.)
│   └── tests/
│
└── xnnpack/debugger/observatory/
    ├── cli.py                   # XNNPACK backend CLI
    └── lenses/                  # XNNPACK-specific lenses

Governance and Merge Strategy

Recommended: Three-PR Stack

PR Scope Dependency Reviewer Focus
PR 1 devtools/fx_viewer/ None Standalone graph viewer; usable via FXGraphExporter(gm).export_html(...)
PR 2 devtools/observatory/ PR 1 Core framework + 7 common lenses + generic CLI
PR 3 Backend CLIs PR 2 Qualcomm + XNNPACK CLIs with model-matrix validation

Ownership Model

  • Core devtools reviewersdevtools/observatory/ core, devtools/fx_viewer/ core, common lenses.
  • Backend teamsbackends/<name>/debugger/observatory/, backend-specific lenses and backend CLI — no core sign-off needed.
  • Cross-cutting changes (Lens protocol, GraphExtension API, JS runtime, JSON schemas) → core sign-off required.

Public API Surfaces (schema stability)

Surface Stability Change requires
Lens protocol (9 methods: get_name, setup, on_session_start, observe, digest, on_session_end, clear, analyze, get_frontend_spec) Experimental (candidate stable) Core sign-off
Frontend API (dashboard, record, json_report, resources) Experimental (candidate stable) Core sign-off
GraphExtension API (add_node_data, set_color_rule, set_sync_key, set_label_formatter) Experimental (candidate stable) Core sign-off
Archive (JSON) schema (sessions[] + records[]) Experimental (candidate stable) Core sign-off
Report (JSON) schema Experimental (candidate stable) Core sign-off
FXGraphViewer.create / FXGraphCompare.create (JS) Experimental (candidate stable) Core sign-off
Backend CLI flags (--output-html, --lens-recipe, etc.) Experimental (candidate stable) Core sign-off
Per-lens config keys (e.g., accuracy.evaluator) Backend-owned Backend team
Backend-specific lenses Backend-owned Backend team

All surfaces are currently experimental. The "candidate stable" designation means they are designed for stability and will be promoted in a follow-up RFC after one release cycle with real external consumers.

Testing Strategy

  • Core infra testsdevtools/observatory/tests/ (owned by core).
  • Backend tests → owned by backend teams.
  • CI invariant: every backend CLI smoke-tests on a representative model.

Implemented Capabilities (on the POC Branch Today)

Capability Mechanism Status
Compile-time per-layer accuracy (intermediate stages) accuracy + per_layer_accuracy lenses; CPU simulation across prepare_pt2e, convert_pt2e, to_edge_transform_and_lower snapshots ✅ Implemented
Graph-state collection at pipeline points pipeline_graph_collector lens patches; captures intermediate graph snapshots not stored in ETRecord ✅ Implemented
Pass diff (before/after) @observe_pass decorator + graph compare mode ✅ Implemented
Collection provenance (stack trace) stack_trace lens ✅ Implemented
Interactive FX graph view fx_viewer: pan, zoom, minimap, fuzzy search, N-way compare ✅ Implemented
Run-metadata dashboard metadata lens: CLI command, env, model ✅ Implemented
Region tree-view toggle Left panel groups Records by region_stack; toggle flat/tree ✅ Implemented
Report (HTML) — self-contained Single file, no server, attach anywhere ✅ Implemented
Archive (JSON) — raw persistence --output-archive; reload for re-analysis ✅ Implemented
Report (JSON) via json_report Structured analysis for LLM triage, CI, dashboards (--output-report-json) ✅ Implemented
Partition/delegation color overlay graph_color lens ✅ Implemented
compare CLI subcommand Load 2+ archives, emit regression Report ✅ Implemented
ADB log capture (logcat + dmesg) AdbLens (Qualcomm backend-specific, not a default core lens) ✅ Implemented
Runtime / delegated-graph accuracy On-device vs CPU comparison via debug_handle + Inspector ❌ Follow-up

Known Limitations

  1. Per-layer accuracy is compile-time simulation only. The per_layer_accuracy lens runs CPU simulation across intermediate graph snapshots — stages not stored in ETRecord. It does not compare against actual on-device (delegated) execution. Runtime/delegated accuracy via Inspector integration is a planned follow-up lens.
  2. fast-sugiyama requires Python ≥ 3.11 and is a hard requirement for the graph lens. The current fx_viewer layout stage raises ImportError when fast-sugiyama (and rectangle-packer, its multi-component packer) are missing, so records that go through GraphLens produce no graph payload. Since the interactive FX graph is the RFC's centrepiece feature, treat this as required. A future pure-Python fallback layout could downgrade this to optional (gated behind executorch[observatory-layout] to avoid raising the project's Python floor), but that fallback is not yet implemented.
  3. Runtime / delegated-graph accuracy not yet implemented. Comparing on-device (delegated) execution against CPU requires a follow-up lens using debug_handle + Inspector.

Pre-Generated Demo Reports

Reports are available for 30+ models across XNNPACK and Qualcomm backends:

Backend Model Nodes Report
xnnpack mobilebert 2361 HTML
xnnpack resnet50 550 HTML
qualcomm inception_v4 1541 HTML
qualcomm mobilenet_v2 521 HTML

Cross-backend comparisons (XNNPACK vs. Qualcomm QNN):

Model Comparison
MobileNetV2 HTML
ViT HTML

Full model matrix and walkthrough video: see rfc_review_real.md, §3.3.


Dependencies

  • fast-sugiyama[all] (Python ≥ 3.11, required for the graph lens) — Sugiyama graph layout for fx_viewer. The graph lens raises ImportError at layout time if this (or its rectangle-packer extra) is missing. A pure-Python fallback that would demote this to optional is planned but not yet implemented; when it lands, gate the fast path behind executorch[observatory-layout].
  • No JavaScript framework dependencies — the canvas renderer is plain HTML/JS with no npm dependencies bundled at runtime.
  • Observatory is a client of existing ExecuTorch capture primitives (ETRecord, ETDump, Inspector) — it adds no new capture formats and requires no changes to Inspector or ETRecord.

@pytorch-bot

pytorch-bot Bot commented May 5, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/19288

Note: Links to docs will display an error until the docs builds have been completed.

⚠️ 12 Awaiting Approval

As of commit f7ddf43 with merge base d54a0c0 (image):

AWAITING APPROVAL - The following workflows need approval before CI can run:

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label May 5, 2026
@github-actions

github-actions Bot commented May 5, 2026

Copy link
Copy Markdown

This PR needs a release notes: label

If your change should be included in the release notes (i.e. would users of this library care about this change?), please use a label starting with release notes:. This helps us keep track and include your important work in the next release notes.

To add a label, you can comment to pytorchbot, for example
@pytorchbot label "release notes: none"

For more information, see
https://github.com/pytorch/pytorch/wiki/PyTorch-AutoLabel-Bot#why-categorize-for-release-notes-and-how-does-it-work.

@quic-boyuc quic-boyuc force-pushed the boyuc/observatory_draft_demo branch 2 times, most recently from 2f5a882 to 6a3afad Compare May 5, 2026 04:34
@linux-foundation-easycla

linux-foundation-easycla Bot commented May 22, 2026

Copy link
Copy Markdown

CLA Missing ID

One or more co-authors of this pull request were not found. You must specify co-authors in commit message trailer via:

Co-authored-by: name <email>

Supported Co-authored-by: formats include:

  1. Anything <id+login@users.noreply.github.com> - it will locate your GitHub user by id part.
  2. Anything <login@users.noreply.github.com> - it will locate your GitHub user by login part.
  3. Anything <public-email> - it will locate your GitHub user by public-email part. Note that this email must be made public on Github.
  4. Anything <other-email> - it will locate your GitHub user by other-email part but only if that email was used before for any other CLA as a main commit author.
  5. login <any-valid-email> - it will locate your GitHub user by login part, note that login part must be at least 3 characters long.

Alternatively, if the co-author should not be included, remove the Co-authored-by: line from the commit message.

Please update your commit message(s) by doing git commit --amend and then git push [--force] and then request re-running CLA check via commenting on this pull request:

/easycla

quic-boyuc and others added 23 commits July 1, 2026 08:37
- Use python as clean API and function arguments
- Fix bug in html_template.py (\n --> \\n)
…otstrap

Three fixes for HTML report correctness and size:

1. Base64-encode HtmlBlock.content in the JSON payload so </script> and
   other special characters cannot corrupt the outer <script> tag.
   The JS runtime decodes with atob() before innerHTML assignment
   (03_blocks.js, renderHtmlCompare).

2. Gzip+base64 compress the full JSON payload when it exceeds 8 KB
   (observatory.py _compress_payload). The browser decompresses via
   DecompressionStream inside an async IIFE, which also moves the
   Observatory runtime execution to after the FX viewer bundle is
   injected — fixing the "FXGraphViewer unavailable" race condition
   that existed when Script 3 ran before Script 2 finished awaiting
   (html_template.py).

3. Base64-encode resources.js[] entries in generate_ui_test_harness.py
   so the test harness goes through the same pipeline as production
   reports instead of bypassing it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two bugs fixed:

1. Wrong initial camera on first load — viewer.init() was called
   synchronously before the browser had laid out the container, so
   getBoundingClientRect() returned {width:0, height:0} and the camera
   was placed far off-screen. Fixed by deferring init() to the next
   animation frame (requestAnimationFrame).

2. All viewer state lost on every record switch — destroyGraphRuntime()
   was unconditionally destroying every live viewer. Camera, selected
   node, active layers, colorBy, and zoom were all reset.

Fix: hybrid viewer cache.

Single-record graph blocks use a live DOM cache keyed by
(recordIndex, lensName, blockId). On navigate-away the wrapper is
detached from the DOM but the viewer stays alive in state.viewerCache.
On return the wrapper is re-appended and a resize rAF is queued — no
re-init, no re-layout, full state preserved. LRU eviction at 10 viewers.

Compare-mode viewers are always freshly created (keeping N side-by-side
viewers alive would multiply memory cost). Instead a lightweight state
snapshot {camera, selectedNodeId, activeExtensions, colorBy} is saved
to state.compareStateCache on every statechange event. On re-entry each
new viewer is seeded from the snapshot: selectNode+animate if a node was
selected, setState({camera}) otherwise.

README section 12 documents the memory budget and trade-off rationale.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…acy lenses

Introduces a `python -m executorch.backends.qualcomm.debugger.observatory.cli`
runner that wraps any standard Qualcomm example script (e.g. swin_v2_t.py)
to automatically enable full Observatory debugging with no script modifications.

Key changes:

PipelineGraphCollectorLens (new):
- Absorbs ETRecordAutoCollector from the deleted auto_collect.py, moving all
  monkey-patching out of the Observatory core framework and into a lens.
- Patches framework-level functions (torch.export.export, prepare_pt2e,
  convert_pt2e, to_edge_transform_and_lower, ETRecord.add_*) to auto-collect
  graph snapshots at each compilation stage: Exported Float, Annotated Model,
  Calibrated Model, Quantized Model, Edge, Transformed Edge, ETRecord records.
- Forces generate_etrecord=True in to_edge_transform_and_lower to ensure
  ETRecord collection fires automatically.
- Framework-level patches work for all backends (QNN, XNNPack, CoreML, etc.).

AccuracyLens (new):
- Ports AccuracyEvaluationLens from legacy debugging_utils to new Observatory
  interfaces (ViewList/TableBlock).
- Adds MaskedTokenAccuracy metric and MLMEvaluator for masked language model
  scripts (bert, roberta, distilbert, albert, eurobert).
- Auto-patches get_imagenet_dataset and get_masked_language_model_dataset to
  capture targets, covering 24 of ~30 standard oss_scripts.
- Auto-detects task type (classification vs MLM), post_process function, and
  default metrics from model output format.

CLI runner (new):
- cli.py + __main__.py: parse observatory flags, register lenses, wrap target
  script in Observatory.enable_context(), run via runpy.run_path(), generate
  HTML + JSON report to {artifact}/observatory_report.{html,json}.
- Flags: --no-accuracy, --no-report, --report-title.

observatory.py:
- Remove ETRecordAutoCollector.install/uninstall calls; patching is now
  delegated to PipelineGraphCollectorLens when registered.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…st "Exported Float" record

AccuracyLens was not producing metrics in HTML reports because the evaluator
was configured in a build_executorch_binary POST-hook, but all
Observatory.collect() calls happen DURING that function — before the
evaluator existed.

The fix removes the build_executorch_binary patch entirely and instead
configures the evaluator lazily when AccuracyLens.observe() first sees the
"Exported Float" record:

1. Extract the float model from the ExportedProgram artifact
2. Use captured dataset (from get_imagenet_dataset patch) as primary source,
   or fall back to sample inputs captured by PipelineGraphCollectorLens
3. Auto-detect task type, post_process, and metrics
4. Compute golden outputs and build the evaluator

PipelineGraphCollectorLens now also captures the sample input tuple from
torch.export.export(mod, args, ...) as _last_export_inputs, providing a
fallback dataset for AccuracyLens when dataset loader patches don't fire
(e.g., custom datasets, non-Qualcomm backends).

Changes:
- accuracy.py: remove _install_build_binary_patch(), remove _captured_model,
  add _configure_from_float_model(), update observe() with lazy init
- pipeline_graph_collector.py: add _last_export_inputs, capture args[1] in
  patched_export, clear on uninstall/clear
- LENSES.md: new reference doc covering all lenses, observation points,
  patching strategy, accuracy lens lazy configuration, data source fallback
  strategy, and custom usage examples

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…screen

Five UI improvements to the Observatory HTML report:

1. Auto-hide sidebar (main.css, 02_layout.js)
   The left index pane is now position:fixed and hidden by default
   (translateX(-100%)). A 12px invisible trigger strip on the left edge
   reveals it on hover; the pane itself stays open while hovered. Main
   content takes the full viewport width — no left margin needed.

2. Auto-hide header (main.css, 02_layout.js)
   The header is now position:fixed and hidden by default
   (translateY(-100%)). A 10px invisible trigger strip at the top edge
   reveals it on hover. Both panels overlay content rather than consuming
   layout space, maximising the graph viewer area.

3. Theme sync Observatory → fx_viewer (04_actions.js, 03_blocks.js)
   setTheme() now propagates the theme to all live mountedViewers via
   viewer.setTheme(theme) and to all mountedCompares by iterating
   compare.viewers. New viewers receive themeName in their initial state
   so they open in the current theme without a separate setTheme call.

4. Compare snap merge-on-write (03_blocks.js)
   Root cause: all viewers in a compare group share one compareStateCache
   entry. Each viewer registers a statechange listener that overwrites the
   snap. When viewer B pans after viewer A selects a node, viewer B's
   statechange fires with selectedNodeId=null and wipes the selection.
   On the next restore both viewers see null → both zoomToFit().

   Fix: the statechange write is now a merge. selectedNodeId is only
   updated when the incoming value is non-null, preserving any selection
   set by any viewer until another viewer explicitly selects a different
   node. camera/activeExtensions/colorBy are always overwritten with the
   latest value.

   Restore priority order (unchanged logic, now actually reachable):
   - node exists in this viewer's graph → selectNode + animate
   - node not found (different record) or no selection → zoomToFit()
   - no snapshot at all → init() default positioning

5. Fullscreen button — correct API key (03_blocks.js)
   Previous implementation passed ui.controls.fullscreenButton which is
   not a recognised fx_viewer config key. Correct key per
   RFC_FX_VIEWER_API_INTERFACE.md and fx_graph_viewer.js default
   (fullscreen: { enabled: true, button: false }) is
   layout.fullscreen.button. Changed ViewerCtor.create() call to
   layout: { preset, fullscreen: { button: true } }.

README section 12.2 updated to document the merge-on-write behaviour,
the corrected restore priority order (no longer uses setState({camera})
as fallback), and the per-viewer node-existence check semantics.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…ple stats, worst-index sharing

## What changed

### 1. Table view cleanup
Remove (diff) entries from the per-record accuracy table.  Diffs already appear
in the left panel index via check_index_diffs() — duplicating them in the table
added noise without value.

### 2. PSNR cap at 100.0 dB
Raw PSNR above 100 dB (e.g. 128 dB for near-zero observer error in the
Annotated Model stage) is not meaningfully different from perfect match and
produced confusing display.  PSNR.MAX_PSNR = 100.0 gives a uniform ceiling:
perfect match → 100.0, real quantization degradation → actual dB below 100.

### 3. Redesigned Metric base class
Replace the Protocol stub with a proper base class:

  class Metric:
      higher_is_better: bool = True   # controls worst-case direction
      def calculate_per_sample(self, predictions) -> List[float]: ...
      def calculate(self, predictions) -> float:          # mean of per-sample
      def worst_index(self, per_sample) -> int:           # argmin or argmax

higher_is_better encodes each metric's direction knowledge:
  True  → worst = argmin  (PSNR, cosine_sim, TopK — lower is worse)
  False → worst = argmax  (MSE, AbsErr — higher is worse)

All existing metrics (PSNR, CosineSimilarity, TopKAccuracy, MaskedTokenAccuracy)
are refactored to implement calculate_per_sample() instead of calculate().

### 4. New metrics: MSE and AbsErr
Both demonstrate higher_is_better=False and are added to the default evaluator
alongside PSNR and CosineSimilarity whenever golden outputs are available.

### 5. Per-sample statistics in Evaluator.evaluate()
When dataset has >1 sample, each metric emits three additional keys:
  {name}_min        — best sample value
  {name}_max        — worst sample value (in the metric's own direction)
  {name}_worst_idx  — dataset index of the worst-performing sample

Single-sample datasets emit only the primary mean value (no _min/_max/_worst_idx)
to keep the digest clean for the common fallback case.

### 6. Cross-lens worst-index sharing via AccuracyLens._worst_indices
AccuracyLens now maintains a class-level dict:
  _worst_indices: Dict[str, int]  # {metric_name: dataset_index}

Updated after every evaluate() call, cleared on session end / Observatory.clear().
Future lenses (e.g. per-layer accuracy analysis) read it during their own
observe() without re-running inference:

  from .accuracy import AccuracyLens
  worst = AccuracyLens._worst_indices.get("psnr")  # int or None

This follows the same pattern as PipelineGraphCollectorLens._last_export_inputs.
AccuracyLens must be registered before any lens that reads _worst_indices.

### 7. Frontend: second table for worst indices
_AccuracyFrontend.record() now emits two TableBlocks:
  - "Accuracy" (order=20): all metric values + min/max, no worst_idx keys
  - "Worst Input Index (per metric)" (order=21): stripped worst_idx values,
    only shown when dataset has >1 sample

check_index_diffs() extended with "mse" and "abs_err" keys.

### 8. LENSES.md updated
Documents: PSNR cap rationale, higher_is_better table, per-sample statistics
contract, _worst_indices cross-lens sharing pattern with code example, updated
expected metric behavior table.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
quic-boyuc and others added 29 commits July 1, 2026 08:37
Two UI improvements addressing feedback that the left panel
requires hovering a hard-to-find 12px strip to open:

Header
- Remove hover-only reveal (no more invisible 10px trigger strip)
- Make header permanently visible with `position: sticky`
- Slightly compact padding so it uses less vertical space

Sidebar toggle tab
- Add a visible 22px-wide tab fixed at the left edge (always visible)
- Shows ›/‹ chevron; click to pin panel open or close
- Tab slides to left:300px when sidebar is pinned open
- State persisted in localStorage (os_sidebar_pinned) so it
  survives page refresh
- Hover-to-reveal still works as a secondary mechanism

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
…n+edge regions

Brings the AOT pipeline records under two top-level Region groups so the
HTML left-panel tree view stays compact and meaningful:

    Session "<script-name>"
    ├── quantization/             top-level (aten-graph work)
    │     ├── Annotated Model     (prepare_pt2e)
    │     ├── Calibrated Model    (convert_pt2e input)
    │     └── Quantized Model     (convert_pt2e output)
    └── edge/                     top-level (edge dialect)
          ├── Pre-EdgeTransform/<method>
          ├── EdgeProgramManager EP
          └── etrecord/           lazy nested under edge
                ├── ETRecord Exported/<method>
                ├── ETRecord Edge/<method>
                └── ETRecord Extra/<module>

Design rationale:
- Quantization outputs (Annotated/Calibrated/Quantized Model) are still
  aten graphs, not edge dialect, so they belong under their own top-level
  region rather than under "edge".
- ETRecord operations are AOT-time (they save the float aten and edge
  dialect programs), so they live under "edge" via a nested "etrecord"
  group rather than appearing as a standalone "runtime" region.
- No per-call sub-regions ("prepare_pt2e", "convert_pt2e", "etc.") --
  every region holds at least 2 records, and the per-call identity is
  already in the record name.

Implementation:
- The runtime region stack only ever holds one chain at a time, so the
  lens opens "quantization" and "edge" lazily through transition helpers
  (`_transition_to_quantization`, `_transition_to_edge`) and closes the
  previous sibling when transitioning. This is monotonic for the typical
  AOT order (prepare -> convert -> to_edge -> ETRecord) but tolerates
  a backward transition defensively.
- `_ensure_etrecord_region` first transitions to edge, then opens
  "etrecord" as a child via a second contextlib.ExitStack.
- Three ExitStacks total (quantization, edge, etrecord) all close in
  on_session_end in safe order (innermost first).

observatory.py fix: enter_context now pushes the Region onto the stack
*before* firing on_session_start so a lens that opens its own
enter_context inside that hook (as pipeline_graph_collector does) sees
the new frame as an inner Region rather than recursing into a fresh
outermost call.

tests/test_pipeline_graph_collector_regions.py (new):
- 8 tests covering: lazy region opening at session_start, transition
  to_quantization, forward transition to_edge closes quantization,
  etrecord nesting under edge, idempotent lazy etrecord open, every
  region holds >=2 records, on_session_end closes all open stacks,
  lens hooks fire exactly once per CLI run.

Verification:
  PYTHONPATH=~ python -m pytest \
    ~/executorch/devtools/observatory/tests/ -v
  -> 49 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>
… region

Adds the third top-level Region in the typical AOT+device flow:

    Session "<script-name>"
    ├── quantization/   (pipeline_graph_collector, AOT)
    ├── edge/           (pipeline_graph_collector, AOT)
    └── device/         (this lens; on-device runtime work)
          ├── adb.execute #1
          ├── adb.execute #2
          └── ...

Region opens lazily on the first patched SimpleADB operation
(via `_ensure_device_region`, called from `note_simple_adb`), so a
CLI run with no on-device inference doesn't get an empty group
cluttering the tree view. The region is closed in `on_session_end`
via a `contextlib.ExitStack`.

Each inference record (`adb.execute #N`) lives **directly** under
`device` — no per-call sub-region — so the region holds multiple
records as designed in RFC §4.5.

Disabled lens (`config={"adb": {"enabled": False}}`) skips region
opening entirely.

tests/test_adb_lens_regions.py (new):
- 6 tests covering: lazy device-region opening, first SimpleADB
  call triggers it, multiple records share the one region,
  disabled-lens does not open it, on_session_end closes the
  ExitStack, idempotent _ensure_device_region.

Verification:
  source ~/executorch/.venv-11/bin/activate
  source ~/executorch/qaisw-*/bin/envsetup.sh
  export QNN_SDK_ROOT=~/executorch/qaisw-v2.41.0.251111144900_191416
  PYTHONPATH=~ python -m pytest \
    ~/executorch/backends/qualcomm/debugger/observatory/tests/ -v
  -> 19 passed (6 new + 13 existing AdbLens tests).

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Adds a "🌳 Tree" / "☰ Flat" button to the left-panel header that
toggles between the existing flat time-ordered list and a new
collapsible tree grouped by `region_stack`.

Per-record JSON payload now carries `session_id` (active session at
collect time) and `region_stack` (list of region names, outermost
first) -- both stamped during `Observatory.collect(...)`. The runtime
reads these to build the tree.

Tree-view behaviour:
- Records keep their original collection order; only the visual
  structure changes. Click handlers (selectRecord, compare modes,
  selection-mode checkboxes) work identically because the per-record
  <li> markup is shared between the two views via renderRecordItem().
- Each region renders as a clickable header with a caret (▼/▶);
  clicking toggles collapse/expand. Per-region state persists in
  localStorage under `obs_collapsed_regions`.
- Nested regions render as nested <ul> blocks indented by a left
  border line.
- diff_index "2-way compare" separators are SUPPRESSED in tree view
  -- two consecutive records in the records[] array can sit in
  different regions, making the adjacent-record diff misleading.
  Switch back to flat view to see those summaries.
- Selection-mode checkboxes still render correctly inside region
  groups; "Select All" continues to cover every record (selection
  state is keyed by record index, which is unchanged across the
  toggle).
- Falls back to flat rendering when no record has a non-empty
  region_stack (zero-code-change scripts produce records with
  region_stack=["<session>"], which has at least one entry, so they
  also get a single top-level "<session>" group in tree view).

Theming:
- Region header / caret / children all use existing CSS variables
  (--accent-color, --bg-tertiary, --bg-code, --text-secondary,
  --border-color) so light and dark themes work without further
  CSS changes.

Toggle state itself persists under `obs_tree_view`.

Verification:
  PYTHONPATH=~ python -m pytest \
    ~/executorch/devtools/observatory/tests/test_observatory_smoke.py -v
  -> 3 passed (template renders cleanly with the new payload fields).

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…y header

UI fixes from playwright e2e on a region-aware demo report:

1. Diff-index "2-way compare" separators in the left panel now ONLY
   suppress in tree view. In flat view (the default) they keep
   rendering between adjacent records as before, since adjacent
   records in flat view ARE visually adjacent. The earlier change
   accidentally affected both views.

2. The sidebar (.index-pane) was anchored at top:0 and z-indexed
   below the sticky header (z=199 vs z=200), so the index header
   row -- which carries the new "Tree" toggle and the "Select Items"
   button -- was getting hidden under the always-on top header.
   Sidebar now starts at top:60px (matching the header height) and
   reclaims the full viewport height when the header is hidden via
   `body:has(header.hidden) .index-pane { top: 0 }`.

devtools/observatory/tests/scripts/build_region_demo_report.py (new):
  Standalone script that opens the framework's enter_context regions
  and emits a small Report (HTML) for visual / playwright testing
  without running a full AOT pipeline. Mirrors the
  pipeline_graph_collector + AdbLens region structure (quantization,
  edge / etrecord, device).

Verified end-to-end with Playwright:
  - "Tree" button visible in sidebar header.
  - Toggling tree view groups records under collapsible region
    headers (aot_compiler -> quantization / edge -> etrecord /
    device) with the expected indentation and caret carets.
  - Sidebar opens below the sticky header (top: 60px).
  - 10 records, 5 nested ULs, identical record indices preserved
    across flat and tree views.
  - Theme variables now exclusively use --accent-color /
    --bg-tertiary / --bg-code / --text-secondary /
    --border-color so light and dark themes both render correctly.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
The Archive (JSON) noun lands in the CLI surface alongside the
runtime data-model rename (RFC §4.5):

* `--output-archive` / `--input-archive` are the canonical names
  in collect, visualize, and the new compare subcommand. The older
  `--output-json` / `--input-json` are accepted as aliases so
  existing workflows keep working.

* `compare` is a new subcommand that overlays multiple archives
  into one Report (HTML). Each archive is loaded; record names,
  `session_id`, and the outermost `region_stack` entry are
  prefixed with the corresponding `--label` so identically-named
  pipeline records (e.g. "Annotated Model") stay distinct across
  backends. The graph lens's `graph_ref` is rewritten in lock-step
  with the prefixed record name.

* `compare_archives` lazily registers any non-default lenses whose
  digests appear in the loaded archives (`accuracy`,
  `per_layer_accuracy`, the qualcomm `adb` lens, ...) so their
  dashboard / per-record sections render in the comparison HTML.
  Without this the cross-backend report would render only the
  default lenses (graph, graph_color, metadata, stack_trace) and
  silently drop the more interesting analysis.

Generic + backend CLIs all forward `args.output_archive` /
`args.input_archive` to the shared `run_observatory` /
`run_visualize` helpers. Verified end-to-end against the demo
pre-generated mv2 archives:

  python -m executorch.devtools.observatory compare \
      --input-archive xnnpack/mv2/observatory_report.json \
      --label XNNPACK/mv2 \
      --input-archive qualcomm/mobilenet_v2/observatory_report.json \
      --label Qualcomm/mobilenet_v2 \
      --output-html /tmp/mv2_compare.html

  -> 2.9 MB merged HTML, both backends' records grouped under
     two top-level Region nodes in the tree-view toggle.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Adds opt-in session awareness to the dashboard frontend hook without
breaking existing lens overrides:

interfaces.py
  Frontend.dashboard now accepts the original (start, end, analysis,
  records) positional args plus the following kw-only args:
  * session, session_records  -- for per-session breakouts
  * all_sessions, all_records -- full archive context
  * pair_sessions, pair_records -- compare-mode pair maps

  Lenses that don't override get the framework default which ignores
  the new args. Lenses that do override accept **_kw to keep
  forward-compatibility cheap; the metadata + qualcomm AdbLens
  overrides now carry an explanatory comment about the merged-archive
  semantics for `start` / `end` in compare mode.

observatory.py
  Call site forwards the new args. `_safe_frontend_call` already
  supports kwargs so no helper change was needed. The call still
  fires once per lens per archive (no behavior change today); when
  a lens wants per-session blocks it can iterate `_kw["all_sessions"]`
  internally and emit one TableBlock / GraphBlock per session.

Verified:
  * 49 observatory tests pass (same 1 pre-existing unrelated failure
    in test_per_layer_accuracy_lens, untouched here).
  * `python -m executorch.devtools.observatory compare ...` still
    produces the cross-backend HTML for the demo's mv2 archives.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…ldview

Brings the in-tree docs in line with the RFC §4.4-4.5 vocabulary
introduced by the Observatory redesign.

README.md
  * Adds a "Vocabulary" section listing the five canonical nouns:
    Region, Session, Record, Archive, Report. Calls out the
    region_name=None -> config-only override semantics.
  * Workflow blurb now ties each stage (capture / store / analyze /
    visualize / share) back to the Archive (sessions[] + records[])
    and Report (HTML/JSON) terms.
  * Adds a "Compare archives across backends" section showing the
    new `compare` subcommand with --input-archive / --label.
  * CLI examples switched to --output-archive (--output-json kept as
    a noted alias). The --lense_recipe typo is gone in favor of
    --lens-recipe.
  * Python API example uses enter_context with a region_name and
    nested Region; mentions enable_context as a thin alias.

USAGE.md
  * "Modes at a glance" table at the top: Collect / Visualize /
    Compare with one-line purpose each.
  * New "Compare archives across backends or runs" section with
    full flag table for the compare subcommand.
  * Visualize section uses --input-archive (--input-json aliased).
  * Disabling-lenses section explains config-only nested calls
    (enter_context(config=...) without a region_name).
  * Quick-reference table updated for archive/compare flags and
    multi-recipe usage.

REFERENCE.md
  * Stage matrix (1.1) gets two new rows: Region/Session boundary
    and Compare-mode merge. Runtime-capture row now lists the new
    `session_id` / `region_stack` fields on RecordDigest.
  * Frontend.dashboard signature (1.3) updated with the kw-only
    args: session, session_records, all_sessions, all_records,
    pair_sessions, pair_records.
  * Information-object map (1.5) lists RecordDigest.region_stack /
    session_id and the per-archive Session list.
  * `Observatory.enter_context` API entry rewritten with
    outermost-vs-inner behavior and the optional region_name
    semantics.
  * Bulk replaces stale `enable_context` references with
    `enter_context` (the alias is still accepted at runtime).

Verified: 23 region/session/smoke tests still pass.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
… auto-discovery

- devtools/observatory/observatory.py: generate_html_from_json now auto-discovers
  non-default lenses from archive digest keys (same logic as compare_archives),
  fixing ADB/accuracy frontend panels missing from re-rendered HTML.

- devtools/observatory/cli.py: run_visualize and run_compare accept an optional
  setup_fn(Observatory) hook called after clear() for backend-specific lens
  pre-registration.

- backends/qualcomm/debugger/observatory/cli.py: add `compare` subcommand;
  `visualize` now accepts --lens-recipe; extract _register_render_lenses() helper
  for frontend-only (no collection patches) lens registration.

- backends/xnnpack/debugger/observatory/cli.py: mirror QNN changes — add `compare`,
  --lens-recipe on visualize, _register_render_lenses().

- backends/qualcomm/debugger/observatory/lenses/adb.py: replace flex-row virtual-
  scroll log renderer with <pre>+CSS-counter spans for full Ctrl+F support and
  lower DOM node count (1 node/line vs 3); remove _rowsHtml / virtual-scroll
  constants; fix transfer-detail rows to use inline styles.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…button

- logcat and dmesg are now shown as compact table rows (title + line count)
  rather than inline <pre> trees, eliminating the browser freeze on record
  open.  Each row has Copy (raw text) and View buttons.

- View opens a full-screen overlay with the log in a single <pre> using
  textContent — one text node, zero per-line DOM nodes, maximum scroll
  performance.  Escape or the Close button dismisses it; body scroll is
  locked while open.

- Inference stdout keeps its inline renderLog rendering (shorter, and
  contains the error lines the user needs to see immediately).

- copy() now captures the button's original label before the async
  clipboard write, fixing the "Copied" flicker on double-click.

- stdout section header converted to .adb-section-header CSS class
  (removes inline style hacks); makeStreamSection inner function removed.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Replaces the unconditional full ring-buffer dump with a windowed fetch
modelled after tradefed/CTS:

  1. begin_inference now issues a single
     adb shell "date '+%m-%d %H:%M:%S.000'; cat /proc/uptime"
     so the device wall clock and kernel uptime are anchored to this
     specific inference call.
  2. After the inference exits, logcat is fetched with
     logcat -d -t '<device-ts>' (native logcat flag, no host-side
     filtering needed); dmesg is dumped fully and post-filtered in
     Python by its [<seconds>.<frac>] line prefix. Post-filtering
     keeps it portable across busybox/toybox dmesg variants on
     embedded Android that lack --since.
  3. If the marker capture fails, the affected stream falls back to a
     full dump and its status is marked "ok (full, fallback)".

New tri-state config for both fetch_logcat and fetch_dmesg:
  - "windowed" (default) — scope to inference window
  - "full"               — legacy full dump (escape hatch)
  - "off"                — skip
Legacy True/"auto" map to "windowed"; False maps to "off". This is a
behaviour change for explicit True users — note in docs.

Module docstring (adb.py) and README (ADB lens section) document the
config keys, the windowing mechanism, and the fallback semantics.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Replaces the singleton Run Dashboard with per-session Session
Dashboards. The legacy flat SessionResult.start_data / end_data
mirror is dropped entirely; per-lens session payloads now live only
on their owning Session.

Backend (devtools/observatory/):
 - interfaces.py: Session gains an `archive: str` field; SessionResult
   simplified to just `sessions: List[Session]`.
 - interfaces.py: Frontend.dashboard signature is now
   `(session, session_records, analysis)` -- no `start`/`end`/`records`
   positional shim, no `**_kw` for compare-mode kwargs.
 - observatory.py: _open_session takes an optional `archive` arg and
   stops writing the flat mirrors. _generate_report_payload invokes
   each lens's dashboard once per (Session, lens) pair, producing
   `dashboard[lens][session_id]`. Payload now carries top-level
   `archives: [{label, session_ids}]` and `sessions: [...]`; the
   legacy `session: {start_data, end_data}` block is removed.
 - observatory.py: export_json writes `{records, sessions}`. New
   `_load_archive_sessions` helper reads either the new shape or the
   legacy nested `session: {sessions, ...}` for backwards compat.
 - observatory.py: compare_archives stops prepending the archive label
   to region_stack (archive grouping moves up to the UI column layer)
   and stops writing namespaced `start_data["<label>/<lens>"]`. Each
   loaded Session gets `archive=label` instead. Per-layer accuracy's
   graph_ref is now also rewritten alongside graph's.

Lenses:
 - metadata.py and adb.py migrated to the new dashboard signature.
   Reads come straight from `session.start_data` / `session.end_data`;
   the previous compare-aware breakout TODO is no longer needed.

Frontend (templates/js/):
 - 02_layout.js: sidebar shows one "Session Dashboard: <name>" entry
   per session; records are grouped under their session by
   session_id. Region tree continues to work within each session.
 - 03_blocks.js: renderSessionDashboard(container, sessionId) replaces
   renderDashboard. Reads dashboard[lens][sessionId] and builds the
   block context as {session, records}. Renders session metadata
   (archive, start/end timestamps, duration) above the blocks.
 - 04_actions.js: new selectSession(sessionId) action; activeRecordIndex
   uses {sessionDashboard: id} to address per-session views.

Compatible with old archive JSONs via the read-side shim.
N-column compare-mode layout follows in commit 2/3; documentation
in 3/3.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
15 tests covering:
- Session.archive default and SessionResult shape (no flat dicts).
- Frontend.dashboard signature is exactly (session, session_records, analysis).
- Per-session lens hook payloads land on the right Session.
- Top-level payload keys archives + sessions; legacy 'session' block gone.
- dashboard[lens][session_id] nested shape; correct invocation count and
  filtered session_records per call.
- export_json writes the new shape; _load_archive_sessions reads both
  the new shape and the legacy nested {session: {sessions, ...}} shape.
- compare_archives groups N sessions by Session.archive, prefixes ids,
  rewrites graph and per_layer_accuracy graph_ref, and does NOT prepend
  the archive label to region_stack.
- Full payload survives JSON encoding via the production encoder.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Fixes the long-standing nesting issue where the AdbLens "device" region
ended up under "edge"/"etrecord" because pipeline_graph_collector kept
the edge region open until session end. The cleanest close boundary is
to_executorch returning -- it is precisely the AOT->runtime transition.

Changes:
- pipeline_graph_collector.py: new close_aot_regions() classmethod that
  pops _etrecord_stack, _edge_stack, _quantization_stack in that order.
  Idempotent. Logs but does not raise on per-stack close failure.
- pipeline_graph_collector.py: _install_to_executorch_patch monkey-patches
  EdgeProgramManager.to_executorch with a finally-block that calls
  close_aot_regions after the wrapped call returns. _uninstall_all
  restores the original.
- adb.py: AdbLens._ensure_device_region now calls close_aot_regions
  defensively before opening "device", so compile-only paths that skip
  to_executorch still get correct sibling scoping.

Region timeline after this change:
  session
  ├── quantization   (close: convert_pt2e returns)
  ├── edge           (close: to_executorch returns)
  │   └── etrecord   (closed alongside edge)
  └── device         (open: first SimpleADB op; sibling of edge)

Adds test_aot_region_close_and_compare_fixes.py with 4 tests covering
both the AOT-region-close fix and the per-layer accuracy graph_ref
rewrite that landed in the previous RFC commit.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…ector

Two simple, low-risk dashboards that put session-relevant data front
and centre when users land on a Session Dashboard panel.

AccuracyLens (devtools/observatory/lenses/accuracy.py):
  Aggregates the session's per-record accuracy digests into one summary
  table -- records_measured plus the mean of every numeric primary
  metric (psnr, mse, cosine_sim, top_k, ...). Internal _* keys, _min /
  _max per-sample stats, and _worst_idx fields are excluded.

PipelineGraphCollectorLens (devtools/observatory/lenses/pipeline_graph_collector.py):
  Counts records by their innermost region (quantization, edge,
  etrecord, ...). Helps users see at a glance how many graphs each
  AOT stage produced.

Per-layer accuracy lens analyze() now emits all four metric layers
(cosine_sim, psnr, mse, abs_err) instead of just cosine_sim. The
TODO that listed the missing metrics is dropped. Existing UT updated
to match the current default_color_by/default_layers (cosine_sim,
which is the production default).

Tests: test_session_dashboards.py (9 tests covering both dashboards
and edge cases). Pre-existing per_layer_accuracy lens UT now passes.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
When the report aggregates multiple archives (compare mode), the left
sidebar is now an N-column grid with one column per archive.

- main.css: new .index-list.compare-mode rule that switches the list to
  CSS grid with grid-template-columns: repeat(var(--archive-cols), 1fr).
  Each archive becomes an .archive-column li with a sticky header
  (.archive-column-header) showing the archive label, and an internal
  ul.archive-sessions holding that archive's session-dashboard links
  and grouped records (region tree or flat). Tightened item styling
  inside columns (no shadows, smaller font) so two columns stay
  readable on standard sidebar widths.

- 02_layout.js: renderIndex() now branches on state.data.archives.length:
  - len > 1: compare-mode N-column grid; sessions filtered into their
    column via _renderArchiveColumn.
  - len == 1 (single archive): flat session list, identical to today's
    rendering.
  - sessions empty: defensive fallback (very old archives).
  --archive-cols CSS var is set at render time so the grid size adjusts
  to the archive count automatically.

Single-archive reports are unchanged visually; only the structure of
state.data.archives drives the layout.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
… 3/3)

Adds devtools/observatory/RFC_SESSIONS.md documenting the worldview
(Archive ⊃ Session ⊃ Region ⊃ Record), the new HTML payload contract,
the archive raw-JSON contract, the lens dashboard contract, the
compare-mode merge rules, the frontend rendering rules (single-archive
vs N-column compare), the test coverage map, and the migration
checklist for downstream consumers.

Updates devtools/observatory/REFERENCE.md to drop stale claims:
- §1.3 Frontend Stage Signatures: dashboard signature is now
  (session, session_records, analysis), invoked once per (Session, lens),
  destination is dashboard[lens][session_id].
- §1.5 Information Object Map: SessionResult flat dicts removed; new
  rows for Session.start_data / end_data per session, the top-level
  archives[] grouping, and the per-(lens, session) ViewList path.
- §1.6 Custom JS callback: dashboard context is now {session, records}
  (records pre-filtered to the session) instead of {start, end, records}.
- Added a top-of-document pointer to RFC_SESSIONS.md.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
The CLI now takes --archive LABEL to set Session.archive directly. When
no inner enter_context(region_name=...) is opened by the user script,
the archive label also names the default session so the dashboard
sidebar carries a meaningful header (instead of the auto-generated
"default" / "default-2"). This is the documented replacement for the
removed --session-name flag.

Plumbing:
 - interfaces / observatory.py: enter_context and enable_context accept
   an `archive` keyword. _open_session already accepts it. When
   region_name is None at outermost and archive is given, archive is
   used as effective_name (Session.id == Session.name == archive).
 - cli.py: make_collect_parser registers --archive; run_observatory
   accepts archive and forwards it to enable_context. Backend CLIs
   (qualcomm, xnnpack) inherit the flag automatically.

Legacy support removed entirely (RFC initial commit, no migration
window):
 - --output-json / --input-json argparse aliases dropped from
   make_collect_parser and make_visualize_parser.
 - Internal output_json / input_json parameter names renamed to
   output_archive / input_archive on run_observatory and run_visualize.
 - _load_archive_sessions no longer reads the legacy nested
   "session": {"sessions": [...], "start_data": {...}, "end_data": {...}}
   shape (the shape never landed in any released archive). It still
   synthesizes a missing `archive` field per Session, which is needed
   for compare_archives label stamping.
 - All references to --output-json / --input-json removed from
   README.md, USAGE.md, qualcomm README, xnnpack README, and CLI
   module docstrings.

Tests: +3 in test_session_first_class.py covering --archive plumbing
through enter_context, the explicit-region-name interaction, and
enable_context forwarding. The legacy-nested-shape test was rewritten
into a "synthesize archive when missing" test to match the new behavior.
81/81 observatory tests green.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Implements the Report (JSON) output described in the RFC. The Archive
(--output-archive) and Report (HTML) (--output-html) contracts are
unchanged; Report (JSON) is a third optional output.

New interface (devtools/observatory/interfaces.py):
  Frontend.json_report(session, session_records, analysis)
  -> Optional[Dict[str, Any]]

  Called once per (Session, lens) pair, mirroring dashboard(). Lenses
  return a JSON-serialisable dict or None to opt out (no ghost keys).

New framework (devtools/observatory/observatory.py):
  _build_archives_payload(sessions_list) -- returns only the archives
    grouping list, shared by HTML and JSON builders. Sessions payloads
    are built separately so each output can carry what it needs: HTML
    keeps full asdict(s) (incl. start_data/end_data), Report (JSON)
    carries only identity+timing fields (consumers that need raw
    payloads load the Archive).
  _generate_json_report_payload(...) -- iterates (Session, lens),
    calls frontend.json_report() directly (not _safe_frontend_call,
    which is ViewList-only), keys results as
    lenses[lens_name][archive_label][session_id].
  export_report_json(path, title, config) -- writes with indent=2 via
    _NonFiniteFloatAsStringJSONEncoder; no-op when no records.

New CLI flag (all three CLIs):
  --output-report-json PATH -- optional; written after HTML. Absent =>
    no file is created.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Adds Frontend.json_report() overrides to the two demo lenses, producing
machine-readable Report (JSON) content suited for CI, LLM triage, and
regression detection.

accuracy (_AccuracyFrontend.json_report):
  Aggregates primary metrics (psnr, mse, cosine_sim, top_k, ...) across
  session_records: mean, min, max, and worst_record. Internal _* keys,
  per-sample _min/_max stats, and _worst_idx indices are excluded.
  worst_record semantics: for quality metrics (psnr, cosine_sim, top_k)
  worst_record = argmin (lower value = worse quality); for error metrics
  (mse, abs_err) worst_record = argmax (higher value = worse quality).

per_layer_accuracy (_PerLayerAccuracyFrontend.json_report):
  Reports anchor/target record names, n_layers, sample_source,
  metric_ranges (from analyze() global_data), and worst_layers: top-N
  rows per metric sorted worst-first.
  - psnr / cosine_sim: ascending sort (lower = worse).
  - mse / abs_err: descending sort (higher = worse).
  - Layer identity: uses from_node_root, falling back to target_node,
    matching the real row schema from observe().
  - top_n: read from analysis.global_data["json_report_top_n"] (stored
    by analyze() from config["per_layer_accuracy"]["json_report_top_n"],
    default 10). No live config-stack access at call time.

Tests (test_json_report.py, 17 tests):
  - Framework: invocation count, payload shape, no-ghost-keys for None
    returns, compare-mode archive grouping, export indentation, NaN survival.
  - AccuracyLens: mean/min/max aggregation, internal-key exclusion,
    mse worst_record = argmax, records_measured count.
  - PerLayerAccuracyLens: None when no data, sort direction for psnr
    (ascending) and mse (descending), metric_ranges propagation,
    top_n config knob via analyze().

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
RFC_SESSIONS.md: new §4a 'Report (JSON)' documents --output-report-json,
the three-level lenses[lens][archive][session_id] nesting, the accuracy
worst_record semantics (argmin for quality metrics, argmax for error
metrics), worst_layers sort direction for per_layer_accuracy, and a
side-by-side table contrasting Archive / Report (HTML) / Report (JSON).

REFERENCE.md §1.3: adds a json_report row parallel to the existing
dashboard row, documenting the call structure, return contract, and
payload destination.

USAGE.md §4a: new 'Report (JSON) for CI and LLM triage' section with
a CLI example, a compact payload sketch, and a pointer to RFC §4a.

README.md: one-sentence mention with USAGE.md link added to step 4
of the workflow list.

accuracy.py: docstring corrected — worst_record for error metrics
(mse, abs_err) = argmax (highest value = worst quality), not argmin.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Three interrelated fixes for per-layer-accuracy usability in Observatory:

**Build-time label cap (exporter.py)**
- Add `_MAX_LABEL_EXTENSIONS = 2` (cap on label rows reserved in the layout
  slot) and `_layout_constants_payload()` which emits line_height, y_padding,
  font sizes, and `max_label_extensions` into `payload.layout` so the JS
  runtime reads the same values Python used for layout.
- `_select_top_label_extensions()`: for each node, picks the 2 extensions
  with the largest label text to reserve bbox space, keeping the reserved slot
  bounded to 2 extra rows regardless of how many label-bearing layers exist.
- `_compute_layout` and `relayout_payload_base` both use the new selector.
- `GraphExtensionPayload.has_label_formatter` flag (models.py + extension.py)
  lets JS identify which extensions are label-bearing without inspecting nodes.
- `GraphPayload.layout` field carries the constants block.

**JS LRU queue (graph_data_store.js + view_controller.js + ui_manager.js)**
- `GraphDataStore` gets `labelLru` (Set, insertion-order LRU, cap from
  `layoutConstants.max_label_extensions`) and `recordLabelActivation /
  recordLabelDeactivation / extensionHasLabels` methods.
- `upsertExtension` preserves `has_label_formatter` and `sync_keys`.
- `computeActiveGraph` gates `label_append` on LRU membership; non-LRU
  labeled extensions still contribute color/info/tooltip.
- `ViewerController.setState` diffs prev/next active set, calling
  `recordLabelActivation` for newly-activated label-bearing exts (LRU evicts
  oldest on overflow) and `recordLabelDeactivation` for newly-removed.
- Seeded at construction time so default layers render labels on first paint.
- Layer panel shows a small "L" badge on the ≤2 extensions whose labels are
  currently visible; badge synced in `syncControlsFromState`.

**Canvas visual-rect shrink + edge re-anchor (canvas_renderer.js)**
- `_visualHeight(node)`: `Math.max(floor, Math.min(desired, node.height))`.
  Hard-clamped to Python-reserved `node.height` so no rendering can exceed
  layout. Floor ensures a rect is always drawn.
- `_visualBottom(node)`: `node.y - node.height/2 + _visualHeight(node)`.
  Top of rect is fixed at the layout-slot top; bottom floats.
- `_effectiveSourceStart(edge)`: substitutes the polyline's first point y
  with source's `_visualBottom`, re-anchoring outgoing edges to the visible
  bottom of the drawn rect. Target-side (incoming) endpoints unchanged.
- All node draw calls (fillRect/strokeRect), label positioning, highlight
  overlays, and hit-testing updated to use `_visualHeight`/`_visualBottom`/
  `_effectiveSourceStart`.
- `edge.bounds.minY` widened at `_initTopology` time by the worst-case
  source-endpoint substitution delta so AABB hover-test covers the new range.
- JS magic literals (16, 14px, 12px) replaced with `layoutConstants.*`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The left panel was a `position: fixed` overlay (300 px wide, covering the
main graph area when open). Two issues:

1. **Overlay blocked the graph.** Sidebar slid in over the main panel via
   `transform: translateX(-100%)` → `translateX(0)`, never affecting layout.
2. **Compare-mode columns were squeezed.** `grid-template-columns:
   repeat(N, 1fr)` divided the fixed 300 px equally, making N-archive
   columns unreadably narrow.

Fix:

* **Push layout.** `.index-pane` moves from `position: fixed` to an
  in-flow flex child of `.container`. Closed state: `width: 0;
  overflow: hidden`. Open state: `width: var(--sidebar-w)` (driven by JS).
  `.main-pane` is the second flex child and naturally gets the remaining
  width, so the main area shrinks/grows as the sidebar opens/closes.

* **Dynamic sidebar width via CSS variable.** JS sets `--sidebar-w` on
  `:root` at toggle time and on `renderIndex` when the archive count changes
  (single archive: 300 px; N archives: N × 200 px, capped at 50 vw).
  The `sidebar-toggle-btn` reads `left: var(--sidebar-w, 0)` so it always
  sits at the sidebar's right edge with no JS positional update.

* **Natural column widths.** `grid-template-columns` changes from
  `repeat(N, minmax(0, 1fr))` to `repeat(N, 200px)` with `overflow-x: auto`
  on the grid container. Each archive column is 200 px; the panel expands
  to accommodate all of them.

* **Hover-to-peek removed.** `.index-pane-trigger` (the invisible fixed
  strip that revealed the sidebar on hover) is removed from the HTML and
  hidden via CSS. It served the overlay model; in push mode it would
  unexpectedly push the main content aside on every mouse-near-edge hover.

Verified: push mode (`mainLeft === sidebarWidth`), `--sidebar-w` driven
correctly for single/compare archives, 98 observatory tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…load

The fx_viewer JS runtime gates canvas label rendering and the "L" badge
in the layer panel on `extData.has_label_formatter` (introduced in
a66f82a). The Observatory pipeline was dropping that flag in two
places, so per-layer-accuracy labels never rendered and the badge never
appeared, even though `Extension.build_payload()` set the flag correctly.

* **`GraphHub.add_analysis_layers`** selectively copied `name` / `legend`
  / `sync_keys` / `nodes` into the per-layer dict; `has_label_formatter`
  was never in the allowlist and silently became `undefined` on the JS
  side. Replaced the manual dict construction with `asdict(payload)` so
  every current and future `GraphExtensionPayload` field forwards
  automatically; `id` is then overwritten with the namespaced form
  because the dict key is authoritative on the JS side.

* **`GraphLayerContribution.to_payload`** rebuilt a
  `GraphExtensionPayload` when `id_override` / `name_override` was set
  but only forwarded `id` / `name` / `legend` / `nodes`. `sync_keys` and
  `has_label_formatter` reset to dataclass defaults, so any lens that
  renamed its layer lost both. Both fields are now forwarded explicitly.

* **`GraphHub.build_viewer_payload`** did not emit `payload.layout`. JS
  fell back to hardcoded constants in `graph_data_store.js`; today they
  match Python defaults, but the first change to `_NODE_LINE_HEIGHT` or
  `_MAX_LABEL_EXTENSIONS` would silently break label-slot reservation
  vs canvas drawing. Now sourced from
  `FXGraphExporter._layout_constants_payload()`.

Tests in `test_graph_hub.py`: 9 new cases lock the contract field-by-
field. Two of them iterate `dataclasses.fields(GraphExtensionPayload)`
and `GraphExtensionNodePayload` so any new field added without
forwarding through `GraphHub` fails CI immediately.

Verified: 11/11 graph_hub tests pass; 102/102 observatory tests pass
(3 unrelated pre-existing failures require Python ≥ 3.11 for
fast-sugiyama and are independent of this change).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
In compare mode each archive column already shows the label in its sticky
header (commit 5ae2a72), so the `<label>/` that compare_archives prepends
to every record and session name renders as redundant noise inside the
column and squeezes the natural-width layout from c9e7bda.

Strip is display-only — `rec.name` / `session.name` in `state.data` stay
prefixed so graph_ref resolution and the global record map keep working.
A `displayPrefix` arg threads through `renderRecordItem`,
`_renderSessionDashboardLink`, `renderIndexFlat`, and `renderIndexTree`;
`_renderArchiveColumn` passes `archive.label`. Single-archive paths pass
no prefix and are unchanged. Collision suffixes (`A/foo #2`) strip cleanly
to `foo #2` since only the head matches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- backends/xnnpack + LENSES.md: correct '--lense_recipe' typo to
  '--lens-recipe' (4 occurrences) so the documented commands actually
  work as pasted.

- devtools/fx_viewer/exporter.py + devtools/observatory/lenses/graph.py:
  GraphLens now fails soft when 'fast-sugiyama' is missing. The
  exporter raises a specific _FastSugiyamaMissingError (subclass of
  ImportError) at the layout site; GraphLens.observe() catches it,
  logs the actionable 'pip install fast-sugiyama[all]' hint exactly
  once per process, and returns None for that record. Other lenses
  continue producing records; the run finishes with exit 0. Restores
  full graph payloads automatically once the dependency is installed.

- devtools/observatory/cli.py: end-of-run export summary via print()
  (not logging.info, which is swallowed at the default WARNING level).
  Applies to run_observatory, run_compare, and run_visualize. Lists
  output paths with byte-size and the collected record names so the
  CLI no longer ends silently after the last 'Running fast-sugiyama
  layout...' line.

Authored with Claude.
@quic-boyuc quic-boyuc force-pushed the boyuc/observatory_draft_demo branch from 79a25e2 to f7ddf43 Compare July 1, 2026 03:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant