Skip to content

Add OG-Core results visualization workspace - #525

Open
error9098x wants to merge 32 commits into
EAPD-DRB:mainfrom
error9098x:feature/524-og-results-visualization
Open

Add OG-Core results visualization workspace#525
error9098x wants to merge 32 commits into
EAPD-DRB:mainfrom
error9098x:feature/524-og-results-visualization

Conversation

@error9098x

@error9098x error9098x commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Dependencies: This PR is stacked on #522, which includes @Adityakushwaha2006's backend PR #498 for end-to-end testing. Merge in order: #498#522 → this PR.

Summary

  • What changed:

    • Added the OG-Core Results page.
    • Added baseline and reform run selection.
    • Added charts, result tables, and export controls.
    • Added Results navigation and frontend tests.
  • Why:

    • To provide a dedicated frontend page for viewing and comparing OG-Core results.

Open-source UI libraries

Results visualization

The Results tab uses Apache ECharts as its visualization engine, replacing the existing result-viewer-specific
charting approach with a model-neutral renderer that supports the required economic charts and future extension to other models.

Apache ECharts is open source under the Apache License 2.0. Version 6.1.0 is vendored in the
repository with the applicable license and notice files.

Parameter tables

The Parameters page uses Tabulator for structured, editable parameter tables. It provides the required table editing,
validation, sorting, and navigation behavior without introducing a custom grid implementation.

Tabulator is open source under the MIT License. Version 6.5.0 and its license are vendored in the
repository.

Linked issue (if applicable)

Screenshots

Results overview

image

Caption: Overview of the selected OG-Core results.

Explore view

image

Caption: Exploring a selected result by variable and comparison.

Results tables

image

Caption: OG-Core analysis tables and export controls.

Demo:

2026-08-18.at.11_45.PM-3.mp4

Validation

  • Tests added/updated (or not applicable)
  • Manual verification steps documented, with evidence where relevant

Checklist

Adityakushwaha2006 and others added 27 commits July 15, 2026 16:07
Cancelling a queued run only dropped it from the queue, so it stayed
pending forever: nothing revisits a pending run, only a running one is
repaired on a status read. It now gets the same terminal state a
cancelled active run gets.

Result tables named the base slot "Baseline" whichever run was in it, so
viewing a reform on its own called it a baseline. OG-Core labels the two
slots by position and takes no label argument, so a one-run table is
relabelled with the run name on our side. Comparisons still read
baseline/reform. Applies to the macro, inequality, gini and time series
tables, and to the CSV download.

Also stops an unwritable run_meta from breaking the queue drain and
stranding everything behind it, and adds pytest coverage for the queue,
cancel, labelling, run guards and restart recovery.
The parameter form came back empty for every country calibration: 129 of 133
parameters had no title, description, default or range. A country defaults
file lists plain values while the base file carries the metadata, and the
overlay projected both the same way, so it replaced everything it touched.
The overlay now keeps the base metadata and swaps in the country's own value,
which is also the value the user actually has.

Deleting a baseline removes the whole case, but unlike deleteCase it asked
for no session, so it was an unguarded case delete. It now clears the same
gate; deleting a reform is unchanged.

Parameters could also be changed while a run was already running or queued.
The worker reads them when it launches, so that either left a finished run's
saved parameters disagreeing with its results, or let a queued run slip past
the dimension check it had already passed. saveParams and uploadTaxParams now
refuse until the run finishes.

Also: reforms record their baseline by name and resolve the path at launch, so
a case restored on another machine still finds it; run_meta is written
atomically since the status endpoints poll it, and one unreadable meta no
longer empties the run list; restoreCase caps the upload and what it may
expand into; run ids are no longer reused after a delete; and the reform
dimension check now includes consumption goods alongside S, T, J and M.
Installing or updating a calibration rewrites the venv a solve is running
under, so the two now refuse to overlap: a run will not start while an
install is in flight for that country, and installing, updating or
re-registering a calibration is refused while a run is using it. A first
install is unaffected, since a country with nothing installed cannot have
a run.

The worker was spawned without -u. Writing to a pipe, Python block-buffers
stdout, so a solve's progress sat in the child until it exited: an hour-long
run showed one log line and never reported an iteration. It is spawned
unbuffered now.

A run left behind by a crash is repaired at startup as well as on the next
status read, and its worker is killed if it outlived the server. The kill
only fires when the recorded pid is still that run's worker, so a reused pid
is never touched, and a run directory no longer matches one it is a prefix
of. Stopping the server stops a running solve alongside a running install.

