Skip to content

feat!: stop one / experiments request from reading the whole store (Layer 3 - 10) - #1660

Open
Manik-Khajuria-5 wants to merge 64 commits into
fossasia:devfrom
Manik-Khajuria-5:Layer3-PR11
Open

feat!: stop one / experiments request from reading the whole store (Layer 3 - 10)#1660
Manik-Khajuria-5 wants to merge 64 commits into
fossasia:devfrom
Manik-Khajuria-5:Layer3-PR11

Conversation

@Manik-Khajuria-5

@Manik-Khajuria-5 Manik-Khajuria-5 commented Jul 29, 2026

Copy link
Copy Markdown
Member

Description

  • /experiments/search: MAX_LIMIT (1000) caps the page, MAX_WINDOW (10000) caps offset + limit.
  • /experiments/compare: MAX_ENV_IDS (1000) caps the id list; the HParams pane batches, so large panes still work.
  • Metadata-only store reads and a bounded ranking heap, so memory follows the page, not the store.
  • Fixes: experiment writes respect readonly mode; SPLOM tooltips escape values.

limit is coerced since the reply reports it; offset/env_ids 400, since a changed page or partial diff is a different answer, not a smaller one.

Fixes : #1639

Integration note

This PR should be merged after #1656 , as it depends on the lower-layer changes introduced there.

Motivation and Context

Every experiments read was unbounded somewhere limit: null returned everything, compare's id list was unlimited, paging depth sized the heap so a small request body could drive server memory.

How Has This Been Tested?

py/tests/ green (~530 tests, Python 3.12.13) with new coverage for the caps and the window boundary; yarn lint clean, yarn build compiles.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Code refactor or cleanup (changes to existing code for improved readability or performance)

Breaking: limit: null now returns up to 1000, not every match, and is no longer nullable in the reply. offset + limit over 10000, and compare over 1000 ids, now 400 page with offset or narrow the query.

Checklist:

  • I adapted the version number under py/visdom/VERSION according to Semantic Versioning — left to the maintainers' release commit, but limit: null is not backwards compatible.
  • My code follows the code style of this project.
  • My change requires a change to the documentation.
  • I have updated the documentation accordingly (openapi.yaml and the client docstrings).

Summary by Sourcery

Introduce server-side experiment metadata APIs with bounded search and comparison, add a hyper-parameter pane UI, and ensure readonly mode blocks all write endpoints while preserving env persistence.

New Features:

  • Add /experiments/log, /experiments/search, /experiments/compare, /experiments/suggest, and hyper-parameter pane endpoints for managing and querying experiment metadata.
  • Expose new Visdom client APIs for logging experiments, searching and comparing runs, suggesting hyper-parameters (stub), and managing hparams panes.
  • Introduce a dedicated Experiments tag and schemas in the OpenAPI spec, plus new Experiment and ExperimentComparisonSection models.

Bug Fixes:

  • Ensure uploads and experiment writes are rejected in readonly mode, preventing unintended persistence changes.
  • Fix environment persistence so experiments survive full-env saves and LazyEnvData reloads without clobbering metadata.
  • Ensure SPLOM tooltips escape values and avoid crashes when metrics or params are missing or non-numeric.

Enhancements:

  • Refactor ExperimentStore to read metadata without materialising env windows, add bounded, heap-based ranking for experiment search, and support stable comparison across mixed-type fields.
  • Add a query parser and evaluator for a safe, human-readable experiment filter language, and share it between search and hparams selection.
  • Extend server utilities and LazyEnvData to better track loaded envs, support experiment-only reads, and handle hparams windows as a first-class pane type.
  • Align JS panes and settings with new hparams pane (styling, rc-slider integration, and pane sizing) and factor shared serverPath logic for API calls.

Build:

  • Add rc-slider and related CSS assets for hparams filters and plots to the JS build configuration.

Documentation:

  • Document the new Experiments API in openapi.yaml and README, including client methods and breaking changes to limit/offset semantics.
  • Update storage skill documentation to reflect the DataStore/JSONStore layer as the persistence abstraction.

Tests:

  • Add comprehensive tests for experiment store lifecycle, search paging and ranking bounds, query parsing, comparison diffs, hparams selection and update flows, client message shapes, and readonly guards across endpoints.

Chores:

  • Wire new experiment and hparams handlers into the Tornado application routing and pane registry, and adjust BaseHandler attribute copying for readonly propagation.

Add vis.experiment/log_metrics/finish_experiment client methods that POST
to a new /experiments/log Tornado handler. The handler records metadata
through ExperimentStore over the server's DataStore and mirrors the blob
into in-memory env state so a later full-env save preserves it. Covers
create/update, metric append+autocreate, and finish (finished/failed).

