# Disposition of the alpha-RTC agent feedback (10 issues) Source: `pystorms/.../alpha_openswmm_findings_and_rerun.md` — an agent's report after driving the gym MCP tools to optimize reactive RTC on the *alpha* network. This records, per issue: whether the root-cause was **accurate**, what was **implemented**, and what was **deferred** (with rationale). Validated and tested in the `openswmm` conda env. ## Summary | # | Issue | Disposition | |---|---|---| | 1 | Complex params rejected as JSON strings | **Fixed** (defensive coercion); root-cause corrected | | 2 | EnvConfig/ObservationSpec not discoverable | **Fixed** (schema + features + examples + better errors) | | 3 | No first-class single/baseline policy eval | Deferred (documented workaround) | | 4 | Baseline corner can be missing from front | **Partly** (documented + guidance in capabilities); seeding deferred | | 5 | Monotonic projection undocumented | **Fixed** (capability note; decode already returns projected) | | 6 | "CSO" reward semantics ambiguous | **Fixed** (capability note; tag-resolver deferred) | | 7 | Results payload bloat | Deferred (documented: read `evaluations.jsonl`) | | 8 | Path/concurrency under-documented | Deferred (doc-only) | | 9 | Silent algorithm/budget fallback | **Fixed** (resolved settings echoed everywhere; #1 removes the trigger) | | 10 | Dashboards not discoverable/offline | Deferred (skills/dashboard layer, out of MCP-tool scope) | --- ## Implemented ### #1 — JSON-string object/array params (critical) **Root-cause (corrected twice).** The report blamed a missing schema type; on FastMCP 3.2.4 the schema is in fact typed (`optimization` is `{"anyOf":[{"type":"object",...},{"type":"null"}],"default":null}`). The real cause is one layer deeper: FastMCP validates each tool argument against the function signature with **Pydantic, at bind time**, so a stringified argument is rejected with `Input should be a valid dictionary [type=dict_type, ..., input_type=str]` **before the tool body runs**. A first attempt that decoded the string *inside* the function body therefore never executed for the failing path — the string was rejected during binding. (Confirmed by driving `mcp.call_tool("gym_start_optimization", {"optimization": "{...}"})`.) **Fix (the correct layer).** A Pydantic `BeforeValidator`, `decode_json_if_str`, that runs **during binding**: a string argument is `json.loads`-decoded there, so Pydantic then validates the resulting object/array; non-strings pass through; bad JSON yields a clear validation error. Exposed as reusable annotated parameter types in `gym_support/config.py` — `JsonObject`, `JsonObjectRequired`, `JsonFloatMatrix`, `JsonFloatVector`, `JsonStrListRequired` — and applied to every complex `gym_*` param: `optimization`/`config`/`policy`/`action` (gym_runs), `config` (gym_envs create/validate), and `front`/`reference_point`/`ideal_point`/`reference_front`/`weights`/`indicators`/`job_ids` (gym_scoring). So `optimization='{"algorithm":"nsga2","budget":600}'` now binds and runs NSGA-II — it can no longer be lost to a bind failure. Crucially, the **client-facing schema is unchanged** (still `object|null` / `array|null`): the validator decodes strings transparently, so clients that send proper objects are unaffected and clients that stringify now also work. The function-body `coerce_json_param` helper is retained as defence for direct/ internal callers (e.g. `_parse_config`), but the binding-layer validator is what fixes the FastMCP path. Tests: `test_gym_scoring.py::test_pareto_filter_accepts_json_string_front_via_binding_layer` (through the real `mcp.call_tool` binding layer), `test_gym_config.py::test_decode_json_if_str_validator_runs_inside_pydantic_binding` and `::test_coerce_json_param_*`, `test_gym_envs.py::test_json_string_config_is_accepted`, `test_gym_jobs.py::test_start_optimization_accepts_json_string_and_echoes_resolved`. ### #9 — Silent algorithm/budget fallback / verifiability `OptimizationConfig.resolved()` returns the applied settings (algorithm, budget, seed, plus `population_size` for MOEAs / `grid_levels` for grid). It is now echoed in the **start response and every `gym_get_job` snapshot** (`optimization` block) and in **`result.json` / `gym_get_job_results`** — previously these lived only in `job.json`. Combined with #1 (the optimization arg now binds, instead of being dropped → defaulting to `random_search`/50), the silent-downgrade path is closed: a malformed `optimization` raises rather than falling back. Tests: `test_gym_jobs.py::test_optimization_config_resolved_includes_only_relevant_fields` and `…_accepts_json_string_and_echoes_resolved`. ### #2 — EnvConfig / ObservationSpec discoverability `gym_list_capabilities` now also returns: `env_config_schema` (`EnvConfig.model_json_schema()`), `observation_spec_schema`, `observation_features` (the 15 valid `observations` keys, from the single source of truth `OBSERVATION_FEATURES`), and one worked `examples` config per common `env_type` (cip/rtc/control_curve/schedule). The config-validation error now, when the failure mentions observations, enumerates the valid feature keys and states they map *element-ID lists*, not IDs directly — fixing the exact three-try loop the agent hit (`observations.J1 Extra inputs are not permitted`). Tests: `test_gym_envs.py::test_list_capabilities_surfaces_env_and_observation_schema`, `…::test_observations_validation_error_enumerates_feature_keys`, `test_gym_config.py::test_observation_features_are_real_observation_spec_fields`. ### #5 / #6 — Projection + CSO semantics (docs) Added to `gym_list_capabilities` notes: (#5) the exact monotonic projection — `nondecreasing` = left-to-right cumulative max, `nonincreasing` = cumulative min, applied at decode time; `gym_decode_policy` already returns the projected `y_values` that ran. (#6) `cso_volume` sums **node overflow**; weir/relief spill is a **link flow** → use `uncontrolled_discharge` with `link_ids`; never `cso_volume` for weir CSO. Also added the #4 baseline guidance (below) as a note. --- ## Deferred (with rationale) ### #3 — First-class single/baseline policy evaluation Real gap: `gym_run_episode` can't run a chosen decision vector for a Box-action policy env (control_curve/schedule/market), so measuring the all-open baseline needs the degenerate `y_low=y_high=1.0` trick. A clean fix is a new `gym_evaluate_policy(name|config, decision_vector)` tool (or a typed `PolicySpec` for `gym_run_episode`). Deferred as a new tool surface; the documented workaround (pin `y_low=y_high`) works today and is now described in the capabilities notes. ### #4 — Baseline (dominating corner) missing from the front Real correctness gap for `control_curve`/design search: random/MOEA sampling may never hit the exact all-`y_high` baseline, so the reported Pareto front can omit the true CSO-optimal corner. **Implemented now:** a capabilities note instructing the agent to measure the baseline separately and union it in. **Deferred:** seeding the initial population with the `y_init`/extreme corners inside the search runners (`jobs.py::_run_*`) — a search-loop change wanted its own tests; the union-the-baseline guidance covers correctness in the meantime. ### #7 — Results payload size `gym_get_job_results` inlines every Pareto point's full decision dict. A compact objectives-only mode + pagination is worthwhile but additive API surface; deferred. The canonical cheap source is documented: each job's `output_dir/evaluations.jsonl` and `result.json`. ### #8 — Path/concurrency docs Doc-only: `inp_path`/`config_dir` must be readable by the server process; jobs run concurrently and share CPU (submit sequentially for best wall-clock). Deferred to the user-guide. ### #10 — Dashboard discoverability / offline rendering Concerns the **skills/dashboard** layer (templates, `viz.figures`, CDN-vs-inline Plotly, no-WebGL desktop viewer), not the gym MCP tools. Valid, but a separate surface; deferred to the skills package. --- ## Incidental finding (not in the 10) `test_gym_jobs.py::test_nsga2_produces_nondominated_front` is **flaky**: `_run_platypus` seeds only numpy (`np.random.default_rng(opt.seed)` is used by `random_search`), not Platypus's global `random`, so MOEA runs are non-deterministic and the `np.allclose`-based Pareto-index reconstruction in `_assemble_result` can occasionally include a near-duplicate dominated point. Recommended follow-up: seed Platypus for reproducibility (carefully, given the global-RNG/thread-safety concern) and/or make the Pareto-index reconstruction exact. Left unchanged here (pre-existing, outside this feedback's scope).