diff --git a/DoodleBUGS/.gitignore b/DoodleBUGS/.gitignore index 4aa60c007..438771e2d 100644 --- a/DoodleBUGS/.gitignore +++ b/DoodleBUGS/.gitignore @@ -10,6 +10,7 @@ digest.txt root.txt src.txt public.txt +runtime.txt node_modules .DS_Store @@ -18,6 +19,7 @@ dist-ssr coverage *.local ztest +tmp /cypress/videos/ /cypress/screenshots/ diff --git a/DoodleBUGS/README.md b/DoodleBUGS/README.md index ceb144105..013d91282 100644 --- a/DoodleBUGS/README.md +++ b/DoodleBUGS/README.md @@ -43,3 +43,35 @@ npm run preview ``` For more information, questions, or to get involved, please contact [@shravanngoswamii](https://github.com/shravanngoswamii) (Ping me on [Julia Slack](https://julialang.slack.com/archives/CCYDC34A0)). + +> [!TIP] +> You can generate a standalone Julia script directly from the app: open the navbar → `Connection` → `Generate Standalone Julia Script`. +> The script opens in the right sidebar's Execution panel under the Files tab, where you can copy or download it. + +## Backend (Julia) Quick Start + +The DoodleBUGS app can connect to a local Julia backend for running models. + +1. Clone this repository and open a terminal at the repo root. +2. Instantiate backend dependencies (first time only): + +```bash +julia --project=DoodleBUGS/runtime -e "using Pkg; Pkg.instantiate()" +``` + +3. Start the backend server (defaults to http://localhost:8081): + +```bash +julia --project=DoodleBUGS/runtime DoodleBUGS/runtime/server.jl +``` + +4. In the DoodleBUGS app, open the navbar → `Connection` → set URL to `http://localhost:8081` → `Connect`. + +Notes: +- Keep the backend terminal open while using the app. +- If the port is in use or blocked by a firewall, change the port in `DoodleBUGS/runtime/server.jl` and reconnect (the port is currently set to 8081 at the end of the file). + +#### Troubleshooting + +- To verify connectivity, open your browser at `http://localhost:8081/api/health` (replace 8081 if you changed the port). A healthy server returns `{ "status": "ok" }`. +- If the health check fails, ensure the server is running, the URL/port are correct, and no firewall or VPN is blocking the port. Check the backend terminal output for errors. diff --git a/DoodleBUGS/public/examples/rats/data.json b/DoodleBUGS/public/examples/rats/data.json index c91f4cac4..268812441 100644 --- a/DoodleBUGS/public/examples/rats/data.json +++ b/DoodleBUGS/public/examples/rats/data.json @@ -1,227 +1,57 @@ { - "data": { - "N": 30, - "T": 5, - "x": [ - 8.0, - 15.0, - 22.0, - 29.0, - 36.0 - ], - "xbar": 22, - "Y": [ - [ - 151, - 199, - 246, - 283, - 320 - ], - [ - 145, - 199, - 249, - 293, - 354 - ], - [ - 147, - 214, - 263, - 312, - 328 - ], - [ - 155, - 200, - 237, - 272, - 297 - ], - [ - 135, - 188, - 230, - 280, - 323 - ], - [ - 159, - 210, - 252, - 298, - 331 - ], - [ - 141, - 189, - 231, - 275, - 305 - ], - [ - 159, - 201, - 248, - 297, - 338 - ], - [ - 177, - 220, - 260, - 309, - 331 - ], - [ - 134, - 182, - 220, - 260, - 295 - ], - [ - 160, - 208, - 261, - 313, - 352 - ], - [ - 143, - 188, - 220, - 273, - 314 - ], - [ - 154, - 200, - 244, - 289, - 325 - ], - [ - 161, - 218, - 266, - 304, - 340 - ], - [ - 142, - 184, - 223, - 262, - 298 - ], - [ - 156, - 203, - 243, - 283, - 317 - ], - [ - 157, - 212, - 259, - 307, - 336 - ], - [ - 152, - 203, - 245, - 286, - 324 - ], - [ - 154, - 205, - 253, - 298, - 334 - ], - [ - 139, - 190, - 225, - 267, - 302 - ], - [ - 146, - 196, - 247, - 289, - 321 - ], - [ - 161, - 215, - 254, - 293, - 332 - ], - [ - 153, - 207, - 256, - 303, - 345 - ], - [ - 149, - 198, - 245, - 287, - 324 - ], - [ - 144, - 192, - 242, - 289, - 326 - ], - [ - 162, - 211, - 262, - 315, - 357 - ], - [ - 157, - 202, - 242, - 281, - 316 - ], - [ - 150, - 204, - 256, - 298, - 335 - ], - [ - 151, - 202, - 248, - 290, - 325 - ], - [ - 163, - 216, - 265, - 307, - 342 - ] - ] - }, - "inits": {} + "data": { + "N": 30, + "T": 5, + "x": [8.0, 15.0, 22.0, 29.0, 36.0], + "xbar": 22, + "Y": [ + [151, 199, 246, 283, 320], + [145, 199, 249, 293, 354], + [147, 214, 263, 312, 328], + [155, 200, 237, 272, 297], + [135, 188, 230, 280, 323], + [159, 210, 252, 298, 331], + [141, 189, 231, 275, 305], + [159, 201, 248, 297, 338], + [177, 236, 285, 350, 376], + [134, 182, 220, 260, 296], + [160, 208, 261, 313, 352], + [143, 188, 220, 273, 314], + [154, 200, 244, 289, 325], + [171, 221, 270, 326, 358], + [163, 216, 242, 281, 312], + [160, 207, 248, 288, 324], + [142, 187, 234, 280, 316], + [156, 203, 243, 283, 317], + [157, 212, 259, 307, 336], + [152, 203, 246, 286, 321], + [154, 205, 253, 298, 334], + [139, 190, 225, 267, 302], + [146, 191, 229, 272, 302], + [157, 211, 250, 285, 323], + [132, 185, 237, 286, 331], + [160, 207, 257, 303, 345], + [169, 216, 261, 295, 333], + [157, 205, 248, 289, 316], + [137, 180, 219, 258, 291], + [153, 200, 244, 286, 324] + ] + }, + "inits": { + "alpha": [ + 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, + 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, + 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0 + ], + "beta": [ + 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, + 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, + 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0 + ], + "alpha.c": 150.0, + "beta.c": 10.0, + "tau.c": 1.0, + "alpha.tau": 1.0, + "beta.tau": 1.0 + } } \ No newline at end of file diff --git a/DoodleBUGS/public/examples/rats/model.json b/DoodleBUGS/public/examples/rats/model.json index 7dd76f50d..199a3afb3 100644 --- a/DoodleBUGS/public/examples/rats/model.json +++ b/DoodleBUGS/public/examples/rats/model.json @@ -2,79 +2,281 @@ "name": "Rats: A Normal Hierarchical Model", "graphJSON": [ { - "id": "plate_i", "name": "Plate.i", "type": "node", "nodeType": "plate", "position": { "x": 450, "y": 400 }, - "loopVariable": "i", "loopRange": "1:N" + "id": "plate_i", + "name": "Plate.i", + "type": "node", + "nodeType": "plate", + "position": { + "x": 342.5, + "y": 350 + }, + "loopVariable": "i", + "loopRange": "1:N" }, { - "id": "plate_j", "name": "Plate.j", "type": "node", "nodeType": "plate", "position": { "x": 450, "y": 350 }, - "parent": "plate_i", "loopVariable": "j", "loopRange": "1:T" + "id": "plate_j", + "name": "Plate.j", + "type": "node", + "nodeType": "plate", + "position": { + "x": 514, + "y": 355 + }, + "parent": "plate_i", + "loopVariable": "j", + "loopRange": "1:T" }, { - "id": "node_Y", "name": "Y", "type": "node", "nodeType": "observed", "position": { "x": 450, "y": 250 }, - "parent": "plate_j", "distribution": "dnorm", "indices": "i,j", "observed": true, - "param1": "mu", "param2": "tau.c" + "id": "node_Y", + "name": "Y", + "type": "node", + "nodeType": "observed", + "position": { + "x": 513, + "y": 487 + }, + "parent": "plate_j", + "distribution": "dnorm", + "indices": "i,j", + "observed": true, + "param1": "mu", + "param2": "tau.c" }, { - "id": "node_mu", "name": "mu", "type": "node", "nodeType": "deterministic", "position": { "x": 450, "y": 350 }, - "parent": "plate_j", "equation": "alpha[i] + beta[i] * (x[j] - xbar)", "indices": "i,j" + "id": "node_mu", + "name": "mu", + "type": "node", + "nodeType": "deterministic", + "position": { + "x": 513, + "y": 355 + }, + "parent": "plate_j", + "equation": "alpha[i] + beta[i] * (x[j] - xbar)", + "indices": "i,j" }, { - "id": "node_alpha", "name": "alpha", "type": "node", "nodeType": "stochastic", "position": { "x": 350, "y": 450 }, - "parent": "plate_i", "distribution": "dnorm", "indices": "i", - "param1": "alpha.c", "param2": "alpha.tau" + "id": "node_alpha", + "name": "alpha", + "type": "node", + "nodeType": "stochastic", + "position": { + "x": 153, + "y": 223 + }, + "parent": "plate_i", + "distribution": "dnorm", + "indices": "i", + "param1": "alpha.c", + "param2": "alpha.tau" }, { - "id": "node_beta", "name": "beta", "type": "node", "nodeType": "stochastic", "position": { "x": 550, "y": 450 }, - "parent": "plate_i", "distribution": "dnorm", "indices": "i", - "param1": "beta.c", "param2": "beta.tau" + "id": "node_beta", + "name": "beta", + "type": "node", + "nodeType": "stochastic", + "position": { + "x": 343, + "y": 223 + }, + "parent": "plate_i", + "distribution": "dnorm", + "indices": "i", + "param1": "beta.c", + "param2": "beta.tau" }, { - "id": "node_tau.c", "name": "tau.c", "type": "node", "nodeType": "stochastic", "position": { "x": 200, "y": 200 }, - "distribution": "dgamma", "param1": "0.001", "param2": "0.001" + "id": "node_tau.c", + "name": "tau.c", + "type": "node", + "nodeType": "stochastic", + "position": { + "x": 735, + "y": 355 + }, + "distribution": "dgamma", + "param1": "0.001", + "param2": "0.001" }, { - "id": "node_sigma", "name": "sigma", "type": "node", "nodeType": "deterministic", "position": { "x": 200, "y": 100 }, + "id": "node_sigma", + "name": "sigma", + "type": "node", + "nodeType": "deterministic", + "position": { + "x": 735, + "y": 487 + }, "equation": "1 / sqrt(tau.c)" }, { - "id": "node_alpha.c", "name": "alpha.c", "type": "node", "nodeType": "stochastic", "position": { "x": 250, "y": 550 }, - "distribution": "dnorm", "param1": "0.0", "param2": "1.0E-6" + "id": "node_alpha.c", + "name": "alpha.c", + "type": "node", + "nodeType": "stochastic", + "position": { + "x": 317, + "y": 41 + }, + "distribution": "dnorm", + "param1": "0.0", + "param2": "1.0E-6" }, { - "id": "node_alpha.tau", "name": "alpha.tau", "type": "node", "nodeType": "stochastic", "position": { "x": 150, "y": 550 }, - "distribution": "dgamma", "param1": "0.001", "param2": "0.001" + "id": "node_alpha.tau", + "name": "alpha.tau", + "type": "node", + "nodeType": "stochastic", + "position": { + "x": 41, + "y": 41 + }, + "distribution": "dgamma", + "param1": "0.001", + "param2": "0.001" }, { - "id": "node_beta.c", "name": "beta.c", "type": "node", "nodeType": "stochastic", "position": { "x": 650, "y": 550 }, - "distribution": "dnorm", "param1": "0.0", "param2": "1.0E-6" + "id": "node_beta.c", + "name": "beta.c", + "type": "node", + "nodeType": "stochastic", + "position": { + "x": 581, + "y": 41 + }, + "distribution": "dnorm", + "param1": "0.0", + "param2": "1.0E-6" }, { - "id": "node_beta.tau", "name": "beta.tau", "type": "node", "nodeType": "stochastic", "position": { "x": 750, "y": 550 }, - "distribution": "dgamma", "param1": "0.001", "param2": "0.001" + "id": "node_beta.tau", + "name": "beta.tau", + "type": "node", + "nodeType": "stochastic", + "position": { + "x": 449, + "y": 41 + }, + "distribution": "dgamma", + "param1": "0.001", + "param2": "0.001" }, { - "id": "node_alpha0", "name": "alpha0", "type": "node", "nodeType": "deterministic", "position": { "x": 450, "y": 650 }, + "id": "node_alpha0", + "name": "alpha0", + "type": "node", + "nodeType": "deterministic", + "position": { + "x": 735, + "y": 223 + }, "equation": "alpha.c - xbar * beta.c" }, { - "id": "node_x", "name": "x", "type": "node", "nodeType": "constant", "position": { "x": 650, "y": 350 }, "indices": "j" + "id": "node_x", + "name": "x", + "type": "node", + "nodeType": "constant", + "position": { + "x": 515, + "y": 223 + }, + "indices": "j", + "parent": "plate_j" }, { - "id": "node_xbar", "name": "xbar", "type": "node", "nodeType": "constant", "position": { "x": 550, "y": 700 } + "id": "node_xbar", + "name": "xbar", + "type": "node", + "nodeType": "constant", + "position": { + "x": 821, + "y": 41 + } }, - { "id": "edge_mu_to_y", "type": "edge", "source": "node_mu", "target": "node_Y" }, - { "id": "edge_tauc_to_y", "type": "edge", "source": "node_tau.c", "target": "node_Y" }, - { "id": "edge_alphai_to_mu", "type": "edge", "source": "node_alpha", "target": "node_mu" }, - { "id": "edge_betai_to_mu", "type": "edge", "source": "node_beta", "target": "node_mu" }, - { "id": "edge_x_to_mu", "type": "edge", "source": "node_x", "target": "node_mu" }, - { "id": "edge_xbar_to_mu", "type": "edge", "source": "node_xbar", "target": "node_mu" }, - { "id": "edge_alphac_to_alphai", "type": "edge", "source": "node_alpha.c", "target": "node_alpha" }, - { "id": "edge_alphatau_to_alphai", "type": "edge", "source": "node_alpha.tau", "target": "node_alpha" }, - { "id": "edge_betac_to_betai", "type": "edge", "source": "node_beta.c", "target": "node_beta" }, - { "id": "edge_betatau_to_betai", "type": "edge", "source": "node_beta.tau", "target": "node_beta" }, - { "id": "edge_tauc_to_sigma", "type": "edge", "source": "node_tau.c", "target": "node_sigma" }, - { "id": "edge_alphac_to_alpha0", "type": "edge", "source": "node_alpha.c", "target": "node_alpha0" }, - { "id": "edge_xbar_to_alpha0", "type": "edge", "source": "node_xbar", "target": "node_alpha0" }, - { "id": "edge_betac_to_alpha0", "type": "edge", "source": "node_beta.c", "target": "node_alpha0" } + { + "id": "edge_mu_to_y", + "type": "edge", + "source": "node_mu", + "target": "node_Y" + }, + { + "id": "edge_tauc_to_y", + "type": "edge", + "source": "node_tau.c", + "target": "node_Y" + }, + { + "id": "edge_alphai_to_mu", + "type": "edge", + "source": "node_alpha", + "target": "node_mu" + }, + { + "id": "edge_betai_to_mu", + "type": "edge", + "source": "node_beta", + "target": "node_mu" + }, + { + "id": "edge_x_to_mu", + "type": "edge", + "source": "node_x", + "target": "node_mu" + }, + { + "id": "edge_xbar_to_mu", + "type": "edge", + "source": "node_xbar", + "target": "node_mu" + }, + { + "id": "edge_alphac_to_alphai", + "type": "edge", + "source": "node_alpha.c", + "target": "node_alpha" + }, + { + "id": "edge_alphatau_to_alphai", + "type": "edge", + "source": "node_alpha.tau", + "target": "node_alpha" + }, + { + "id": "edge_betac_to_betai", + "type": "edge", + "source": "node_beta.c", + "target": "node_beta" + }, + { + "id": "edge_betatau_to_betai", + "type": "edge", + "source": "node_beta.tau", + "target": "node_beta" + }, + { + "id": "edge_tauc_to_sigma", + "type": "edge", + "source": "node_tau.c", + "target": "node_sigma" + }, + { + "id": "edge_alphac_to_alpha0", + "type": "edge", + "source": "node_alpha.c", + "target": "node_alpha0" + }, + { + "id": "edge_xbar_to_alpha0", + "type": "edge", + "source": "node_xbar", + "target": "node_alpha0" + }, + { + "id": "edge_betac_to_alpha0", + "type": "edge", + "source": "node_beta.c", + "target": "node_alpha0" + } ] -} +} \ No newline at end of file diff --git a/DoodleBUGS/runtime/Project.toml b/DoodleBUGS/runtime/Project.toml new file mode 100644 index 000000000..8dbd2c8c7 --- /dev/null +++ b/DoodleBUGS/runtime/Project.toml @@ -0,0 +1,14 @@ +[deps] +AbstractMCMC = "80f14c24-f653-4e6a-9b94-39d6b0f70001" +AdvancedHMC = "0bf59076-c3b1-5ca4-86bd-e02cd72cde3d" +DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" +HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3" +JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1" +JuliaBUGS = "ba9fb4c0-828e-4473-b6a1-cd2560fee5bf" +LogDensityProblems = "6fdf6af0-433a-55f7-b3ed-c6c6e0b8df7c" +LogDensityProblemsAD = "996a588d-648d-4e1f-a8f0-a84b347e47b1" +MCMCChains = "c7f686f2-ff18-58e9-bc7b-31028e88f75d" +Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" +Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" +ReverseDiff = "37e2e3b7-166d-5795-8a7a-e32c996b4267" +StatsBase = "2913bbd2-ae8a-5f71-8c99-4fb6c76f3a91" diff --git a/DoodleBUGS/runtime/server.jl b/DoodleBUGS/runtime/server.jl new file mode 100644 index 000000000..cd3cf5e41 --- /dev/null +++ b/DoodleBUGS/runtime/server.jl @@ -0,0 +1,357 @@ +# This server is designed to be a generic executor. +# It creates a temporary, sandboxed Julia environment for each request, + +using HTTP +using JSON3 +using Logging + +const CORS_HEADERS = [ + "Access-Control-Allow-Origin" => "*", + "Access-Control-Allow-Headers" => "Content-Type", + "Access-Control-Allow-Methods" => "POST, GET, OPTIONS", +] + +function cors_handler(handler) + return function(req::HTTP.Request) + if HTTP.method(req) == "OPTIONS" + return HTTP.Response(200, CORS_HEADERS) + else + response = handler(req) + append!(response.headers, CORS_HEADERS) + return response + end + end +end + +function health_check_handler(req::HTTP.Request) + @info "Health check ping (backend reachable)" + return HTTP.Response(200, ["Content-Type" => "application/json"], JSON3.write(Dict("status" => "ok"))) +end + + +function run_model_handler(req::HTTP.Request) + logs = String[] + log!(logs, "Received /api/run request") + log!(logs, "Backend processing started.") + + tmp_dir = mktempdir() + log!(logs, "Created temporary working directory at: $(tmp_dir)") + + try + body = JSON3.read(String(req.body)) + model_code = get(body, :model_code, "") + data_json = haskey(body, :data) ? body[:data] : JSON3.Object() + inits_json = haskey(body, :inits) ? body[:inits] : JSON3.Object() + data_string = get(body, :data_string, "") + inits_string = get(body, :inits_string, "") + settings = get(body, :settings, JSON3.Object()) + + log!(logs, "Request body parsed successfully.") + + model_path = joinpath(tmp_dir, "model.bugs") + write(model_path, model_code) + log!(logs, "Wrote BUGS model to: $(model_path)") + + model_literal = repr(String(model_code)) + + results_path = joinpath(tmp_dir, "results.json") + + payload_path = joinpath(tmp_dir, "payload.json") + payload_obj = Dict( + "model_path" => model_path, + "data" => data_json, + "inits" => inits_json, + "data_string" => data_string, + "inits_string" => inits_string, + "settings" => Dict( + "n_samples" => get(settings, :n_samples, 1000), + "n_adapts" => get(settings, :n_adapts, 1000), + "n_chains" => get(settings, :n_chains, 1), + "seed" => get(settings, :seed, nothing), + "timeout_s" => get(settings, :timeout_s, nothing), + ), + ) + open(payload_path, "w") do f + JSON3.write(f, payload_obj) + end + log!(logs, "Wrote payload to: $(payload_path)") + + script_path = joinpath(tmp_dir, "run_script.jl") + run_script_content = """ + using JuliaBUGS, AbstractMCMC, AdvancedHMC, LogDensityProblems, LogDensityProblemsAD, MCMCChains, ReverseDiff, Random, JSON3, DataFrames, StatsBase, Statistics + + try + # Read payload + payload = JSON3.read(read($(repr(String(payload_path))), String)) + settings = payload.settings + + # Robust integer parsing for settings that may arrive as numbers or strings + to_int(x, default) = begin + xv = x + if xv isa Integer + return Int(xv) + else + p = tryparse(Int, string(xv)) + return p === nothing ? default : p + end + end + + # Build data/inits as NamedTuples; prefer strings when valid, else JSON fallback that supports keys like "alpha.c" + to_nt(obj) = (; (Symbol(String(k)) => v for (k, v) in pairs(obj))...) + data_nt = begin + if !isempty(payload.data_string) + try + eval(Meta.parse(payload.data_string)) + catch + to_nt(payload.data) + end + else + to_nt(payload.data) + end + end + inits_nt = begin + if !isempty(payload.inits_string) + try + eval(Meta.parse(payload.inits_string)) + catch + to_nt(payload.inits) + end + else + to_nt(payload.inits) + end + end + + # Define and compile model + model_def = JuliaBUGS.@bugs($(model_literal), true, false) + model = JuliaBUGS.compile(model_def, data_nt, inits_nt) + + # Wrap for AD (ReverseDiff by default) + ad_model = ADgradient(:ReverseDiff, model) + ld_model = AbstractMCMC.LogDensityModel(ad_model) + + # Settings + n_samples = to_int(get(settings, :n_samples, 1000), 1000) + n_adapts = to_int(get(settings, :n_adapts, 1000), 1000) + n_chains = to_int(get(settings, :n_chains, 1), 1) + seed = get(settings, :seed, nothing) + + # RNG + seed_val = seed isa Integer ? Int(seed) : tryparse(Int, string(seed)) + rng = seed_val === nothing ? Random.MersenneTwister() : Random.MersenneTwister(seed_val) + + # Initial params + D = LogDensityProblems.dimension(ad_model) + initial_theta = rand(rng, D) + + # Sample + if n_chains > 1 && Threads.nthreads() > 1 + samples = AbstractMCMC.sample( + rng, + ld_model, + NUTS(0.8), + AbstractMCMC.MCMCThreads(), + n_samples, + n_chains; + n_adapts=n_adapts, + chain_type=Chains, + init_params=initial_theta, + discard_initial=n_adapts, + progress=false, + ) + else + if n_chains > 1 + samples = AbstractMCMC.sample( + rng, + ld_model, + NUTS(0.8), + AbstractMCMC.MCMCSerial(), + n_samples, + n_chains; + n_adapts=n_adapts, + chain_type=Chains, + init_params=initial_theta, + discard_initial=n_adapts, + progress=false, + ) + else + samples = AbstractMCMC.sample( + rng, + ld_model, + NUTS(0.8), + n_samples; + n_adapts=n_adapts, + chain_type=Chains, + init_params=initial_theta, + discard_initial=n_adapts, + progress=false, + ) + end + end + + # Summaries + summary_df = DataFrame(MCMCChains.summarystats(samples)) + summary_json = [Dict(pairs(row)) for row in eachrow(summary_df)] + + q = [0.025, 0.25, 0.5, 0.75, 0.975] + quant_df = DataFrame(MCMCChains.quantile(samples; q=q)) + quant_json = [Dict(pairs(row)) for row in eachrow(quant_df)] + + open($(repr(String(results_path))), "w") do f + JSON3.write(f, Dict( + "success" => true, + "summary" => summary_json, + "quantiles" => quant_json, + )) + end + catch e + open($(repr(String(results_path))), "w") do f + JSON3.write(f, Dict("success" => false, "error" => sprint(showerror, e))) + end + end + """ + write(script_path, run_script_content) + log!(logs, "Generated execution script: $(script_path)") + + julia_executable = joinpath(Sys.BINDIR, "julia") + project_dir = abspath(@__DIR__) + cmd = `$(julia_executable) --project=$(project_dir) --threads=auto $(script_path)` + + log!(logs, "Executing script in worker process...") + timeout_s = try Int(get(settings, :timeout_s, 0)) catch; 0 end + if timeout_s <= 0 + run(cmd) + log!(logs, "Script execution finished.") + else + proc = run(cmd; wait=false) + log!(logs, "Worker process started; enforcing timeout of $(timeout_s)s") + deadline = time() + timeout_s + while process_running(proc) && time() < deadline + sleep(0.1) + end + if process_running(proc) + log!(logs, "Timeout reached; killing worker process...") + try + kill(proc) + log!(logs, "Worker process killed due to timeout.") + catch e + log!(logs, "Failed to kill worker process: $(sprint(showerror, e)))") + end + throw(ErrorException("Execution timed out after $(timeout_s) seconds")) + else + wait(proc) + log!(logs, "Script execution finished within timeout.") + end + end + + log!(logs, "Reading results from: $(results_path)") + results_content = JSON3.read(read(results_path, String)) + + log!(logs, "Preparing response...") + + if !results_content.success + throw(ErrorException(results_content.error)) + end + + files_arr = Any[ + Dict("name" => "model.bugs", "content" => model_code), + Dict("name" => "run_script.jl", "content" => run_script_content), + Dict("name" => "payload.json", "content" => read(payload_path, String)), + ] + if isfile(results_path) + push!(files_arr, Dict("name" => "results.json", "content" => read(results_path, String))) + end + + # Diagnostics: log attachment sizes + sizes = String[] + for f in files_arr + push!(sizes, string(f["name"], "=", sizeof(f["content"])) ) + end + log!(logs, "Attaching $(length(files_arr)) files; sizes(bytes): $(join(sizes, ", "))") + + response_body = Dict( + "success" => true, + "results" => (haskey(results_content, :summary) ? results_content[:summary] : (haskey(results_content, :results) ? results_content[:results] : Any[])), + "summary" => (haskey(results_content, :summary) ? results_content[:summary] : Any[]), + "quantiles" => (haskey(results_content, :quantiles) ? results_content[:quantiles] : Any[]), + "logs" => logs, + "files" => files_arr, + ) + + log!(logs, "Serializing response...") + buf = IOBuffer() + JSON3.write(buf, response_body) + resp_json = String(take!(buf)) + log!(logs, "Serialization complete. Response bytes=$(sizeof(resp_json))") + log!(logs, "Completed backend execution.") + return HTTP.Response(200, ["Content-Type" => "application/json"], resp_json) + catch e + log!(logs, "An error occurred: $(sprint(showerror, e))") + @error "Error during model execution" exception=(e, catch_backtrace()) + + files_arr = Any[] + if @isdefined(model_code) + push!(files_arr, Dict("name" => "model.bugs", "content" => model_code)) + end + if @isdefined(run_script_content) + push!(files_arr, Dict("name" => "run_script.jl", "content" => run_script_content)) + end + if @isdefined(payload_path) && isfile(payload_path) + push!(files_arr, Dict("name" => "payload.json", "content" => read(payload_path, String))) + end + if @isdefined(results_path) && isfile(results_path) + push!(files_arr, Dict("name" => "results.json", "content" => read(results_path, String))) + end + error_response = Dict( + "success" => false, + "error" => sprint(showerror, e), + "logs" => logs, + "files" => files_arr, + ) + return HTTP.Response(500, ["Content-Type" => "application/json"], JSON3.write(error_response)) + finally + # Clean up temp directory in background with retries to avoid EBUSY on Windows + @async safe_rmdir(tmp_dir) + end +end + +""" +Log a message to both the in-memory logs (returned to the client) and the terminal. +UI logs are kept clean (no timestamps); terminal logging can include timestamps via the logger formatter. +""" +function log!(logs::Vector{String}, msg::AbstractString) + push!(logs, msg) + @info msg +end + +""" +Remove directory tree with retries and backoff. Resilient to transient EBUSY on Windows. +Intended to be called in a background task. +""" +function safe_rmdir(path::AbstractString; retries::Int=6, sleep_s::Float64=0.25) + for _ in 1:retries + try + GC.gc() + rm(path; recursive=true, force=true) + return + catch e + msg = sprint(showerror, e) + if occursin("EBUSY", msg) || e isa IOError + sleep(sleep_s) + continue + else + @warn "Unexpected error removing temp dir" path error=e + return + end + end + end + @warn "Failed to remove temp dir after retries; leaving it on disk" path +end + +const ROUTER = HTTP.Router() +HTTP.register!(ROUTER, "GET", "/api/health", health_check_handler) +HTTP.register!(ROUTER, "POST", "/api/run", run_model_handler) +HTTP.register!(ROUTER, "POST", "/api/run_model", run_model_handler) + +port = 8081 +println("Starting JuliaBUGS backend server on http://localhost:$(port)...") +HTTP.serve(cors_handler(ROUTER), "0.0.0.0", port) diff --git a/DoodleBUGS/src/components/layouts/MainLayout.vue b/DoodleBUGS/src/components/layouts/MainLayout.vue index 02fb4c5c0..c192d4927 100644 --- a/DoodleBUGS/src/components/layouts/MainLayout.vue +++ b/DoodleBUGS/src/components/layouts/MainLayout.vue @@ -1,14 +1,18 @@ + @@ -377,7 +666,8 @@ const isModelValid = computed(() => validationErrors.value.size === 0); @new-graph="showNewGraphModal = true" @save-current-graph="saveCurrentGraph" @open-about-modal="showAboutModal = true" @export-json="handleExportJson" @open-export-modal="openExportModal" @apply-layout="handleGraphLayout" @load-example="handleLoadExample" @validate-model="validateGraph" - :is-model-valid="isModelValid" @show-validation-issues="showValidationModal = true" /> + :is-model-valid="isModelValid" @show-validation-issues="showValidationModal = true" + @connect-to-backend-url="connectToBackendUrl" @run-model="runModel" @abort-run="abortRun" @generate-standalone="handleGenerateStandalone" /> @@ -405,6 +699,9 @@ const isModelValid = computed(() => validationErrors.value.size === 0); + + + @@ -428,6 +725,8 @@ const isModelValid = computed(() => validationErrors.value.size === 0); @click="uiStore.setActiveRightTab('code')">Code JSON + Connection @@ -445,39 +744,69 @@ const isModelValid = computed(() => validationErrors.value.size === 0); + + + + Create New Project - Project - Name: - + + Project Name: + + Cancel Create + Create New Graph - Graph Name: - + + Graph Name: + + Cancel Create + + + + Connect to Backend + + + + Backend Server URL: + + The URL of your running JuliaBUGS backend server. + + + + Cancel + + Connecting... + Connect + + + + @@ -686,4 +1015,20 @@ const isModelValid = computed(() => validationErrors.value.size === 0); .resizer-right { border-left: 1px solid var(--color-border); } + +.modal-body-content { + display: flex; + flex-direction: column; + gap: 8px; +} + +.modal-body-content label { + font-weight: 500; +} + +.modal-body-content small { + display: block; + margin-top: 4px; + color: var(--color-secondary); +} diff --git a/DoodleBUGS/src/components/layouts/TheNavbar.vue b/DoodleBUGS/src/components/layouts/TheNavbar.vue index 65ef266a2..6a8c349ae 100644 --- a/DoodleBUGS/src/components/layouts/TheNavbar.vue +++ b/DoodleBUGS/src/components/layouts/TheNavbar.vue @@ -1,10 +1,12 @@ + + + + + Sampler Settings + + Samples + + + + Adaptation Steps + + + + Chains + + + + Seed (optional) + + + + + + + + diff --git a/DoodleBUGS/src/components/panels/DataInputPanel.vue b/DoodleBUGS/src/components/panels/DataInputPanel.vue index 20a6e79f3..badcfc2a2 100644 --- a/DoodleBUGS/src/components/panels/DataInputPanel.vue +++ b/DoodleBUGS/src/components/panels/DataInputPanel.vue @@ -4,29 +4,31 @@ import { useDataStore } from '../../stores/dataStore'; import 'codemirror/lib/codemirror.css'; import 'codemirror/theme/material-darker.css'; import 'codemirror/mode/javascript/javascript.js'; +import 'codemirror/mode/julia/julia.js'; import 'codemirror/addon/scroll/simplescrollbars.css'; import 'codemirror/addon/scroll/simplescrollbars.js'; import 'codemirror/addon/fold/foldgutter.css'; import 'codemirror/addon/fold/foldgutter.js'; import 'codemirror/addon/fold/brace-fold.js'; import CodeMirror from 'codemirror'; -import type { Editor } from 'codemirror'; +import type { Editor, EditorConfiguration } from 'codemirror'; +import BaseButton from '../ui/BaseButton.vue'; const props = defineProps<{ isActive: boolean; }>(); const dataStore = useDataStore(); -const editorContainer = ref(null); -let cmInstance: Editor | null = null; +const dataEditorContainer = ref(null); +const initsEditorContainer = ref(null); + +let dataCm: Editor | null = null; +let initsCm: Editor | null = null; let isUpdatingFromSource = false; const jsonError = ref(null); +const jsonInitsError = ref(null); -/** - * Validates a string to see if it is valid JSON. - * @param jsonString The string to validate. - */ const validateJson = (jsonString: string) => { try { JSON.parse(jsonString); @@ -36,57 +38,106 @@ const validateJson = (jsonString: string) => { } }; -onMounted(async () => { - await nextTick(); - if (editorContainer.value) { - cmInstance = CodeMirror(editorContainer.value, { - value: dataStore.currentGraphDataString, - mode: { name: "javascript", json: true }, - theme: 'material-darker', - lineNumbers: true, - tabSize: 2, - scrollbarStyle: "simple", - lineWrapping: false, - foldGutter: true, - gutters: ["CodeMirror-linenumbers", "CodeMirror-foldgutter"] - }); +const validateJsonInits = (jsonString: string) => { + try { + JSON.parse(jsonString); + jsonInitsError.value = null; + } catch (e: unknown) { + jsonInitsError.value = e instanceof Error ? e.message : String(e); + } +}; - cmInstance.on('change', (instance: Editor) => { - if (isUpdatingFromSource) return; - const currentValue = instance.getValue(); - dataStore.currentGraphDataString = currentValue; - validateJson(currentValue); - }); - - validateJson(dataStore.currentGraphDataString); +const setupCodeMirror = () => { + // Destroy existing instances + [dataCm, initsCm].forEach(cm => { + if (cm) { + const wrapper = cm.getWrapperElement(); + wrapper.parentNode?.removeChild(wrapper); + } + }); + dataCm = null; + initsCm = null; - if (props.isActive) { - nextTick(() => cmInstance?.refresh()); + nextTick(() => { + if (dataStore.inputMode === 'julia') { + if (dataEditorContainer.value) { + dataCm = createCmInstance(dataEditorContainer.value, `data = ${dataStore.dataString}\n\ninits = ${dataStore.initsString}`, 'julia'); + dataCm.on('change', (instance) => { + if (isUpdatingFromSource) return; + const combinedValue = instance.getValue(); + const dataMatch = combinedValue.match(/data\s*=\s*(\([\s\S]*?\))\s*/m); + const initsMatch = combinedValue.match(/inits\s*=\s*(\([\s\S]*?\))\s*/m); + dataStore.dataString = dataMatch ? dataMatch[1] : '()'; + dataStore.initsString = initsMatch ? initsMatch[1] : '()'; + }); + } + } else { // JSON mode + if (dataEditorContainer.value) { + dataCm = createCmInstance(dataEditorContainer.value, dataStore.dataString, { name: "javascript", json: true }); + dataCm.on('change', (instance) => { + if (isUpdatingFromSource) return; + dataStore.dataString = instance.getValue(); + validateJson(instance.getValue()); + }); + } + if (initsEditorContainer.value) { + initsCm = createCmInstance(initsEditorContainer.value, dataStore.initsString, { name: "javascript", json: true }); + initsCm.on('change', (instance) => { + if (isUpdatingFromSource) return; + dataStore.initsString = instance.getValue(); + validateJsonInits(instance.getValue()); + }); + } } - } -}); + }); +}; + +const createCmInstance = (container: HTMLElement, value: string, mode: string | EditorConfiguration["mode"]): Editor => { + return CodeMirror(container, { + value, + mode, + theme: 'material-darker', + lineNumbers: true, + tabSize: 2, + scrollbarStyle: "simple", + lineWrapping: false, + foldGutter: true, + gutters: ["CodeMirror-linenumbers", "CodeMirror-foldgutter"] + }); +}; +onMounted(setupCodeMirror); onUnmounted(() => { - if (cmInstance) { - const editorElement = cmInstance.getWrapperElement(); - editorElement.parentNode?.removeChild(editorElement); - cmInstance = null; - } + [dataCm, initsCm].forEach(cm => { + if (cm) { + const wrapper = cm.getWrapperElement(); + wrapper.parentNode?.removeChild(wrapper); + } + }); }); -watch(() => dataStore.currentGraphDataString, (newData) => { - if (cmInstance && cmInstance.getValue() !== newData) { - isUpdatingFromSource = true; - cmInstance.setValue(newData); - isUpdatingFromSource = false; - validateJson(newData); +watch(() => dataStore.inputMode, setupCodeMirror); + +watch([() => dataStore.dataString, () => dataStore.initsString], () => { + if (!dataCm) return; + isUpdatingFromSource = true; + if (dataStore.inputMode === 'julia') { + const combined = `data = ${dataStore.dataString}\n\ninits = ${dataStore.initsString}`; + if (dataCm.getValue() !== combined) dataCm.setValue(combined); + } else { + if (dataCm.getValue() !== dataStore.dataString) dataCm.setValue(dataStore.dataString); + if (initsCm && initsCm.getValue() !== dataStore.initsString) initsCm.setValue(dataStore.initsString); + validateJson(dataStore.dataString); + validateJsonInits(dataStore.initsString); } -}); + isUpdatingFromSource = false; +}, { deep: true }); watch(() => props.isActive, (newVal) => { - if (newVal && cmInstance) { + if (newVal) { nextTick(() => { - cmInstance?.refresh(); + dataCm?.refresh(); + initsCm?.refresh(); }); } }); @@ -94,19 +145,49 @@ watch(() => props.isActive, (newVal) => { - Model Data & Inits + + Model Data & Inits + + JSON + Julia + + - Define observed data and initial values for your model in JSON format. + Define observed data and initial values for your model. - - + + + + + + + + Data + + + + Inits + + +
- Define observed data and initial values for your model in JSON format. + Define observed data and initial values for your model.