Once an experiment is terminal, further log/metrics writes are rejected:
the store raises ExperimentFinishedError and the handler maps it to 409
Conflict, so a finished run's recorded data cannot change after the fact.
Validation returns 400 (bad action/params/status), 404 (finish without
experiment), 409 (write to terminal). Adds end-to-end + client-shape tests.
Add py/visdom/experiments/query.py: a small, injection-safe query
language ('lr < 0.01 AND acc > 90') that tokenizes and parses into a
predicate AST evaluated as a pure Python walk over a dict (no eval/exec,
no SQL). Supports < <= > >= = != contains, AND/OR with correct
precedence, parentheses, and dtype-aware casting (numeric/string/bool).
build_record() flattens an Experiment into a queryable dict with bare
and namespaced (param./metric./tag.) keys.

Exported from the experiments package. Covered by
py/tests/test_query.py (45 tests: tokenizing, grammar, precedence,
type handling, malformed input, injection-style strings, build_record).
build_record() was annotated `experiment: Any`, justified in its docstring
as keeping query.py free of storage dependencies. That rationale was wrong:
models.py imports only stdlib and never touches store.py, so importing it
costs nothing and cannot cycle. The real cost of `Any` was an invisible,
uncheckable contract.

Replace it with an ExperimentLike Protocol (structural, so Experiment
satisfies it without importing or inheriting anything) plus a _KeyValueLike
protocol for Param/Metric/Tag. params/metrics/tags are declared read-only
via @Property: as plain attributes they are invariant, which rejects
list[Param] against Sequence[_KeyValueLike] and would have failed the
concrete Experiment.

Collect metrics in one reverse pass instead of a set comprehension plus an
O(keys x metrics) latest_metric() re-scan per key. Semantics are unchanged,
including latest meaning last logged rather than highest step.

Document the two behaviours the code relied on but never stated: bare and
namespaced keys are duplicated deliberately (in-memory, per query; the
persisted shape is still to_dict()), and bare-name precedence runs
built-ins > params > metrics > tags.

Tests 45 -> 48: bare-name precedence across a param/metric/tag collision,
latest-metric ordering, and a non-Experiment fake proving the decoupling.
Adds the search layer on top of the L2-3 query parser, so the experiments
logged by /experiments/log can actually be found again.

ExperimentStore.search(query, sort_by, descending) filters experiments
through the parser's predicate and sorts them (newest first by default).
Records are built once per experiment and reused for both the filter and
the sort, and a run missing the sort field always lands last rather than
jumping to the front when the sort is reversed. Mixed-type fields sort
without raising, since params are user-supplied.

POST /experiments/search wires that to HTTP: it validates query/sort_by/
limit/offset/descending, pages the result, and reports the unpaged total
so a caller can walk the pages. A malformed query is a 400 with the
parser's reason. Queries stay evaluated in Python -- never eval'd, never
SQL -- so an injection payload is a parse error, not an execution.

Client side, vis.search_experiments(query, limit, offset, sort_by,
descending) returns the server's reply; the JSON-decoding plumbing is
factored out of _experiment_send so both endpoints share one path.
Endpoint documented in openapi.yaml.

Tests: py/tests/test_experiment_search.py (42) covering the store
filter/sort, the endpoint e2e over a real Application, validation and
paging, and the client message shape.
Remove the inline # comments PR-4 added and fold the useful context into
docstrings instead, matching the experiments package style. No behaviour
change.
Layer 2 continues: after search, ask what actually differs between the
runs it found.

New visdom/experiments/compare.py holds the diff itself, pure and free of
storage the way query.py's build_record is: build_comparison() lines the
experiments up and reports, per section (params/metrics/tags), the union
of fields, the shared ones every run agrees on, the differing rest, and
the per-run values. Metrics diff on their latest observation, the same
value a search compares on, so a run found by "acc > 0.9" shows that acc
here. A field only some runs carry is a difference rather than a
consensus among those that have it.

Two comparisons are deliberately not ==. Bools are not numbers, so amp=True
and amp=1 differ, matching query.py's refusal to treat a bool as a number.
NaN agrees with itself, since a metric NaN in every run is not a difference
and calling it one would bury the real ones.

ExperimentStore.compare() selects the runs and delegates. Selection is by
name or by query, mutually exclusive: both or neither raises rather than
guessing which was meant. env_ids compares in the order given, dedupes, and
raises KeyError naming any id without an experiment -- a comparison silently
missing a run it was asked for reads as a comparison of the rest. A query
selects via search() ordered by sort_by/descending and capped by limit;
matching nothing is an empty comparison, not an error.

ExperimentCompareHandler (POST /experiments/compare) mirrors the search
handler's shape and maps those to 400/404. It rejects a bare-string env_ids,
which would otherwise iterate into a comparison of runs "r", "u", "n".
The three request validators move from ExperimentSearchHandler statics to
module level so both handlers share one copy.

Client: vis.compare_experiments(env_ids=None, query=None, limit=None,
sort_by=None, descending=True). Documented in openapi.yaml
(compareExperiments + ExperimentComparisonSection schema).