Also: getRunStatus reports why a run failed rather than only that it did; a
failed status read no longer overwrites a run that finished in the meantime;
cancel kills outside the lock so it cannot block status polls; the wealth
moments table accepts the data moments OG-Core needs to build it at all; and
runs get an optional inactivity ceiling, off by default because a healthy
solve can be quiet for a long time.
A malformed MUIOGO_OGC_RUN_TIMEOUT_SECONDS raised at import and took the
whole app down, while the inactivity value beside it fell back quietly.
Both read through one helper now, so a typo in a tuning knob leaves the
default in place.

Also covers the repair that only fires after a re-read, so a solve that
finishes while its status is being polled is not written off as failed.
Without -ww, ps clips the command to the terminal width, or to $COLUMNS when
there is no terminal. The run directory sits at the end of a worker's command
line, so it was being cut off and the orphan check could never match: on Linux
and macOS a leftover worker would simply never be recognised. It surfaced as a
test failure because pytest sets COLUMNS.
OG-Core builds this table from a "Data" column of survey moments, and leaves
that column empty when none are given, so the frame cannot be built at all
and the endpoint failed for every calibration. Hand it blanks and drop the
column afterwards, which leaves the model's own numbers. A scalar broadcasts,
so this does not depend on how many moments the table has. Passing real data
moments still returns both columns.
OGResults.run_completed and OGRunner.alive were written during the build and
never called from anywhere, including the tests. The run layer's own docstring
still described a single wall-clock watchdog, from before the inactivity
ceiling and the orphan kill were added. The rest is whitespace left by earlier
edits, and one copy of a test helper that existed in two files.

No behaviour changes.
Functional testing on a deployed backend turned up two small gaps in the
analysis tables.

time_series took no options through the API while the worker behind it
accepts stationarized, so the option could not be reached from outside.
Same route/worker mismatch as the macro table output_type and the wealth
moments data_moments.

Asking for the macro table with only a steady state solved was refused
with the path of the pickle it could not find, which tells the caller
nothing they can act on. It now says there are no transition path results
and to run with the full time path, matching what getResults already
said. Only the transition path case changed; a missing steady state or
model_params still names the file, since neither is something a caller
can fix and the path helps when diagnosing one.
restoreCase unpacked the backup straight into the final case folder. A
restore that stopped partway, on a full disk or a name the filesystem
refuses, left the files written so far sitting under the case name. That
wreckage read as a real case, and it also blocked the obvious fix of
simply trying again, because the name now existed.

Unpack into a staging directory and publish with one rename, so a failed
restore leaves nothing at all. Staging goes beside the cases directory
rather than inside it: same filesystem, so publishing stays a rename, and
a restore in flight is never visible to the two things that walk the
cases directory, list_cases and the startup reconcile pass, both of which
treat any directory there as a case.

The move can still lose a race, since the earlier existence check is not
a lock, so a failure there reports the case as already existing when it
now does. Nothing is published in that case either.

Tests cover the round trip, the refusal to overwrite, and the three
things the old code got wrong: no half-case is published, the retry after
a failure works, and nothing appears under cases while unpacking.
Case names were global, so "Baseline" could only exist once across every country.
Creating it a second time either hit a confusing refusal about country_id or, if
the client left country_id out, silently edited the other country's case.

Cases now live at cases/<country_id>/<casename>. A case is identified by the pair,
so country_id is required wherever an endpoint names one, and the session carries
both halves. The directory a case sits in decides its country: genData is pinned
to it on write, and the listing reads it from the path.

Two things fall out of that. is_country_running answers from the run's own key
instead of reading genData, so an unreadable case cannot hide a live run from the
install guard. And an edit can no longer move a case between countries, which
retires the country_id immutability check.

Cases stored in the old layout are moved under their country at startup. One that
records no country is left alone rather than guessed at.
# Conflicts:
#	API/Classes/OGCore/OGCoreCase.py
#	API/Classes/OGCore/RunJob.py
#	API/Routes/OGCore/OGCoreRunRoute.py
#	tests/ogcore/test_run_queue_cancel.py
@error9098x error9098x self-assigned this Aug 18, 2026
@error9098x
error9098x marked this pull request as ready for review August 18, 2026 18:47
@error9098x
error9098x force-pushed the feature/524-og-results-visualization branch from 67546dc to e5f9904 Compare August 18, 2026 18:54
@autibet

autibet commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

@error9098x Well-built page! Lazy-loading ECharts instead of adding a megabyte to index.html, the requestID / selectionKey guards against out-of-order responses, the dimension-aware view switching, and the two new JS-level Playwright tests are all great. The vendoring test (test_results_chart_runtime_is_vendored) pinning the local path and asserting no CDN reference is a nice touch.

Three blocking issues — all cases where the page displays wrong analysis rather than failing loudly — then smaller correctness, performance, and cleanup.


Blocking

1. The policy-change panel hides real differences between the two runs being compared

renderPolicy falls back to the baseline's value when a parameter is absent from the reform:

