Skip to content

Add OG-Core cases, parameters, and run management - #522

Open
error9098x wants to merge 28 commits into
EAPD-DRB:mainfrom
error9098x:feature/521-og-core-cases-parameters-run
Open

Add OG-Core cases, parameters, and run management#522
error9098x wants to merge 28 commits into
EAPD-DRB:mainfrom
error9098x:feature/521-og-core-cases-parameters-run

Conversation

@error9098x

@error9098x error9098x commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

Dependency: This PR is stacked on @Adityakushwaha2006's backend PR #498, whose commits are included here for end-to-end testing. Merge #498 first, then rebase this branch onto main.

  • What changed:

    • Added the OG-Core Cases, Parameters, and Run workspace flow.
    • Added baseline and reform creation, editing, deletion, parameter
      editing, and run selection.
    • Added run queueing, cancellation, live status, logs, outcomes,
      cached-result status, and rerun handling.
    • Added workspace entry/exit handling and OG-Core sidebar navigation
      without changing CLEWS behavior.
    • Added tests for case/run behavior, country access guards, queue
      recovery, navigation, and workspace lifecycle.
  • Why:

    • This completes the core modelling workflow after a country
      calibration has been opened: users can create a baseline, add
      reforms, adjust parameters, and run the model.

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

Screenshots

Ethiopia Cases workspace

image

Shows the country workspace, baseline/reform organisation, case
actions, and country context.

Parameters Page

image

Active model run

image

Shows a running reform with current status, live worker log output,
and recent outcomes.

Validation

  • Tests added and updated for OG-Core case, parameter, run, queue,
    recovery, navigation, and country-access behavior.
  • JavaScript syntax, Python compilation, and whitespace checks
    completed.
  • Manual verification completed for:
    • opening an OG-Core workspace;
    • creating a baseline and reform;
    • editing parameters;
    • starting a run and viewing live logs;
    • cancelling runs and reviewing outcomes;
    • switching between Cases, Parameters, and Run.

Checklist

Adityakushwaha2006 and others added 22 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.
@error9098x error9098x self-assigned this Aug 12, 2026
@error9098x error9098x added the Track: OG Onboarding Onboarding, first-run UX, and contributor/user guidance label Aug 12, 2026
Adityakushwaha2006 and others added 4 commits August 13, 2026 20:25
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 requested a review from autibet August 18, 2026 14:52
@error9098x
error9098x marked this pull request as ready for review August 18, 2026 18:24
@error9098x

error9098x commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

Fixed the legacy storage edge case: CLEWS now lists only directories containing genData.json, so stale WebAPP/DataStorage/OGCore state is no longer shown as a CLEWS model. No data is moved or deleted, and regression coverage was added in commit #cc5494d

@autibet autibet left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The shape of this is good: the workspace lifecycle, the page-token guards against stale repaints, and the Tabulator-based table editor all land cleanly, and the new backend tests cover the queue and freshness rules well.

Three blocking issues, then smaller correctness, performance, and cleanup.


Blocking

1. A parameter edit makes a completed run read "Not run", with no reason shown

OGCoreCase.invalidate_run sets an invalidated run back to status="pending", clears completed_at, and records why in stale_reason. But the Run page decides staleness from the status:

// OGRuns.js:96
let backendStale = hasReusable
    ? (run.status == 'completed' && !run.reusable)
    : ...

For an invalidated run status is "pending", so backendStale is false, normaliseState returns 'pending', and the row renders "Not run". stale_reason is never read anywhere — git grep stale_reason WebAPP/ returns nothing.

Repro: complete a baseline → edit one parameter → open Run. The row that had results a moment ago says "Not run", explains nothing, and disappears from "Latest outcomes". The stale / "Needs run" state that STATUS defines is only ever reached by the calibration-commit-change path, which keeps status="completed" and flips reusable.

Proposed fix: make reusable: false the single invalidation signal and stop rewriting the status — drop meta["status"] = "pending" and the completed_at clear from invalidate_run, so a run that completed stays completed with reusable: false and a stale_reason. The existing run.status == 'completed' && !run.reusable test then fires unchanged, and _results_gate can gate on reusable instead of status so stale results still aren't served. Then render stale_reason in the row title or the outcome panel. If keeping the status rewrite is preferred for other reasons, the minimum is backendStale = hasReusable ? (!run.reusable && !!run.stale_reason) : ... plus surfacing the reason.

2. A failed createRun leaves an invisible case that can't be deleted and blocks its name

Baseline creation is two calls:

// OGCases.js:411
request = Ogc.saveCase({ casename: name, ... })
    .then(response => OGCases.requireCreated(response, '...'))
    .then(() => Ogc.createRun({ ..., run_name: 'baseline', run_type: 'baseline', ... }));