Tests: py/tests/test_experiment_compare.py (50 -- pure diff, store
selection, endpoint e2e, validation, client shape); 414 py/tests pass.
Verified live against a real server and client: both selection modes,
limit, 404/400 paths, injection payload inert with data intact.
Fold the example's trailing # annotations into the prose instead, matching
the experiments package style. No behaviour change.
The stubs cover experiment/log_metrics/finish_experiment but stop there,
so search_experiments (L2-4) and compare_experiments (L2-5) were the two
client methods a type checker could not see.

env_ids is spelled Union[List[Text], Tuple[Text, ...]] rather than the
obvious Sequence[Text]: a bare str is itself a Sequence[str], and
compare_experiments rejects one at runtime, so Sequence would have a
checker bless the very call the client raises TypeError on. A tuple is
accepted, hence the union rather than a plain List.

Both replies are the decoded JSON of their endpoint, named
_ExperimentReply for the Mapping the existing experiment stubs return.

Verified the stub parameter names and defaults against inspect.signature
of the real methods; no type checker is configured in this repo, so
nothing else guards the drift.
Compare took either env_ids or a query. The query mode was redundant:
search already answers "which runs match?", so compare's copy of it was a
second way to do the same thing, reachable only by duplicating search's
syntax, sorting and paging into a second endpoint.

It also carried a caveat that could mislead. limit truncated the compared
set, so shared/differing were computed over only the runs that survived
the cap -- correct, but easily read as a diff of everything matching.

Compare is now purely "diff these runs": compare(env_ids). To compare a
query's matches, search first and pass the ids on, which is one extra call
and keeps the diff honest -- it always describes exactly the runs named.
The two endpoints now have one job each.

Removed with it: the mutual-exclusion checks (nothing to be exclusive
with), sort_by/descending/limit on compare, and the QueryParseError path.
env_ids is now required, so a missing one is a 400 rather than a fallback
to query. The three request validators move back from module level to
ExperimentSearchHandler statics, since compare no longer shares them --
web_handlers.py is byte-identical to L2-4 apart from the new handler.

Client compare_experiments(env_ids) loses its optional selection knobs;
__init__.pyi follows, and _EnvIds drops its Optional now that env_ids is
required.

Tests: the query-mode cases go; added that search-then-compare composes,
that a stale caller still sending query/limit is ignored rather than a
500, and that a traversal env id degrades to 404 rather than a file read
(JSONStore._primary_path already guards this) -- the latter replacing the
injection test, whose parser surface compare no longer has.

398 py/tests pass. Verified live against a real server and client: compare,
search-then-compare, the 400/404 paths, and the traversal id.
shared/differing was all-or-nothing: a field counted as shared only if
every run agreed, so with run-a lr=0.1, run-b lr=0.001, run-c lr=0.1 the
answer was just "lr differs". That run-a and run-c actually match was in
the values map, but the reader had to spot it.

Each section now also carries groups: per field, the runs clustered by the
value they used.

    "lr": [{"value": 0.1,   "env_ids": ["run-a", "run-c"]},
           {"value": 0.001, "env_ids": ["run-b"]}]

shared/differing stay as the at-a-glance "what changed?"; groups answers
the finer "which runs agree?". They cannot disagree: a field is in shared
exactly when its groups are a single cluster holding every compared run,
and shared is now derived from the groups rather than computed twice.

Grouping deliberately does not use a dict keyed by value. Values need not
be hashable (a param may hold a list), hash(True) == hash(1) with True == 1
so a dict would silently merge them and undo the bool rule _same_value
exists to enforce, and NaN never equals itself so it would never group.
_group_values scans with _same_value instead, keeping one definition of
sameness for the module; the run count per comparison is small.

Groups are ordered by first appearance and env_ids within a group keep the
compared order, so the output is deterministic. A run that never logged the
field is in no group for it.

Tests: 7 new in TestBuildComparison (clustering, shared<->groups agreement,
missing field, bool-vs-1, NaN, unhashable lists, ordering) plus a JSON
round-trip through the endpoint. 406 py/tests pass. Verified live: three
runs on two learning rates cluster as intended, including list-valued
params, with shared and groups agreeing on every field.
Reserve /experiments/suggest for the next-run hyper-parameter suggestion
strategy (Optuna-backed), which belongs to a later layer. The endpoint is a
stub: it parses the request like its siblings and replies 501 Not Implemented
with a JSON body ({"status": "not_implemented", "suggestion": null, ...}) so a
caller gets a stable, decodable answer it can tell apart from a real result.

- ExperimentSuggestHandler (web_handlers.py) + route (app.py), mirroring the
  log/search/compare handler shape (static wrap_func, @check_auth post).
- Visdom.suggest_experiment(params=None, env=None) client method + .pyi stub;
  type-checks params and posts the search space through for the eventual
  strategy.
- openapi.yaml: /experiments/suggest documented (operationId suggestExperiment,
  501 stub response schema).
- test_experiment_suggest.py: endpoint 501/JSON-body contract and client
  message shape.