// OGResults.js:543
let baseValue = name in OGResults.baseParams ? OGResults.baseParams[name] : defaultValue;
let reformValue = name in OGResults.reformParams ? OGResults.reformParams[name] : baseValue;

That is not what the worker does. _execute_run reads only the run's own ogcParams.json and applies it over the calibration defaults (ogc_worker.py:396) — it never layers the baseline's overrides underneath. So a parameter set on the baseline and absent from the reform means the two runs genuinely solved with different values (baseline override vs calibration default), and this panel reports "no change" for exactly that case.

It's the normal path, not an edge case: OGParameters.savePayload() only writes names that changed relative to the reference, and for a reform the reference is its baseline — so any baseline-only edit lands in the baseline's params file and nowhere else.

Repro:

  1. Create a baseline, open Parameters, change frisch from 0.4 to 0.5, save. Run it to completion.
  2. Add a reform off that baseline. Change nothing. Run it to completion.
  3. Open Results and select the pair. The policy strip reads "No parameter changes."
  4. Confirm the runs actually differ: cat cases/<country>/<case>/res/baseline/ogcParams.json contains frisch, res/<reform>/ogcParams.json does not — and the worker gave the reform 0.4.

Proposed fix: make the fallback the calibration default on both sides, matching the worker:

let baseValue   = name in OGResults.baseParams   ? OGResults.baseParams[name]   : defaultValue;
let reformValue = name in OGResults.reformParams ? OGResults.reformParams[name] : defaultValue;

That turns the hidden divergence into a visible row, which is the honest outcome. Worth flagging to the #522 author separately that the reform not inheriting its baseline's parameters may itself be unintended — but this panel should report what actually ran either way.

2. Level-table column headers show group indices instead of group names

// OGResults.js:872
$.each(OGResults.groups, label => headers.push('Baseline · ' + label, 'Reform · ' + label));

jQuery's $.each passes (index, value), so label is the index. Headers come out Baseline · 0, Reform · 0, Baseline · 1, … instead of Baseline · Bottom 25%. The trap is that $.map — used two lines up in renderProfileControls and everywhere else in this file — passes (value, index), the opposite order. This is the only $.each over an array in the file; the three at lines 537–540 iterate objects, where key-first is correct.

Repro: Results → Explore → variable Household consumption → measure Levels → view Table. Every column header past the first reads Baseline · 0 / Reform · 0 / Baseline · 1

Proposed fix: $.each(OGResults.groups, (index, label) => headers.push(...)), or switch to $.map for consistency with the rest of the file. Worth a test — the existing test_results_tables_use_user_facing_headers_and_numeric_alignment covers renderTableRows but never reaches renderExploreTable.

3. setDimensions silently guesses a US 7-group split, and reads schema defaults rather than the run's parameters

// OGResults.js:490
let lambdas = OGResults.schema.lambdas && OGResults.schema.lambdas.default;
...
if (!$.isArray(lambdas) || !lambdas.length) lambdas = [0.25, 0.25, 0.20, 0.10, 0.10, 0.09, 0.01];

Two problems, and they compound:

  • The schema request is swallowed. loadComparison fetches it as Ogc.getParameterSchema(...).catch(() => ({})) (line 467), so any failure produces the hardcoded US split and startAge = 20 with no warning.
  • It reads the calibration default, not the run. lambdas is a by_j parameter the Parameters page lets users edit (with a sum_to_one constraint). OGResults.baseParams is already loaded and available, but unused here — so a baseline that changed lambdas gets axis labels describing the calibration, not what it solved.

The compounding part: ageGroupMatrix identifies an age×income matrix by matching OGResults.groups.length. If the guessed group count is wrong, it returns null, OGResults.ages becomes [], shape() degrades to 'matrix', and every age×income view silently disappears — the heatmap, the lifecycle profile, and all Explore views except Table. The user sees a page with missing panels and no error.

Repro: open Results for a calibration whose J ≠ 7 (or block /ogc/getParameterSchema in devtools and reload). The Distribution and Lifecycle cards render empty, Explore offers only Table for c / n / b_s, and the Explore dimensions readout says matrix instead of Age × income group.

Proposed fix: three steps.

  1. Resolve lambdas and starting_age from OGResults.baseParams first, falling back to the schema default — the run's own values are already fetched.
  2. When neither is available, infer the group count from the data rather than assuming: take the inner length of OGResults.base.c and label groups Group 1…N. Correct dimensions with generic labels beats wrong labels and vanished charts.
  3. Don't swallow the schema failure — showEmpty('Unable to load results', ...), or at minimum a visible note that group labels are approximate.