If the second fails — dropped connection, a 400 from createRun — the case is already on disk with zero runs, and there is no way to get rid of it:

  • OGCases.entries() (OGCases.js:182) builds rows by iterating c.runs, so a run-less case produces no row at all.
  • data-act="del-case" is never emitted by entryRows or defaultRow — only del-run is — so openDeleteCase, deleteCaseConfirm, and the del-case / del-case-confirm handlers (OGCases.js:623, :651) are unreachable. There is no UI path to delete a case, orphaned or not.
  • findCase(name) still matches it, so retrying the same name reports "A baseline with this name already exists."

Proposed fix: two parts.

  1. Make creation atomic. Either have saveCase create the baseline run in the same request, or add a compensating Ogc.deleteCase(countryId, name) in the .catch of newCaseConfirm before the error is shown.
  2. Decide whether case deletion is meant to be reachable. If yes, add a del-case control to the baseline row's action menu — the dialog and handlers already exist, they just have no trigger. If no, delete openDeleteCase, deleteCaseConfirm, and both handler lines rather than leaving them as dead paths.

Guarding rendering against run-less cases (entries() emitting a bare case row) would also stop them being invisible in the first place.

3. Reloading outside the workspace strands the backend country session

Routes.Class.js:299 only calls OGWorkspace.leave() on a workspace→non-workspace transition. On a fresh page load acceptedHash equals the current hash, so currentRoute == route and nothing runs.

Repro: open the ETH workspace, then reload the browser at #/OGCore (or paste that URL). Both localStorage['osy-ogc-country'] and the backend's session['ogccountry'] survive. Click "Open workspace" on a different country: Ogc.setSession(null, 'ZAF') gets a 409, "Exit the active country workspace before opening another." The failure dialog offers only Retry (same 409) and "Return to OG-Core". The only escape is reopening ETH and exiting it through the confirm flow.

Proposed fix: reconcile once at startup, before the first crossroads.parse. If the entry route is not a workspace route but OGWorkspace.current() is set, call OGWorkspace.clearLocal() and Ogc.setSession(null) — no confirm dialog needed, since the user did not navigate away from anything. Belt-and-braces: let prepare() treat a 409 from setSession as recoverable by clearing the session and retrying once, so the dialog is never a dead end.


Smaller correctness

  • OGParameters.isCurrent is missing the page checkOGParameters.js:33 is just pageID == PAGE_ID, and PAGE_ID only changes in onLoad. Leave Parameters before its four-request Promise.all resolves and the callback still renders into a destroyed DOM, then calls initEvents(), which re-arms NavigationGuard.activate(...) and a beforeunload listener for a page you're no longer on — so the next navigation is arbitrated by the dead page's guard.
    Fix: match the siblings — return pageID == PAGE_ID && localStorage.getItem('osy-pageId') == 'OGParameters'; (OGCases.js:126, OGRuns.js:57 already do this).

  • The Run page stops polling for good if nothing is active at loadOGRuns.js:616. The monitor's loop body opens with if (!active.length) return;, and load() — the only restart — runs just from onLoad and the runSelected finally-block. A run started from another tab, or a FIFO entry this page didn't submit, never appears; rows stay frozen at their load-time state.
    Fix: don't exit on an empty pass — await the 2s delay and re-check, keeping the loop alive as long as isCurrent(pageToken) holds. Cheaper still: poll getRunQueue per case on that tick instead of getRunStatus per entry, so an idle page costs one request per interval.

  • _preceding_baseline_time_path_locked conflates two casesRunJob.py:279 returns meta.get("time_path"), and get_run_meta returns {} for a missing or unreadable file, so None means both "no baseline in flight" and "baseline in flight, time_path unknown". The caller's preceding_time_path is not None then rejects a reform with "The baseline must complete before running a reform." while that baseline is actively running.
    Fix: return (found: bool, time_path) and have the caller test found for precedence and time_path is True for the transition-path requirement.

  • A successful migration is logged as a failureOGCoreCase.py:240. The order is os.replace(case_dir, staged)os.replace(staged, target)staged.parent.rmdir()moved += 1. If the rmdir raises (stray file, open handle on Windows), the except OSError branch runs after the case is already correctly in place. No data is lost — the rollback guard correctly sees staged is gone — but moved isn't incremented and the log says "Could not move case", so an operator believes a case was left behind.
    Fix: move moved += 1 and the info log to immediately after the second os.replace, and wrap the staged.parent.rmdir() in its own best-effort try/except OSError: pass, the same way staging_root is already cleaned up.