The Layer-2 experiments client API had no README coverage (L2-1..L2-5 were
local-only with docs deferred here). Add an Experiments section — both the API
list entry and the per-method Details — covering the full workflow:
experiment / log_metrics / finish_experiment / search_experiments /
compare_experiments, plus suggest_experiment documented honestly as a reserved
501 stub. Argument names and defaults match the client signatures.
Add py/visdom/data_model/README.md describing the storage abstraction: the
DataStore interface (env/layout/undo operations), the JSONStore backend
(on-disk layout, in-memory mode, id sanitisation/path-traversal guard,
long-id hash fallback, byte-stable JSON, atomic undo writes), how it is wired
through Application.storage, and how to add a new backend. Point the
env-persistence skill at the layer, which previously referenced only the old
serialization path.
Add the client-side entry point for the Layer 3 hyper-parameter view. `vis.hparams()` gathers experiments through the existing `experiments/search` endpoint (same query syntax, optional `env_ids` filter), flattens them into a compact records payload via the new module-level `_flatten_experiments` helper, and creates a dedicated `hparams` window that renders from its content like the properties/embeddings panes.

`_flatten_experiments` collapses each run's params/metrics lists into per-run maps, keeping only each metric's latest value, and returns the sorted param/metric key unions so the frontend renders a table/parallel-coordinates view without re-deriving columns.

Includes the `.pyi` stub for the new method and pytest covering the flatten helper (unions, latest-metric, heterogeneous/empty/malformed input) and the client message shape (window type, env/win pass-through, env_ids validation and ordering).
Add a `mode` argument to `vis.hparams` ("query" | "env_ids" | "both",
default "both") so the caller explicitly chooses how the shown runs are
selected: by query alone, by an explicit env_ids list alone, or the
intersection of the two. mode="env_ids" requires env_ids and skips the
query; mode="query" ignores env_ids.

Include tags in `_flatten_experiments`: each run now carries a tags map
alongside params/metrics, and the payload exposes a sorted tag_keys union,
matching the server-side build_record which also flattens tags.

Switch the flatten test fixture to TemporaryDirectory with a tearDown so
the seeded JSONStore is removed after each test instead of leaking temp
directories, and extend coverage for the mode paths and tag flattening.
Previously the env_ids selection always went through search_experiments,
which reads every environment on disk and returns every experiment over
the wire, only for the client to keep the few it named. When no query is
in play, route the selection through compare_experiments instead, which
reads only the named environments and returns just those runs; a query,
when present, still uses search.

Also type-check query up front and treat a blank/whitespace query as no
query so it takes the by-id path too. Tests stub both read endpoints and
assert which one each mode reaches.
Make the three selection modes strict and mutually exclusive in their
arguments instead of leniently combining them:

  query   -> non-empty query, no env_ids; fetched via search
  env_ids -> non-empty env_ids, no query; fetched via compare (reads only
             the named environments)
  both    -> both required and non-empty; search then narrow by env_ids

mode defaults to None and is inferred from which of query/env_ids are
given; an explicit mode enforces its rule and rejects the wrong argument.
A blank or whitespace-only query counts as no query. There is no
"show everything" call: with neither query nor env_ids a ValueError is
raised.
Move the hyper-parameter selection, flattening and window creation off the
Visdom.hparams client and into a POST /experiments/hparams handler. The
handler resolves the strict query/env_ids/both modes the client used to
resolve itself (invalid combinations are 400s), flattens the selected runs
via visdom.experiments.flatten_experiments, and writes an hparams window with
that content into the env state.

For now the handler only writes to state; it does not broadcast the window to
connected clients, since the frontend has no dedicated hparams pane to render
it yet. The pane is served on the next env load, and broadcasting lands with
that pane.

vis.hparams is now a thin call: it validates opts and posts
query/env_ids/mode/win/opts to the endpoint, returning the created window id,
instead of gathering runs and posting to the events endpoint itself. window()
learns the hparams type so the content is stored as-is rather than as a plot.
Register an `hparams` pane so `vis.hparams()` windows are drawn in the
browser instead of falling through to the plot fallback:

- js/panes/HParamsPane.js: functional pane (PlotPane-style React.memo)
  reading the flattened records payload from window content; renders
  empty / error / summary states and leaves an .hparams-views seam for
  the table / parallel-coordinates / SPLOM / filter views of later PRs.
- js/settings.js: register the pane in PANES and PANE_SIZE.
- py/visdom/static/css/hparams.css + index.html link: pane styling.

Switch the /experiments/hparams handler from the persist-only
_store_window to the standard register_window, so the pane is broadcast
to connected clients and appears live (broadcast was deferred until this
pane existed).
Replace the placeholder run list in the hparams pane with HParamsTable,
mounted in the .hparams-views seam. Adds:

- hparamsUtils: pure sort/format/filter/color helpers; the comparator
  mirrors the backend order rule (numbers before strings, missing/NaN
  last in both directions) so the table and server-side search agree.