Smaller correctness

  • Teardown lives on the success path onlyOGResults.js:1002. onLoad binds a dozen $(document).on('....ogresults') handlers immediately (line 1079), but the only code that unbinds them on navigation is the hashchange.ogresults handler installed by bindResize — which is called at the end of renderAll(). If the page ends in an empty state (no cases, no completed comparison) or if loadECharts / getCases rejects, renderAll never runs and those document-level handlers survive after the user leaves the page.
    Repro: open a workspace with no completed baseline+reform pair, visit Results (empty state), navigate to Cases, then run $._data(document, 'events').change.filter(e => e.namespace == 'ogresults').length in the console — the handlers are still attached.
    Fix: register the hashchange.ogresults teardown in initEvents() alongside the handlers it tears down, rather than in bindResize. bindResize should then only own resize.ogresults.

  • isCurrent() compares the hash exactlyOGResults.js:339 uses window.location.hash == '#/OGResults', and the teardown at line 1006 does the same. Every other controller strips the query first (window.location.hash.split('?')[0], e.g. OGRuns.js:618). Any hash carrying a query string or trailing slash makes isCurrent() permanently false, so every async callback bails and the page sits on the loading spinner forever with no error.
    Repro: navigate to #/OGResults?tab=explore. The route matches and the view loads, but the spinner never resolves.
    Fix: window.location.hash.split('?')[0] == '#/OGResults' in both places.

  • renderExploreTable drops the guard matrixTransform hasOGResults.js:873, :876 dereference r[i][j] directly, where matrixTransform (line 634) correctly writes reform[i] && reform[i][j]. If ageGroupMatrix(OGResults.reform[name]) returns null or a shorter matrix, this throws a TypeError and blanks the Explore pane rather than degrading. The backend's _DIMS guard makes mismatched S/J unlikely, but null is reachable whenever the reform's array shape differs from the baseline's for that one variable.
    Fix: early-return when either matrix is missing, and reuse matrixTransform for the non-levels branch instead of recomputing the same thing with a weaker guard.


Performance

  • Every table view spawns a fresh Python subprocess, and one dropdown change fires several. _table_endpointOGTables.run_worker_mode(python_path, argv) starts a new interpreter in the calibration's venv, which imports OG-Core and pandas — seconds, not milliseconds. loadComparison unconditionally calls loadInequalitySummary (one subprocess) plus loadTable('macro') if the Tables tab is open (a second), on top of 2× getSSVars, 2× getParams, and getParameterSchema. And OGResults.tables = {} is cleared on every selection change (line 452), so flipping the reform dropdown to another value and back re-spawns everything that was already computed.
    Repro: open Results, switch the reform dropdown A → B → A, and watch the server log or ps — the inequality worker runs three times for two distinct comparisons.
    Fix: key the cache by selection instead of clearing it — OGResults.tables[selectionKey][tableKey], with selectionKey the JSON.stringify(selection) value the code already computes for staleness checks. Revisiting a comparison then costs nothing. Optionally cap the cache at a handful of entries.

Cleanup

  • heatOption smuggles its scale out through the ECharts option object. Line 654 sets option.ogcScale = scale, and both call sites have to remember to delete option.ogcScale before setOption (lines 671 and 824). A third caller that forgets hands ECharts an unknown option key.
    Fix: return { option, scale } and let callers destructure. Removes the two delete statements and the ordering requirement.

  • esc is now in six files, and dimensions in four. Line 60 is a verbatim sixth copy of the escaper already duplicated across OGCases.js, OGCore.js, OGParameters.js, OGRuns.js, and OGWorkspace.Class.js; dimensions / rank / flatten repeat helpers in OGParameters.js, OGTableEditor.js, and OGParameters.Model.js. Since this PR adds another copy rather than the shared module, it's the natural place to introduce one.
    Fix: one small WebAPP/Classes/Html.Class.js (or similar) exporting esc, plus an array-helpers module for rank / dimensions / flatten, imported by all six.

  • metaLabel is a single-use alias declared after the class — the last line of the file, function metaLabel(name){ return info(name).label; }, is called exactly once, in comparisonOption (line 851), where every neighbouring call site writes info(name).label directly.
    Fix: inline it and delete the function.

  • The baseline selector can never have more than one option. compatibleReforms (line 415) carries a bases.length == 1 fallback for reforms with no baseline_run, and renderRunOptions builds a #ogcResultBase dropdown — but OGCoreCase.create_run enforces one baseline per case ("This case already has a baseline run.") and requires baseline_run_name on every reform, so both the fallback and the multi-option dropdown are unreachable.
    Fix: drop the !run.baseline_run fallback and the now-dead bases computation inside compatibleReforms. Whether the baseline selector should stay as a one-option <select> or become a static label is a UI call, but it shouldn't imply a choice the backend forbids.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Task] Add OG-Core results visualization page

3 participants