Boyuc/observatory draft demo#19288
Conversation
🔗 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.
|
This PR needs a
|
2f5a882 to
6a3afad
Compare
One or more co-authors of this pull request were not found. You must specify co-authors in commit message trailer via: Supported
Alternatively, if the co-author should not be included, remove the Please update your commit message(s) by doing |
- 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>
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.
79a25e2 to
f7ddf43
Compare
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.Observatoryprovides the missing coordination + synthesis layer: write one Lens once, get a shareable report.fx_viewerprovides 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:
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:
region_stack.prepare_pt2e,convert_pt2e, andto_edge_transform_and_lower(stages not stored in ETRecord). Runtime/delegated accuracy viaInspectoris a planned follow-up lens.Two machine-readable outputs accompany the HTML:
sessions[]+records[], no analysis baked in; the input forcomparesubcommand and late re-analysis.Architecture Overview
Observatory follows a three-layer design: Interface → Core → Lenses.
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.
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_viewerand Observatory meet at exactly two API surfaces — one in Python (build-time), one in JavaScript (run-time in the browser).Python API (Build-Time)
FXGraphExporter(gm)GraphModule, compute Sugiyama layout (x, y + edge routing), export to HTML or JSONGraphExtension(id, name)NumericColorRule(attribute, cmap)"viridis","reds","blues","greens")CategoricalColorRule(attribute)Standalone usage (no Observatory dependency):
JavaScript API (Run-Time)
FXGraphViewer.create(config)FXGraphCompare.create({viewers, layout, sync})setLayers(ids[])/setColorBy(id)/setTheme(name)debug_handleorfrom_nodeThe 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)
Lens Lifecycle Diagram
Frontend and Block Types
get_frontend_spec()returns aFrontendinstance. The framework calls its methods at report-emit time:dashboard(session, records, analysis)ViewListof session-level blocks, orNonerecord(digest, analysis, context)ViewListof per-record blocks, orNonejson_report(session, records, analysis)Dictfor Report (JSON), orNoneresources(){"js": ..., "css": ...}for shared assetsAvailable block types in a
ViewList:TableBlockHtmlBlockCustomBlockGraphBlockfx_viewer; synchronized N-way compareShipped Common Lenses
graphFXGraphExportermetadataaccuracyper_layer_accuracypipeline_graph_collectorprepare_pt2e,convert_pt2e,to_edge_transform_and_lowerstack_tracegraph_colorWorked Example: AdbLogLens (Custom Lens)
This example shows the full extension surface — a lens that captures device-side logs from ADB without touching Observatory core:
Key design patterns demonstrated:
observe()readscontext.configat call time — nestedenter_contextoverrides work correctly.Nonefor artifacts it doesn't own.collect()is type-agnostic — multiple lenses coexist without knowing about each other.get_frontend_spec()returns aFrontend;record()returns aViewListwith aTableBlock.Nested Config Scoping in Practice
Config overrides are pushed on
enter_contextentry and popped on exit — even on exception.Invocation Surfaces
There are two real surfaces: the CLI and the context + collection points pair.
@observe_passis 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:1a.
compareSubcommand (overlay multiple archives)compareis 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.Which backend CLI to invoke
comparefrom — this matters. Each backend CLI pre-registers its own lens frontends (HTML/CSS/JS assets baked into the report). The genericpython -m executorch.devtools.observatory compareonly registers core lens frontends (graph, metadata, accuracy). To render backend-specific overlays (e.g. Qualcomm's ADB log panel) in the compared report, runcompareunder 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-recipeoncompare. 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.
visualizeSubcommand (Archive JSON → HTML re-render)visualizere-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
configdict, keyed by lens name:3.
@observe_passDecorator (sugar over context + collect)@observe_passwraps a pass so its input and output graphs arecollect()-ed automatically — no manual collection points needed:Collection-Point Mechanisms
Three distinct mechanisms produce collection points — all flow into the same
collect(name, artifact)path:pipeline_graph_collectorlens patcheson_session_start; each patch callscollect()with the returned object, then restores aton_session_endprepare_pt2e,convert_pt2e,to_edge_transform_and_lower,ETRecord.add_*@observe_passdecoratorPassBasesubclass/instance; wraps each pass call in its own Region and collects graph before + afterObservatory.collect(name, artifact)All patches are installed in
on_session_startand removed inon_session_end— the user's script runs unchanged.Key Vocabulary
enter_context(name). Nests. Pure labelling — no lens hooks fire at region boundaries.on_session_start/on_session_endfire here.collect(name, artifact)item. Tagged withsession_id+region_stack.sessions[]+records[]. The only thing persisted.analyze+Frontend. HTML or JSON.get_frontend_spec(); contributesdashboard()andrecord()blocks.POC Implementation Structure (
~/executorch)Governance and Merge Strategy
Recommended: Three-PR Stack
devtools/fx_viewer/FXGraphExporter(gm).export_html(...)devtools/observatory/Ownership Model
devtools/observatory/core,devtools/fx_viewer/core, common lenses.backends/<name>/debugger/observatory/, backend-specific lenses and backend CLI — no core sign-off needed.GraphExtensionAPI, JS runtime, JSON schemas) → core sign-off required.Public API Surfaces (schema stability)
get_name,setup,on_session_start,observe,digest,on_session_end,clear,analyze,get_frontend_spec)FrontendAPI (dashboard,record,json_report,resources)GraphExtensionAPI (add_node_data,set_color_rule,set_sync_key,set_label_formatter)sessions[]+records[])FXGraphViewer.create/FXGraphCompare.create(JS)--output-html,--lens-recipe, etc.)accuracy.evaluator)Testing Strategy
devtools/observatory/tests/(owned by core).Implemented Capabilities (on the POC Branch Today)
accuracy+per_layer_accuracylenses; CPU simulation acrossprepare_pt2e,convert_pt2e,to_edge_transform_and_lowersnapshotspipeline_graph_collectorlens patches; captures intermediate graph snapshots not stored in ETRecord@observe_passdecorator +graphcompare modestack_tracelensfx_viewer: pan, zoom, minimap, fuzzy search, N-way comparemetadatalens: CLI command, env, modelregion_stack; toggle flat/tree--output-archive; reload for re-analysisjson_report--output-report-json)graph_colorlenscompareCLI subcommandAdbLens(Qualcomm backend-specific, not a default core lens)debug_handle+InspectorKnown Limitations
per_layer_accuracylens 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 viaInspectorintegration is a planned follow-up lens.fast-sugiyamarequires Python ≥ 3.11 and is a hard requirement for the graph lens. The currentfx_viewerlayout stage raisesImportErrorwhenfast-sugiyama(andrectangle-packer, its multi-component packer) are missing, so records that go throughGraphLensproduce 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 behindexecutorch[observatory-layout]to avoid raising the project's Python floor), but that fallback is not yet implemented.debug_handle+Inspector.Pre-Generated Demo Reports
Reports are available for 30+ models across XNNPACK and Qualcomm backends:
mobilebertresnet50inception_v4mobilenet_v2Cross-backend comparisons (XNNPACK vs. Qualcomm QNN):
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 forfx_viewer. The graph lens raisesImportErrorat layout time if this (or itsrectangle-packerextra) 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 behindexecutorch[observatory-layout].ETRecord,ETDump,Inspector) — it adds no new capture formats and requires no changes to Inspector or ETRecord.