Performance

  • Reusability fingerprinting is recomputed from scratch on every readOGCoreCase.py:460. get_runs_enriched calls is_run_reusable per run → execution_input_fingerprint_calibration_identityCalibrationRegistry.get, which reopens and json.loads the registry file under a lock on every call (CalibrationRegistry.py:75), plus a params read and a full SHA-256 of ogcTaxParams.pkl when present. The getRuns route also calls RunJob.get_live once per run, each taking RunJob._lock. That per-run cost is fanned out over every case in the country by OGWorkspace.prepare, OGCases.refresh, and OGRuns.load — a dozen cases means dozens of registry parses and pickle hashes per page load.
    Fix: three cheap changes, in order of payoff. (a) Resolve the calibration record once per request and thread it into execution_input_fingerprint, instead of calling CalibrationRegistry.get per run. (b) Cache the tax-params digest keyed on (path, st_mtime_ns, st_size) so an unchanged pickle is hashed once. (c) Take one RunJob queue snapshot per getRuns call and match run names against it, rather than calling get_live per run.

  • Every pending run gets an extra status probe even when the snapshot succeededOGRuns.js:219. The comment frames this as a fallback for older backends, but candidates selects every pending or active entry and probes it unconditionally, including when getRunQueue already reported exactly which runs are queued or running. Each probe triggers a server-side fingerprint recomputation, so 30 configured-but-unrun runs means 30 extra round trips per Run-page load.
    Fix: track whether every getRunQueue call succeeded and skip the probe loop entirely when they did; probe only the cases whose snapshot request failed. The typeof Ogc.getRunQueue == 'function' check on line 208 — for a method this same PR adds — can go at the same time.


Cleanup

  • Three parallel answers to "can this result be reused?" — worth a decision rather than a patch. Reusability is currently computed by (a) input_fingerprint / result_fingerprint + is_run_reusable, (b) invalidate_run overwriting status to pending, and (c) markRunsStale in browser localStorage — then re-derived a fourth time in OGRuns.buildQueue.canReuse and again in render. They already disagree: (b) fires first on the common path and masks (a), which is what produces the "Not run" bug, and it also makes (c) unreachable.
    Fix: keep (a) as the only source of truth — the backend owns the verdict and reports reusable + stale_reason on both getRuns and getRunStatus; delete (b)'s status rewrite and (c) entirely; and have the frontend read entry.reusable in one place rather than re-deriving it in canReuse and render.

  • The localStorage staleness store is write-onlyOGCases.js:20. isRunStale is read at exactly one site, OGRuns.js:98, inside the run.reusable === undefined branch — which this PR's backend never produces, since get_runs_enriched sets reusable on every run. Meanwhile OGParameters.save() (OGParameters.js:954) still calls markRunsStale on every save, so osy-ogc-stale-runs accumulates an entry per run forever and is never read back.
    Fix: delete STALE_KEY, loadStale, markRunsStale, isRunStale, clearRunStale, the markRunsStale call in save(), and the reusable === undefined fallback branch; add a one-line localStorage.removeItem('osy-ogc-stale-runs') on workspace entry so existing installs shed the orphaned key.

  • esc is copy-pasted into five filesOGCases.js:9, OGCore.js:11, OGParameters.js:9, OGRuns.js:8, OGWorkspace.Class.js:17, all verbatim. It's the escaper guarding every interpolated backend value, so a fix to one silently misses four.
    Fix: export it once (e.g. WebAPP/Classes/Html.Class.js or alongside Message.Class.js) and import it in all five.

  • OGTableEditor re-implements four helpers, and one has already driftedclone, equal, flatten, dimensions (OGTableEditor.js:1, :3, :19, :26) duplicate Model.clone / Model.equal (OGParameters.Model.js:281, :305) and OGParameters.flatten / .dimensions. OGTableEditor.equal runs parseFloat first while Model.equal short-circuits null/undefined before the numeric compare, so equal(null, 0) is false in the model and true in the table editor — the grid and the field view can disagree about whether a cell changed.
    Fix: import Model.clone / Model.equal in OGTableEditor and delete its local copies; move flatten / dimensions onto Model (or a small shared array-helpers module) so the parameters page and the grid share one definition of "changed".

@autibet

autibet commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

@error9098x Add "hover text" when you're over buttons for more than 1 second with a brief explanation of what the button does:
image
For example, what's a "Add Case" for. What's "Create Baseline" for.

I'd remove "Change country". They can already do that just by pressing on the Home icon so it adds nothing.

Shouldn't all buttons be orange? Or is there a reason why "Add Case" is the only orange?

"Separate Tables" is quite meaningless — it needs a better name to easily understand what it's for.

@marcelolafleur Shouldn't the "Default calibration" already be an inmutable Baseline in itself? Do we need to create a Baseline just to be able to run the model?

@autibet

autibet commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

@error9098x Can the log window automatically scroll down so the user can see the latest messages without having to scroll down constantly? Every time it updates it sends you back to the top:
image

Shouldn't the mkdir be done wherever the country calibration is? Which reminds me, when you install a country calibration, shouldn't we ask the user where they want to put it?

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

Labels

Track: OG Onboarding Onboarding, first-run UX, and contributor/user guidance

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Task] Add OG-Core cases, parameters, and run management

3 participants