- Column-header sorting plus a tree 'sort by' dropdown with a 3-state
  direction toggle (asc/desc/off); active sort column is highlighted.
- Client-side text filter and per-run selection checkboxes.
- 'color by' shades a numeric param or metric column on a ramp off the
  Visdom blue (#3b5998).
- Both dropdowns reuse rc-tree-select to match the environment selector.
- Add a centered, larger pane title: the experiments handler defaults the
  hparams window title, and Pane tags its bar with a per-type class so the
  hparams title can be styled without touching other panes.
- Draw solid vertical dividers at the param/metric/tag group boundaries,
  aligned across the header band, header row, and body.
- Fix the double horizontal scrollbar by scrolling only the inner table
  and adding min-width:0 to the flex chain.
…and color-by

Adds a Table | Scatter matrix switcher to the hyper-parameter pane and a new
HParamsSplom view that renders a Plotly splom trace from the window records.
Users pick up to six numeric param/metric axes and an optional color-by metric
(same light-to-dark ramp as the table color spine). NaN/missing values are
dropped so empty axes never appear. Reuses hparamsUtils for column building and
numeric detection; global Plotly and the PlotPane resize pattern; no new deps
and no backend/API changes.
…ree views

Centralize the numeric-column selection, grouped TreeSelect data, run
labelling, and numeric-or-null mapping in hparamsUtils, and move the
Plotly-facing snapshot download, notifier, snapshot mode-bar button, and
resize observer into a new hparamsPlot module. The table, scatter matrix, and
parallel coordinates views now consume these instead of each carrying their
own copy.
The top margin was 30px, so Plotly drew the axis titles above the plotting
area and the container clipped them, leaving the names half-cut. Widen the
margins so titles, the top/bottom range values, and the colorbar all have
room, and pin the label, tick, and range font colors for consistent contrast.
The hparams views grew their own copies of the same things as each view
landed. Pull them into one place:

- ColumnSelect / ColumnMultiSelect replace four hand-rolled rc-tree-select
  blocks that differed only in value and tree data.
- HParamsMessage replaces eleven wrap-plus-message div pairs.
- useHParamsColumns replaces three identical buildColumns memos.
- plotRevision builds the datarevision key the three plots each concatenated
  by hand; the splom now derives its axis style from plotAxisStyle instead of
  restating it; the pane downloads through hparamsExport's downloadText, which
  also stops it leaking the object URL.
- One sortGlyph for the caret and the direction button.

Also give a single-observation run a visible marker: a run with one logged
value drew nothing under mode 'lines', so the metrics plot read as empty.
The centred title rule matched every .pull-right in the pane bar, so the
comment button was stretched across the bar and its icon landed on top of
the title. Scope the rule to the title div and leave the buttons floating.
ExperimentStore read the env it was about to persist from disk, so every
metric log rewrote the env file from a snapshot that was missing windows
created since the last save and still carried windows the user had closed.
The store now takes an env_provider (the server passes state.get) and
prefers the live env for both reads and writes; the env may be a
LazyEnvData, so it is mutated through the mapping interface (gaining
__delitem__ for experiment deletion) and never copied. Logging now
persists the environment the server is actually serving, panes included.
The generic /update route only understands plot-shaped content, so an
hparams pane could not be changed without re-registering the whole
window. POST /experiments/hparams/update rebuilds an existing hparams
window in place: a query/env_ids/mode body replaces its selection under
the same validation as create, and a bare win re-runs the selection now
stored on the window (a manual refresh). The rebuilt window keeps its id
and position but mints a fresh contentID, is broadcast to subscribers,
and the env is saved so disk reflects the update immediately. Creating a
pane also saves the env right away instead of waiting for the next
explicit save.
Thin wrapper over the experiments/hparams/update endpoint, next to
vis.hparams: pass a selection to replace the pane's one, or only the
window id to re-run the stored selection and pick up runs logged since
the pane was built. win is required client-side; opts are validated like
the other plotting methods and override the pane's title/size when
given.
/experiments/hparams was never added to openapi.yaml when the endpoint
landed; document it together with the new /experiments/hparams/update,
in the style of the other experiment paths. README gains the matching
vis.hparams and vis.update_hparams entries next to the other experiment
methods, and the type stub gains update_hparams.
A readonly server was accepting every experiment write: /experiments/log
created and overwrote metadata, and both hparams endpoints registered
windows and saved envs to disk. Only /upload_env was guarded, and it was
guarded by an inline check that each new write handler had to remember to
copy -- which is why the check was lost twice while these endpoints were
being built.

Make the guard something a handler declares rather than restates.
reject_readonly(message) joins check_auth in server_utils as a decorator
on the write entry point, "readonly" moves into the app attributes every
web handler is given (socket handlers already had it), and the four write
endpoints are switched onto it, upload_env included, so a single spelling
of the guard remains in the tree.

The refusal is checked before the request is validated, so a rejected
hparams update cannot be used to probe which windows exist.

Covered by py/tests/test_readonly_guard.py: every write endpoint refuses
and leaves disk untouched, the read endpoints (search, compare) still
answer, and the same requests succeed with readonly off. openapi.yaml
documents the 403 on all three experiment paths.
tipHtml() builds markup that is assigned to innerHTML, and escaped every
interpolated value except the two coordinates, which went in as raw
formatValue() output.

Those coordinates are numeric today, so nothing escapes through in
practice -- but that is a property of the caller, not of the function.
formatValue() ends in String(value) and returns whatever it is handed,
so the safety of the tooltip rested on an upstream fact rather than on
anything at the sink. A string-valued column, or a different caller,
turns a logged param into markup in the dashboard.

Escape both, so every value in the string is escaped by the same rule.
Searching visits every environment, and every visit went through the
full env: ExperimentStore._read asked the server's state for the env and
took "experiment" out of it. For a LazyEnvData that single lookup parses
the entire env file -- every window, every encoded image -- to reach a
few hundred bytes of metadata, and caches it on the object the server
holds. The cost is therefore not transient. One search permanently
converts every lazily-loaded env into a resident one, which is exactly
what LazyEnvData exists to avoid.

Measured on 200 envs of 100 KB each, searching for 29 matches: peak
23.1 MB, 20.8 MB still held afterwards, 200/200 envs materialised. After
this change: peak 2.5 MB, 0.1 MB held, 0 materialised.

Add DataStore.load_experiment(eid), a projection returning just the
metadata blob, and implement it in JSONStore by taking the blob and
dropping the rest. It is abstract like the rest of the interface rather
than defaulting to load_env: a default would be correct everywhere and
right nowhere, since a backend that reads whole environments would
inherit it silently and do the one thing the projection exists to avoid,
looking fine until someone measured it.

Reads that want metadata alone go through a new ExperimentStore
._read_metadata, which prefers a live env only when it is already
resident (LazyEnvData answers via a new is_loaded()); an env nobody has
materialised cannot hold unsaved changes, which is the same invariant
serialize_env already relies on when it skips those envs on write.
Writes are untouched and still go through the live env.

list_experiments() keeps its contract but is now a list() of a new
iter_experiments() generator, so a filtering caller can collect what it
rejects instead of holding the whole store.
search() held every match, plus a flattened record per match, for the
whole scan: it built the record list first and filtered second, so a
query matching one run out of thousands still materialised thousands of
records before discarding them. Records are several times the size of
the experiment they describe -- build_record duplicates every param,
metric and tag under both a bare and a namespaced key -- and only the
sort value outlives the comparison.

Restructure the scan around that. _scan() builds one record at a time,
takes the sort value, and drops it; a non-match is not retained at all.
Add search_page(), which returns a page and the unpaged total, and give
_scan a keep bound: the working set is trimmed back to the best
offset+limit entries whenever it doubles, since an entry already outside
the top k cannot re-enter it, and the value-less tail stops growing at
the same bound. A request's memory is then set by the page it asked for
rather than by how much the server stores. total stays exact -- it is a
counter, and every match must be visited anyway to know it matched.

Ranking moves from a stable list.sort to heap selection, which is not
stable, so the arrival counter becomes part of the key and is negated
for descending sorts. Without that, reversing the comparison would also
reverse tied runs, and a page of equally-scoring runs would reorder
itself depending on the sort direction.

search() keeps its signature and its meaning (every match, sorted) for
the pane path and existing callers; it is now _scan without a bound.

py/tests/test_experiment_search_paging.py holds the equivalence that
makes this safe to trust: a page equals the same slice of the unbounded
result, at every offset, in both directions, across the boundary where
sorted runs give way to value-less ones, and with enough ties to force
repeated trims.
The endpoint documented "null returns all matches", and meant it: an
explicit null limit sliced the entire result set, so one request could
ask the server to rank and serialize every experiment it stores. The
default of 100 was the only thing standing between a caller and that,
and a caller chooses the limit.

Add MAX_LIMIT (1000, matching what comparable tracking servers default
to) and route the endpoint through the store's new search_page, so the
scan retains a page rather than everything. null now means "as many as
allowed" and a larger limit is coerced down to the cap rather than
refused -- the AIP-158 rule, and the one that cannot turn a working
integration into a failing one. The reply's limit field reports what was
actually applied, so a capped caller can see it was capped; total still
counts every match.

_require_index grows an optional maximum instead of a second validator,
since offset is deliberately left uncapped -- it costs nothing to skip.

openapi.yaml documents the cap, the coercion and that the reply's limit
is no longer nullable; the client docstring stops promising that
limit=None returns everything and says to page with offset instead.
The comments added alongside search_page said what the code beside them
already says: that a bounded scan trims its working set, that the
unsorted branch takes the first entries it sees, that a page slice ends
at the window. Each one restated its own line rather than explaining why
the line is what it is, which is the only thing a comment can add that
the code cannot.

The reasoning worth keeping was already in the docstrings, where it
describes the contract rather than the statement underneath it.
Capping limit bounded the page but not the request. A page at an offset
is found by ranking every match above it and discarding all but the last
limit of them -- search_page asks the store to keep offset + limit
entries -- so the cost of a request is its depth, not its page. An
offset of ten million with a limit of one asked the server to rank ten
million entries to return one, a larger working set than the uncapped
limit the cap was added to prevent, from a smaller body.

MAX_WINDOW bounds offset + limit, checked on the pair because the pair
is what the store keeps. Capping offset alone would leave the same hole
open at a large enough limit, and capping neither leaves it open at any.

The window is refused rather than coerced, unlike limit. A coerced limit
returns fewer experiments and reports what it applied, so a caller can
see what happened; a coerced offset would return a page other than the
one asked for, and nothing in the reply would say so -- the caller would
read someone else's page as its own. Reaching past the window is a sign
the query is too broad, so the message says to narrow it.

The window is checked after limit has been resolved, so a null limit
cannot buy a deeper page than the cap it was coerced to.
/experiments/compare named its runs explicitly, so the request grew with
the ask and looked self-limiting. It is not: an env_id is a few bytes and
the run behind it is a whole environment, read from the store and echoed
back in full, so a short list of ids is a long reply and a large read.

MAX_ENV_IDS bounds the list, checked before the ids are read since
loading them is the cost being bounded -- a check after the load would
also answer 404 for the unknown ids and hide the real reason. It is set
to search's MAX_LIMIT so that any single page of search results can be
handed straight to compare, which is the flow the endpoint documents; a
test asserts that relationship rather than the number, so the two cannot
drift into a documented flow the server refuses.

The list is refused rather than truncated, for the reason a missing run
is already a 404: a diff of some of the runs asked for is a different
answer, not a smaller one, and no field in the reply would reveal the
substitution.

The HParams pane sends one id per row, so a pane over a large sweep
could have exceeded the cap. It now batches, merging only the experiments
out of each reply, which is all it ever read. The batching is in the pane
rather than in fetchExperimentComparison because a comparison is a diff:
the params/metrics/tags sections of a batch describe that batch, and
merging them would quietly answer a question nobody asked.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @Manik-Khajuria-5, your pull request is larger than the review limit of 150000 diff characters

@sourcery-ai

sourcery-ai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Adds a full experiments API (log/search/compare/suggest/hparams) across backend, Python client, server handlers, JS UI and OpenAPI docs, and refactors experiment storage to be metadata-only for reads with bounded search paging; also introduces a readonly write guard and a new hparams visualization pane.

Sequence diagram for creating a hyper-parameter pane (hparams)

sequenceDiagram
  actor User
  participant Visdom as Visdom.hparams
  participant H as ExperimentHparamsHandler.post
  participant ES as ExperimentStore
  participant DS as DataStore_JSONStore
  participant WS as Env_state\n(register_window)
  participant UI as HParamsPane

  User->>Visdom: hparams(query, env_ids, mode, win, env, opts)
  Visdom->>H: POST /experiments/hparams
  H->>H: _resolve_spec(query, env_ids, mode)
  H->>ES: ExperimentStore.search(query)
  ES->>DS: iter_experiments() via\nlist_envs / load_experiment
  DS-->>ES: Experiment objects
  ES-->>H: matching experiments
  H->>H: flatten_experiments(experiments)
  H->>WS: window({type: hparams,\ncontent, hparams spec})
  WS->>DS: save_env(eid, env)
  DS-->>WS: persisted
  H-->>Visdom: win id (text/plain)
  Visdom-->>User: win id
  User->>UI: open pane for win
  UI->>UI: render HParamsPane\nfrom window.contentID and\nrecords/param_keys/metric_keys/tag_keys
Loading

File-Level Changes

Change Details Files
Add experiments REST API endpoints and schemas, including logging, search with paging caps, comparison, suggestion stub, and hparams pane creation/update.
  • Define /experiments/log, /experiments/search, /experiments/compare, /experiments/suggest, /experiments/hparams, and /experiments/hparams/update paths with detailed request/response schemas in OpenAPI.
  • Introduce Experiment, ExperimentComparisonSection schemas describing stored experiment structure and comparison output.
  • Document new experiments capabilities and breaking semantics for limit/offset behavior in README.
openapi.yaml
README.md
Implement server-side experiments handlers and experiment storage with metadata-only reads, bounded ranking, and readonly protection.
  • Add Tornado handlers for experiments log/search/compare/suggest and hparams pane create/update, wiring them into the main Application URL routing.
  • Extend ExperimentStore to support metadata-only reads via load_experiment, searching with Query-based filtering and heap-bounded ranking, and comparing named experiments with grouping semantics.
  • Integrate ExperimentStore with LazyEnvData and env_provider to ensure writes operate on live envs and pure reads avoid materializing env windows.
  • Add reject_readonly decorator and wire readonly behavior into UploadEnvHandler and experiment/hparams handlers.
py/visdom/server/handlers/web_handlers.py
py/visdom/server/handlers/experiments_handler.py
py/visdom/experiments/store.py
py/visdom/experiments/compare.py
py/visdom/experiments/query.py
py/visdom/experiments/records.py
py/visdom/utils/server_utils.py
py/visdom/data_model/base.py
py/visdom/data_model/json_store.py
py/visdom/server/app.py
Expose experiments API on the Python Visdom client with type hints and documentation, including experiment logging, search, comparison, suggestion, and hparams pane management.
  • Add helper methods _experiment_request and _experiment_send, and public methods experiment, log_metrics, finish_experiment, search_experiments, compare_experiments, suggest_experiment, hparams, and update_hparams on Visdom.
  • Update type stubs (init.pyi) to include experiment-related methods and reply types.
  • Extend README with an Experiments section documenting all new client methods and usage patterns.
py/visdom/__init__.py
py/visdom/__init__.pyi
README.md
Add front-end support for hyper-parameter panes, including new pane type, React components, styles, and plotting utilities.
  • Register hparams pane type and default size in JS settings and Pane; tag hparams windows/bar with type-specific CSS classes.
  • Implement HParamsPane and subcomponents for table view, filters, parallel coordinates, scatter matrix (SPLOM), comparison, and metrics plots, including rc-tree-select and rc-slider integrations.
  • Add CSS for hparams pane layout and rc-slider overrides; load rc-slider assets in the JS entrypoint.
  • Introduce API helpers for experiments comparison and serverPath reuse.
js/settings.js
js/panes/Pane.js
js/panes/HParamsPane.js
js/panes/hparams/*
py/visdom/static/css/hparams.css
py/visdom/static/css/rc-slider-overrides.css
js/main.js
js/api/ApiProvider.js
js/api/experimentsApi.js
js/api/serverPath.js
py/visdom/static/index.html
package.json
Extend experiment model with terminal-state error and helpers; adjust window serialization to support hparams content.
  • Add ExperimentFinishedError and is_terminal helper on Experiment model to guard against logging to finished experiments.
  • Allow window() helper to serialize hparams window content alongside other visdom types.
  • Ensure LazyEnvData keeps full env dict when loading and supports item deletion so ExperimentStore.delete_experiment and hparams handlers can operate on live envs.
py/visdom/experiments/models.py
py/visdom/utils/server_utils.py
py/visdom/server/handlers/web_handlers.py
Add comprehensive tests for experiment logging, storage behavior, search, comparison, hparams selection/update, query language, paging, readonly guard, and metadata-only reads.
  • Add unit and integration tests for ExperimentStore behavior including env_provider, terminal experiment enforcement, metadata survival across saves, and no-persistence mode.
  • Add AsyncHTTPTestCase tests for /experiments/log, /experiments/search, /experiments/compare, /experiments/hparams, /experiments/hparams/update, /experiments/suggest, and readonly guard behavior.
  • Add query parser tests for tokenization, comparison semantics, boolean handling, contains operator, AND/OR precedence, injection safety, and build_record flattening.
  • Add paging tests ensuring search_page matches unbounded search, handles ties and missing values, and bounds retained entries.
  • Add client message-shape tests for experiment, search_experiments, compare_experiments, suggest_experiment, hparams, and update_hparams when send=False.
py/tests/test_experiment_store.py
py/tests/test_experiment_log_handler.py
py/tests/test_experiment_search.py
py/tests/test_experiment_compare.py
py/tests/test_experiment_hparams.py
py/tests/test_hparams_update.py
py/tests/test_query.py
py/tests/test_experiment_search_paging.py
py/tests/test_experiment_suggest.py
py/tests/test_readonly_guard.py
py/tests/test_experiment_metadata_reads.py
Adjust server base handler attribute copying and socket app attributes related to readonly flag propagation.
  • Include readonly in common app attributes but stop copying it into _SOCKET_APP_ATTRIBUTES so socket handlers derive readonly via other means.
  • Ensure BaseHandler sees readonly from app so reject_readonly decorator works for all web handlers.
py/visdom/server/handlers/base_handlers.py
Register Experiments tag in OpenAPI and README to surface experiments API grouping.
  • Add Experiments tag with description to openapi.yaml tags section.
  • Add Experiments section in README listing new client methods.
openapi.yaml
README.md

Possibly linked issues

  • #N/A: PR adds hard caps, bounded heaps, and metadata-only experiment reads, directly fixing unbounded server memory growth described.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@Manik-Khajuria-5 Manik-Khajuria-5 changed the title feat!: stop one / experiments request from reading the whole store (Layer 3 - 11) feat!: stop one / experiments request from reading the whole store (Layer 3 - 10) Jul 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat : Hyperparameter tracking pane

1 participant