diff --git a/agent_config.yaml b/agent_config.yaml index 3a3aa2f9..51b3c772 100644 --- a/agent_config.yaml +++ b/agent_config.yaml @@ -17,7 +17,16 @@ agent: opencode_url: http://127.0.0.1:4096 # OpenCode model, "providerID/modelID" (can also be set as env variable - # OPENCODE_MODEL). Empty string self-heals to whatever OpenCode's own - # config was last set to, or a configured provider default, falling back - # to the free-tier "opencode/deepseek-v4-flash-free" if neither resolves. - opencode_model: "opencode/deepseek-v4-flash-free" + # OPENCODE_MODEL). + # + # Left EMPTY on purpose. Empty means "follow OpenCode's own config" -- which + # is what the studio's model picker (.wl-ag-model) writes on every pick, via + # PUT /config -- so choosing a model in the UI also changes the model + # weightslab's own queries use, and it is re-checked before every turn. + # When OpenCode names no model either, the fallback is "opencode/big-pickle". + # + # A value here SEEDS the choice: it is used when nothing has been chosen in + # OpenCode's config yet (and is published there, so the studio shows it), but + # a model picked in the UI afterwards wins. Set OPENCODE_MODEL instead to + # PIN a model that nothing can override. + opencode_model: "" diff --git a/docs/agent.rst b/docs/agent.rst index 596555f4..9519418d 100644 --- a/docs/agent.rst +++ b/docs/agent.rst @@ -211,16 +211,71 @@ If ``OPENCODE_URL`` is set and reachable, the UI server adopts it directly instead of spawning a child; the backend SDK agent reads the same variable (``agent.py``'s ``_load_config``) — set it once and both sides talk to the one server, so a model you authenticate once is available everywhere. -``OPENCODE_MODEL`` (or ``agent_config.yaml``'s ``agent.opencode_model``) picks -the default model for the backend SDK agent, as an OpenCode -``providerID/modelID`` string (e.g. ``openrouter/anthropic/claude-opus-4.6``). -Leave it unset to fall back, in order, to: whatever model OpenCode's own -``/config`` was last set to (the model picker's own pick, e.g. from the -Weights Studio landing page), and otherwise the free-tier -``opencode/deepseek-v4-flash-free`` automatically — a provider's own -reported default used to be tried in between, but that could itself be an -arbitrary, non-text-reasoning model whenever any provider had credentials -configured, so it no longer overrides this. +Which model gets used, and how the two sides agree +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +**OpenCode's own config is the shared source of truth.** ``GET /config``'s +``model`` field is read by every client of that server -- the Weights Studio +model picker, the OpenCode CLI, and the backend SDK agent -- and written by the +picker (``PATCH /global/config``, ``global.config.update``) whenever you choose +a model. Neither side has to know the other exists; they meet in that one +field. + +The backend SDK agent resolves its model in this order: + +1. ``OPENCODE_MODEL`` -- a hard **pin**, for automation that must force a + model regardless of what anyone picked. The UI picker cannot move it; if the + two disagree, ``agent status`` logs which model this backend is actually + using and why. +2. ``GET /config``'s ``model`` -- the shared choice above. Re-read **before + every turn**, so switching models in the UI mid-run takes effect + immediately; it is not latched at start-up. +3. ``agent_config.yaml``'s ``agent.opencode_model`` -- a **seed**, not a pin: + which model to use when nothing has been chosen in OpenCode's config yet. + It is published there, so the studio shows it; once anything is chosen (in + the UI, by ``agent model``, or by the CLI), that choice wins and the seed + goes unused. Pinning the yaml value instead meant a run started *after* + picking a model in the studio quietly went back to the yaml one. +4. ``opencode/big-pickle``, the built-in fallback, when nothing above resolves. + Published too, so a backend that started before the UI hands the picker the + model it is itself using. + +A provider's own reported default used to be tried just before the built-in +fallback, but that could itself be an arbitrary, non-text-reasoning model +whenever any provider had credentials configured, so it no longer overrides it. + +Whoever chooses last wins, and both surfaces follow: picking in the UI moves +the backend's next query, and ``agent model `` from the +CLI moves the UI's picker. + +Either start order therefore converges on one model: + +.. code-block:: text + + Studio first: pick a model in the UI -> PATCH /global/config + -> weightslab start -> GET /config -> same model + (agent_config.yaml's seed is not used: something was chosen) + + weightslab first: nothing chosen anywhere + -> agent_config.yaml's opencode_model, else + opencode/big-pickle -- and published + -> Studio starts -> GET /config -> same model + +The start-up banner states which of the three won: + +.. code-block:: text + + Agent initialized from configuration C:\Users\you\wl_agent_config.yaml: + OpenCode URL=http://127.0.0.1:4096 + Model=opencode/muse-spark-1.3-contributor-free (from agent_config.yaml's opencode_model (nothing chosen yet), published to OpenCode's config so the studio shows it) + +Other values in the parentheses are ``pinned by OPENCODE_MODEL``, ``from +OpenCode's config, which the studio model picker writes``, ``chosen here and +published to OpenCode's config`` (an ``agent model`` switch), ``built-in +default, published to OpenCode's config for the studio``, and ``unresolved -- +OpenCode unreachable, retried on the first query``. It used to print ``Model=(server default)`` whenever nothing was +pinned, which read as "my pick was ignored" even when the first query would +have picked it up. Credentials and provider setup live in OpenCode itself, never in WeightsLab: @@ -335,7 +390,8 @@ configure ``agent_config.yaml`` and/or environment variables. # 3. agent_config.yaml agent: opencode_url: http://127.0.0.1:4096 - opencode_model: "" # empty = use OpenCode's own configured default + opencode_model: "" # empty = follow OpenCode's config (the UI picker); + # a value here PINS the model instead Then check it from the CLI: diff --git a/docs/perf/o_change_register.md b/docs/perf/o_change_register.md new file mode 100644 index 00000000..270f3417 --- /dev/null +++ b/docs/perf/o_change_register.md @@ -0,0 +1,170 @@ +# Interactivity for 100GB+ datasets — register of O(data) operations + +Goal: every per-step and per-request operation should cost **O(change)** or +**O(page)**, never **O(dataset)**. Today several do, so cost grows with the +dataset while the actual work stays constant. + +> **Baseline caveat.** The measurements below were taken against a stale +> in-place copy of weightslab (`~/weightslab_src`, 1.3.3+multiview), which +> differs from `dev` in 51 files. Two serving costs listed there are **already +> fixed on dev** (see §B). Serving numbers must be re-measured on this branch +> before any serving change is attributed an improvement. The storage findings +> (§A) were re-verified against dev and still hold. + +Reference measurements (UltraEdit, 3,959,093 rows, ~19 cols, A10G box): + +| | measured | +|---|---| +| bare-torch step (no WL) | 1,162 ms → 20.66 samples/s | +| with WL, no UI client | ~5.5 samples/s (**3.8× slower**) | +| with WL + image requests | ~1.5 samples/s (**13.8× slower**) | +| per-step signal write itself | **4 ms (0.34%)** — already fine | +| grid page latency (64 imgs) | p50 5.2 s, p95 9.6 s | + +The signal path is not the problem. Storage write-amplification and the view +rebuild are. + +--- + +## A. STORAGE — `data/h5_dataframe_store.py` + +`upsert()` **receives** only dirty rows but **implements** a full table +replacement. + +| line | operation | cost | +|---|---|---| +| 693 | `_create_backup()` — full file copy **before every upsert** | O(file) | +| 708 | `existing = store.select(key)` — read entire table | O(N) | +| ~768 | `pd.concat([existing, delta])` | O(N) | +| ~772 | `existing[~existing.index.duplicated()]` — dedupe all rows | O(N) | +| ~785 | `_decategorize_for_storage(existing)` | O(N) | +| 801 | `store.remove(key)` — drop table | O(N) | +| 804 | `store.append(..., data_columns=True)` — rewrite + index **every** column | O(N·cols) | +| 846–883 | same read/remove/rewrite in the column-delete path | O(N) | + +**Amplification:** ~5 KB of changed signals per flush → ~200 MB written, +roughly **40,000×**. At `ledger_flush_interval=3.0s` vs ~1.5 s steps, that is a +full-table rewrite about every 2 steps. + +Fix direction: append new rows; modify existing rows in place +(`select_as_coordinates` + `table.modify_rows`). Backup incrementally, not per +upsert. No `data_columns=True` — no `store.select()` in this file uses `where=`, +so those per-column indexes are built and never read. + +*(A previous attempt to narrow `data_columns` broke the write path entirely — +678 upsert failures, zero persisted data. Any change here needs a +write→read→assert-contents check, not just a timing check.)* + +## B. SERVING — `trainer/services/data_service.py` + +`_pull_into_all_data_view_df()` (line 938) runs several full-frame passes. + +**Already fixed on dev — do not re-report as wins:** +- the collapse no longer re-enters `get_combined_df()`; the pulled frame is + passed in, so the frame is copied once, not twice +- `array_proxy` no longer does a per-cell `.apply(convert_to_proxy)` (was + 1,660 ms at 4M rows) + +Remaining, to be **re-measured on this branch**: + +| line | operation | cost (measured @4M) | +|---|---|---| +| 946 | `get_combined_df()` → `dataframe_manager:2140 self._df.copy()` | 101 ms–1.2 s | +| — | `get_collapse_annotations_to_samples_df(df)` — groupby collapse | 6,326 ms* | +| — | `safe_reset_index(df)` | 1,912 ms | +| — | `set_index([origin, sample_id])` | O(N) | +| 3636 | `updated_df.reindex(target_order)` | 290 ms | + +Callers — each one is a full O(N) rebuild: lines **440, 851, 3593, 4605, 4626**, +reached from `GetDataSamples`, `GetMetaData`, `EditDataSample`, `GetDataSplits`. + +Held under `_update_lock`, which the trainer also needs → measured lock holds of +39–126 s and the 3.7× training penalty while browsing. + +**The collapse is provably a no-op when `annotation_id.max() == 0`** (UltraEdit +is exactly 1:1) yet still costs 6.3 s per rebuild. + +Fix direction: serve a page from the source frame by index (O(page)); rebuild +the full view only for genuinely global operations (histogram, global sort); +apply deltas rather than rebuilding; never hold the writer lock across a +rebuild — build off-lock and swap the reference. + +## C. OTHER FULL SCANS + +| location | note | +|---|---| +| `dataframe_manager:1875` `data_snapshot.iterrows()` | input is O(change), but row-wise Python per flush | +| `dataframe_manager:2400` `.apply(lambda …)` | per cell | +| `data_service:1200` `_compute_natural_sort_stats` | builds a list of one Series per row (4M objects). Gated off (`compute_natural_sort=False`) — latent | +| `data_service:538` PreviewCache | bounded by `WL_MAX_PREVIEW_CACHE_SIZE` — OK | + +## D. ALREADY O(change) — keep + +- `self._pending` dirty-row set (`dataframe_manager:95, 751, 761`) +- flush work set: `work = list(self._pending)` (`:1827`) +- `_origin_revisions` per-origin version counters (`:94, 1235`) + +The bookkeeping needed for differential updates already exists; the storage and +view layers just don't use it. + +\* measured on the stale copy; re-measure on dev. + +## Measurement protocol + +Fixed workload: **1,000 train samples** = 41 steps at batch 24. Every change is +reported as: + +1. wall-clock for the 41 steps, vs the bare-torch floor +2. bytes written to H5 for those steps +3. grid-page latency (64 images) and training throughput **while** serving +4. **ledger contents verified** — signal columns present, measured-row count + +(4) is not optional: a previous "10× win" was writes silently failing. + +--- + +# E. Triage — which call sites need a full reconstruction + +`_slowUpdateInternals()` rebuilds the whole view: `copy → collapse → reset_index +→ set_index → reindex`. It has 18 call sites, and almost none of them need +that. Most just want **fresh values for rows the trainer touched**, which is +`O(change)`. + +`_fastUpdateInternals()` applies only dirty rows, via a maintained +`sample_id → position` map (`_rebuild_view_pos_map`), and returns `False` — +falling back to the full rebuild — whenever it cannot safely apply: + +- no view yet, or no position map (first build) +- a dirty `sample_id` absent from the map (new rows ⇒ structural change) +- backlog > `max_dirty` (a rebuild is genuinely cheaper) + +So the worst case is today's behaviour, never wrong data. + +| site | routing | why | +|---|---|---| +| `_bg_view_refresh` | **fast** | exists purely to refresh values after a stale read — the textbook differential case, and the one that holds `_lock` against the trainer | +| `_process_get_data_samples` | **fast** | grid fetch needs current values, not a new frame | +| `_compute_custom_signals` | **fast** | writes new signal *values*; schema unchanged | +| `GetDataSplits` | **fast** | read-only summary | +| `EditDataSample` ×5 | **fast** | per-sample value edits | +| `EditDataSample` ×3 (`df.modify`, `df.drop_column`) | **full** | changes the schema — differential cannot add/remove columns | +| `ApplyDataQuery` `@reset`/`@clear` | **full** | clears `_is_filtered` to restore the full universe; a differential updates values but cannot restore *dropped rows* | +| `ApplyDataQuery` filter + agent paths | **full** (deferred) | a forced rebuild preserves `_is_filtered` (`:3691`), so swapping in a differential changes which rows the user sees. Rare, user-initiated, low perf value, high blast radius — not worth the risk until the filter semantics are pinned down | +| `_compute_natural_sort_stats` | **full** | gated off (`compute_natural_sort=False`); latent | +| `_manual_save_data_state` | **full** | explicit user save; correctness over speed | + +**Kill-switch:** `WL_FAST_VIEW=0` disables the differential and the position-map +build, reproducing prior behaviour exactly. This is what makes a like-for-like +A/B possible from a single tree. + +## Why the no-client benchmark cannot show this + +A 41-step run with no UI client attached records **0 rebuild events** — nothing +calls `_slowUpdateInternals` at all, so the fast path has nothing to improve and +correctly measures as no change. The rebuild cost only materialises when a +client is attached, which is the case that measured **3.7× slower** with p50 +grid latency of 5.2 s. + +The A/B is therefore run under load: baseline → under-load → recovery phases +within one training process (`t_imgload.py`), so each arm is normalised against +its own idle throughput. diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..32c86157 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,37 @@ +"""Shared pytest setup. + +Keeps the suite out of the developer's own WeightsLab state. + +``weightslab.utils.active_experiment`` records the active experiment directory +in a real per-user file (``~/.weightslab/active_experiment.json``) so a +training run started in another terminal lands in the experiment the UI +established. That is deliberate at runtime -- and poison in tests: a run of +this suite while a ``weightslab start`` was up resolved its root_log_dir into +that live experiment, found the config and checkpoints of whatever was running +there, and failed in setUp with an unrelated config (seen for real: +tests/gRPC/test_grpc_tag_operations.py loading a segmentation example's +hyperparameters). + +So every test session gets its own throwaway state directory. Tests that +exercise the handoff itself set ``WEIGHTSLAB_STATE_DIR`` to their own temp +directory anyway; this only changes the default. +""" + +import os +import tempfile + +import pytest + + +@pytest.fixture(scope="session", autouse=True) +def _isolate_weightslab_state(): + previous = os.environ.get("WEIGHTSLAB_STATE_DIR") + with tempfile.TemporaryDirectory(prefix="wl-test-state-") as state_dir: + os.environ["WEIGHTSLAB_STATE_DIR"] = state_dir + try: + yield state_dir + finally: + if previous is None: + os.environ.pop("WEIGHTSLAB_STATE_DIR", None) + else: + os.environ["WEIGHTSLAB_STATE_DIR"] = previous diff --git a/tests/test_src_functions.py b/tests/test_src_functions.py index d99ed489..50c7a2b3 100644 --- a/tests/test_src_functions.py +++ b/tests/test_src_functions.py @@ -1,4 +1,6 @@ +import json import os +import shutil import tempfile import unittest import numpy as np @@ -6,6 +8,7 @@ import torch as th import weightslab.src as src +from weightslab.utils import active_experiment from unittest.mock import MagicMock, patch @@ -13,16 +16,27 @@ class TestResolveConfiguredRootLogDir(unittest.TestCase): - """root_log_dir resolution: explicit config > WEIGHTSLAB_ROOT_LOG_DIR > temp dir.""" + """root_log_dir resolution: explicit config > WEIGHTSLAB_ROOT_LOG_DIR > + the directory `weightslab start` recorded > temp dir.""" def setUp(self): self._env_prev = os.environ.get("WEIGHTSLAB_ROOT_LOG_DIR") + # The marker is a real per-user file; point it at a scratch directory so + # these tests never read (or write) the developer's own active run. + self._state_prev = os.environ.get("WEIGHTSLAB_STATE_DIR") + self._state_dir = tempfile.mkdtemp() + os.environ["WEIGHTSLAB_STATE_DIR"] = self._state_dir def tearDown(self): if self._env_prev is None: os.environ.pop("WEIGHTSLAB_ROOT_LOG_DIR", None) else: os.environ["WEIGHTSLAB_ROOT_LOG_DIR"] = self._env_prev + if self._state_prev is None: + os.environ.pop("WEIGHTSLAB_STATE_DIR", None) + else: + os.environ["WEIGHTSLAB_STATE_DIR"] = self._state_prev + shutil.rmtree(self._state_dir, ignore_errors=True) def test_explicit_config_value_wins_over_env(self): os.environ["WEIGHTSLAB_ROOT_LOG_DIR"] = "/env/dir" @@ -49,6 +63,162 @@ def test_falls_back_to_tempdir_when_neither_set(self): self.assertEqual(src._resolve_configured_root_log_dir(None), "/tmp/generated") mk.assert_called_once() + def test_a_recorded_directory_whose_ui_has_exited_is_not_adopted(self): + # The handoff means "the UI is up over there, join its experiment". A + # record left by a `weightslab start` that has since exited must not + # redirect an unrelated run -- it did, and this repo's own gRPC tests + # resolved into a previous session's experiment and loaded its config. + os.environ.pop("WEIGHTSLAB_ROOT_LOG_DIR", None) + with tempfile.TemporaryDirectory() as ui_dir: + active_experiment.record_ui_experiment(ui_dir) + state = active_experiment.read_state() + state["ui"][-1]["pid"] = 2 ** 31 - 1 # cannot be running + active_experiment.state_path().write_text(json.dumps(state), encoding="utf-8") + + with patch("weightslab.src.tempfile.mkdtemp", return_value="/tmp/generated") as mk: + self.assertEqual(src._resolve_configured_root_log_dir(None), "/tmp/generated") + mk.assert_called_once() + + def test_adopts_the_directory_weightslab_start_recorded(self): + # `weightslab start` exports WEIGHTSLAB_ROOT_LOG_DIR into its OWN + # process only. A training run in another terminal never saw it and + # went to a temp dir, so the UI listed an empty reports/ while the run + # wrote elsewhere. The recorded directory closes that gap. + os.environ.pop("WEIGHTSLAB_ROOT_LOG_DIR", None) + with tempfile.TemporaryDirectory() as ui_dir: + active_experiment.record_ui_experiment(ui_dir) + with patch("weightslab.src.tempfile.mkdtemp", return_value="/tmp/generated") as mk: + resolved = src._resolve_configured_root_log_dir(None) + mk.assert_not_called() + self.assertEqual(os.path.realpath(resolved), os.path.realpath(ui_dir)) + + def test_explicit_config_still_wins_over_the_recorded_directory(self): + with tempfile.TemporaryDirectory() as ui_dir: + active_experiment.record_ui_experiment(ui_dir) + self.assertEqual(src._resolve_configured_root_log_dir("/explicit/dir"), "/explicit/dir") + + def test_env_wins_over_the_recorded_directory(self): + with tempfile.TemporaryDirectory() as ui_dir, tempfile.TemporaryDirectory() as env_dir: + active_experiment.record_ui_experiment(ui_dir) + os.environ["WEIGHTSLAB_ROOT_LOG_DIR"] = env_dir + self.assertEqual(src._resolve_configured_root_log_dir(None), env_dir) + + def test_a_recorded_directory_that_no_longer_exists_is_ignored(self): + os.environ.pop("WEIGHTSLAB_ROOT_LOG_DIR", None) + gone = tempfile.mkdtemp() + active_experiment.record_ui_experiment(gone) + shutil.rmtree(gone, ignore_errors=True) + with patch("weightslab.src.tempfile.mkdtemp", return_value="/tmp/generated") as mk: + self.assertEqual(src._resolve_configured_root_log_dir(None), "/tmp/generated") + mk.assert_called_once() + + +class TestActiveExperimentMarker(unittest.TestCase): + """The cross-process handoff itself.""" + + def setUp(self): + self._state_prev = os.environ.get("WEIGHTSLAB_STATE_DIR") + self._state_dir = tempfile.mkdtemp() + os.environ["WEIGHTSLAB_STATE_DIR"] = self._state_dir + + def tearDown(self): + if self._state_prev is None: + os.environ.pop("WEIGHTSLAB_STATE_DIR", None) + else: + os.environ["WEIGHTSLAB_STATE_DIR"] = self._state_prev + shutil.rmtree(self._state_dir, ignore_errors=True) + + def test_two_uis_are_recorded_side_by_side(self): + # Two experiments at once (a cls UI and a seg UI) is supported; with one + # slot per side the second `weightslab start` erased the first. + with tempfile.TemporaryDirectory() as cls_dir, tempfile.TemporaryDirectory() as seg_dir: + active_experiment.record_ui_experiment(cls_dir, ui_port=8080, backend_port=50051) + # A second UI, standing in for another process. + state = active_experiment.read_state() + state["ui"].append({ + "root_log_dir": seg_dir, "pid": os.getpid(), + "ui_port": 8081, "backend_port": 50052, + }) + active_experiment.state_path().write_text(json.dumps(state), encoding="utf-8") + + recorded = {e["root_log_dir"] for e in active_experiment.entries("ui")} + self.assertEqual(len(recorded), 2) + + def test_two_live_uis_are_not_guessed_between(self): + with tempfile.TemporaryDirectory() as a_dir, tempfile.TemporaryDirectory() as b_dir: + active_experiment.record_ui_experiment(a_dir) + state = active_experiment.read_state() + state["ui"].append({"root_log_dir": b_dir, "pid": os.getpid()}) + active_experiment.state_path().write_text(json.dumps(state), encoding="utf-8") + + # Adopting either would put the run in the wrong experiment. + self.assertIsNone(active_experiment.live_ui_experiment_dir()) + + def test_a_backend_is_found_by_the_port_the_caller_talks_to(self): + with tempfile.TemporaryDirectory() as cls_dir, tempfile.TemporaryDirectory() as seg_dir: + state = {"backend": [ + {"root_log_dir": cls_dir, "pid": os.getpid(), "grpc_port": 50051}, + {"root_log_dir": seg_dir, "pid": os.getpid(), "grpc_port": 50052}, + ]} + active_experiment.state_path().parent.mkdir(parents=True, exist_ok=True) + active_experiment.state_path().write_text(json.dumps(state), encoding="utf-8") + + self.assertEqual( + os.path.realpath(active_experiment.live_backend_experiment_dir(50051)), + os.path.realpath(cls_dir)) + self.assertEqual( + os.path.realpath(active_experiment.live_backend_experiment_dir(50052)), + os.path.realpath(seg_dir)) + # No port, two candidates: no guess. + self.assertIsNone(active_experiment.live_backend_experiment_dir()) + # A port nobody serves: no guess either. + self.assertIsNone(active_experiment.live_backend_experiment_dir(50099)) + + def test_the_older_single_object_marker_is_still_readable(self): + with tempfile.TemporaryDirectory() as ui_dir: + active_experiment.state_path().parent.mkdir(parents=True, exist_ok=True) + active_experiment.state_path().write_text( + json.dumps({"ui": {"root_log_dir": ui_dir, "pid": os.getpid()}}), + encoding="utf-8") + self.assertEqual(os.path.realpath(active_experiment.ui_experiment_dir()), + os.path.realpath(ui_dir)) + self.assertEqual(os.path.realpath(active_experiment.live_ui_experiment_dir()), + os.path.realpath(ui_dir)) + + def test_ui_and_backend_entries_do_not_clobber_each_other(self): + with tempfile.TemporaryDirectory() as ui_dir, tempfile.TemporaryDirectory() as be_dir: + active_experiment.record_ui_experiment(ui_dir, ui_port=8080) + active_experiment.record_backend_experiment(be_dir) + self.assertEqual(os.path.realpath(active_experiment.ui_experiment_dir()), + os.path.realpath(ui_dir)) + self.assertEqual(os.path.realpath(active_experiment.backend_experiment_dir()), + os.path.realpath(be_dir)) + self.assertEqual(active_experiment.entries("ui")[-1]["ui_port"], 8080) + + def test_missing_marker_reads_as_nothing_recorded(self): + self.assertEqual(active_experiment.read_state(), {}) + self.assertIsNone(active_experiment.ui_experiment_dir()) + self.assertIsNone(active_experiment.backend_experiment_dir()) + + def test_a_corrupt_marker_is_ignored_rather_than_raising(self): + path = active_experiment.state_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("{not json", encoding="utf-8") + self.assertEqual(active_experiment.read_state(), {}) + self.assertIsNone(active_experiment.ui_experiment_dir()) + + def test_recording_nothing_is_a_no_op(self): + self.assertIsNone(active_experiment.record_ui_experiment("")) + self.assertEqual(active_experiment.read_state(), {}) + + def test_clear_removes_the_marker(self): + with tempfile.TemporaryDirectory() as ui_dir: + active_experiment.record_ui_experiment(ui_dir) + self.assertTrue(active_experiment.state_path().exists()) + active_experiment.clear() + self.assertFalse(active_experiment.state_path().exists()) + active_experiment.clear() # idempotent + class TestSrcTagAndDiscardFunctions(unittest.TestCase): def setUp(self): diff --git a/tests/trainer/services/test_agent_opencode_provider.py b/tests/trainer/services/test_agent_opencode_provider.py index c2140950..868ba9c5 100644 --- a/tests/trainer/services/test_agent_opencode_provider.py +++ b/tests/trainer/services/test_agent_opencode_provider.py @@ -58,6 +58,118 @@ def _make_agent(df=None): return agent_mod, agent +def _agent_module(): + with mock.patch.dict(sys.modules, _install_agent_dependency_stubs(), clear=False): + return importlib.import_module("weightslab.trainer.services.agent.agent") + + +def _bare_agent(mod, model="opencode/ling-3.0-flash-fin-free"): + """An agent with just the OpenCode attributes _setup_providers touches. + + Sidesteps the full DataManipulationAgent construction (schema build, ctx, + handler registry) -- these tests are about the model plumbing only. + """ + agent = mod.DataManipulationAgent.__new__(mod.DataManipulationAgent) + agent.opencode_url = "http://127.0.0.1:4096" + agent.opencode_model = model + agent.opencode_workspace_dir = "." + agent._opencode_url_explicit = False + agent._opencode_model_explicit = False + agent._config_source_path = "(test)" + agent.preferred_provider = "opencode" + return agent + + +def _fake_chat(publish_ok=True, configured="opencode/ling-3.0-flash-fin-free"): + chat = MagicMock() + chat.base_url = "http://127.0.0.1:4096" + chat.model_is_explicit = False + chat.publish_model.return_value = publish_ok + # What the shared config would hand back if it were consulted. + chat.resolve_model.return_value = (configured, "opencode-config") + return chat + + +class TestModelSwitchAndReporting(unittest.TestCase): + """`agent model X` must actually switch to X, and `agent status` must name + the model the NEXT query will use. + + Regression: the shared-config re-read that lets the studio's model picker + move the backend also ran on the explicit switch path, overwriting the model + the caller had just asked for -- `agent model opencode/big-pickle` replied + "Model switched to opencode/ling-3.0-flash-fin-free". + """ + + def test_change_model_switches_and_publishes_the_choice(self): + mod = _agent_module() + agent = _bare_agent(mod) + chat = _fake_chat(publish_ok=True) + with mock.patch.object(mod, "OpenCodeChat", return_value=chat): + ok, message = agent.change_model("opencode/big-pickle") + + self.assertTrue(ok) + self.assertEqual(agent.opencode_model, "opencode/big-pickle") + self.assertIn("opencode/big-pickle", message) + self.assertNotIn("ling-3.0", message) + chat.publish_model.assert_called_once_with("opencode/big-pickle") + # The shared config must NOT be consulted on an explicit switch. + chat.resolve_model.assert_not_called() + # Published, so later turns keep following the config (the studio can + # still move it) rather than being pinned to this backend. + self.assertFalse(agent._opencode_model_explicit) + self.assertEqual(agent._opencode_model_source, "user-published") + + def test_change_model_pins_when_the_config_refuses_the_write(self): + mod = _agent_module() + agent = _bare_agent(mod) + chat = _fake_chat(publish_ok=False) + with mock.patch.object(mod, "OpenCodeChat", return_value=chat): + ok, message = agent.change_model("opencode/big-pickle") + + self.assertTrue(ok) + self.assertEqual(agent.opencode_model, "opencode/big-pickle") + self.assertTrue(agent._opencode_model_explicit) + self.assertTrue(chat.model_is_explicit) + self.assertEqual(agent._opencode_model_source, "user-pinned") + self.assertIn("pinned", message) + + def test_change_model_rejects_an_empty_model(self): + mod = _agent_module() + agent = _bare_agent(mod) + ok, message = agent.change_model(" ") + self.assertFalse(ok) + self.assertIn("empty", message.lower()) + + def test_startup_without_a_request_follows_the_shared_config(self): + mod = _agent_module() + agent = _bare_agent(mod, model="") + chat = _fake_chat(configured="opencode/muse-spark-1.3-contributor-free") + with mock.patch.object(mod, "OpenCodeChat", return_value=chat): + agent._setup_providers() + + chat.resolve_model.assert_called_once_with(publish_default=True) + self.assertEqual(agent.opencode_model, "opencode/muse-spark-1.3-contributor-free") + self.assertEqual(agent._opencode_model_source, "opencode-config") + + def test_current_model_reports_a_studio_pick_made_after_start_up(self): + mod = _agent_module() + agent = _bare_agent(mod, model="opencode/big-pickle") + chat = _fake_chat(configured="openrouter/openai/gpt-5-mini") + agent._opencode_chat = chat + + self.assertEqual(agent.current_model(), "openrouter/openai/gpt-5-mini") + self.assertEqual(agent.opencode_model, "openrouter/openai/gpt-5-mini") + + def test_current_model_keeps_the_last_known_model_when_the_server_is_down(self): + mod = _agent_module() + agent = _bare_agent(mod, model="opencode/big-pickle") + chat = _fake_chat() + chat.resolve_model.side_effect = OSError("refused") + agent._opencode_chat = chat + + self.assertEqual(agent.current_model(), "opencode/big-pickle") + + @unittest.skip("Not ready yet -- OpenCodeChat is still a stub, and the test suite needs to be reworked to support it") class TestOpenCodeConfigLoading(unittest.TestCase): def test_opencode_url_and_model_default(self): @@ -118,11 +230,16 @@ def test_opencode_chain_is_built(self): mock_cls.return_value.as_runnable.return_value = fake_runnable initialized = agent._setup_providers() + # seed_model carries agent_config.yaml's opencode_model, which SEEDS + # the shared choice instead of pinning it (a model picked in the studio + # wins over it); model/model_is_explicit carry OPENCODE_MODEL, which + # does pin. mock_cls.assert_called_once_with( agent.opencode_url, agent.opencode_model, workspace_dir=agent.opencode_workspace_dir, url_is_explicit=agent._opencode_url_explicit, model_is_explicit=agent._opencode_model_explicit, + seed_model=agent._opencode_model_seed, ) self.assertTrue(initialized) self.assertIs(agent.chain_opencode, fake_runnable) diff --git a/tests/trainer/services/test_data_service_discard_flag.py b/tests/trainer/services/test_data_service_discard_flag.py new file mode 100644 index 00000000..97da1fa5 --- /dev/null +++ b/tests/trainer/services/test_data_service_discard_flag.py @@ -0,0 +1,308 @@ +"""The 'samples discard themselves in the studio while training' regression. + +Reported for detection/segmentation runs: as the model worked through the +dataset, sample after sample greyed out in the UI, while the dataframe (checked +from the notebook) said nothing was discarded. + +The chain, reproduced below: + +1. the trainer touches a sample -> its rows go dirty; +2. `_fastUpdateInternals` syncs the columns the trainer mutates + (``signals*``, ``last_seen``, ``discarded``, ``prediction``, ``target``) + from the source into the view. It collapsed the source's per-annotation rows + with ``duplicated(keep="last")``, i.e. it kept the LAST annotation row -- + whose *sample-level* columns are NaN, because the real values live on the + canonical row (annotation_id == 0); +3. NaN therefore landed in the view's ``discarded``; +4. GetDataSamples reported that flag as ``"1" if bool(value) else "0"`` -- and + ``bool(float("nan"))`` is True in Python. + +So every sample the model had seen was served as discarded. Only for +annotation-expanded (detection/segmentation) ledgers, and only after training +touched the sample: exactly the reported shape. +""" + +import unittest + +import numpy as np +import pandas as pd + +from weightslab.data.sample_stats import SampleStatsEx +from weightslab.trainer.services.data_service import ( + DataService, + is_set_flag, + set_flag_mask, +) + + +SID = SampleStatsEx.SAMPLE_ID.value +ANNOT = SampleStatsEx.INSTANCE_ID.value +DISCARDED = SampleStatsEx.DISCARDED.value + + +def _source_rows(): + """A segmentation ledger: sample-level values on annotation 0 only.""" + return pd.DataFrame( + { + DISCARDED: [False, np.nan, np.nan, True, np.nan], + "last_seen": [11, 11, 11, 12, 12], + "prediction": ["a", None, None, "b", None], + }, + index=pd.MultiIndex.from_tuples( + [("0", 0), ("0", 1), ("0", 2), ("1", 0), ("1", 1)], + names=[SID, ANNOT], + ), + ) + + +class _FakeManager: + """Minimal dataframe manager: dirty tracking + source row lookup.""" + + def __init__(self, source, dirty): + self._df = source + self._dirty = list(dirty) + + def take_view_dirty(self, limit=None): + dirty, self._dirty = self._dirty, [] + return dirty + + def get_source_rows(self, sample_ids, columns=None): + wanted = [str(s) for s in sample_ids] + level = self._df.index.get_level_values(SID).astype(str) + rows = self._df[level.isin(wanted)] + return rows[columns] if columns else rows + + +class TestFastViewSyncKeepsSampleLevelValues(unittest.TestCase): + def _service(self, source, dirty, view): + service = DataService.__new__(DataService) + service._all_datasets_df = view + service._df_manager = _FakeManager(source, dirty) + return service + + def _collapsed_view(self, source): + """One row per sample, as the real view is built: annotation 0 wins.""" + base = source[source.index.get_level_values(ANNOT) == 0].droplevel(ANNOT) + return base.copy() + + def test_a_seen_sample_keeps_its_discarded_flag(self): + source = _source_rows() + view = self._collapsed_view(source) + service = self._service(source, ["0", "1"], view) + + self.assertTrue(service._fastUpdateInternals()) + + # Sample 0 is NOT discarded and must stay that way; sample 1 is. + self.assertIs(bool(view.loc["0", DISCARDED]), False) + self.assertIs(bool(view.loc["1", DISCARDED]), True) + # The give-away of the old behaviour: a NaN in a column that had a value. + self.assertFalse(view[DISCARDED].isna().any(), + "sample-level flags were overwritten with an instance row's NaN") + + def test_the_columns_the_trainer_owns_still_sync(self): + source = _source_rows() + view = self._collapsed_view(source) + source.loc[("0", 0), "last_seen"] = 99 + service = self._service(source, ["0"], view) + + self.assertTrue(service._fastUpdateInternals()) + self.assertEqual(view.loc["0", "last_seen"], 99) + + def test_falls_back_to_the_first_row_when_no_canonical_row_is_present(self): + # A slice of instance rows only (no annotation 0) must still sync + # something sane rather than raising or inventing a flag. + source = _source_rows().drop(index=("0", 0)) + view = self._collapsed_view(_source_rows()) + service = self._service(source, ["0"], view) + + self.assertTrue(service._fastUpdateInternals()) + self.assertTrue(pd.isna(view.loc["0", DISCARDED]) or view.loc["0", DISCARDED] is False) + + +class TestDiscardedFlagIsNaNSafe(unittest.TestCase): + """The second half: how a nullable flag becomes what the studio renders. + + is_set_flag / set_flag_mask are shared by the three places that read one: + GetDataSamples' `discarded` rendering flag, the boolean ``tag:*`` columns in + the metadata response, and the histogram's per-(origin, discarded) split. + """ + + @staticmethod + def _served_flag(value): + return "1" if is_set_flag(value) else "0" + + def test_missing_is_not_discarded(self): + # bool(float("nan")) is True -- the whole bug in one line. + self.assertEqual(self._served_flag(np.nan), "0") + self.assertEqual(self._served_flag(None), "0") + self.assertEqual(self._served_flag(pd.NA), "0") + + def test_real_values_still_come_through(self): + self.assertEqual(self._served_flag(True), "1") + self.assertEqual(self._served_flag(np.bool_(True)), "1") + self.assertEqual(self._served_flag(1), "1") + self.assertEqual(self._served_flag(False), "0") + self.assertEqual(self._served_flag(0), "0") + + + def test_a_string_flag_is_read_as_a_word_not_as_a_non_empty_string(self): + # bool("False") is True, and a boolean column that has been through the + # H5 store (categorical) can come back as these strings. + self.assertEqual(self._served_flag("False"), "0") + self.assertEqual(self._served_flag("false"), "0") + self.assertEqual(self._served_flag("0"), "0") + self.assertEqual(self._served_flag(""), "0") + self.assertEqual(self._served_flag("True"), "1") + self.assertEqual(self._served_flag("true"), "1") + self.assertEqual(self._served_flag("1"), "1") + + +class TestSetFlagMask(unittest.TestCase): + """The column-wide form, used for tags and the histogram split.""" + + def test_a_sparse_tag_column_marks_only_the_tagged_samples(self): + # A tag is set on a few samples; every other row is NaN. astype(bool) + # turned those into True -- every sample wore every tag. + column = pd.Series([True, np.nan, False, None, True], dtype=object) + self.assertEqual(set_flag_mask(column).tolist(), + [True, False, False, False, True]) + + def test_it_handles_a_categorical_column(self): + # The H5 store optimises tag:* and discarded to categorical dtype. + column = pd.Series([True, None, False], dtype=object).astype("category") + self.assertEqual(set_flag_mask(column).tolist(), [True, False, False]) + + def test_it_handles_a_float_column_of_zeros_and_nans(self): + column = pd.Series([1.0, np.nan, 0.0]) + self.assertEqual(set_flag_mask(column).tolist(), [True, False, False]) + + def test_an_absent_column_is_all_false(self): + self.assertEqual(set_flag_mask(None).tolist(), []) + + +class TestFastViewSyncAddressing(unittest.TestCase): + """How dirty source rows are matched to view rows. + + The view is indexed (origin, sample_id) precisely because one sample_id can + appear under two origins. Looking positions up in that non-unique level + raised InvalidIndexError, so the differential refresh failed on every call + and quietly fell back to the full rebuild. + """ + + def _service(self, source, dirty, view): + service = DataService.__new__(DataService) + service._all_datasets_df = view + service._df_manager = _FakeManager(source, dirty) + return service + + def _source(self): + return pd.DataFrame( + {DISCARDED: [False, np.nan], "last_seen": [7, 7]}, + index=pd.MultiIndex.from_tuples([("5", 0), ("5", 1)], names=[SID, ANNOT]), + ) + + def test_one_sample_id_under_two_origins_does_not_raise(self): + view = pd.DataFrame( + {DISCARDED: [False, False], "last_seen": [1, 2]}, + index=pd.MultiIndex.from_tuples([("train_loader", "5"), ("test_loader", "5")], + names=["origin", SID]), + ) + service = self._service(self._source(), ["5"], view) + + self.assertTrue(service._fastUpdateInternals()) + # The source can only speak per sample_id, so both rows take its values. + self.assertEqual(view["last_seen"].tolist(), [7, 7]) + self.assertFalse(view[DISCARDED].isna().any()) + + def test_a_dirty_sample_the_view_does_not_hold_forces_a_rebuild(self): + view = pd.DataFrame( + {DISCARDED: [False], "last_seen": [1]}, + index=pd.MultiIndex.from_tuples([("train_loader", "5")], names=["origin", SID]), + ) + source = pd.concat([self._source(), pd.DataFrame( + {DISCARDED: [False], "last_seen": [3]}, + index=pd.MultiIndex.from_tuples([("99", 0)], names=[SID, ANNOT]))]) + service = self._service(source, ["5", "99"], view) + + # 99 is new -> structural change -> only the full rebuild can add it. + self.assertFalse(service._fastUpdateInternals()) + + def test_nothing_to_do_when_no_dirty_sample_is_in_the_view(self): + view = pd.DataFrame( + {DISCARDED: [False], "last_seen": [1]}, + index=pd.MultiIndex.from_tuples([("train_loader", "7")], names=["origin", SID]), + ) + service = self._service(self._source(), ["5"], view) + self.assertTrue(service._fastUpdateInternals()) + self.assertEqual(view["last_seen"].tolist(), [1]) + + +class TestDocumentedFlagDefaults(unittest.TestCase): + """`discarded` should never be NaN in the first place. + + SampleStats.DEFAULTS documents it as False, right under the comment "None + are not accepted by PD H5 storage". It held NaN anyway: the existing + normalisation only visits columns an upsert ADDS, and only when the + incoming slice's dtype is already bool -- which it is not exactly when the + slice carries missing values. So a sample registered without the flag, and + every per-annotation row (sample-level values live on annotation 0), kept a + NaN, which `bool()` then read as True. + + Belt and braces with the read-side fix above: the flag is defaulted at the + source, AND a NaN that reaches a reader anyway is read as not-set. + """ + + def _manager(self, frame): + from weightslab.data.dataframe_manager import LedgeredDataFrameManager + manager = LedgeredDataFrameManager.__new__(LedgeredDataFrameManager) + manager._df = frame + return manager + + def test_missing_discarded_becomes_false(self): + frame = pd.DataFrame( + {DISCARDED: [True, np.nan, np.nan, False, np.nan]}, + index=pd.MultiIndex.from_tuples( + [("0", 0), ("0", 1), ("0", 2), ("1", 0), ("1", 1)], names=[SID, ANNOT]), + ) + manager = self._manager(frame) + manager._fill_documented_flag_defaults() + + self.assertEqual(frame[DISCARDED].tolist(), [True, False, False, False, False]) + self.assertFalse(frame[DISCARDED].isna().any()) + + def test_a_categorical_flag_column_is_widened_rather_than_raising(self): + # fillna on a Categorical raises unless the value is a known category, + # and the H5 store hands these columns back as categorical. + # NB: build the frame first, THEN cast -- handing a Series with its own + # RangeIndex to DataFrame(index=[...]) reindexes it to all-NaN. + frame = pd.DataFrame({DISCARDED: [True, None]}, index=pd.Index(["0", "1"], name=SID)) + frame[DISCARDED] = frame[DISCARDED].astype("category") + self.assertIsInstance(frame[DISCARDED].dtype, pd.CategoricalDtype) + manager = self._manager(frame) + manager._fill_documented_flag_defaults() + self.assertEqual(frame[DISCARDED].tolist(), [True, False]) + + def test_sparse_tag_columns_are_left_alone(self): + # NaN and False mean the same thing for a boolean tag, and NaN is + # cheaper; for a CATEGORICAL tag, NaN means "unset", not a default. + frame = pd.DataFrame({"tag:hard": [True, np.nan], DISCARDED: [False, np.nan]}, + index=pd.Index(["0", "1"], name=SID)) + manager = self._manager(frame) + manager._fill_documented_flag_defaults() + self.assertTrue(pd.isna(frame["tag:hard"].iloc[1])) + self.assertIs(bool(frame[DISCARDED].iloc[1]), False) + + def test_a_column_with_nothing_missing_is_not_rewritten(self): + frame = pd.DataFrame({DISCARDED: [True, False]}, index=pd.Index(["0", "1"], name=SID)) + before = frame[DISCARDED].dtype + manager = self._manager(frame) + manager._fill_documented_flag_defaults() + self.assertEqual(frame[DISCARDED].dtype, before) + + def test_an_empty_frame_is_a_no_op(self): + frame = pd.DataFrame() + self._manager(frame)._fill_documented_flag_defaults() # must not raise + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/trainer/services/test_opencode_chat.py b/tests/trainer/services/test_opencode_chat.py index 81468e21..13d8caf5 100644 --- a/tests/trainer/services/test_opencode_chat.py +++ b/tests/trainer/services/test_opencode_chat.py @@ -398,15 +398,37 @@ def test_explicit_model_is_never_auto_replaced(self): request_mock.assert_not_called() self.assertEqual(chat.model, "openrouter/anthropic/claude-opus-4.6") - def test_already_set_non_explicit_model_is_left_alone(self): - # Already resolved once (e.g. a prior call) -- don't re-resolve or - # re-request every single turn. + def test_already_set_non_explicit_model_follows_a_later_ui_pick(self): + # The studio's model picker writes the pick into OpenCode's own config + # (PUT /config). A backend that kept the model it resolved on its first + # turn went on answering with the old one for the rest of the run, so a + # NON-explicit model re-checks /config every turn and follows it. chat = OpenCodeChat("http://127.0.0.1:4096", model="openrouter/openai/gpt-5", model_is_explicit=False) + with mock.patch.object( + chat, "_request", + return_value=self._fake_response({"model": "opencode/big-pickle"}), + ) as request_mock: + chat._ensure_model_resolved() + request_mock.assert_called_once_with("/config") + self.assertEqual(chat.model, "opencode/big-pickle") + + def test_explicit_model_is_never_overridden_by_the_config(self): + # OPENCODE_MODEL / agent_config.yaml's opencode_model PIN the model: + # no request, and the UI picker cannot move it. + chat = OpenCodeChat("http://127.0.0.1:4096", model="openrouter/openai/gpt-5", model_is_explicit=True) with mock.patch.object(chat, "_request") as request_mock: chat._ensure_model_resolved() request_mock.assert_not_called() self.assertEqual(chat.model, "openrouter/openai/gpt-5") + def test_a_failed_config_read_keeps_the_model_already_resolved(self): + # One local request failing must not drop a working model for the + # fallback mid-run. + chat = OpenCodeChat("http://127.0.0.1:4096", model="anthropic/claude-haiku-4.5", model_is_explicit=False) + with mock.patch.object(chat, "_request", side_effect=OSError("refused")): + chat._ensure_model_resolved() + self.assertEqual(chat.model, "anthropic/claude-haiku-4.5") + def test_unset_model_resolves_from_config_own_model_field(self): chat = OpenCodeChat("http://127.0.0.1:4096", model=None, model_is_explicit=False) with mock.patch.object(chat, "_request", return_value=self._fake_response({"model": "anthropic/claude-haiku-4.5"})) as request_mock: @@ -425,13 +447,13 @@ def test_falls_back_to_the_hardcoded_default_when_config_has_no_model(self): with mock.patch.object(chat, "_request", return_value=self._fake_response({})) as request_mock: chat._ensure_model_resolved() request_mock.assert_called_once_with("/config") - self.assertEqual(chat.model, "opencode/deepseek-v4-flash-free") + self.assertEqual(chat.model, "opencode/big-pickle") def test_no_resolvable_model_falls_back_to_the_hardcoded_default(self): chat = OpenCodeChat("http://127.0.0.1:4096", model=None, model_is_explicit=False) with mock.patch.object(chat, "_request", side_effect=OSError("refused")): chat._ensure_model_resolved() # must not raise - self.assertEqual(chat.model, "opencode/deepseek-v4-flash-free") + self.assertEqual(chat.model, "opencode/big-pickle") def test_config_field_that_is_not_provider_slash_model_falls_through(self): # A malformed/unexpected `model` field (missing the "/") is treated @@ -441,7 +463,155 @@ def test_config_field_that_is_not_provider_slash_model_falls_through(self): with mock.patch.object(chat, "_request", return_value=self._fake_response({"model": "not-a-provider-model-pair"})) as request_mock: chat._ensure_model_resolved() request_mock.assert_called_once_with("/config") - self.assertEqual(chat.model, "opencode/deepseek-v4-flash-free") + self.assertEqual(chat.model, "opencode/big-pickle") + + + +class PublishModelTests(unittest.TestCase): + """publish_model / resolve_model -- how the two sides converge on one model. + + Verified against a live server before these were written: PATCH /config + (workspace scope) answers 200 and echoes the value back but does NOT change + what GET /config reports, while PATCH /global/config does. GET /config is + what both the studio picker and this backend read, so the write has to go + to the global scope, and be CONFIRMED rather than trusted. + """ + + @staticmethod + def _resp(payload): + return mock.MagicMock( + __enter__=mock.MagicMock( + return_value=mock.MagicMock(read=lambda: json.dumps(payload).encode())), + __exit__=mock.MagicMock(return_value=False), + ) + + def test_publish_writes_the_global_scope_first(self): + chat = OpenCodeChat("http://127.0.0.1:4096", model="opencode/big-pickle", + model_is_explicit=False) + calls = [] + + def fake_request(path, method="GET", body=None, headers=None): + calls.append((path, method, body)) + if method == "GET": + return self._resp({"model": "opencode/big-pickle"}) + return self._resp({}) + + with mock.patch.object(chat, "_request", side_effect=fake_request): + self.assertTrue(chat.publish_model()) + self.assertEqual(calls[0], ("/global/config", "PATCH", {"model": "opencode/big-pickle"})) + # Confirmed by reading the effective config back, not by the echo. + self.assertIn(("/config", "GET", None), calls) + + def test_publish_falls_back_to_the_workspace_route(self): + chat = OpenCodeChat("http://127.0.0.1:4096", model="opencode/big-pickle", + model_is_explicit=False) + seen = [] + + def fake_request(path, method="GET", body=None, headers=None): + seen.append((path, method)) + if path == "/global/config": + raise OSError("no such route") + if method == "GET": + return self._resp({"model": "opencode/big-pickle"}) + return self._resp({}) + + with mock.patch.object(chat, "_request", side_effect=fake_request): + self.assertTrue(chat.publish_model()) + self.assertIn(("/config", "PATCH"), seen) + + def test_publish_reports_failure_when_the_write_does_not_stick(self): + # Exactly the live failure mode: 200 back, effective config unchanged. + chat = OpenCodeChat("http://127.0.0.1:4096", model="opencode/big-pickle", + model_is_explicit=False) + + def fake_request(path, method="GET", body=None, headers=None): + if method == "GET": + return self._resp({}) # no model -- nothing was stored + return self._resp({"model": "opencode/big-pickle"}) # echo only + + with mock.patch.object(chat, "_request", side_effect=fake_request): + self.assertFalse(chat.publish_model()) + + def test_resolve_publishes_the_fallback_so_the_studio_adopts_it(self): + # weightslab started FIRST: nothing chosen anywhere, so the built-in + # default is resolved AND published for the UI to read. + chat = OpenCodeChat("http://127.0.0.1:4096", model="", model_is_explicit=False) + with mock.patch.object(chat, "_configured_model", side_effect=[None, "opencode/big-pickle"]), mock.patch.object(chat, "_request", return_value=self._resp({})), mock.patch.object(chat, "_ensure_reachable"): + model, source = chat.resolve_model(publish_default=True) + self.assertEqual(model, "opencode/big-pickle") + self.assertEqual(source, "default-published") + + def test_resolve_adopts_the_studio_pick_without_publishing(self): + # studio started FIRST: its pick is already in OpenCode's config, so + # follow it and write nothing. + chat = OpenCodeChat("http://127.0.0.1:4096", model="", model_is_explicit=False) + with mock.patch.object(chat, "_configured_model", return_value="openrouter/openai/gpt-5-mini"), mock.patch.object(chat, "publish_model") as publish, mock.patch.object(chat, "_ensure_reachable"): + model, source = chat.resolve_model(publish_default=True) + self.assertEqual((model, source), ("openrouter/openai/gpt-5-mini", "opencode-config")) + publish.assert_not_called() + + def test_the_studio_pick_wins_over_a_yaml_seed(self): + # The reported bug: pick a model in the UI, then start a run -- + # agent_config.yaml's opencode_model pinned the backend back to its own + # value, ignoring the pick. A yaml model is a SEED, not a pin. + chat = OpenCodeChat("http://127.0.0.1:4096", model="", model_is_explicit=False, + seed_model="opencode/muse-spark-1.3-contributor-free") + with mock.patch.object(chat, "_configured_model", return_value="openrouter/openai/gpt-5-mini"), mock.patch.object(chat, "publish_model") as publish, mock.patch.object(chat, "_ensure_reachable"): + model, source = chat.resolve_model(publish_default=True) + self.assertEqual((model, source), ("openrouter/openai/gpt-5-mini", "opencode-config")) + publish.assert_not_called() + + def test_the_yaml_seed_is_used_and_published_when_nothing_is_chosen(self): + chat = OpenCodeChat("http://127.0.0.1:4096", model="", model_is_explicit=False, + seed_model="opencode/muse-spark-1.3-contributor-free") + with mock.patch.object(chat, "_configured_model", return_value=None), mock.patch.object(chat, "publish_model", return_value=True) as publish, mock.patch.object(chat, "_ensure_reachable"): + model, source = chat.resolve_model(publish_default=True) + self.assertEqual( + (model, source), + ("opencode/muse-spark-1.3-contributor-free", "config-seed-published")) + publish.assert_called_once() + + def test_the_yaml_seed_beats_the_builtin_default(self): + chat = OpenCodeChat("http://127.0.0.1:4096", model="", model_is_explicit=False, + seed_model="openrouter/openai/gpt-5-mini") + with mock.patch.object(chat, "_configured_model", return_value=None), mock.patch.object(chat, "publish_model", return_value=False), mock.patch.object(chat, "_ensure_reachable"): + model, _ = chat.resolve_model(publish_default=True) + self.assertEqual(model, "openrouter/openai/gpt-5-mini") + + def test_the_env_pin_still_beats_the_studio_pick(self): + # OPENCODE_MODEL is per-process and deliberate: automation must be able + # to force a model regardless of what anyone picked in the UI. + chat = OpenCodeChat("http://127.0.0.1:4096", model="openrouter/pinned/model", + model_is_explicit=True, seed_model="opencode/seed") + with mock.patch.object(chat, "_configured_model", return_value="openrouter/openai/gpt-5-mini"), mock.patch.object(chat, "publish_model", return_value=True), mock.patch.object(chat, "_ensure_reachable"): + model, source = chat.resolve_model(publish_default=True) + self.assertEqual((model, source), ("openrouter/pinned/model", "pinned-published")) + + def test_resolve_publishes_a_pinned_model_so_the_studio_shows_it(self): + # A model pinned in agent_config.yaml / OPENCODE_MODEL is a deliberate + # choice too. It used to stay invisible to the studio, which then + # displayed an unrelated default while every backend query ran on the + # pinned model. + chat = OpenCodeChat("http://127.0.0.1:4096", model="openrouter/pinned/model", + model_is_explicit=True) + with mock.patch.object(chat, "publish_model", return_value=True) as publish, mock.patch.object(chat, "_ensure_reachable"): + model, source = chat.resolve_model(publish_default=True) + self.assertEqual((model, source), ("openrouter/pinned/model", "pinned-published")) + publish.assert_called_once() + + def test_a_pinned_model_stays_pinned_when_it_cannot_be_published(self): + chat = OpenCodeChat("http://127.0.0.1:4096", model="openrouter/pinned/model", + model_is_explicit=True) + with mock.patch.object(chat, "publish_model", return_value=False), mock.patch.object(chat, "_ensure_reachable"): + model, source = chat.resolve_model(publish_default=True) + self.assertEqual((model, source), ("openrouter/pinned/model", "pinned")) + + def test_a_model_read_from_the_config_is_never_republished(self): + chat = OpenCodeChat("http://127.0.0.1:4096", model="", model_is_explicit=False) + with mock.patch.object(chat, "_configured_model", return_value="opencode/big-pickle"), mock.patch.object(chat, "publish_model") as publish, mock.patch.object(chat, "_ensure_reachable"): + model, source = chat.resolve_model(publish_default=True) + self.assertEqual((model, source), ("opencode/big-pickle", "opencode-config")) + publish.assert_not_called() if __name__ == "__main__": diff --git a/tests/ui/test_server_experiment_reports.py b/tests/ui/test_server_experiment_reports.py index bd0c0e7a..b1cf36ee 100644 --- a/tests/ui/test_server_experiment_reports.py +++ b/tests/ui/test_server_experiment_reports.py @@ -17,6 +17,7 @@ import json import os +import shutil import tempfile import threading import time @@ -25,6 +26,7 @@ import urllib.request from weightslab.ui import server as ui_server +from weightslab.utils import active_experiment class _ServerTestCase(unittest.TestCase): @@ -33,6 +35,14 @@ class _ServerTestCase(unittest.TestCase): def setUp(self): self.tmp = tempfile.mkdtemp() + # The active-experiment marker is a real per-user file, and a LIVE + # backend recorded in it redirects these listings on purpose (see + # _experiment_dir_path). Point it at a scratch directory so the tests + # exercise the explicit experiment_dir below instead of whatever run + # the developer happens to have going. + self._state_prev = os.environ.get("WEIGHTSLAB_STATE_DIR") + self._state_dir = tempfile.mkdtemp() + os.environ["WEIGHTSLAB_STATE_DIR"] = self._state_dir self.httpd = ui_server.serve_ui( ui_host="127.0.0.1", ui_port=0, backend_host="localhost", backend_port=50051, @@ -47,6 +57,11 @@ def setUp(self): def tearDown(self): self.httpd.shutdown() self.thread.join(timeout=5) + if self._state_prev is None: + os.environ.pop("WEIGHTSLAB_STATE_DIR", None) + else: + os.environ["WEIGHTSLAB_STATE_DIR"] = self._state_prev + shutil.rmtree(self._state_dir, ignore_errors=True) def _get(self, path): return urllib.request.urlopen(f"http://127.0.0.1:{self.port}{path}", timeout=5) @@ -96,6 +111,49 @@ def test_entries_include_name_path_and_modified_at(self): self.assertIsInstance(entry["modified_at"], (int, float)) +class TestReportsFollowTheRunningBackend(_ServerTestCase): + """The listing follows where the RUNNING backend actually writes. + + Regression: `weightslab start` and a training run started in another + terminal resolved different directories (the run fell through to %TEMP%), + so right-clicking "Generate report" listed nothing even though reports had + just been generated. + """ + + def _write_report_in(self, directory, name): + reports_dir = os.path.join(directory, "reports") + os.makedirs(reports_dir, exist_ok=True) + with open(os.path.join(reports_dir, name), "w", encoding="utf-8") as f: + f.write("") + + def test_a_live_backend_directory_is_listed_instead_of_the_uis_own(self): + backend_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, backend_dir, ignore_errors=True) + self._write_report_in(backend_dir, "experiment_report_20260909_193124.html") + # This test process stands in for the running backend. + active_experiment.record_backend_experiment(backend_dir) + + with self._get("/experiment-report/list") as r: + data = json.loads(r.read().decode()) + self.assertEqual([e["name"] for e in data["reports"]], + ["experiment_report_20260909_193124.html"]) + + def test_a_finished_backend_does_not_hijack_the_listing(self): + backend_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, backend_dir, ignore_errors=True) + self._write_report_in(backend_dir, "stale.html") + active_experiment.record_backend_experiment(backend_dir) + # Rewrite the record with a pid that cannot be running. + state = active_experiment.read_state() + state["backend"][-1]["pid"] = 2 ** 31 - 1 + active_experiment.state_path().write_text(json.dumps(state), encoding="utf-8") + + self._write_report("mine.html") + with self._get("/experiment-report/list") as r: + data = json.loads(r.read().decode()) + self.assertEqual([e["name"] for e in data["reports"]], ["mine.html"]) + + class TestServeExperimentReport(_ServerTestCase): def test_serves_html_content_with_correct_content_type(self): diff --git a/tests/ui/test_server_shared_model.py b/tests/ui/test_server_shared_model.py new file mode 100644 index 00000000..58ffa1f1 --- /dev/null +++ b/tests/ui/test_server_shared_model.py @@ -0,0 +1,184 @@ +"""Tests for the UI server's same-origin shared-model endpoints: + +- GET /agent-server/model -- the model OpenCode's config names, or null. +- POST /agent-server/model -- set it (global scope, confirmed by reading back). + +Why they exist: the browser CAN call OpenCode directly, but only while +OpenCode's ``--cors`` allowlist contains the page's exact origin. A LAN +address, a tunnel hostname, or an ``opencode serve`` started by hand with no +``--cors`` all make that cross-origin PATCH fail its preflight, so a model +picked in the studio silently never reached OpenCode -- and therefore never +reached weightslab's backend, which reads that same field to choose the model +for its own queries. Proxying through this server is same-origin: no +preflight, no allowlist. + +A fake OpenCode stands in for the real server, reproducing the two behaviours +confirmed live: PATCH /global/config sticks, PATCH /config answers 200 and +echoes the value back without changing what GET /config reports. +""" + +import json +import threading +import time +import unittest +import unittest.mock +import urllib.error +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +from weightslab.ui import server as ui_server + + +class _FakeOpencode(BaseHTTPRequestHandler): + """model lives in the class so every request sees the same value.""" + + model = None + workspace_patch_sticks = False + reachable = True + + def log_message(self, *args): # silence + pass + + def _json(self, status, payload): + body = json.dumps(payload).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + if not type(self).reachable: + self._json(500, {"error": "down"}) + return + if self.path == "/config": + self._json(200, {"model": type(self).model} if type(self).model else {}) + return + self._json(404, {}) + + def do_PATCH(self): + length = int(self.headers.get("Content-Length", "0") or 0) + body = json.loads(self.rfile.read(length).decode() or "{}") if length else {} + model = body.get("model") + if self.path == "/global/config": + type(self).model = model + self._json(200, {"model": model}) + return + if self.path == "/config": + # Answers 200 and echoes the value, but only *stores* it when the + # server actually honours workspace scope -- the live one does not. + if type(self).workspace_patch_sticks: + type(self).model = model + self._json(200, {"model": model}) + return + self._json(404, {}) + + +class TestSharedModelEndpoints(unittest.TestCase): + def setUp(self): + _FakeOpencode.model = None + _FakeOpencode.workspace_patch_sticks = False + _FakeOpencode.reachable = True + + self.oc = ThreadingHTTPServer(("127.0.0.1", 0), _FakeOpencode) + self.oc_thread = threading.Thread(target=self.oc.serve_forever, daemon=True) + self.oc_thread.start() + oc_url = f"http://127.0.0.1:{self.oc.server_address[1]}" + + # The UI server resolves OpenCode from the running session, then from + # OPENCODE_URL -- point it at the fake. + self._prev_url = ui_server.os.environ.get("OPENCODE_URL") + ui_server.os.environ["OPENCODE_URL"] = oc_url + + self.httpd = ui_server.serve_ui( + ui_host="127.0.0.1", ui_port=0, + backend_host="localhost", backend_port=50051, + open_browser=False, block=False, + ) + self.port = self.httpd.server_address[1] + self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True) + self.thread.start() + time.sleep(0.1) + + def tearDown(self): + self.httpd.shutdown() + self.thread.join(timeout=5) + self.oc.shutdown() + self.oc_thread.join(timeout=5) + if self._prev_url is None: + ui_server.os.environ.pop("OPENCODE_URL", None) + else: + ui_server.os.environ["OPENCODE_URL"] = self._prev_url + + # -- helpers --------------------------------------------------------- + def _get(self): + with urllib.request.urlopen( + f"http://127.0.0.1:{self.port}/agent-server/model", timeout=5) as r: + return json.loads(r.read().decode()) + + def _post(self, model): + req = urllib.request.Request( + f"http://127.0.0.1:{self.port}/agent-server/model", method="POST", + data=json.dumps({"model": model}).encode(), + headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=5) as r: + return r.status, json.loads(r.read().decode()) + except urllib.error.HTTPError as exc: + return exc.code, json.loads(exc.read().decode() or "{}") + + # -- tests ----------------------------------------------------------- + def test_get_reports_nothing_when_no_model_is_configured(self): + self.assertEqual(self._get(), {"ok": True, "model": None}) + + def test_get_reports_the_configured_model(self): + _FakeOpencode.model = "opencode/big-pickle" + self.assertEqual(self._get(), {"ok": True, "model": "opencode/big-pickle"}) + + def test_get_ignores_a_malformed_model_field(self): + _FakeOpencode.model = "not-a-provider-model-pair" + self.assertEqual(self._get(), {"ok": True, "model": None}) + + def test_post_writes_the_global_scope_and_confirms_it(self): + status, payload = self._post("openrouter/openai/gpt-5-mini") + self.assertEqual(status, 200) + self.assertTrue(payload["ok"]) + self.assertEqual(payload["model"], "openrouter/openai/gpt-5-mini") + self.assertEqual(payload["via"], "/global/config") + # ...and it really is what OpenCode now reports. + self.assertEqual(_FakeOpencode.model, "openrouter/openai/gpt-5-mini") + self.assertEqual(self._get()["model"], "openrouter/openai/gpt-5-mini") + + def test_post_rejects_a_model_without_a_provider(self): + status, payload = self._post("big-pickle") + self.assertEqual(status, 400) + self.assertFalse(payload["ok"]) + + def test_post_reports_failure_when_the_write_does_not_stick(self): + # Both routes answer 200 but nothing is stored -- exactly the live + # workspace-scope behaviour, generalised. + class _EchoOnly(_FakeOpencode): + pass + + def do_PATCH(self): # noqa: N802 -- HTTP handler naming + length = int(self.headers.get("Content-Length", "0") or 0) + if length: + self.rfile.read(length) + self._json(200, {"model": "whatever"}) + + with unittest.mock.patch.object(_FakeOpencode, "do_PATCH", do_PATCH): + status, payload = self._post("opencode/big-pickle") + self.assertEqual(status, 200) + self.assertFalse(payload["ok"]) + self.assertIn("did not accept", payload["error"]) + + def test_get_says_so_when_opencode_is_unreachable(self): + _FakeOpencode.reachable = False + payload = self._get() + self.assertFalse(payload["ok"]) + self.assertIsNone(payload["model"]) + self.assertIn("not reachable", payload["error"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/weightslab/backend/cli.py b/weightslab/backend/cli.py index c3d8a853..11ccbeb7 100644 --- a/weightslab/backend/cli.py +++ b/weightslab/backend/cli.py @@ -801,12 +801,21 @@ def _handle_command(cmd: str) -> Any: available = bool(agent.is_available()) except Exception: available = False + # current_model() re-reads OpenCode's shared config, so a model + # picked in the studio after this backend started is reported + # here instead of the model resolved at start-up. + model = getattr(agent, 'opencode_model', None) + try: + if hasattr(agent, 'current_model'): + model = agent.current_model() or model + except Exception: + pass return { 'ok': True, 'available': available, 'preferred_provider': getattr(agent, 'preferred_provider', None), 'opencode_url': getattr(agent, 'opencode_url', None), - 'opencode_model': getattr(agent, 'opencode_model', None), + 'opencode_model': model, 'message': 'Agent available. Ready to help you.' if available else 'Agent not configured. Use agent init.', } diff --git a/weightslab/backend/dataloader_interface.py b/weightslab/backend/dataloader_interface.py index 9d796a97..19f7f6b4 100644 --- a/weightslab/backend/dataloader_interface.py +++ b/weightslab/backend/dataloader_interface.py @@ -43,6 +43,44 @@ _DENY_LIST_REFRESH_INTERVAL = 32 +def _close_inherited_h5_fds(worker_id: int = 0) -> None: + """DataLoader worker_init_fn: drop HDF5 handles inherited from the parent. + + torch forks workers, so every fd the parent had open at fork time is + duplicated into the child -- including the ledger store. The child never + uses them, but their mere existence makes HDF5 refuse the parent's + read-write open, which silently kills ledger persistence. + + Closes rather than just dropping the Python object: the fd is what holds + the file, and the child has no Python-level reference to it at all. + """ + import os + try: + fd_dir = "/proc/self/fd" + for entry in os.listdir(fd_dir): + try: + target = os.readlink(os.path.join(fd_dir, entry)) + except OSError: + continue + if target.endswith(".h5") or target.endswith(".h5.lock"): + try: + os.close(int(entry)) + except OSError: + pass + except Exception: + # Never let cleanup break a worker: a leaked handle degrades + # persistence, a raising worker_init_fn kills the run. + pass + + +def _with_worker_init(kwargs: dict, num_workers: int) -> dict: + """Attach the fd cleanup unless the caller supplied its own init.""" + if num_workers and not kwargs.get("worker_init_fn"): + kwargs = dict(kwargs) + kwargs["worker_init_fn"] = _close_inherited_h5_fds + return kwargs + + def _resolve_safe_num_workers(dataset: Any, num_workers: int, loader_name: Optional[str] = None) -> int: """Clamp worker count for datasets that cannot be pickled by Windows spawn.""" try: @@ -177,6 +215,12 @@ def _get_deny_list_revision(self) -> Optional[tuple[str, int]]: try: origin = self._get_current_origin() df_manager = get_dataframe() + if origin and df_manager is not None and hasattr(df_manager, "get_discard_revision"): + # Deliberately NOT get_origin_revision: that moves on every + # per-sample signal write, i.e. every training step, so the + # cache below could never hit and __len__ rescanned 3.96M rows + # per batch. Discard state is what the deny-list depends on. + return ("discard", int(df_manager.get_discard_revision(origin))) if origin and df_manager is not None and hasattr(df_manager, "get_origin_revision"): return ("origin", int(df_manager.get_origin_revision(origin))) except Exception: @@ -514,6 +558,7 @@ def __init__( self.tracked_dataset, batch_sampler=batch_sampler, num_workers=num_workers, + worker_init_fn=_close_inherited_h5_fds, pin_memory=pin_memory, collate_fn=collate_fn, persistent_workers=self._should_persist_workers(num_workers), @@ -1134,6 +1179,7 @@ def restore_iteration_state(self, state: dict) -> None: self.tracked_dataset, batch_sampler=sampler, num_workers=num_workers, + worker_init_fn=_close_inherited_h5_fds, pin_memory=pin_memory, collate_fn=collate_fn, # Ensure no conflicting args are passed alongside batch_sampler @@ -1220,6 +1266,7 @@ def set_batch_size(self, new_batch_size: int) -> None: self.tracked_dataset, batch_sampler=sampler, num_workers=num_workers, + worker_init_fn=_close_inherited_h5_fds, pin_memory=pin_memory, collate_fn=collate_fn, persistent_workers=self._should_persist_workers(num_workers), @@ -1232,6 +1279,7 @@ def set_batch_size(self, new_batch_size: int) -> None: batch_size=batch_size, shuffle=shuffle, num_workers=num_workers, + worker_init_fn=_close_inherited_h5_fds, drop_last=drop_last, pin_memory=pin_memory, collate_fn=collate_fn, diff --git a/weightslab/backend/logger.py b/weightslab/backend/logger.py index 7ce7febe..0c2cbc3e 100644 --- a/weightslab/backend/logger.py +++ b/weightslab/backend/logger.py @@ -36,7 +36,7 @@ import os import threading import time -from collections import defaultdict +from collections import defaultdict, deque import duckdb import pandas as pd @@ -64,6 +64,18 @@ _STAGE_FLUSH_THRESHOLD = 50_000 # How often the background flush thread wakes up (see LoggerQueue._flush_loop). +def _default_history_tail() -> int: + """Recent points kept per sample for signal-DAG history reads. + + Bounded so history() costs O(batch) instead of scanning the whole + per_sample table (140ms at 20M rows, once per step, and growing). + """ + try: + return max(0, int(os.environ.get("WL_HISTORY_TAIL", "16"))) + except (TypeError, ValueError): + return 16 + + def _default_flush_interval_seconds() -> float: try: return float(os.environ.get("WL_LOGGER_FLUSH_INTERVAL_SECONDS", "2.0")) @@ -276,6 +288,10 @@ def __init__(self, register: bool = True, db_path: str = ":memory:") -> None: _qps_maxsize = int(os.environ.get("WL_QUERY_CACHE_MAXSIZE", "2048")) self._qps_version: dict = defaultdict(int) self._qps_cache_step: int = -1 + # {signal: {sample_id: deque}} -- the recent tail of each sample's + # per-sample values, maintained on write so history() never scans. + self._tail_len = _default_history_tail() + self._recent_tail: dict = defaultdict(dict) self._qps_cache = functools.lru_cache(maxsize=_qps_maxsize)(self._query_per_sample_uncached) self._qps_step_cache = functools.lru_cache(maxsize=_qps_maxsize)(self._query_per_sample_at_step_uncached) @@ -757,6 +773,20 @@ def _next_seq(self) -> int: self._seq += 1 return s + def recent_per_sample(self, graph_name: str, sample_ids): + """Recent in-memory values per sample: ``{sample_id: [values]}``. + + O(batch). Samples not written in this process are absent rather than + empty-listed; callers treat both as "not enough history yet". + """ + tail = self._recent_tail.get(graph_name) or {} + out = {} + for s in sample_ids: + q = tail.get(str(s)) + if q: + out[s] = list(q) + return out + def _maybe_autoflush(self) -> None: if (len(self._stage_signals) + len(self._stage_sample) + len(self._stage_instance)) >= _STAGE_FLUSH_THRESHOLD: @@ -823,6 +853,13 @@ def _stage_sample_row(self, graph_name, exp_hash, sample_id, step, value): ) self._qps_version[graph_name] += 1 # invalidate this signal's cached reads self._loss_shape_dirty_samples[(graph_name, exp_hash)].add(str(sample_id)) + if self._tail_len: + _tail = self._recent_tail[graph_name] + _sid = str(sample_id) + _q = _tail.get(_sid) + if _q is None: + _q = _tail[_sid] = deque(maxlen=self._tail_len) + _q.append(float(value)) self._maybe_autoflush() def _invalidate_qps_cache(self) -> None: diff --git a/weightslab/cli.py b/weightslab/cli.py index d4e5adb4..65a592e1 100644 --- a/weightslab/cli.py +++ b/weightslab/cli.py @@ -623,6 +623,31 @@ def example_start(args): try: env = os.environ.copy() env['WEIGHTSLAB_SUPPRESS_BANNER'] = '1' + # `weightslab start` runs in its own terminal, so its + # WEIGHTSLAB_ROOT_LOG_DIR export never reaches this process -- read the + # directory it recorded and hand it to the example, so the run lands in + # the experiment the UI is showing instead of a throwaway temp dir. + # Anything already set in this shell wins: an explicit choice by the + # user must not be overridden by the last UI launch. + if not (env.get('WEIGHTSLAB_ROOT_LOG_DIR') or '').strip(): + try: + from weightslab.utils.active_experiment import live_ui_experiment_dir + adopted = live_ui_experiment_dir() + except Exception as exc: # noqa: BLE001 + logger.debug(f"Could not read the active experiment directory: {exc}") + adopted = None + if adopted: + env['WEIGHTSLAB_ROOT_LOG_DIR'] = adopted + logger.info(f" Using the experiment directory from `weightslab start`: {adopted}") + else: + logger.warning( + " No experiment directory found (no WEIGHTSLAB_ROOT_LOG_DIR here and no " + "`weightslab start` on record) — this example will write to a temporary " + "directory, and the UI will not find its reports or notebook. Start the UI " + "first with `weightslab start`, or set WEIGHTSLAB_ROOT_LOG_DIR." + ) + else: + logger.info(f" Using WEIGHTSLAB_ROOT_LOG_DIR from this shell: {env['WEIGHTSLAB_ROOT_LOG_DIR']}") result = subprocess.run([sys.executable, str(main_py)], cwd=str(example_dir), env=env) except KeyboardInterrupt: logger.info("Example stopped.") @@ -892,6 +917,18 @@ def ui_start_native(args): experiment_dir = _resolve_experiment_dir(getattr(args, "experiment_dir", None)) os.environ["WEIGHTSLAB_ROOT_LOG_DIR"] = str(experiment_dir) os.environ["WL_LAST_EXPERIMENT_DIR"] = str(experiment_dir) + # An environment variable reaches only THIS process and its children. A + # training run started from another terminal is a different process tree, + # so also record the directory in the marker file every later weightslab + # process reads (weightslab.utils.active_experiment) -- without it, such a + # run fell through to a throwaway %TEMP% directory while this UI listed an + # empty reports/ from the directory established here. + try: + from weightslab.utils.active_experiment import record_ui_experiment + record_ui_experiment(experiment_dir) + except Exception as exc: # noqa: BLE001 -- advisory record, never fatal + logger.debug(f"Could not record the active experiment directory: {exc}") + # Re-recorded below with the resolved ports, once they are known. _print_experiment_guidance(experiment_dir) # If the agent has been initialized, provision OpenCode up front (in the @@ -955,6 +992,15 @@ def ui_start_native(args): ui_port = actual_port logger.info(f"UI port source: {ui_port_source} (preferred {preferred_ui_port}, using {ui_port})") os.environ["WL_LAST_UI_PORT"] = str(ui_port) + # Now that the ports are settled, stamp them on this UI's record: the + # backend port is what lets this UI ask for the experiment directory of + # ITS backend rather than of whichever backend started last -- the + # difference that matters when two experiments run side by side. + try: + from weightslab.utils.active_experiment import record_ui_experiment + record_ui_experiment(experiment_dir, ui_port=ui_port, backend_port=backend_port) + except Exception as exc: # noqa: BLE001 + logger.debug(f"Could not record the UI ports: {exc}") ui_server.serve_ui( ui_host=ui_host, diff --git a/weightslab/data/dataframe_manager.py b/weightslab/data/dataframe_manager.py index 76b9a8f7..59afaf2c 100644 --- a/weightslab/data/dataframe_manager.py +++ b/weightslab/data/dataframe_manager.py @@ -92,7 +92,15 @@ def __init__(self, flush_interval: float = 3.0, flush_max_rows: int = 100, enabl self._store: H5DataFrameStore | None = None self._array_store: H5ArrayStore | None = None self._origin_revisions: Dict[str, int] = {} + # Bumped ONLY when the `discarded` column changes. The deny-list cache + # keys on this instead of _origin_revisions, which training bumps every + # step (per-sample signal writes) and which therefore never lets a cache + # hit -- turning a per-batch len() into a 3.96M-row scan. + self._discard_revisions: Dict[str, int] = {} self._pending: set[int] = set() + # Parallel dirty set for the view. _pending is drained by the H5 flush, + # so the view cannot share it without one consumer starving the other. + self._view_pending: set = set() self._force_flush = False self._flush_interval = flush_interval self._flush_max_rows = flush_max_rows @@ -301,9 +309,66 @@ def _expand_dataframe_with_annotations(self, df: pd.DataFrame) -> pd.DataFrame: if not isinstance(work.index, pd.MultiIndex) and work.index.name != SID: work = work.copy() work.index.name = SID + fast = self._expand_fast_no_instances(work) + if fast is not None: + return fast + records = work.reset_index().to_dict("records") return self._expand_records_to_multi_index(records) + def _expand_fast_no_instances(self, work: pd.DataFrame): + """Vectorized expansion for frames with no per-instance targets. + + Returns the (sample_id, annotation_id=0) frame, or None to signal the + caller must use the record-by-record path. + """ + SID = SampleStats.Ex.SAMPLE_ID.value + ANNOT = SampleStats.Ex.INSTANCE_ID.value + TARGET = SampleStats.Ex.TARGET.value + try: + flat = work.reset_index() + if SID not in flat.columns: + return None + if TARGET in flat.columns: + tgt = flat[TARGET] + # Non-object dtype cannot hold a list => every target is scalar. + if tgt.dtype == object: + for v in tgt.to_numpy(): + if isinstance(v, (list, tuple, np.ndarray)) and len(v) > 0: + return None + # _normalize_sample_id always returns str(). astype(str) reproduces + # that for numeric dtypes only -- bytes would render as "b'x'". + sid_ser = flat[SID] + if pd.api.types.is_integer_dtype(sid_ser) or pd.api.types.is_float_dtype(sid_ser): + sids = sid_ser.astype(str).tolist() + else: + sids = [self._normalize_sample_id(v) for v in sid_ser.to_numpy()] + + out = flat.drop(columns=[c for c in (SID, ANNOT) if c in flat.columns]) + # The record path goes through python lists, so extension dtypes + # (string[pyarrow], categorical) come back as object. Match it. + for c in out.columns: + if isinstance(out[c].dtype, pd.api.extensions.ExtensionDtype): + out[c] = out[c].astype(object) + out.index = pd.MultiIndex.from_arrays( + [sids, np.zeros(len(out), dtype=np.int64)], names=[SID, ANNOT]) + return out + except Exception: + return None + + def _normalize_sample_id_index(self, values) -> "pd.Index": + """Vectorized _normalize_sample_id over an Index (~2x; 0.8s -> 0.5s at 2M). + + _normalize_sample_id is str() after unwrapping numpy scalars/bytes, so + astype(str) is exact for numeric dtypes; anything else keeps the loop. + """ + try: + if pd.api.types.is_integer_dtype(values) or pd.api.types.is_float_dtype(values): + return pd.Index(values.astype(str)) + except Exception: + pass + return pd.Index([self._normalize_sample_id(v) for v in values]) + def _normalize_sample_id(self, sample_id: Any) -> Any: """Normalize incoming sample IDs while preserving numeric IDs when possible.""" try: @@ -320,6 +385,22 @@ def _normalize_sample_id(self, sample_id: Any) -> Any: return str(sample_id) + def _level0_index(self): + """Level-0 (sample_id) values of the ledger index, cached. + + Keyed on the index object's identity: pandas Index is immutable, so a + reindex or rebuild yields a new object and invalidates this. Reusing the + object also reuses its hash engine, which is what makes a membership + probe O(1) instead of O(rows). + """ + idx = self._df.index + key = id(idx) + if getattr(self, "_lvl0_key", None) != key: + self._lvl0_key = key + self._lvl0 = (idx.get_level_values(0) + if isinstance(idx, pd.MultiIndex) else idx) + return self._lvl0 + def _coerce_sample_id_for_index(self, sample_id: Any) -> Any: """Coerce sample_id to match current dataframe index representation. @@ -332,8 +413,10 @@ def _coerce_sample_id_for_index(self, sample_id: Any) -> Any: # Check if multi-index if isinstance(self._df.index, pd.MultiIndex): - # Get level 0 (sample_id level) values - level_0_values = self._df.index.get_level_values(0) + # Cached: get_level_values(0) built a new Index over every row on each + # call, and a new object means a new hash engine, so this probe was + # O(rows) per sample. + level_0_values = self._level0_index() if sid in level_0_values: return sid sid_str = str(sid) @@ -355,6 +438,11 @@ def set_array_store(self, array_store: H5ArrayStore): if self._enable_h5_persistence: self._array_store = array_store + def _bump_discard_revisions(self, origins: Sequence[Any]) -> None: + for origin in origins or []: + key = str(origin) + self._discard_revisions[key] = self._discard_revisions.get(key, 0) + 1 + def _bump_origin_revisions(self, origins: Sequence[Any]) -> None: for origin in origins: if origin is None or pd.isna(origin): @@ -720,7 +808,7 @@ def upsert_df(self, df_local: List | pd.DataFrame, origin: str = None, force_flu # Normalize sample_id values in multi-index if isinstance(df_norm.index, pd.MultiIndex) and df_norm.index.nlevels >= 1: - level_0_normalized = pd.Index([self._normalize_sample_id(v) for v in df_norm.index.get_level_values(0)]) + level_0_normalized = self._normalize_sample_id_index(df_norm.index.get_level_values(0)) try: if df_norm.index.nlevels == 2: df_norm.index = pd.MultiIndex.from_arrays( @@ -784,7 +872,29 @@ def upsert_df(self, df_local: List | pd.DataFrame, origin: str = None, force_flu for col in all_cols: if col in self._df.columns and isinstance(self._df[col].dtype, pd.CategoricalDtype): self._df[col] = self._df[col].astype(object) - self._df.loc[existing_idx, all_cols] = df_norm.loc[existing_idx, all_cols] + # Label-aligned 2D .loc realigns the entire frame (22s at 4M rows + # when adding columns to every row). Resolve row positions once, + # then write each column positionally. Falls back if the index + # has duplicates/misses, where get_indexer returns -1. + _pos = self._df.index.get_indexer(existing_idx) + if len(_pos) and (_pos >= 0).all(): + for _c in all_cols: + _ci = self._df.columns.get_loc(_c) + _vals = df_norm.loc[existing_idx, _c].to_numpy() + # An object array (None for "no value yet") written into + # a float column upcasts the WHOLE column to object and + # it never returns -- an 8x penalty on every later sort. + # Coerce to the target dtype so None becomes NaN instead. + try: + _tgt = self._df[_c].dtype + if (_vals.dtype == object + and getattr(_tgt, "kind", "") in "fiu"): + _vals = pd.to_numeric(_vals, errors="coerce") + except Exception: + pass + self._df.iloc[_pos, _ci] = _vals + else: + self._df.loc[existing_idx, all_cols] = df_norm.loc[existing_idx, all_cols] # Append rows that do not exist yet. Use a boolean mask (not # .loc[difference]) so a duplicate key in df_norm can't be @@ -800,12 +910,28 @@ def upsert_df(self, df_local: List | pd.DataFrame, origin: str = None, force_flu if df_norm[col].dtype == bool: self._df[col] = self._df[col].fillna(False).astype(bool) + # Columns SampleStatsEx documents a boolean default for must never + # hold NaN ("None are not accepted by PD H5 storage" -- see its + # DEFAULTS). Two ways they did anyway, and the loop above catches + # neither: it only visits columns being ADDED by this upsert, and + # only when the incoming slice's dtype is already bool -- which it + # is not precisely when the slice carries missing values. So a + # sample registered without the flag, and every per-annotation row + # (sample-level values live on annotation 0 only), kept a NaN. + # + # That is not cosmetic: bool(float("nan")) is True in Python, so a + # NaN flag read as a set one -- samples served to the studio as + # discarded while the dataframe said nothing was. + self._fill_documented_flag_defaults() + # Auto-register any string-valued tag: columns as categorical tags # (e.g. dataset metadata declaring tag:weather = "rainy"/"sunny"). self._auto_register_categorical_tags(df_norm) # Optimize memory by converting repetitive columns to categorical - self._df = self._optimize_dataframe_memory(self._df) + # Only columns just written can have changed dtype-wise; a full-frame + # nunique() over every object column was ~14s of a 670s startup. + self._df = self._optimize_dataframe_memory(self._df, columns=set(df_norm.columns)) # Mark dirty for flush (handle multi-index) if isinstance(df_norm.index, pd.MultiIndex): @@ -814,6 +940,8 @@ def upsert_df(self, df_local: List | pd.DataFrame, origin: str = None, force_flu sample_ids = df_norm.index.tolist() self.mark_dirty_batch(sample_ids, force_flush=force_flush) self._bump_origin_revisions(affected_origins) + if SampleStats.Ex.DISCARDED.value in set(df_norm.columns): + self._bump_discard_revisions(affected_origins) def mark_dirty(self, sample_id: int): """Mark sample as dirty for H5 flush. @@ -823,6 +951,7 @@ def mark_dirty(self, sample_id: int): with self._lock: normalized_id = self._coerce_sample_id_for_index(sample_id) self._pending.add(normalized_id) + self._view_pending.add(normalized_id) def drop_column(self, column: str): with self._lock: @@ -833,6 +962,7 @@ def drop_column(self, column: str): def mark_dirty_batch(self, sample_ids: List[int], force_flush: bool = False): with self._lock: self._pending.update(set(sample_ids)) + self._view_pending.update(set(sample_ids)) if force_flush: self._force_flush = True @@ -1337,9 +1467,55 @@ def update_values(self, origin: str, sample_id: int, updates: Dict[str, Any], an self._df = pd.concat([self._df, df_local]) self._bump_origin_revisions([origin]) - def get_origin_revision(self, origin: str) -> int: + def take_view_dirty(self, limit: int | None = None): + """Drain and return the sample_ids changed since the last view sync. + + Returns None when the backlog exceeds *limit*, meaning a differential + update would cost more than a rebuild — the caller should fall back. + """ + with self._lock: + if limit is not None and len(self._view_pending) > limit: + # Do NOT clear here. The caller is expected to rebuild, but that + # rebuild can bail (contended update lock) -- and these ids would + # then be lost with nothing to re-mark them. clear_view_dirty() + # is called once the rebuilt view is actually swapped in. + return None + out = list(self._view_pending) + self._view_pending.clear() + return out + + def clear_view_dirty(self): + """Drop the view-dirty backlog: a full rebuild has made the view current.""" + with self._lock: + self._view_pending.clear() + + def get_source_rows(self, sample_ids, columns=None): + """Rows for *sample_ids* straight from the source frame. O(len(ids)).""" with self._lock: - return int(self._origin_revisions.get(str(origin), 0)) + if self._df.empty or not len(sample_ids): + return None + idx = self._df.index + keys = idx.get_level_values(0) if isinstance(idx, pd.MultiIndex) else idx + want = set(str(s) for s in sample_ids) + mask = keys.astype(str).isin(want) + sub = self._df.loc[mask] + return sub[columns] if columns else sub + + def get_origin_revision(self, origin: str) -> int: + # No lock: a dict read is atomic under the GIL, and this is polled from + # __len__ on every batch. Taking self._lock here put the training thread + # in contention with the flush thread on the hot path. + return int(self._origin_revisions.get(str(origin), 0)) + + def get_discard_revision(self, origin: str) -> int: + """Revision of the `discarded` column for *origin*. + + Changes only on discard/restore, so a consumer that depends purely on + deny-list state can cache against it across training steps. Read without + the lock: called per batch, and a stale-by-one read is harmless (the next + batch picks the change up), whereas lock contention here is not. + """ + return int(self._discard_revisions.get(str(origin), 0)) def update_by_groups_bulk(self, origin: str, group_ids: List[Any], updates_list: List[Dict[str, Any]]): """Broadcast updates to multiple groups in one pass.""" @@ -1535,22 +1711,42 @@ def get_sample_column_values(self, sample_ids: List[Any], column: str) -> Dict[A coerced_ids = [self._coerce_sample_id_for_index(sid) for sid in sample_ids] + idx = self._df.index try: - if isinstance(self._df.index, pd.MultiIndex) and self._df.index.nlevels >= 2: - sample_level = self._df.index.get_level_values(0) - anno_level = self._df.index.get_level_values(1) - mask = sample_level.isin(coerced_ids) & (anno_level == 0) - slice_df = self._df[mask] - sids = slice_df.index.get_level_values(0) + # _positional NB_SEEN lookup: the wanted rows are exactly + # (sample_id, 0), so resolve their POSITIONS instead of scanning. + # The masked form below materialised both index levels and copied + # a boolean-masked frame over every row -- ~1.2s/step at 3.96M + # just to read a batch of integers. + if idx.has_duplicates: + raise ValueError("non-unique index; use the scan path") + if isinstance(idx, pd.MultiIndex) and idx.nlevels >= 2: + pos = idx.get_indexer([(cid, 0) for cid in coerced_ids]) else: - mask = self._df.index.isin(coerced_ids) - slice_df = self._df[mask] - sids = slice_df.index - - for sid, val in zip(sids, slice_df[column]): - values[self._normalize_sample_id(sid)] = val + pos = idx.get_indexer(list(coerced_ids)) + col = self._df[column].to_numpy() + for cid, p in zip(coerced_ids, pos): + if p >= 0: + values[self._normalize_sample_id(cid)] = col[p] except Exception: - pass + # Fallback: original scan, for a non-unique index or any dtype + # mismatch get_indexer will not tolerate. + try: + if isinstance(idx, pd.MultiIndex) and idx.nlevels >= 2: + sample_level = idx.get_level_values(0) + anno_level = idx.get_level_values(1) + mask = sample_level.isin(coerced_ids) & (anno_level == 0) + slice_df = self._df[mask] + sids = slice_df.index.get_level_values(0) + else: + mask = idx.isin(coerced_ids) + slice_df = self._df[mask] + sids = slice_df.index + + for sid, val in zip(sids, slice_df[column]): + values[self._normalize_sample_id(sid)] = val + except Exception: + pass return values @@ -1907,7 +2103,9 @@ def _apply_buffer_records(self, records: List[Dict[str, Any]]): self._apply_updates_frame_locked(instance_df, broadcast=False) self._apply_updates_frame_locked(sample_df, broadcast=True) # Keep newly-added signal columns float32 and empty object cells as None. - self._df = self._optimize_dataframe_memory(self._df) + self._df = self._optimize_dataframe_memory( + self._df, + columns=set(sample_df.columns) | set(instance_df.columns)) # Mark all as pending for h5 flush (outside lock) self.mark_dirty_batch(sample_ids) @@ -1945,13 +2143,22 @@ def _apply_buffer_records_nonblocking(self, records: List[Dict[str, Any]]): applied_index = written_s.append(written_i) if len(written_i) else written_s update_cols = sample_df.columns.union(instance_df.columns) # Keep newly-added signal columns float32 and empty object cells as None. - _df = self._optimize_dataframe_memory(self._df) + _df = self._optimize_dataframe_memory(self._df, columns=set(update_cols)) self._df = _df finally: self._lock.release() # Det→seg conversion / array normalization over all written rows. - if applied_index is not None and len(applied_index) > 0: + # This prepares cells for the H5 write, so it is only worth doing for + # columns that write will actually include. When predictions/targets are + # excluded from the save list (WEIGHTSLAB_SAVE_PREDICTIONS_IN_H5=0) the + # pass would otherwise call get_mask per row -- which reads the source + # image to size the mask -- for arrays that are never persisted. + _savable = set(_filter_columns_by_patterns( + list(update_cols), SAMPLES_STATS_TO_SAVE_TO_H5)) + _norm_cols = [c for c in update_cols + if c in self._array_columns and c in _savable] + if applied_index is not None and len(applied_index) > 0 and _norm_cols: if applied_index.has_duplicates: applied_index = applied_index[~applied_index.duplicated()] normalized_rows = self._df.loc[applied_index].apply( @@ -2008,6 +2215,33 @@ def _flush_to_h5_if_needed(self, force: bool = False, blocking: bool = False): # Everything below happens WITHOUT locks - fully async self._flush_snapshot_to_h5(data_snapshot, work) + def _rows_with_array_cells(self, data_snapshot: pd.DataFrame): + """Index labels of rows that may hold an array-valued cell. + + Column-wise scan replacing a full iterrows() pass: only object-dtype + columns can hold an ndarray/list/tuple/ArrayH5Proxy, and rows with none + of those are no-ops for the caller. + """ + cols = [c for c in self._array_columns if c in data_snapshot.columns] + if not cols: + return [] + hits = None + for col in cols: + ser = data_snapshot[col] + if ser.dtype != object: + continue # a numeric column cannot hold an array object + vals = ser.to_numpy() + mask = np.fromiter( + (isinstance(v, (np.ndarray, list, tuple, ArrayH5Proxy)) for v in vals), + dtype=bool, count=len(vals)) + if not mask.any(): + continue + found = ser.index[mask] + hits = found if hits is None else hits.union(found) + if hits is None: + return [] + return list(hits) + def _flush_snapshot_to_h5(self, data_snapshot: pd.DataFrame, work: List[int]): """Flush data snapshot to H5 - runs completely outside locks. @@ -2030,7 +2264,10 @@ def _flush_snapshot_to_h5(self, data_snapshot: pd.DataFrame, work: List[int]): arrays_to_store: Dict[str, Dict[str, np.ndarray]] = {} rowkey_to_index: Dict[str, Any] = {} - for idx, row in data_snapshot.iterrows(): + for idx in self._rows_with_array_cells(data_snapshot): + row = data_snapshot.loc[idx] + if isinstance(row, pd.DataFrame): # duplicate label guard + row = row.iloc[0] if is_multi: sample_id, annot = idx[0], int(idx[1]) else: @@ -2103,7 +2340,7 @@ def _flush_snapshot_to_h5(self, data_snapshot: pd.DataFrame, work: List[int]): except Exception as e: logger.error(f"[LedgeredDataFrameManager] Error flushing to H5: {e}") - def _optimize_dataframe_memory(self, df: pd.DataFrame, categorical_tags: Dict[str, List[str]] | None = None) -> pd.DataFrame: + def _optimize_dataframe_memory(self, df: pd.DataFrame, categorical_tags: Dict[str, List[str]] | None = None, columns=None) -> pd.DataFrame: """Optimize dataframe memory by converting repetitive string columns to categorical. Categorical dtype compresses repeated values: instead of storing each string, @@ -2130,12 +2367,39 @@ def _optimize_dataframe_memory(self, df: pd.DataFrame, categorical_tags: Dict[st if categorical_tags is None: categorical_tags = self._categorical_tags + # Honour `columns`: only the columns this flush actually wrote can have + # gained a float64 signal value or a fresh NaN, so scanning the rest is + # O(rows) of pure waste on every flush. + _scan_cols = (list(df.columns) if columns is None + else [c for c in df.columns if c in columns]) + + # === 0) Repair signal columns that were upcast to object === + # A single None written into a float column converts it permanently, and + # object columns sort ~8x slower. signals//* are numeric by definition, + # so any object one is damage rather than intent. Runs BEFORE the + # NaN->None pass below, which only touches object columns and would + # otherwise keep them that way. + for col in _scan_cols: + if not str(col).startswith("signals") or df[col].dtype != object: + continue + try: + coerced = pd.to_numeric(df[col], errors="coerce") + # Only if nothing was lost: a genuine non-numeric value means the + # column is not what we think it is, so leave it alone. + if coerced.notna().sum() == df[col].notna().sum(): + df[col] = coerced.astype(np.float32) + logger.debug( + "[LedgeredDataFrameManager] restored '%s' object -> float32", col) + except Exception as exc: + logger.debug("[LedgeredDataFrameManager] dtype repair skipped for '%s': %s", + col, exc) + # === 1) Downcast float64 signal columns to float32 === # Per-sample / per-instance signal scalars (loss & metric values) don't need # float64 precision, so this halves the cost of every ``signals//*`` column # with no practical loss for monitoring. Numeric dtype is preserved (NaNs # stay NaN). Done before categorical conversion below. - for col in df.columns: + for col in _scan_cols: if str(col).startswith("signals") and df[col].dtype == np.float64: try: df[col] = df[col].astype(np.float32) @@ -2159,7 +2423,12 @@ def _optimize_dataframe_memory(self, df: pd.DataFrame, categorical_tags: Dict[st # into a Categorical, its missing cells are stuck as NaN (categorical code # -1) and can no longer be set to a plain None — so we clean object cells to # None here first, while they are still plain object dtype. - for col in df.columns: + for col in _scan_cols: + # The docstring above says numeric/bool/categorical are skipped, but the + # loop had no dtype check: at registration every signals//* column is all + # NaN, so this did a full label-aligned write per float column. + if df[col].dtype != object: + continue na_mask = df[col].isna() if na_mask.any(): df.loc[na_mask, col] = None @@ -2171,6 +2440,17 @@ def _optimize_dataframe_memory(self, df: pd.DataFrame, categorical_tags: Dict[st SampleStats.Ex.TASK_TYPE.value, # Task type (e.g. 'classification', 'segmentation') ] + # nunique() over a 4M-row MultiIndex costs ~8s AND holds the GIL, stalling + # the training thread inside forward/backward. Only an object-dtype + # candidate reads it, so compute it on first use rather than every flush. + _n_rows_cache = [] + + def _n_rows(): + if not _n_rows_cache: + _n_rows_cache.append( + df.index.get_level_values(0).nunique() + if isinstance(df.index, pd.MultiIndex) else len(df)) + return _n_rows_cache[0] for col in categorical_candidates: if col not in df.columns: continue @@ -2182,15 +2462,14 @@ def _optimize_dataframe_memory(self, df: pd.DataFrame, categorical_tags: Dict[st # Only convert to categorical if: # 1. Column contains strings (object dtype) # 2. Number of unique values < 50% of total rows (good compression ratio) + if columns is not None and col not in columns: + continue if df[col].dtype == 'object': n_unique = df[col].nunique() # Use unique sample count, not row count — with MultiIndex each # sample has multiple annotation rows which would inflate n_rows # and make the ratio appear better than it really is. - if isinstance(df.index, pd.MultiIndex): - n_rows = df.index.get_level_values(0).nunique() - else: - n_rows = len(df) + n_rows = _n_rows() compression_ratio = n_unique / n_rows if n_rows > 0 else 1.0 if compression_ratio < 0.5 and n_unique > 1: # Worth compressing if < 50% unique @@ -2319,6 +2598,44 @@ def get_combined_df( return df + def _fill_documented_flag_defaults(self) -> None: + """Give every boolean column SampleStatsEx documents a default its default. + + Only the columns with a ``bool`` default in + ``SampleStatsEx.DEFAULTS`` (today: ``discarded``) -- a ``tag:*`` column + is deliberately left sparse, where NaN and False mean the same thing + and NaN costs nothing to store, and a *categorical* tag's NaN means + "unset", which is not a default at all. + + Cheap by design: an ``isna().any()`` short-circuit per flag column, so + the common case (nothing missing) touches no rows. + """ + if self._df is None or self._df.empty: + return + # DEFAULTS lives on SampleStats, the outer class -- SampleStatsEx is + # only its `Ex` enum, so reading it off that is a silent no-op. + from weightslab.data.sample_stats import SampleStats + defaults = getattr(SampleStats, "DEFAULTS", {}) or {} + for col, default in defaults.items(): + if not isinstance(default, bool) or col not in self._df.columns: + continue + try: + series = self._df[col] + if not series.isna().any(): + continue + if isinstance(series.dtype, pd.CategoricalDtype): + # fillna on a Categorical raises unless the value is a + # known category; widen first, then let the memory pass + # re-apply the dtype. + series = series.astype(object) + self._df[col] = series.fillna(default) + logger.debug( + "[LedgeredDataFrameManager] filled missing %r with its " + "documented default %r", col, default) + except Exception as exc: # noqa: BLE001 -- never fail an upsert on this + logger.debug( + "[LedgeredDataFrameManager] could not default %r: %s", col, exc) + def get_collapse_annotations_to_samples_df(self, df: pd.DataFrame | None = None) -> pd.DataFrame: """Collapse a (sample_id, annotation_id) multi-index df to one row per sample. diff --git a/weightslab/data/h5_array_store.py b/weightslab/data/h5_array_store.py index 02c3e7e3..405cf02b 100644 --- a/weightslab/data/h5_array_store.py +++ b/weightslab/data/h5_array_store.py @@ -514,6 +514,52 @@ def save_array( finally: self._rw_lock.release_write() + def _try_inplace_batch(self, prepared): + """Overwrite existing datasets in place; None means "cannot, fall back". + + Two passes under the write lock: check every destination exists with a + matching shape and dtype, and only then write. A partial in-place write + followed by a fallback would corrupt silently, so nothing is written + until the whole batch is known to fit. + """ + if not self._path.exists(): + return None + with self._local_lock: + self._rw_lock.acquire_write() + try: + with _InterProcessFileLock(self._lock_path, timeout=self._lock_timeout, + poll_interval=self._poll_interval): + with h5py.File(str(self._path), 'a') as f: + for group_name, key_data in prepared.items(): + grp = f.get(group_name) + if grp is None: + return None + for key_name, (array, _meta) in key_data.items(): + kg = grp.get(key_name) + if kg is None or 'data' not in kg: + return None + dset = kg['data'] + if dset.shape != array.shape or dset.dtype != array.dtype: + return None + for group_name, key_data in prepared.items(): + for key_name, (array, metadata) in key_data.items(): + kg = f[group_name][key_name] + kg['data'][...] = array + for mk, mv in metadata.items(): + kg.attrs[mk] = mv + return { + group_name: { + key_name: self._build_path_reference(group_name, key_name) + for key_name in key_data + } + for group_name, key_data in prepared.items() + } + except Exception as exc: + logger.debug(f"[H5ArrayStore] in-place batch fell back: {exc}") + return None + finally: + self._rw_lock.release_write() + def save_arrays_batch( self, arrays_dict: Dict[int, Dict[str, np.ndarray]], @@ -562,6 +608,15 @@ def save_arrays_batch( if not prepared: return {} + # O(change): if every array already exists with the same shape and dtype, + # overwrite the values in place. That is not a structural change, so it + # needs neither the temp file nor the full-file backup (9.5GB per flush + # at current ledger size). Returns None if anything would need creating + # or resizing, and the original two-phase path below runs unchanged. + inplace_refs = self._try_inplace_batch(prepared) + if inplace_refs is not None: + return inplace_refs + tmp_path = self._path.with_suffix(f".h5.writing_{uuid.uuid4().hex[:8]}") try: with h5py.File(str(tmp_path), 'w') as f_tmp: diff --git a/weightslab/data/h5_dataframe_store.py b/weightslab/data/h5_dataframe_store.py index 476b5655..431425d1 100644 --- a/weightslab/data/h5_dataframe_store.py +++ b/weightslab/data/h5_dataframe_store.py @@ -680,6 +680,120 @@ def load_all(self, origins: Iterable[str] = None, columns: Optional[Iterable[str return pd.DataFrame() raise + def ensure_index(self, origin: str, columns=("sample_id",)) -> bool: + """Build the on-disk column index deliberately (checkpoint / first query). + + Kept OFF the flush path: rebuilding it per upsert costs 92.7s at 4M rows + versus 6.9s without, for an index no hot-path read uses. + """ + key = self._key(origin) + try: + with self._local_lock: + with _InterProcessFileLock(self._lock_path, timeout=self._lock_timeout, + poll_interval=self._poll_interval): + with pd.HDFStore(str(self._path), mode="a") as store: + if key not in store: + return False + store.create_table_index(key, columns=list(columns), + optlevel=6, kind="medium") + return True + except Exception as exc: + logger.warning(f"[H5DataFrameStore] ensure_index({origin}) failed: {exc}") + return False + + # --- O(change) in-place update ------------------------------------------ + _POSMAP_CACHE: dict = {} + + def _posmap(self, store, key, force=False): + """(sample_id, annotation_id) -> row position. Rows are registered once + and never deleted, so positions are stable; built from ONE column (2.9s + at 4M rows) rather than reading the table.""" + ck = (str(self._path), key) + if not force and ck in self._POSMAP_CACHE: + return self._POSMAP_CACHE[ck] + try: + sids = store.select_column(key, "sample_id").values + try: + aids = store.select_column(key, "annotation_id").values + except Exception: + aids = np.zeros(len(sids), dtype="i8") + # Hoist the normalisation out of the insert loop: the per-row + # decode/str/int calls interleaved with dict inserts cost 3.6s at 4M + # rows, against ~1.7s for dict(zip(...)) over pre-normalised lists. + # One pass that is also the type check: str hits the identity branch, + # so a mixed-dtype column stays correct without a second full scan. + sid_list = [s if type(s) is str + else (s.decode() if isinstance(s, bytes) else str(s)) + for s in sids.tolist()] + m = dict(zip(zip(sid_list, aids.tolist()), range(len(sid_list)))) + self._POSMAP_CACHE[ck] = m + return m + except Exception as exc: + logger.debug(f"[H5DataFrameStore] posmap build failed: {exc}") + return None + + def _invalidate_posmap(self, key): + self._POSMAP_CACHE.pop((str(self._path), key), None) + + def _try_inplace(self, store, key, df_norm) -> bool: + """Overwrite existing rows' values in place. True if fully applied.""" + try: + import tables as _tables + except Exception: + return False + try: + node = store._handle.get_node(key) + tbl = getattr(node, "table", node) + if not isinstance(tbl, _tables.Table): + return False + # An indexed column cannot be modified in place (PyTables raises). + if any(tbl.cols._f_col(c).is_indexed for c in tbl.colnames): + return False + + cols = [c for c in df_norm.columns if c in tbl.colnames] + if len(cols) != len(df_norm.columns): + return False # new column => schema change + + pos = self._posmap(store, key) + if not pos: + return False + + idx = df_norm.index + if isinstance(idx, pd.MultiIndex): + pairs = [(str(a), int(b)) for a, b in zip(idx.get_level_values(0), + idx.get_level_values(1))] + else: + pairs = [(str(a), 0) for a in idx] + + coords = np.empty(len(pairs), dtype=np.int64) + for i, p in enumerate(pairs): + j = pos.get(p) + if j is None: + return False # unknown row => not an update + coords[i] = j + + order = np.argsort(coords) # PyTables wants ascending coords + coords_sorted = coords[order] + rec = tbl.read_coordinates(coords_sorted) + for c in cols: + vals = df_norm[c].to_numpy()[order] + tgt = rec[c].dtype + if tgt.kind == "S": + vals = np.array([("" if v is None else str(v)).encode()[:tgt.itemsize] + for v in vals], dtype=tgt) + else: + try: + vals = vals.astype(tgt, copy=False) + except Exception: + return False # dtype mismatch => fall back + rec[c] = vals + tbl.modify_coordinates(coords_sorted, rec) + tbl.flush() + return True + except Exception as exc: + logger.debug(f"[H5DataFrameStore] in-place update fell back: {exc}") + return False + def upsert(self, origin: str, df: pd.DataFrame) -> int: """Atomic upsert with corruption prevention via backup and checksum verification.""" df_norm = self._normalize_for_write(df) @@ -689,13 +803,26 @@ def upsert(self, origin: str, df: pd.DataFrame) -> int: key = self._key(origin) self._ensure_parent() - # Create backup BEFORE any writes - backup_path = self._create_backup() + # The backup is a full copy of the file (696MB at 4M rows). Only the + # read-merge-rewrite path below can destroy the table -- the in-place + # path just overwrites values in already-allocated rows -- so the copy + # is deferred until we know we are taking the destructive route. + backup_path = None with self._local_lock: with _InterProcessFileLock(self._lock_path, timeout=self._lock_timeout, poll_interval=self._poll_interval): try: with pd.HDFStore(str(self._path), mode="a") as store: + # O(change): if every row already exists and the schema is + # unchanged, overwrite values in place (0.1ms vs 42s). + if key in store and self._try_inplace(store, key, df_norm): + return len(df_norm) + + # Nothing has been written yet in this call; flush so the + # copy below captures a consistent on-disk file. + store.flush() + backup_path = self._create_backup() + existing = pd.DataFrame() # Try to load existing data. A ValueError can surface from a @@ -801,7 +928,8 @@ def upsert(self, origin: str, df: pd.DataFrame) -> int: store.remove(key) # Write new data - store.append(key, existing, format="table", data_columns=True) + store.append(key, existing, format="table", data_columns=True, index=False) + self._invalidate_posmap(key) # Force flush to disk store.flush() @@ -880,7 +1008,7 @@ def delete_column(self, column_name: str, origins: Optional[Iterable[str]] = Non # Remove old key and write updated dataframe store.remove(key) if not df.empty: - store.append(key, df, format="table", data_columns=True) + store.append(key, df, format="table", data_columns=True, index=False) modified_count += 1 logger.debug(f"[H5DataFrameStore] Deleted column {column_name} from {origin}") diff --git a/weightslab/examples/PyTorch/wl-video-generation/utils/data.py b/weightslab/examples/PyTorch/wl-video-generation/utils/data.py index ddb4d3ab..42112fba 100644 --- a/weightslab/examples/PyTorch/wl-video-generation/utils/data.py +++ b/weightslab/examples/PyTorch/wl-video-generation/utils/data.py @@ -31,7 +31,6 @@ """ import csv import logging -import os import subprocess import shutil import wave diff --git a/weightslab/src.py b/weightslab/src.py index 88067e30..bf3c3e3e 100644 --- a/weightslab/src.py +++ b/weightslab/src.py @@ -83,7 +83,16 @@ def _resolve_configured_root_log_dir(configured): actually points at an existing directory; if it's set but stale/typo'd, a warning is logged and resolution falls through to (3) instead of silently training into a directory the UI never established. - 3. A throwaway ``tempfile.mkdtemp()`` — last resort so serving never fails + 3. The directory a RUNNING ``weightslab start`` established, read from + the marker file (see weightslab.utils.active_experiment). Only while + that UI is alive: a record left by one that has since exited must not + redirect an unrelated run. The + environment variable only reaches processes started FROM that same + shell; a training run launched in another terminal (or by + ``weightslab start example``) is a different process tree and used to + fall straight through to (4), landing in %TEMP% while the UI listed an + empty reports/ from the directory it had established. + 4. A throwaway ``tempfile.mkdtemp()`` — last resort so serving never fails for lack of a directory. """ if configured: @@ -94,9 +103,28 @@ def _resolve_configured_root_log_dir(configured): return env_dir logger.warning( f"WEIGHTSLAB_ROOT_LOG_DIR is set to '{env_dir}', but that directory " - "does not exist. Falling back to a temporary directory instead." + "does not exist. Falling back to the experiment directory recorded " + "by `weightslab start`, then to a temporary directory." ) - return tempfile.mkdtemp() + try: + from weightslab.utils.active_experiment import live_ui_experiment_dir + marker_dir = live_ui_experiment_dir() + except Exception: # noqa: BLE001 -- never block serving on the marker + marker_dir = None + if marker_dir: + logger.info( + "Using the experiment directory established by `weightslab start`: " + "%s (no root_log_dir configured and WEIGHTSLAB_ROOT_LOG_DIR is not " + "set in this process).", marker_dir) + return marker_dir + tmp_dir = tempfile.mkdtemp() + logger.warning( + "No root_log_dir configured, no WEIGHTSLAB_ROOT_LOG_DIR, and no " + "experiment directory recorded by `weightslab start` — this run will " + "write to the throwaway directory %s. Checkpoints, reports and the " + "notebook will NOT be where the UI looks for them. Start the UI first " + "(`weightslab start`), or set root_log_dir in your config.", tmp_dir) + return tmp_dir # Get global dataframe proxy (auto-updated when ledger registers real manager) @@ -494,6 +522,16 @@ def history(self, signal_name): out = {s: [] for s in self.sample_ids} if self.logger is None: return out + # Read the bounded in-memory tail: O(batch). The full-history query this + # replaced scanned the entire per_sample table on every call (140ms at + # 20M rows, once per step, growing without bound). + if not os.environ.get("WL_HISTORY_FROM_DB"): + recent = self.logger.recent_per_sample(signal_name, self.sample_ids) + for s in self.sample_ids: + vals = recent.get(s) + if vals: + out[s] = list(vals) + return out # query_per_sample accepts a list of ids -> one scan for the whole batch. for sid, step, val, _ in self.logger.query_per_sample(signal_name, sample_ids=self.sample_ids): out.setdefault(int(sid), []).append(val) # rows already ordered by seq (= step order) @@ -1023,9 +1061,23 @@ def wrappered_fwd(original_forward, kwargs, reg_name, *a, **kw): # batch and call the signal once. It returns a length-B # array. Avoids B Python calls + B SignalContext allocs, # and lets the signal do batched ledger reads. + # inputs= must be populated here too: on the + # subscribe_to path the context was built without it, + # so b.inputs was {} and any signal declaring + # inputs=[...] raised KeyError on every call. The + # subscribed signal IS the declared input here, and + # its per-sample values are already in val_vec. + _decl = meta.get('inputs') or [] + _sub = meta.get('subscribe_to') + _vals = [float(v) for v in val_vec] + _bin = {} + for _d in _decl: + if _sub is None or _d == _sub or _d == reg_name: + _bin[_d] = _vals bctx = BatchSignalContext( sample_ids=[int(u) for u in ids_np], - subscribed_values=[float(v) for v in val_vec], + subscribed_values=_vals, + inputs=_bin, logger=_lg, dataframe=df_proxy, origin=kwargs.get('origin', 'train'), @@ -1446,6 +1498,14 @@ def new_forward(*a, **kw): # _resolve_configured_root_log_dir for the resolution order). _hp_cfg['root_log_dir'] = _resolve_configured_root_log_dir( _hp_cfg.get('root_log_dir')) + # Publish where training ACTUALLY writes, so the UI lists this + # run's reports/notebooks even when the two resolved their + # directory by different routes. + try: + from weightslab.utils.active_experiment import record_backend_experiment + record_backend_experiment(_hp_cfg['root_log_dir']) + except Exception as _exc: # noqa: BLE001 -- advisory only + logger.debug("Could not record the backend experiment dir: %s", _exc) try: # Check if a checkpoint manager is already registered in ledger try: @@ -1738,6 +1798,25 @@ def serve(serving_cli: bool = True, serving_grpc: bool = True, _notebook_service.configure_embedded_kernel(embed_kernel_decision) if serving_grpc: + # Stamp the gRPC port on this backend's active-experiment record, so a + # UI proxying to that port finds THIS experiment's directory rather + # than whichever backend happened to start last (see + # active_experiment._sole_live_dir). Advisory: never fail serving on it. + try: + from weightslab.utils.active_experiment import record_backend_experiment + _grpc_port = (kwargs.get("grpc_port") + or int(os.getenv("GRPC_BACKEND_PORT", 50051))) + _root_log_dir = None + try: + _hp = ledgers.get_hyperparams() + _root_log_dir = _hp["root_log_dir"] if _hp is not None else None + except Exception: # noqa: BLE001 -- no hyperparameters registered + _root_log_dir = None + if _root_log_dir: + record_backend_experiment(_root_log_dir, grpc_port=int(_grpc_port)) + except Exception as _exc: # noqa: BLE001 + logger.debug("Could not record the backend gRPC port: %s", _exc) + grpc_serve(**kwargs) if embed_kernel_decision: @@ -4935,6 +5014,16 @@ def resolve_signal_classifier(signal_name): return _GLOBAL_CLASSIFIER or classify_loss_shape +_SHAPE_LABELS: dict = {} + + +def _label_counts(cache): + out = {} + for lab in cache.values(): + out[lab] = out.get(lab, 0) + 1 + return out + + def write_signal_shapes(signal_name, tag_name=None, classifier=None, exp_hash=None, sample_ids=None): """Reusable engine: classify each sample's own trajectory of *signal_name* into a categorical tag and return the ``{label: count}`` distribution. @@ -4953,17 +5042,29 @@ def write_signal_shapes(signal_name, tag_name=None, classifier=None, exp_hash=No clf = classifier or resolve_signal_classifier(signal_name) if tag_name is None: tag_name = signal_name + "_shape" if signal_name.endswith('_loss') else signal_name + "_loss_shape" + + # Labels for samples this pass does not reclassify are carried here, so an + # incremental call still returns a distribution over the WHOLE dataset, and + # a sample whose label is unchanged is not re-written to the ledger. + cache = _SHAPE_LABELS.setdefault(signal_name, {}) + if sample_ids is not None and not list(sample_ids): + return _label_counts(cache) + series = {} for sid, step, val, _ in query_signal_history(signal_name, exp_hash=exp_hash, sample_ids=sample_ids): series.setdefault(sid, []).append((step, val)) by_label = {} for sid, pts in series.items(): label = clf([v for _, v in sorted(pts)]) - if label is not None: - by_label.setdefault(label, []).append(sid) + if label is None: + continue + if cache.get(sid) == label: + continue # unchanged -> no ledger write needed + cache[sid] = label + by_label.setdefault(label, []).append(sid) for label, sids in by_label.items(): set_categorical_tag(sids, tag_name, label) - return {k: len(v) for k, v in by_label.items()} + return _label_counts(cache) def write_loss_shapes(loss_signal="loss_sample", classifier=None): diff --git a/weightslab/trainer/services/agent/agent.py b/weightslab/trainer/services/agent/agent.py index 7158d484..b1701b94 100644 --- a/weightslab/trainer/services/agent/agent.py +++ b/weightslab/trainer/services/agent/agent.py @@ -735,6 +735,12 @@ def _load_config(self): # actually chose is exempt from that self-healing. self._opencode_model_explicit = "OPENCODE_MODEL" in os.environ self.opencode_model = os.environ.get("OPENCODE_MODEL", "") + # agent_config.yaml's opencode_model lands here instead of pinning the + # model: it SEEDS the shared choice (used when nothing has been chosen + # yet, then published so the studio shows it), while a model picked in + # the UI afterwards wins. Pinning it meant a run started after picking + # a model in the studio silently went back to the yaml value. + self._opencode_model_seed = "" # The same directory `weightslab start ` roots the browser # landing-page agent at (WEIGHTSLAB_ROOT_LOG_DIR) -- the shared key # opencode_process.py's lock file is discovered/published under, so @@ -759,6 +765,9 @@ def _load_config(self): inner_pkg / "agent_config.yaml", Path.cwd() / "agent_config.yaml" ] + # Overwritten below only when a file is actually applied, so the banner + # can say "none found" instead of naming the last candidate it tried. + self._config_source_path = "(no agent config file found)" for path in config_paths: if not path.exists(): continue try: @@ -771,28 +780,36 @@ def _load_config(self): if a_cfg.get("opencode_url"): self._opencode_url_explicit = True self.opencode_url = a_cfg.get("opencode_url", self.opencode_url) - if a_cfg.get("opencode_model"): - self._opencode_model_explicit = True - self.opencode_model = a_cfg.get("opencode_model", self.opencode_model) + _cfg_model = str(a_cfg.get("opencode_model") or "").strip() + if _cfg_model: + # Seed, not pin -- see _opencode_model_seed above. + # OPENCODE_MODEL still wins: it is set per process, on purpose. + self._opencode_model_seed = _cfg_model + self._config_source_path = path _LOGGER.info(f"Applied agent configuration from {path}") _LOGGER.debug(f"Agent Config: {cfg}") break except Exception as e: _LOGGER.warning(f"Error loading config from {path}: {e}") - # Log the final configuration for transparency - _LOGGER.info( - "" + "\n" + - "\n# #######################################" + "\n" + - "# #######################################" + "\n" + - f"Agent initialized from configuration {path}: " + "\n" + - f"\tOpenCode URL={self.opencode_url}, Model={self.opencode_model or '(server default)'}" + "\n" + - "# #######################################" + "\n" + - "# #######################################" + "\n" + "" - ) - - def _setup_providers(self): + # The banner itself is emitted by _log_agent_configuration() AFTER + # _setup_providers has resolved the model, so it names the model + # actually in use instead of the pre-resolution blank -- which printed + # "(server default)" and read as "the studio's pick was ignored". + + def _setup_providers(self, requested_model: Optional[str] = None): + """(Re)build the OpenCode provider. + + `requested_model` is a model the USER just chose (CLI `agent model`, + `agent init --model`, the SetAgentModel RPC). It must survive this + call: the shared-config re-read below exists to follow the studio's + picker, and it used to overwrite the very model the caller had just + asked for -- `agent model X` answered "Model switched to ". A user choice is instead PUBLISHED to OpenCode's config, so it + becomes the shared choice the studio picker and every other client see + too, and is only pinned in-process when that write is refused. + """ self.chain_opencode = None self._opencode_chat = None initialized = False @@ -806,18 +823,85 @@ def _setup_providers(self): workspace_dir=self.opencode_workspace_dir, url_is_explicit=self._opencode_url_explicit, model_is_explicit=self._opencode_model_explicit, + seed_model=getattr(self, "_opencode_model_seed", ""), ) self.chain_opencode = self._opencode_chat.as_runnable() initialized = True + # Resolve up front rather than on the first query: the model is + # part of what the start-up banner reports, and a backend started + # before the studio publishes its fallback so the UI adopts the + # same model (see OpenCodeChat.resolve_model). + try: + if requested_model: + self._opencode_chat.model = requested_model + self.opencode_model = requested_model + if self._opencode_chat.publish_model(requested_model): + # Shared, not pinned: later turns keep re-reading the + # config, which now names this model -- so a studio + # pick after this still wins, as it should. + self._opencode_model_source = "user-published" + else: + # The config would not take it (read-only, older + # server). Pin it for this process so the switch the + # user asked for still takes effect. + self._opencode_chat.model_is_explicit = True + self._opencode_model_explicit = True + self._opencode_model_source = "user-pinned" + else: + resolved, source = self._opencode_chat.resolve_model(publish_default=True) + if resolved: + self.opencode_model = resolved + self._opencode_model_source = source + # _ensure_reachable may have moved us to a discovered server. + self.opencode_url = self._opencode_chat.base_url + except Exception as exc: # noqa: BLE001 -- server down; lazy path retries + _LOGGER.debug("[Agent] deferred OpenCode model resolution: %s", exc) + self._opencode_model_source = "unresolved" _LOGGER.info( f"[Agent] OpenCode enabled: {self.opencode_url} " - f"(model={self.opencode_model or 'server default'})" + f"(model={self.opencode_model or 'unresolved'})" ) except Exception as e: _LOGGER.error(f"OpenCode error: {e}") + self._log_agent_configuration() return initialized + # Human-readable provenance for the start-up banner. + _MODEL_SOURCE_LABELS = { + "pinned": "pinned by OPENCODE_MODEL", + "pinned-published": ("pinned by OPENCODE_MODEL, and published to " + "OpenCode's config so the studio shows it"), + "config-seed": ("from agent_config.yaml's opencode_model; nothing was " + "chosen in OpenCode's config yet"), + "config-seed-published": ("from agent_config.yaml's opencode_model " + "(nothing chosen yet), published to OpenCode's " + "config so the studio shows it"), + "opencode-config": "from OpenCode's config, which the studio model picker writes", + "default": "built-in default; OpenCode's config could not be updated", + "default-published": "built-in default, published to OpenCode's config for the studio", + "user-published": "chosen here and published to OpenCode's config", + "user-pinned": "chosen here; OpenCode's config refused the write, pinned to this backend", + "kept": "kept from this session", + "unresolved": "unresolved -- OpenCode unreachable, retried on the first query", + } + + def _log_agent_configuration(self) -> None: + """Start-up banner: the model actually in use, and where it came from.""" + source = getattr(self, "_opencode_model_source", "unresolved") + detail = self._MODEL_SOURCE_LABELS.get(source, source) + path = getattr(self, "_config_source_path", "(no agent config file found)") + _LOGGER.info( + "" + "\n" + + "\n# #######################################" + "\n" + + "# #######################################" + "\n" + + f"Agent initialized from configuration {path}: " + "\n" + + f"\tOpenCode URL={self.opencode_url}" + "\n" + + f"\tModel={self.opencode_model or '(unresolved)'} ({detail})" + "\n" + + "# #######################################" + "\n" + + "# #######################################" + "\n" + "" + ) + def is_available(self) -> bool: """Return True if the OpenCode provider is ready to serve requests.""" return self.chain_opencode is not None @@ -843,10 +927,12 @@ def initialize_with_cloud_key(self, api_key: str, provider: str, model: Optional if model is not None and not model.strip(): return False, "Model cannot be empty." - self.opencode_model = model.strip() if model and model.strip() else self.opencode_model + requested = model.strip() if model and model.strip() else None + if requested: + self.opencode_model = requested self.preferred_provider = "opencode" - success = self._setup_providers() + success = self._setup_providers(requested_model=requested) if self.chain_opencode is None or not success: return False, "Could not reach the OpenCode server. Please verify OPENCODE_URL and that it is running." @@ -865,11 +951,56 @@ def change_model(self, model: str) -> "tuple[bool, str]": if not model or not model.strip(): return False, "Model cannot be empty." - self.opencode_model = model.strip() - success = self._setup_providers() + requested = model.strip() + self.opencode_model = requested + success = self._setup_providers(requested_model=requested) if self.chain_opencode is None or not success: return False, "Could not reach the OpenCode server. Please verify OPENCODE_URL and that it is running." - return True, f"Model switched to {self.opencode_model}. Ready to help you." + if self.opencode_model != requested: + # Never report a switch that did not happen. + return False, (f"Could not switch to {requested}: the model in use is " + f"{self.opencode_model}.") + shared = self._opencode_model_source == "user-published" + return True, ( + f"Model switched to {self.opencode_model}. " + + ("Published to OpenCode's config, so the studio picker shows it too. " + if shared else + "OpenCode's config would not take it, so it is pinned to this backend only. ") + + "Ready to help you." + ) + + def current_model(self) -> Optional[str]: + """The model the NEXT query will actually use. + + `self.opencode_model` is only what was resolved when the provider was + last (re)initialised. A model chosen in the studio afterwards lands in + OpenCode's config, which every turn re-reads -- so reporting the + snapshot made a UI pick look ignored (`agent status` kept naming the + old model while queries already used the new one). Re-resolves here: + one local GET, on a command the user typed. + """ + chat = self._opencode_chat + if chat is None: + return self.opencode_model or None + try: + model, source = chat.resolve_model() + if model: + self.opencode_model = model + self._opencode_model_source = source + if source == "pinned": + # A pin wins for this backend, so say plainly when the studio + # is showing something else -- otherwise the two surfaces + # disagree with no explanation anywhere. + chosen = chat._configured_model() + if chosen and chosen != self.opencode_model: + _LOGGER.warning( + "[Agent] OpenCode's configured model is %s (the studio's " + "pick), but this backend is pinned to %s by " + "OPENCODE_MODEL. Unset that variable to follow the " + "picker.", chosen, self.opencode_model) + except Exception as exc: # noqa: BLE001 -- report the last known model + _LOGGER.debug("[Agent] current_model could not re-resolve: %s", exc) + return self.opencode_model or None def _opencode_base_url(self) -> str: """Same self-heal `OpenCodeChat._ensure_reachable` gives every chat diff --git a/weightslab/trainer/services/agent/opencode_chat.py b/weightslab/trainer/services/agent/opencode_chat.py index e67581a4..139f0abf 100644 --- a/weightslab/trainer/services/agent/opencode_chat.py +++ b/weightslab/trainer/services/agent/opencode_chat.py @@ -64,7 +64,7 @@ # unset -- which is exactly the "OpenCode picks WHATEVER model happens to be # configured, arbitrarily" failure this method exists to avoid in the first # place. -_DEFAULT_MODEL = "opencode/deepseek-v4-flash-free" +_DEFAULT_MODEL = "opencode/big-pickle" class OpenCodeError(RuntimeError): @@ -79,7 +79,7 @@ class OpenCodeChat: def __init__(self, base_url: str, model: Optional[str] = None, timeout: float = 60.0, workspace_dir: Optional[str] = None, url_is_explicit: bool = True, - model_is_explicit: bool = True): + model_is_explicit: bool = True, seed_model: Optional[str] = None): self.base_url = (base_url or "http://127.0.0.1:4096").rstrip("/") self.model = model self.timeout = timeout @@ -105,6 +105,13 @@ def __init__(self, base_url: str, model: Optional[str] = None, timeout: float = # (confirmed live: an image-generation preview model, useless for # this class's structured-JSON-reply use case). self.model_is_explicit = model_is_explicit + # A model from agent_config.yaml SEEDS the shared choice rather than + # pinning it: it is what to use when nobody has chosen anything yet + # (and it is then published, so the studio shows it), but a model + # picked in the UI afterwards wins. It used to pin, so a run started + # after picking a model in the studio quietly went back to the yaml + # value. OPENCODE_MODEL stays a hard pin -- automation needs one. + self.seed_model = (seed_model or "").strip() or None # -- wire helpers --------------------------------------------------- # @@ -317,7 +324,11 @@ def _ensure_model_resolved(self) -> None: this class's structured-JSON intent-parsing, since it isn't a text-reasoning model at all). + Returns a short source label ("pinned" | "opencode-config" | + "config-seed" | "default" | "kept") for the banner/logs. + Resolution order: + 0. `OPENCODE_MODEL` (model_is_explicit) -- a hard pin, left alone. 1. `GET /config`'s own `model` field -- the one the model picker writes back to opencode.json on every pick (opencodeClient.ts's setDefaultModel), so it's "whatever the user last actually @@ -337,22 +348,116 @@ def _ensure_model_resolved(self) -> None: explicitly chosen, land on the known-good free model" now means exactly that, with no provider-reported default able to override it. - Resolved once and cached on self.model. An explicit model - (model_is_explicit=True) is left alone -- deliberately chosen, not - a placeholder to override. + Re-checked before every turn, NOT cached for the life of the + process: the studio's model picker (.wl-ag-model) writes the pick + into OpenCode's own config via `PUT /config`, and a backend that + latched onto the model it saw at startup went on answering with the + old one for the rest of the run. `GET /config` is a local request on + the same machine, so following it per turn costs nothing next to the + completion it precedes. A failed read keeps the current model instead + of falling back. + + An explicit model (model_is_explicit=True -- OPENCODE_MODEL or + agent_config.yaml's `opencode_model`) is left alone: deliberately + chosen, not a placeholder to override, and NOT overridable from the + UI picker either. """ - if self.model_is_explicit or self.model: - return + if self.model_is_explicit: + return "pinned" + model_id = self._configured_model() + if model_id: + if model_id != self.model: + _LOGGER.info("[OpenCodeChat] following OpenCode's configured " + "model: %s (was %s)", model_id, self.model or "unset") + self.model = model_id + return "opencode-config" + # Nothing chosen anywhere yet -- fall back to the configured seed + # before the built-in default, so a project's agent_config.yaml still + # decides which model a fresh setup starts on. + if self.seed_model: + self.model = self.seed_model + return "config-seed" + # /config could not be read (or names no model): keep whatever was + # resolved on an earlier turn rather than dropping a working model for + # the fallback because one local request happened to fail. + if not self.model: + self.model = _DEFAULT_MODEL + return "default" + return "kept" + + def publish_model(self, model: Optional[str] = None) -> bool: + """Write `model` (default: the resolved one) into OpenCode's own config. + + Same call the studio's model picker makes, so whichever side starts + first leaves ONE answer behind for the other to read, and both ends of + a session agree on the model without talking to each other. + + GLOBAL scope, with the workspace route as fallback: verified live, + PATCH /config echoes the value back but does NOT change what GET + /config then reports, while PATCH /global/config + (`global.config.update`) does, immediately -- and GET /config is what + both sides read. + + Best-effort: a read-only config or an older server must never stop the + agent from working with the model it already resolved in-process. + """ + model = model or self.model + if not model: + return False + for path in ("/global/config", "/config"): + try: + with self._request(path, method="PATCH", body={"model": model}) as resp: + resp.read() + except Exception as exc: # noqa: BLE001 -- advisory write, never fatal + _LOGGER.debug("[OpenCodeChat] could not publish model %s via %s: %s", + model, path, exc) + continue + # Confirm against the effective config rather than trusting the + # echo: the workspace route answers 200 for a write it drops. + if self._configured_model() == model: + _LOGGER.info("[OpenCodeChat] published model %s to OpenCode (%s) at %s", + model, path, self.base_url) + return True + _LOGGER.debug("[OpenCodeChat] model %s could not be published to %s", + model, self.base_url) + return False + + def _configured_model(self) -> Optional[str]: + """The model OpenCode itself reports (GET /config) -- the shared choice + the studio picker writes and this backend follows.""" try: with self._request("/config") as resp: config = json.loads(resp.read().decode("utf-8")) - model_id = (config or {}).get("model") - if isinstance(model_id, str) and "/" in model_id: - self.model = model_id - return - except Exception: # noqa: BLE001 - fall through to the hardcoded default - pass - self.model = _DEFAULT_MODEL + except Exception: # noqa: BLE001 + return None + model_id = (config or {}).get("model") + return model_id if isinstance(model_id, str) and "/" in model_id else None + + def resolve_model(self, publish_default: bool = False): + """Resolve the model NOW instead of lazily on the first turn, and say + where it came from: ("pinned" | "pinned-published" | "opencode-config" + | "default" | "default-published" | "kept"). + + Called at agent start-up so the banner states the model actually in + use -- it used to print "(server default)" whenever nothing was pinned, + which read as "the studio's choice was ignored" even when the first + turn would have picked it up correctly. + + With publish_default=True, the model is also written back to + OpenCode's config whenever this side is the one deciding it -- a pinned + model (OPENCODE_MODEL), a seed from agent_config.yaml, or the built-in + fallback. A backend started BEFORE the studio then hands the UI the + model it is itself using, instead of the studio showing an unrelated + default while every backend query ran on the pinned one. + + A model that CAME from OpenCode's config is never re-published: there + is nothing to write, and doing so would fight the picker. + """ + self._ensure_reachable() + source = self._ensure_model_resolved() + if publish_default and source in ("default", "config-seed", "pinned") and self.publish_model(): + source = f"{source}-published" + return self.model, source def _call(self, prompt_value): from langchain_core.messages import AIMessage diff --git a/weightslab/trainer/services/data_service.py b/weightslab/trainer/services/data_service.py index dd065d33..a2b0b477 100755 --- a/weightslab/trainer/services/data_service.py +++ b/weightslab/trainer/services/data_service.py @@ -44,8 +44,8 @@ from weightslab.data import media_store from weightslab.trainer.trainer_tools import execute_df_operation, generate_overview, encode_image_to_raw_bytes from weightslab.data.data_utils import load_raw_image_array - # Image encoding / mask compression / proto helpers (extracted) + from weightslab.trainer.services.data_image_utils import ( rle_encode_mask, create_data_stat, @@ -146,6 +146,47 @@ def _media_chunk_bytes() -> int: _NON_MASK_TASKS = ("classification", "tabular") +def is_set_flag(value) -> bool: + """True only for a boolean column value that is really SET and true. + + Two traps, both hit in production, both of them "a missing value read as + set": + + * ``bool(float("nan")) is True`` in Python, and a nullable flag column is + full of NaN -- ``discarded`` for a sample nothing has written yet, or a + ``tag:*`` column for every sample that does not carry the tag. Reading it + with ``bool()`` / ``astype(bool)`` reported all of them as set: samples + greyed out as discarded while the dataframe said False, and every sample + wearing every tag. + * ``bool("False") is True`` as well, and a boolean column that has been + through the H5 store (where these columns become categorical) can come + back holding the STRINGS "True"/"False". + + So: missing is false, a string is read as a word, everything else falls + back to ``bool()``. + """ + try: + if value is None or pd.isna(value): + return False + except (TypeError, ValueError): + # pd.isna raises for some array-likes; those are not missing. + pass + if isinstance(value, str): + return value.strip().lower() in ("1", "true", "yes", "y", "t") + try: + return bool(value) + except Exception: # noqa: BLE001 -- an exotic value is not a set flag + return False + + +def set_flag_mask(series) -> "np.ndarray": + """``is_set_flag`` over a whole column, as a numpy bool array.""" + if series is None: + return np.zeros(0, dtype=bool) + return np.fromiter((is_set_flag(v) for v in series.tolist()), + dtype=bool, count=len(series)) + + def _is_non_mask_task(task_type) -> bool: """True when labels/predictions for this task must not be read as masks.""" return task_type in _NON_MASK_TASKS or is_generation_task(task_type) @@ -494,6 +535,23 @@ def rewrite_boolean_keywords_to_bitwise(code: str) -> str: return code +def _histogram_category_cap() -> int: + """Max distinct bars a categorical histogram returns (WL_HIST_CATEGORY_CAP). + + Beyond this the response is neither renderable nor informative -- the + remainder is folded into a single "(other)" bar. + """ + try: + return max(1, int(os.environ.get("WL_HIST_CATEGORY_CAP", "200"))) + except Exception: + return 200 + + +def _fast_view_enabled() -> bool: + """Differential view refresh. On by default; set WL_FAST_VIEW=0 to opt out.""" + return os.environ.get("WL_FAST_VIEW", "1") not in ("0", "false", "False") + + class DataService: """ @@ -1109,6 +1167,23 @@ def _pull_into_all_data_view_df(self): # merge + proxy conversion) a second time over the whole dataset every refresh. df = self._df_manager.get_collapse_annotations_to_samples_df(df) + # The collapse yields object dtype for signals//* columns. They are + # numeric by definition, and an object column sorts ~8x slower + # (6.17s vs 0.76s at 3.96M) while also slowing histogram binning, + # groupby and every differential write. Coerce them back here, at the + # single point the view is materialised. + for _c in df.columns: + if not str(_c).startswith("signals") or df[_c].dtype != object: + continue + try: + _num = pd.to_numeric(df[_c], errors="coerce") + # Only when nothing is lost: a genuinely non-numeric value + # means the column is not what we assume, so leave it be. + if _num.notna().sum() == df[_c].notna().sum(): + df[_c] = _num.astype("float32") + except Exception as _exc: + logger.debug("[DataService] dtype restore skipped for %r: %s", _c, _exc) + # Ensure sample_id is a column if it was the index df = safe_reset_index(df) @@ -1131,7 +1206,9 @@ def _pull_into_all_data_view_df(self): return df except Exception as e: - logger.debug(f"[DataService] Error pulling data view: {e}") + # Was debug: a swallowed failure here silently freezes the view at + # the previous snapshot, which looks exactly like "no new data". + logger.error("[DataService] Error pulling data view: %s", e, exc_info=True) # Use getattr to safely check for attribute during __init__ current_df = getattr(self, "_all_datasets_df", None) return current_df if current_df is not None else pd.DataFrame() @@ -1448,8 +1525,9 @@ def _compute_custom_signals(self): except Exception as e: logger.error(f"[DataService] Failed to compute signals for loader '{loader_name}': {e}") - # Force view update - self._slowUpdateInternals(force=True) + # Refresh signal values; differential unless the schema actually changed. + if not self._fastUpdateInternals(): + self._slowUpdateInternals(force=True) def _process_sample_row(self, args): """Process a single dataframe row to create a DataRecord.""" @@ -1477,6 +1555,8 @@ def _process_sample_row(self, args): skip_prediction_for_request = metadata_only_request # ====== Step 2: Load dataset lazily (avoid unnecessary IO for metadata-only) ====== + # Views the client explicitly asked for; empty => send everything. + _wanted_stats = set(getattr(request, "stats_to_retrieve", None) or []) needs_dataset = bool(request.include_raw_data) or (not skip_label_for_request) dataset = self._get_dataset(origin) if needs_dataset else None @@ -1569,10 +1649,10 @@ def _process_sample_row(self, args): # 'discarded' drives the grayed-out cell rendering, so it rides with the # image data as "1"/"0" (not treated as analytical metadata). This keeps # the gray-out reliable on every grid (re)fetch / scroll. - try: - _discarded_str = "1" if bool(row.get(SampleStatsEx.DISCARDED.value)) else "0" - except Exception: - _discarded_str = "0" + # is_set_flag, not bool(): a nullable flag full of NaN read as + # set is what greyed out every sample the model had seen. + _discarded_str = "1" if is_set_flag( + row.get(SampleStatsEx.DISCARDED.value)) else "0" data_stats.append( create_data_stat( SampleStatsEx.DISCARDED.value, 'string', shape=[1], value_string=_discarded_str, thumbnail=b"" @@ -2150,14 +2230,91 @@ def _json_default(o): target_height=target_height, ) - data_stats.append( - create_data_stat( - name='raw_data', - stat_type='bytes', - thumbnail=raw_data_bytes, - shape=raw_shape, + # An explicit stats_to_retrieve means the client knows which + # views it will draw; raw_data duplicates view rank 0, so + # only send it when actually asked for. + if not _wanted_stats or 'raw_data' in _wanted_stats: + data_stats.append( + create_data_stat( + name='raw_data', + stat_type='bytes', + thumbnail=raw_data_bytes, + shape=raw_shape, + ) ) - ) + + # Paired/multi-view datasets (e.g. a source+edited image + # pair) can optionally expose additional named views via + # extra_images() -- send each as its own 'image_' + # stat so the frontend renders it as its own grid column + # (isImageFieldName() already recognizes 'image_*'). This + # duck-typed hook is a no-op for datasets that don't + # define it. raw_data above already covers view rank 0 + # (e.g. 'source'); extra_images() may repeat that view + # under its own name too -- one small duplicated + # thumbnail, traded for not having to assume which named + # view is redundant across arbitrary datasets. + # Probe the UNWRAPPED dataset: `dataset` is WL's tracking + # wrapper and does not forward extra_images, so testing it + # silently disables every named view. + if hasattr(ds, "extra_images"): + try: + extra_views = ds.extra_images(ds_idx) or {} + except Exception as e: + extra_views = {} + logger.debug(f"extra_images failed for sample_id={sample_id}: {e}") + for view_name, view_pil in extra_views.items(): + if view_pil is None: + continue + # Filter BEFORE resize/encode -- that is the cost. + # Still ADVERTISE the view with an empty thumbnail: + # the panel builds its modality list from the stats + # present, so omitting it entirely would delete the + # toggle and make the view unrecoverable. + if (_wanted_stats + and ("image_%s" % view_name) not in _wanted_stats): + data_stats.append( + create_data_stat( + name="image_%s" % view_name, + stat_type='bytes', + thumbnail=b"", + shape=[], + ) + ) + continue + try: + resized_view = view_pil + if resized_view.size != (target_width, target_height): + _view_resample = ( + Image.Resampling.LANCZOS if is_full_resolution + else Image.Resampling.BILINEAR + ) + resized_view = resized_view.resize( + (target_width, target_height), _view_resample + ) + view_bytes, view_shape, _ = encode_image_to_raw_bytes( + np_img=None, + middle_pil=resized_view, + original_shape=[], + is_volumetric=False, + is_full_resolution=is_full_resolution, + target_width=target_width, + target_height=target_height, + ) + data_stats.append( + create_data_stat( + name=f"image_{view_name}", + stat_type='bytes', + thumbnail=view_bytes, + shape=view_shape, + ) + ) + del view_bytes, resized_view + except Exception as e: + logger.debug( + f"extra_images encode failed for sample_id={sample_id} " + f"view={view_name}: {e}" + ) # For video samples the bytes above are only the poster # frame, so advertise the clip's shape here. This lets the @@ -2470,16 +2627,49 @@ def _sample_id_sortable_series(self, values): return numeric return values.astype(str) + def _numeric_like_sort_cols(self, df: pd.DataFrame, by) -> set: + """Sort columns whose values are strings but mean numbers. + + '1916469' < '191647' lexicographically but not numerically, so sorting a + numeric-valued string column by raw string order is simply wrong. Only + object/string columns are candidates; genuinely numeric dtypes already + sort correctly and must not be touched. + """ + out = set() + by_list = [by] if isinstance(by, str) else list(by or []) + for col in by_list: + if col == SampleStatsEx.SAMPLE_ID.value: + out.add(col) + continue + try: + s = df[col] if col in df.columns else None + if s is None or pd.api.types.is_numeric_dtype(s) or hasattr(s, "cat"): + continue + probe = s.dropna() + if probe.empty: + continue + # Cheap decision on a sample: a full 4M-row coercion here would + # cost more than the sort it is meant to correct. + head = probe.head(2048) + coerced = pd.to_numeric(head, errors="coerce") + if coerced.notna().all(): + out.add(col) + except Exception: + continue + return out + def _sort_values_numeric_aware(self, df: pd.DataFrame, sort_params: dict) -> None: - """Sort dataframe while treating sample_id as numeric when possible.""" + """Sort dataframe, ordering numeric-valued string columns numerically.""" params = dict(sort_params) - if params.get("key") is None and self._sort_includes_sample_id(params.get("by")): - def _key(series: pd.Series): - if str(getattr(series, "name", "")) == SampleStatsEx.SAMPLE_ID.value: - return self._sample_id_sortable_series(series) - return series + if params.get("key") is None: + numeric_like = self._numeric_like_sort_cols(df, params.get("by")) + if numeric_like: + def _key(series: pd.Series): + if str(getattr(series, "name", "")) in numeric_like: + return self._sample_id_sortable_series(series) + return series - params["key"] = _key + params["key"] = _key df.sort_values(inplace=True, **params) @@ -3527,6 +3717,38 @@ def _apply_agent_operation(self, df, func: str, params: dict) -> str: # silently stops refreshing). orig_index_names = [n for n in df.index.names if n is not None] + # Fast path: nothing in `by` is an index level, so the frame can be + # sorted where it stands. Avoids reset_index + astype(int)/astype(str) + # over every sample_id + a set_index that re-factorizes 3.96M string + # keys -- measured as the bulk of a ~20s sort. + _fp_by = params.get("by") + _fp_list = [_fp_by] if isinstance(_fp_by, str) else list(_fp_by or []) + _fp_res = [ + (c if c in df.columns + else ("signals//" + c if ("signals//" + c) in df.columns else c)) + for c in _fp_list + ] + if (_fp_res + and all(c in df.columns for c in _fp_res) + and not any(c in orig_index_names for c in _fp_list) + and not any(c in orig_index_names for c in _fp_res)): + _fp_params = dict(params) + _fp_params["by"] = (_fp_res if isinstance(_fp_by, (list, tuple)) + else _fp_res[0]) + try: + # Through the helper, NOT df.sort_values directly: a + # numeric-valued string column (group_id, target, ...) + # otherwise sorts lexicographically -- '1916469' before + # '191647'. + self._sort_values_numeric_aware(df, _fp_params) + return "Applied operation: sort_values" + except (TypeError, ValueError, KeyError) as _fp_exc: + # Mixed dtypes or an unexpected key: fall through to the + # original reset/restore path rather than failing the query. + logger.debug( + "[sort] fast path declined (%s); using index round-trip", + type(_fp_exc).__name__) + def _restore_index(): cols = [n for n in orig_index_names if n in df.columns] if cols and not isinstance(df.index, pd.MultiIndex): @@ -3789,12 +4011,137 @@ def _bg_view_refresh(self) -> None: real rebuild+swap via force=True OFF the request path, then releases the guard so a later stale read can trigger another. Never raises into a request.""" try: - self._slowUpdateInternals(force=True) + if not self._fastUpdateInternals(): + self._slowUpdateInternals(force=True) except Exception: logger.exception("[ViewRefresh] background view refresh failed") finally: self._refresh_in_flight.release() + # Columns the trainer mutates. Structural columns (origin, edit_prompt, + # task_type, ...) never change after registration, so a differential sync + # only has to carry these. + _FAST_SYNC_PREFIXES = ("signals", "last_seen", "discarded", "prediction", "target") + + def _fast_sync_columns(self, view): + return [c for c in view.columns + if str(c).startswith(self._FAST_SYNC_PREFIXES)] + + + + def _fastUpdateInternals(self, max_dirty: int = 250_000) -> bool: + """O(change) view refresh. True if applied, False -> caller must rebuild. + + Falls back when there is no view yet, when a dirty row is absent from + the view (new rows => structural change), or when the backlog is large + enough that a rebuild is cheaper. + """ + if not _fast_view_enabled(): + return False # opt-out: behave exactly as before + view = self._all_datasets_df + dfm = self._df_manager + if view is None or getattr(view, "empty", True) or dfm is None: + return False + # A manager without dirty tracking cannot serve a delta -- rebuild. + if not (hasattr(dfm, "take_view_dirty") and hasattr(dfm, "get_source_rows")): + return False + # A column the ledger has but the view lacks can only arrive via a full + # rebuild -- the differential write below addresses existing columns + # only. Per-sample signal columns are created on their first write, so + # on a fresh ledger the view predates them and would never gain them. + try: + _src = getattr(dfm, "_df", None) + if _src is not None: + _have = set(view.columns) + _missing = [c for c in _src.columns + if str(c).startswith(self._FAST_SYNC_PREFIXES) + and c not in _have] + if _missing: + return False + except Exception: + pass + + dirty = dfm.take_view_dirty(limit=max_dirty) + if dirty is None: + return False # backlog too large; rebuild is cheaper + if not dirty: + return True # nothing changed since last sync + + sids = [str(s) for s in dirty] + # Address rows by LABEL. pandas keeps a hash engine on the index, built + # in C and cached, so this needs no precomputed position map -- and a + # label that is absent surfaces below as a no-match rather than as a + # silently wrong row. + keep = sids + + cols = self._fast_sync_columns(view) + if not cols: + return True + sub = dfm.get_source_rows(keep, columns=[c for c in cols if c in view.columns]) + if sub is None or sub.empty: + return True + # Collapse the source's per-annotation rows to ONE row per sample the + # same way the view itself was built (see + # get_collapse_annotations_to_samples_df): the canonical row is + # annotation_id == 0, and sample-level columns live only there. + # + # This used to keep the LAST annotation row, which for a multi-instance + # sample carries NaN in every sample-level column -- so each sample the + # trainer touched had its view row's `discarded` / `prediction` / + # `target` overwritten with NaN. And `bool(float("nan"))` is True in + # Python, so GetDataSamples then reported discarded="1" and the studio + # greyed the sample out, progressively, exactly as the model worked + # through the dataset -- while the dataframe itself still said False. + if isinstance(sub.index, pd.MultiIndex): + ANNOT = SampleStatsEx.INSTANCE_ID.value + names = list(getattr(sub.index, "names", []) or []) + if ANNOT in names: + annot = sub.index.get_level_values(ANNOT) + try: + canonical = np.asarray(annot).astype(int) == 0 + except (TypeError, ValueError): + canonical = np.array([str(a) in ("0", "0.0") for a in annot]) + if canonical.any(): + sub = sub[canonical] + sub = sub.droplevel(-1) + # Whatever is left, one row per sample: prefer the FIRST (the canonical + # row when the level was present, the first occurrence otherwise) -- + # never the last, for the reason above. + sub = sub[~sub.index.duplicated(keep="first")] + + # Only rows the view actually holds; a structural change (new sample) + # must still fall back to the full rebuild rather than be invented here. + SID = SampleStatsEx.SAMPLE_ID.value + _names = list(getattr(view.index, "names", []) or []) + view_keys = (view.index.get_level_values(SID) + if isinstance(view.index, pd.MultiIndex) and SID in _names + else view.index) + # Looked up the other way round -- `sub` (deduplicated just above, so + # unique) is the index being searched, and the VIEW's keys are the + # target. Searching the view's keys instead raised + # InvalidIndexError("Reindexing only valid with uniquely valued Index + # objects") whenever one sample_id appeared under two origins, which + # the view's own (origin, sample_id) index exists precisely to allow -- + # and the differential refresh then failed every time, silently falling + # back to the full rebuild. This direction also updates BOTH rows of + # such a sample, which is the only thing the source (indexed by + # sample_id alone) can mean. + _view_keys = pd.Index(view_keys.astype(str)) + _sub_keys = pd.Index(sub.index.astype(str)) + _src = _sub_keys.get_indexer(_view_keys) # sub row per view row, -1 if none + _rows = np.flatnonzero(_src >= 0) + if _rows.size == 0: + return True + # A dirty sample the view does not hold is a structural change (a new + # sample): only the full rebuild can add it. + if len(set(_sub_keys)) != len(set(_view_keys[_rows])): + return False + _take = _src[_rows] + for c in sub.columns: + _ci = view.columns.get_loc(c) + view.iloc[_rows, _ci] = sub[c].to_numpy()[_take] + return True + def _slowUpdateInternals(self, force: bool = False, reset_view: bool = False) -> None: """Update the internal dataframe view with the latest data from the manager. @@ -3965,6 +4312,13 @@ def _slowUpdateInternals(self, force: bool = False, reset_view: bool = False) -> # Atomic swap to make the new view available to readers self._all_datasets_df = updated_df self._last_internals_update_time = current_time + # The rebuilt view reflects every row, so the differential backlog is + # satisfied. This is the only point at which that is true. + try: + if self._df_manager is not None: + self._df_manager.clear_view_dirty() + except Exception: + pass finally: held_ms = (time.time() - t_held_start) * 1000 @@ -4180,8 +4534,11 @@ def _build_metadata_only_response(self, df_slice: pd.DataFrame, requested_cols=N _DataStat(name=col, type="string", shape=[1], value_string=v) ) else: - # Boolean tag: presence indicator "1" when True. - bools = series.astype(bool).tolist() + # Boolean tag: presence indicator "1" when True. is_set_flag, + # not astype(bool): a tag column is NaN for every sample that + # does not carry the tag, and astype(bool) turns NaN into True + # -- which showed every tag on every sample. + bools = set_flag_mask(series) for i, b in enumerate(bools): if b: row_stats[i].append( @@ -4482,7 +4839,8 @@ def _process_get_data_samples(self, request, context): ) # Trigger update if needed (it has its own internal locking) - self._slowUpdateInternals() + if not self._fastUpdateInternals(): + self._slowUpdateInternals() # Atomic snapshot of the current authoritative dataframe current_df = self._all_datasets_df @@ -4793,37 +5151,39 @@ def ApplyDataQuery(self, request, context): operations = self._parse_direct_query(request.query) # Apply operations with lock - with self._watched_lock("_lock[ApplyDataQuery/ops]"): - # Skip the forced full-view rebuild for SORT-ONLY operations. Sorting just - # re-orders the existing snapshot, so a fresh collapse+combine (hundreds of - # ms on large views, and — being lock-held — contends with the training - # thread for multi-second stalls) is unnecessary. Filters/edits still refresh - # so they operate on the latest data. The view is frozen on direct queries - # anyway (_is_filtered=True), so it wasn't auto-refreshing mid-sort regardless. - _SORT_FUNCS = {"df.sort_values", "df.sort_index", "df.sort_view_slice"} - is_sort_only = bool(operations) and all( - op.get("function") in _SORT_FUNCS for op in operations) - if not is_sort_only: - self._slowUpdateInternals(force=True) # Refresh internals before applying non-sort operations - - # Work on a copy to allow concurrent readers to see a consistent state - df = self._all_datasets_df # Remove copy because memory waste and slowdown - messages = [] + _SORT_FUNCS = {"df.sort_values", "df.sort_index", "df.sort_view_slice"} + is_sort_only = bool(operations) and all( + op.get("function") in _SORT_FUNCS for op in operations) + def _run_ops(target): + out = [] for op in operations: - func = op.get("function") - params = op.get("params", {}) or {} - msg = self._apply_agent_operation(df, func, params) - messages.append(msg) - - final_message = " | ".join(messages) if messages else "No operation performed" - - # Atomic swap - self._all_datasets_df = df - - # Direct queries are manipulations -> Freeze the view - if operations: - self._is_filtered = True + out.append(self._apply_agent_operation( + target, op.get("function"), op.get("params", {}) or {})) + return " | ".join(out) if out else "No operation performed" + + if is_sort_only: + # Sorting 4M rows costs ~10s; under _lock that stalls the trainer + # for the whole duration (measured 11716ms). Sort a shallow copy + # off-lock, then hold the lock only for the reference swap. + base = self._all_datasets_df + df = base.copy(deep=False) if base is not None else base + final_message = _run_ops(df) + # No position map to rebuild: the differential refresh addresses + # rows by label through pandas' own index engine, so reordering + # the view invalidates nothing. + with self._watched_lock("_lock[ApplyDataQuery/swap]"): + self._all_datasets_df = df + if operations: + self._is_filtered = True + else: + with self._watched_lock("_lock[ApplyDataQuery/ops]"): + self._slowUpdateInternals(force=True) + df = self._all_datasets_df + final_message = _run_ops(df) + self._all_datasets_df = df + if operations: + self._is_filtered = True return self._build_success_response( df=df, @@ -5047,17 +5407,38 @@ def GetHistogram(self, request, context): if df is None or df.empty: return pb2.HistogramResponse( success=False, message="empty dataframe view", total_rows=0, bins=[]) - df = safe_reset_index(df) + # safe_reset_index copies AND consolidates the entire frame (~70% of + # this RPC at 3.96M x 19 by py-spy). It is only needed to reach fields + # that live in the index; when they are already columns, use the frame + # as it stands. + def _field(frame, name): + """Series for *name* whether it is a column or an index level.""" + if name in frame.columns: + return frame[name] + names = list(getattr(frame.index, "names", []) or []) + if name in names: + return pd.Series(frame.index.get_level_values(name), + index=frame.index) + if getattr(frame.index, "name", None) == name: + return pd.Series(frame.index, index=frame.index) + return None + + # Only reset when the histogrammed column itself cannot be reached. + # safe_reset_index copies AND block-consolidates the whole frame + # (~82% of this RPC by py-spy); get_level_values is ~0.02s. + if _field(df, column) is None: + df = safe_reset_index(df) n = len(df) if column not in df.columns: return pb2.HistogramResponse( success=False, message=f"column '{column}' not in view", total_rows=n, bins=[]) - origin = (df["origin"].astype(str).to_numpy() if "origin" in df.columns - else np.full(n, "")) - disc = (df["discarded"].astype(bool).to_numpy() if "discarded" in df.columns - else np.zeros(n, bool)) + _o = _field(df, "origin") + origin = _o.astype(str).to_numpy() if _o is not None else np.full(n, "") + _d = _field(df, "discarded") + # Same trap as above: NaN in a nullable flag is NOT "discarded". + disc = set_flag_mask(_d) if _d is not None else np.zeros(n, bool) # Detect whether column is categorical (string/object) or numeric. # A column is numeric if ANY value coerces to a finite number — even @@ -5068,7 +5449,9 @@ def GetHistogram(self, request, context): # as a spurious "unset" bar). We therefore treat as categorical only # a genuine pandas ``category`` dtype, or a column whose values do # not coerce to any numeric value at all (pure strings). - col_series = df[column] + col_series = _field(df, column) + if col_series is None: + col_series = df[column] numeric_vals = pd.to_numeric(col_series, errors="coerce") is_category_dtype = ( str(col_series.dtype) == "category" or hasattr(col_series, "cat") @@ -5087,20 +5470,44 @@ def GetHistogram(self, request, context): if is_categorical: # --- Categorical path --- labels = col_series.astype(str).where(col_series.notna(), "") - gf = pd.DataFrame({"l": labels, "o": origin, "d": disc}) - total_count = gf.groupby("l")["l"].count().rename("count") + # Count first with a single-key value_counts, then restrict the + # three-key breakdown to the rows that survive the cap. Grouping + # all 3.96M rows by (label, discarded, origin) when the column is + # free text builds 722,870 groups to then discard all but 200. + total_count = labels.value_counts().rename("count") + _cap_pre = _histogram_category_cap() + _keep = set(total_count.iloc[:_cap_pre].index) + _m = labels.isin(_keep).to_numpy() sub_map: dict = {} - for (lbl, d, o), c in gf.groupby(["l", "d", "o"]).size().items(): - sub_map.setdefault(str(lbl), []).append( - pb2.HistogramSubBar(origin=str(o), discarded=bool(d), count=int(c))) + if _m.any(): + gf = pd.DataFrame({"l": labels.to_numpy()[_m], + "o": origin[_m], "d": disc[_m]}) + for (lbl, d, o), c in gf.groupby(["l", "d", "o"]).size().items(): + sub_map.setdefault(str(lbl), []).append( + pb2.HistogramSubBar(origin=str(o), discarded=bool(d), count=int(c))) + # Cap the output: a free-text column (e.g. edit_prompt) has one + # category per sample -- 722,870 bars / 54 MB / 30.5s measured, + # which no viewer can draw. Keep the top-N by count and fold the + # remainder into one "(other)" bar so the response stays bounded. + _ordered = total_count.sort_values(ascending=False) + _cap = _histogram_category_cap() + _head, _tail = _ordered.iloc[:_cap], _ordered.iloc[_cap:] cat_bars = [ pb2.CategoricalHistogramBar( label=str(lbl), count=int(cnt), sub_bars=sub_map.get(str(lbl), []), ) - for lbl, cnt in total_count.sort_values(ascending=False).items() + for lbl, cnt in _head.items() ] + if len(_tail): + cat_bars.append(pb2.CategoricalHistogramBar( + label="(other: %d categories)" % len(_tail), + count=int(_tail.sum()), + sub_bars=[], + )) + logger.info("[HistCat] column=%s capped %d categories -> %d bars", + column, len(_ordered), len(cat_bars)) logger.info("[HistCat] column=%s rows=%d categories=%d", column, n, len(cat_bars)) return pb2.HistogramResponse( @@ -5113,6 +5520,11 @@ def GetHistogram(self, request, context): ) # --- Numeric path (unchanged) --- + # Each bar covers a fixed slice of the VIEW: total_rows / max_bins + # samples. That is what makes the chart show density -- a column only + # 0.2% populated shows a few filled bars and the rest empty. Binning + # over just the rows that carry a value makes the chart look equally + # full at any coverage, which reads as "everything has a value". bars = max(1, min(n, max_bins)) vals = numeric_vals.to_numpy() edges = (np.arange(bars + 1) * n) // bars @@ -5627,7 +6039,8 @@ def EditDataSample(self, request, context): with self._watched_lock("_lock[EditDataSample/__copy_metadata__]"): try: - self._slowUpdateInternals() + if not self._fastUpdateInternals(): + self._slowUpdateInternals() if self._all_datasets_df is None or self._all_datasets_df.empty: return pb2.DataEditsResponse( success=False, @@ -5795,7 +6208,8 @@ def EditDataSample(self, request, context): with self._watched_lock("_lock[EditDataSample/delete-col]"): try: - self._slowUpdateInternals() + if not self._fastUpdateInternals(): + self._slowUpdateInternals() if self._all_datasets_df is None or self._all_datasets_df.empty: return pb2.DataEditsResponse( success=False, @@ -5833,7 +6247,8 @@ def EditDataSample(self, request, context): # Kick a background view-refresh (non-blocking) — the in-memory view # is already consistent after the drop above, so blocking inline rebuild # is unnecessary and causes the gRPC response to stall for 5-10 s. - self._slowUpdateInternals() + if not self._fastUpdateInternals(): + self._slowUpdateInternals() return pb2.DataEditsResponse( success=True, @@ -5856,7 +6271,8 @@ def EditDataSample(self, request, context): with self._watched_lock("_lock[EditDataSample/__discard_by_tag__]"): try: - self._slowUpdateInternals() + if not self._fastUpdateInternals(): + self._slowUpdateInternals() if self._all_datasets_df is None or self._all_datasets_df.empty: return pb2.DataEditsResponse( success=False, @@ -5865,7 +6281,8 @@ def EditDataSample(self, request, context): tag_col = f"{SampleStatsEx.TAG.value}:{tag_name}" if tag_col not in self._all_datasets_df.columns: - self._slowUpdateInternals() + if not self._fastUpdateInternals(): + self._slowUpdateInternals() df = safe_reset_index(self._all_datasets_df) if tag_col not in df.columns: return pb2.DataEditsResponse( @@ -6079,7 +6496,8 @@ def GetDataSplits(self, request, context): # IMPORTANT: keep lock ordering consistent (_update_lock -> _lock). # Calling _slowUpdateInternals() while holding _lock can deadlock # with concurrent readers/writers under high UI refresh pressure. - self._slowUpdateInternals() + if not self._fastUpdateInternals(): + self._slowUpdateInternals() if context is not None and not context.is_active(): return pb2.DataSplitsResponse(success=False, split_names=[]) diff --git a/weightslab/trainer/trainer_tools.py b/weightslab/trainer/trainer_tools.py index 136dd34c..1b2d467c 100644 --- a/weightslab/trainer/trainer_tools.py +++ b/weightslab/trainer/trainer_tools.py @@ -434,10 +434,15 @@ def _get_input_tensor_for_sample(dataset, sample_id, device): def process_sample(sid, dataset, do_resize, resize_dims, experiment): try: - if hasattr(dataset, "_getitem_raw"): - tensor, idx, label = dataset._getitem_raw(id=sid) - else: - tensor, idx, label = dataset[sid] + # _getitem_raw returns (data, id, target, *metadata) by contract -- datasets + # implementing get_items() with metadata yield 4+ elements, so a fixed + # 3-way unpack raises "too many values to unpack" and kills every + # thumbnail. Unpack positionally instead. + _res = dataset._getitem_raw(id=sid) if hasattr(dataset, "_getitem_raw") else dataset[sid] + if not isinstance(_res, (tuple, list)): + _res = (_res, sid, None) + tensor = _res[0] + label = _res[2] if len(_res) > 2 else None if isinstance(tensor, torch.Tensor): img = tensor.detach().cpu() diff --git a/weightslab/ui/server.py b/weightslab/ui/server.py index 53c7eec7..0d7fed6b 100644 --- a/weightslab/ui/server.py +++ b/weightslab/ui/server.py @@ -1577,6 +1577,9 @@ class _UIRequestHandler(BaseHTTPRequestHandler): grpc_auth_token: Optional[str] = None rpc_timeout: float = 300.0 experiment_dir: Optional[str] = None + # The gRPC port this server proxies to; used to pick the right backend's + # experiment directory out of the active-experiment marker. + backend_port: Optional[int] = None # -- logging: quiet by default, honour WEIGHTSLAB_UI_VERBOSE ------------- # def log_message(self, fmt, *args): # noqa: D401 @@ -1612,6 +1615,9 @@ def do_GET(self): # noqa: N802 if path == "/agent-server/status": self._send_json(HTTPStatus.OK, _opencode_session.status()) return + if path == "/agent-server/model": + self._get_shared_model() + return if path == "/agent-server/loop/list": self._send_json(HTTPStatus.OK, {"loops": _loop_registry.list()}) return @@ -1645,6 +1651,8 @@ def do_POST(self): # noqa: N802 self._start_local_notebook() elif path == "/agent-server/start": self._start_agent_server() + elif path == "/agent-server/model": + self._set_shared_model() elif path == "/agent-server/loop/start": self._start_loop() elif path.startswith("/agent-server/loop/") and path.endswith("/stop"): @@ -1745,9 +1753,120 @@ def _collect_metadata(self): # ------------------------------------------------------------------ # # Local Jupyter Notebook launcher (landing-page button) # ------------------------------------------------------------------ # + # ------------------------------------------------------------------ # + # Shared agent model, proxied SAME-ORIGIN + # ------------------------------------------------------------------ # + # OpenCode's own config holds the one model every client of that server + # agrees on: the studio's picker, the OpenCode CLI, `weightslab agent + # model`, and the backend SDK agent (which reads it to choose the model for + # its own queries). The browser could call OpenCode directly -- but only + # while OpenCode's --cors allowlist happens to contain the exact origin the + # page is served from, which quietly fails for a LAN address, a tunnel + # hostname, or an OpenCode somebody started by hand with no --cors at all. + # The page then showed a model nobody else was using, and picking one + # changed nothing outside the tab. + # + # Same-origin here means no preflight and no allowlist: this server talks + # to OpenCode over plain HTTP on the machine they share. + def _opencode_base_url(self) -> Optional[str]: + status = _opencode_session.status() + url = status.get("url") if isinstance(status, dict) else None + if isinstance(url, str) and url.strip(): + return url.rstrip("/") + env_url = (os.environ.get("OPENCODE_URL") or "").strip() + return env_url.rstrip("/") if env_url else None + + def _opencode_json(self, path: str, method: str = "GET", body: Optional[dict] = None, + timeout: float = 10.0): + """One request to the local OpenCode server; (status, parsed-json-or-None).""" + base = self._opencode_base_url() + if not base: + return None, None + import urllib.error + import urllib.request + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Content-Type": "application/json"} if data is not None else {} + req = urllib.request.Request(base + path, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + raw = resp.read().decode("utf-8", "replace") + try: + return resp.status, json.loads(raw or "null") + except ValueError: + return resp.status, None + except urllib.error.HTTPError as exc: + return exc.code, None + except Exception as exc: # noqa: BLE001 + logger.debug("[ui] OpenCode %s %s failed: %s", method, path, exc) + return None, None + + def _get_shared_model(self): + status, payload = self._opencode_json("/config") + # A transport failure AND an error status both mean "we do not know + # which model is configured" -- reporting ok:True with model=None there + # would tell the page "nothing is configured", and it would then show + # its own default over whatever the backend is really using. + if status is None or not (200 <= int(status) < 300): + self._send_json(HTTPStatus.OK, {"ok": False, "model": None, + "error": "OpenCode server is not reachable"}) + return + model = (payload or {}).get("model") if isinstance(payload, dict) else None + if not (isinstance(model, str) and "/" in model): + model = None + self._send_json(HTTPStatus.OK, {"ok": True, "model": model}) + + def _set_shared_model(self): + body = self._read_json_body() + model = str((body or {}).get("model") or "").strip() + if "/" not in model: + self._send_json(HTTPStatus.BAD_REQUEST, + {"ok": False, "error": "model must be \"providerID/modelID\""}) + return + # Global scope first: PATCH /config (workspace scope) answers 200 and + # echoes the value back without changing what GET /config reports. + for target in ("/global/config", "/config"): + status, _ = self._opencode_json(target, method="PATCH", body={"model": model}) + if status is None or not (200 <= int(status) < 300): + continue + _, payload = self._opencode_json("/config") + if isinstance(payload, dict) and payload.get("model") == model: + self._send_json(HTTPStatus.OK, {"ok": True, "model": model, "via": target}) + return + self._send_json(HTTPStatus.OK, { + "ok": False, "model": None, + "error": "OpenCode did not accept the model (config may be read-only)"}) + + def _experiment_dir_path(self) -> str: + """The experiment directory to browse for this run's own files. + + A RUNNING training backend's own resolved root_log_dir comes first: it + is where reports and notebooks are actually written, and it is not + always the directory this UI established -- training may have been + pointed elsewhere by a config file's `root_log_dir:`, or (before the + marker existed) have fallen through to a temp directory. Listing this + server's own directory then showed an empty reports/ right after a + report had been generated. + + Only a LIVE backend counts: the marker outlives the process that wrote + it, and a finished run's directory must never hijack the listing of a + UI that was given an experiment directory of its own. Falls back to the + UI's own directory, the environment, then the working directory. + """ + try: + from weightslab.utils.active_experiment import live_backend_experiment_dir + # By PORT: with two experiments up, "the live backend" is ambiguous + # and picking the wrong one shows the other experiment's reports. + backend_dir = live_backend_experiment_dir( + getattr(self, "backend_port", None)) + except Exception: # noqa: BLE001 -- never break a listing on the marker + backend_dir = None + return (backend_dir + or self.experiment_dir + or os.environ.get("WEIGHTSLAB_ROOT_LOG_DIR") + or os.getcwd()) + def _notebooks_dir_path(self) -> str: - experiment_dir = self.experiment_dir or os.environ.get("WEIGHTSLAB_ROOT_LOG_DIR") or os.getcwd() - return os.path.join(experiment_dir, "notebooks") + return os.path.join(self._experiment_dir_path(), "notebooks") def _read_json_body(self) -> dict: try: @@ -2088,13 +2207,12 @@ def _start_local_notebook(self): # (ApplyDataQuery -> the "generate_experiment_report" action, see # data_service.py) -- these two endpoints only browse what's already on # disk under /reports/, exactly like the local-notebook - # endpoints above browse /notebooks/. Same assumption: - # this UI server and the connected training backend share a filesystem - # (the documented `weightslab start` usage), so root_log_dir resolved - # here is the same directory the backend wrote reports into. + # endpoints above browse /notebooks/. The one assumption + # left is a shared filesystem (the documented `weightslab start` usage): + # WHICH directory is resolved by _experiment_dir_path(), which prefers the + # backend's own recorded root_log_dir over this server's. def _agent_history_dir_path(self) -> str: - experiment_dir = self.experiment_dir or os.environ.get("WEIGHTSLAB_ROOT_LOG_DIR") or os.getcwd() - return os.path.join(experiment_dir, "agent") + return os.path.join(self._experiment_dir_path(), "agent") def _dump_agent_history(self): """Write the agent conversation to the experiment directory. @@ -2138,8 +2256,7 @@ def _dump_agent_history(self): self._send_json(HTTPStatus.OK, {"ok": True, "path": path}) def _reports_dir_path(self) -> str: - experiment_dir = self.experiment_dir or os.environ.get("WEIGHTSLAB_ROOT_LOG_DIR") or os.getcwd() - return os.path.join(experiment_dir, "reports") + return os.path.join(self._experiment_dir_path(), "reports") def _list_experiment_reports(self): reports_dir = self._reports_dir_path() @@ -2456,6 +2573,7 @@ def serve_ui( { "static_root": root, "channel": channel, + "backend_port": backend_port, "api_prefix": "/api", "grpc_auth_token": grpc_auth_token, "experiment_dir": experiment_dir, diff --git a/weightslab/utils/active_experiment.py b/weightslab/utils/active_experiment.py new file mode 100644 index 00000000..fb53f9b2 --- /dev/null +++ b/weightslab/utils/active_experiment.py @@ -0,0 +1,340 @@ +"""Cross-process handoff of the active experiment directory. + +``weightslab start`` establishes the experiment directory (checkpoints, logs, +``notebooks/``, ``reports/``) and exports ``WEIGHTSLAB_ROOT_LOG_DIR`` -- but it +can only export it into ITS OWN process. A training run launched from a second +terminal, or by ``weightslab start example``, is a different process tree and +never saw that variable: it fell through to a throwaway ``tempfile.mkdtemp()``, +so the run wrote into ``%TEMP%\\tmpXXXXXXXX`` while the UI listed an empty +``reports/`` (the "right-click Generate Report shows nothing, yet I generated +reports" symptom) from the directory it had established itself. + +Hence this marker file: one small JSON document, per user, that both sides +write to and read from. Each side is a LIST, because running two experiments +side by side -- a classification UI on one port, a segmentation UI on another -- +is a supported thing to do. With a single slot per side, the second +``weightslab start`` erased the first, and both UIs would then have listed the +reports of whichever backend started last. + + { + "ui": [{"root_log_dir": "...", "pid": 123, "ui_port": 8080, + "backend_port": 50051, "updated_at": "..."}, ...], + "backend": [{"root_log_dir": "...", "pid": 456, "grpc_port": 50051, + "updated_at": "..."}, ...] + } + +* ``ui`` is written by ``weightslab start`` -- the directory it established. + A later training process with nothing configured adopts it, which is what + makes the two halves land in the same experiment. +* ``backend`` is written by ``wl.serve()`` -- the directory training ACTUALLY + resolved, whatever the route (an explicit ``root_log_dir:`` in a config file, + the environment, or the ``ui`` value above). The UI prefers it when listing + reports and notebooks, so those lists stay right even when training was + pointed somewhere the UI never chose. + +Entries are keyed by pid: a process replaces its own, dead ones are pruned on +every write, and the list is bounded. Readers never guess -- a port pins the +entry when the caller knows one (a UI asking for ITS backend), a single live +entry is unambiguous, and two or more return nothing with a line in the log, +leaving the caller its own directory. Showing one experiment's reports inside +another experiment's UI, or writing a run into the wrong experiment, is worse +than declining to answer. + +Neither side is required: every reader validates that the recorded directory +still exists and falls back to its previous behaviour otherwise, and every +write is best-effort -- a read-only home directory must never stop a run. + +Set ``WEIGHTSLAB_STATE_DIR`` to relocate the file (tests use it to stay out of +the developer's real state). +""" + +from __future__ import annotations + +import json +import logging +import os +import tempfile +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + +_FILE_NAME = "active_experiment.json" +_SECTIONS = ("ui", "backend") +# A long-lived machine should not accumulate entries nobody can attribute. +_MAX_ENTRIES = 16 + + +def state_dir() -> Path: + """Directory holding the marker: ``$WEIGHTSLAB_STATE_DIR`` or ``~/.weightslab``.""" + override = (os.environ.get("WEIGHTSLAB_STATE_DIR") or "").strip() + if override: + return Path(override).expanduser() + return Path.home() / ".weightslab" + + +def state_path() -> Path: + """Absolute path of the marker file (may not exist yet).""" + return state_dir() / _FILE_NAME + + +def read_state() -> dict: + """The whole marker, or ``{}`` when it is absent or unreadable.""" + path = state_path() + try: + with open(path, "r", encoding="utf-8") as fh: + data = json.load(fh) + except FileNotFoundError: + return {} + except Exception as exc: # noqa: BLE001 -- a corrupt marker is not fatal + logger.debug("[active-experiment] could not read %s: %s", path, exc) + return {} + return data if isinstance(data, dict) else {} + + +def entries(section: str, state: Optional[dict] = None) -> list: + """One section as a list, accepting the older single-object shape.""" + value = (read_state() if state is None else state).get(section) + if isinstance(value, list): + return [e for e in value if isinstance(e, dict)] + if isinstance(value, dict): + return [value] + return [] + + +def _pid_is_running(pid) -> bool: + try: + pid = int(pid) + except (TypeError, ValueError): + return False + if pid <= 0: + return False + try: + import psutil + return psutil.pid_exists(pid) + except Exception: # noqa: BLE001 -- psutil missing/unusable: assume gone + return False + + +class _MarkerLock: + """Brief exclusive hold on the marker, via an O_EXCL lock file. + + Read-modify-write on a shared file loses updates when two processes do it + at once, and two processes doing it at once is precisely the case this + marker exists for: starting a classification UI and a segmentation UI + together dropped one of their port stamps, which is the field that tells + them apart afterwards. + + Best-effort by design: if the lock cannot be taken (a stale file nobody + cleaned up, a read-only home), the write proceeds anyway -- an advisory + record must never block a run. A stale lock older than a few seconds is + broken on purpose, since nothing here holds it for more than a file write. + """ + + STALE_AFTER = 5.0 + + def __init__(self, path: Path, attempts: int = 60, delay: float = 0.02): + self._path = Path(str(path) + ".lock") + self._attempts = attempts + self._delay = delay + self._held = False + + def __enter__(self): + import time + for _ in range(self._attempts): + try: + fd = os.open(str(self._path), os.O_CREAT | os.O_EXCL | os.O_WRONLY) + os.write(fd, str(os.getpid()).encode()) + os.close(fd) + self._held = True + return self + except FileExistsError: + try: + age = time.time() - os.path.getmtime(self._path) + if age > self.STALE_AFTER: + os.unlink(self._path) + continue + except OSError: + pass + time.sleep(self._delay) + except OSError: + break # cannot lock here at all; proceed unlocked + return self + + def __exit__(self, *exc): + if self._held: + try: + os.unlink(self._path) + except OSError: + pass + return False + + +def _write_section(section: str, root_log_dir, **meta) -> Optional[Path]: + """Record this process's entry in one section, keeping the others. + + Replaces the entry for this pid (a process re-recording, e.g. once its port + is known), drops entries whose process is gone, and leaves every other live + entry in place -- that is what lets two experiments run side by side. Held + under _MarkerLock so two processes starting together cannot lose each + other's entry. + """ + if section not in _SECTIONS: + raise ValueError(f"unknown section {section!r}") + if not root_log_dir: + return None + + pid = os.getpid() + entry = { + "root_log_dir": str(Path(root_log_dir).expanduser().resolve()), + "pid": pid, + "updated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), + } + entry.update({k: v for k, v in meta.items() if v is not None}) + + path = state_path() + try: + path.parent.mkdir(parents=True, exist_ok=True) + with _MarkerLock(path): + # Re-read INSIDE the lock: another process may have added its own + # entry since this function started. + state = read_state() + kept = [e for e in entries(section, state) + if e.get("pid") != pid and _pid_is_running(e.get("pid"))] + state[section] = (kept + [entry])[-_MAX_ENTRIES:] + # Written via a temp file in the same directory, then replaced, so a + # concurrent reader never sees a half-written document. + fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=".active-", suffix=".json") + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + json.dump(state, fh, indent=2) + os.replace(tmp_name, path) + except Exception: + try: + os.unlink(tmp_name) + except OSError: + pass + raise + except Exception as exc: # noqa: BLE001 -- advisory record, never fatal + logger.debug("[active-experiment] could not record %s dir in %s: %s", + section, path, exc) + return None + logger.debug("[active-experiment] recorded %s root_log_dir=%s", section, entry["root_log_dir"]) + return path + + +def record_ui_experiment(root_log_dir, ui_port: Optional[int] = None, + backend_port: Optional[int] = None) -> Optional[Path]: + """Record the directory ``weightslab start`` just established. + + ``backend_port`` is what makes two concurrent UIs distinguishable: it is + the gRPC port this UI proxies to, so it can later ask for the experiment + directory of ITS backend rather than of whichever one started last. + """ + return _write_section("ui", root_log_dir, ui_port=ui_port, backend_port=backend_port) + + +def record_backend_experiment(root_log_dir, grpc_port: Optional[int] = None) -> Optional[Path]: + """Record the directory training actually resolved (``wl.serve()``).""" + return _write_section("backend", root_log_dir, grpc_port=grpc_port) + + +def _entry_dir(section: str, entry: dict) -> Optional[str]: + value = (entry or {}).get("root_log_dir") + if not isinstance(value, str) or not value: + return None + if not os.path.isdir(value): + # A run whose directory was deleted (or a marker copied between + # machines) must not redirect anything. + logger.debug("[active-experiment] %s dir %s no longer exists; ignoring", section, value) + return None + return value + + +def _live_entries(section: str) -> list: + return [e for e in entries(section) + if _pid_is_running(e.get("pid")) and _entry_dir(section, e)] + + +def _sole_live_dir(section: str, port_key: str = "", port: Optional[int] = None) -> Optional[str]: + """The directory of the ONE live entry that matches, or None. + + With a port, an entry naming it wins outright -- that is how a UI finds ITS + backend rather than whichever backend started last. Otherwise a single live + entry is unambiguous and is used; two or more are not guessed between. + """ + live = _live_entries(section) + if port is not None and port_key: + matching = [e for e in live if e.get(port_key) == port] + if matching: + # The same port twice can only be stale bookkeeping: newest wins. + return _entry_dir(section, matching[-1]) + if len(live) == 1: + return _entry_dir(section, live[0]) + if len(live) > 1: + logger.info( + "[active-experiment] %d live %s experiments recorded (%s); not " + "guessing between them -- name the directory explicitly " + "(WEIGHTSLAB_ROOT_LOG_DIR, or root_log_dir in the config).", + len(live), section, + ", ".join(str(e.get("root_log_dir")) for e in live)) + return None + + +def ui_experiment_dir() -> Optional[str]: + """Directory of the most recent recorded ``weightslab start``, live or not. + + Raw record: it outlives the process that wrote it. Callers that REDIRECT a + run on this should use :func:`live_ui_experiment_dir` instead. + """ + for entry in reversed(entries("ui")): + found = _entry_dir("ui", entry) + if found: + return found + return None + + +def live_ui_experiment_dir() -> Optional[str]: + """Directory of a ``weightslab start`` that is *still running*. + + The handoff exists for "the UI is up over there, put this run in its + experiment". A record left behind by a UI that has since exited must not + silently redirect an unrelated run months later -- which is exactly what + happened to this repo's own gRPC tests: they resolved into a previous + session's experiment directory and loaded ITS config. And with two UIs up + (two experiments side by side) there is no right answer to guess. + """ + return _sole_live_dir("ui") + + +def backend_experiment_dir() -> Optional[str]: + """Directory of the most recent recorded ``wl.serve()``, live or not.""" + for entry in reversed(entries("backend")): + found = _entry_dir("backend", entry) + if found: + return found + return None + + +def live_backend_experiment_dir(grpc_port: Optional[int] = None) -> Optional[str]: + """Directory of a backend that is *still running*. + + Pass ``grpc_port`` -- the port the caller actually talks to -- and the + backend serving it is picked out by name. Without it, one live backend is + unambiguous and two are not guessed between: a UI showing the OTHER + experiment's reports is worse than a UI showing its own directory. + + Dead entries are ignored: the record outlives the process that wrote it. + """ + return _sole_live_dir("backend", "grpc_port", grpc_port) + + +def clear() -> None: + """Remove the marker (best-effort). Used by tests and ``weightslab`` teardown.""" + try: + state_path().unlink() + except FileNotFoundError: + pass + except Exception as exc: # noqa: BLE001 + logger.debug("[active-experiment] could not clear marker: %s", exc) diff --git a/weightslab/utils/logs.py b/weightslab/utils/logs.py index dd75872f..717165b4 100644 --- a/weightslab/utils/logs.py +++ b/weightslab/utils/logs.py @@ -9,7 +9,7 @@ # Define the log format to include timestamp, level, module name, and function name -FORMAT = '%(asctime)s.%(msecs)03d %(levelname)s:%(name)s:%(funcName)s: %(message)s' +FORMAT = '%(asctime)s.%(msecs)03d %(levelname)s:%(name)s:%(filename)s:%(lineno)d:%(funcName)s: %(message)s' DATE_FORMAT = '%d/%m/%Y-%H:%M:%S' # Global variables to track the log file path and handler