@mcmcjs/julia 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/driver.jl CHANGED
@@ -1,184 +1,16 @@
1
- # MCMC.js fit driver. Reads a run-request JSON, runs the spec's backend (Turing or
2
- # JuliaBUGS), and writes the draws as MCMCChains-JSON (the @mcmcjs/core samples
3
- # artifact). On success a single line of provenance JSON is printed to stdout;
4
- # failures print one-line JSON to stderr ({error, stage}) and exit nonzero. This is
5
- # the only place that touches the backend chain types, so a chain-type change is
6
- # contained here.
7
- using JSON
8
- using Random
9
- using StableRNGs
10
- using Turing
11
- using Turing: VarName
12
- using MCMCChains
13
- using SHA
14
- using Pkg
15
- # Imported (not `using`) so they do not pull `@model` etc. into Main and clash
16
- # with Turing's exports, which would break Turing model files on include.
17
- import JuliaBUGS
18
- import AdvancedHMC
19
- import ForwardDiff
20
-
21
- function build_sampler(sampler, backend)
22
- algorithm = get(sampler, "algorithm", "NUTS")
23
- algorithm == "NUTS" || error("unsupported sampler algorithm: $algorithm")
24
- warmup = Int(get(sampler, "warmup", 1000))
25
- adapt_delta = Float64(get(sampler, "adapt_delta", 0.8))
26
- nuts = backend == "juliabugs" ? AdvancedHMC.NUTS(adapt_delta) : Turing.NUTS(warmup, adapt_delta)
27
- return nuts, warmup
28
- end
29
-
30
- to_namedtuple(data) = (; (Symbol(k) => v for (k, v) in data)...)
31
-
32
- # JuliaBUGS requires dense arrays with a concrete numeric eltype, but JSON parses
33
- # them as Vector{Any} (and nested arrays as vectors of vectors). narrow promotes a
34
- # numeric array to a concrete eltype and stacks equal-sized nested arrays into a
35
- # dense array; an empty array becomes Float64[].
36
- function narrow(v::AbstractVector)
37
- isempty(v) && return Float64[]
38
- elems = [narrow(x) for x in v]
39
- if all(e -> e isa AbstractArray, elems) && allequal(size.(elems))
40
- return stack(elems; dims = 1)
41
- end
42
- return eltype(elems) <: Real && !isconcretetype(eltype(elems)) ? float.(elems) : elems
43
- end
44
- narrow(x) = x
45
- bugs_namedtuple(data) = (; (Symbol(k) => narrow(v) for (k, v) in data)...)
46
-
47
- # JSON null arrives as `nothing`; map it to `missing` so a blanked outcome becomes
48
- # a sampling (predictive) statement in the model.
49
- to_missing(x) = x === nothing ? missing : (x isa AbstractVector ? map(to_missing, x) : x)
50
- predict_namedtuple(data) = (; (Symbol(k) => to_missing(v) for (k, v) in data)...)
51
-
52
- # Rebuild a posterior Chains (latents only) from our MCMCChains-JSON wire object so
53
- # it can feed Turing.predict. The varname_to_symbol map is required for VarName
54
- # indexing; target outcome columns are excluded.
55
- function chains_from_wire(wire, targets)
56
- sz = wire["size"]
57
- nIter, nParams, nChains = Int(sz[1]), Int(sz[2]), Int(sz[3])
58
- flat = wire["value_flat"]
59
- allnames = wire["parameters"]
60
- is_target(n) = any(t -> n == t || startswith(n, t * "["), targets)
61
- keep = [n for n in wire["name_map"]["parameters"] if !is_target(n)]
62
- index = Dict(n => i for (i, n) in enumerate(allnames))
63
- arr = Array{Float64,3}(undef, nIter, length(keep), nChains)
64
- for (newp, n) in enumerate(keep)
65
- p = index[n]
66
- for c in 1:nChains, i in 1:nIter
67
- v = flat[i + (p - 1) * nIter + (c - 1) * nIter * nParams]
68
- arr[i, newp, c] = v === nothing ? NaN : Float64(v)
69
- end
70
- end
71
- syms = Symbol.(keep)
72
- vmap = Dict{VarName,Symbol}(eval(:(Turing.@varname($(Meta.parse(n))))) => Symbol(n) for n in keep)
73
- return MCMCChains.Chains(arr, syms, Dict(:parameters => syms); info = (; varname_to_symbol = vmap))
74
- end
75
-
76
- # Build the model and sample in one call so both run in the latest world age:
77
- # the model methods are defined by `include` at runtime, so a plain call from
78
- # this (older) world would fail with "method too new". invokelatest fixes that.
79
- function build_and_sample(entry, data, sampler, draws, chains, rng)
80
- model = entry(data)
81
- # Sample into Turing's default chain type (a FlexiChain), then convert to
82
- # MCMCChains for serialization.
83
- chn = sample(rng, model, sampler, MCMCSerial(), draws, chains; progress = false)
84
- return MCMCChains.Chains(chn)
85
- end
86
-
87
- # JuliaBUGS compiles the model at runtime, so building and sampling must run in
88
- # separate latest-world frames; merging them reintroduces a world-age error. The
89
- # result is already an MCMCChains.Chains, so it is not re-wrapped.
90
- function sample_bugs(model, sampler, warmup, draws, chains, rng)
91
- return JuliaBUGS.AbstractMCMC.sample(
92
- rng, model, sampler, JuliaBUGS.AbstractMCMC.MCMCSerial(), draws, chains;
93
- chain_type = MCMCChains.Chains, n_adapts = warmup, discard_initial = warmup, progress = false,
94
- )
95
- end
96
-
97
- # Build the exact wire object parseMCMCChainsJson consumes: value_flat is indexed
98
- # i + p*nIter + c*nIter*nParams (column-major over [iteration, parameter, chain]).
99
- function chains_to_wire(chn)
100
- arr = Array(chn.value)
101
- nIter, nParams, nChains = size(arr)
102
- flat = Vector{Union{Float64,Nothing}}(undef, nIter * nParams * nChains)
103
- for c in 1:nChains, p in 1:nParams, i in 1:nIter
104
- v = arr[i, p, c]
105
- flat[i + (p - 1) * nIter + (c - 1) * nIter * nParams] = v === missing ? nothing : Float64(v)
106
- end
107
- return Dict(
108
- "size" => [nIter, nParams, nChains],
109
- "value_flat" => flat,
110
- "parameters" => string.(names(chn)),
111
- "name_map" => Dict(
112
- "parameters" => string.(get(chn.name_map, :parameters, Symbol[])),
113
- "internals" => string.(get(chn.name_map, :internals, Symbol[])),
114
- ),
115
- )
116
- end
117
-
118
- function provenance()
119
- packages = Dict{String,String}()
120
- for (_, info) in Pkg.dependencies()
121
- if info.name in
122
- ("Turing", "MCMCChains", "DynamicPPL", "AbstractMCMC", "JuliaBUGS", "AdvancedHMC", "ForwardDiff", "ADTypes") &&
123
- info.version !== nothing
124
- packages[info.name] = string(info.version)
125
- end
126
- end
127
- manifest = joinpath(dirname(Base.active_project()), "Manifest.toml")
128
- return Dict(
129
- "julia_version" => string(VERSION),
130
- "packages" => packages,
131
- "manifest_sha256" => isfile(manifest) ? bytes2hex(sha256(read(manifest))) : "",
132
- )
133
- end
1
+ # MCMC.js one-shot fit driver. Reads a run-request JSON file and runs it via
2
+ # fitlib's handle_request. On success a single line of provenance JSON is
3
+ # printed to stdout; failures print one-line JSON to stderr ({error, stage})
4
+ # and exit nonzero. Progress streams to stderr as {"mcmcjs":"progress"} lines.
5
+ include(joinpath(@__DIR__, "fitlib.jl"))
134
6
 
135
7
  function main()
136
8
  request = JSON.parsefile(ARGS[1])
137
- out = request["out"]
138
- mode = get(request, "mode", "fit")
139
- backend = get(get(request, "backend", Dict()), "id", "turing")
140
- stage = "compile"
141
- try
142
- Random.seed!(Int(request["seed"]))
143
- rng = StableRNG(Int(request["seed"]))
144
- include(abspath(request["model"]["file"]))
145
- entry = getfield(Main, Symbol(request["model"]["entry"]))
146
-
147
- wire = if mode == "predict"
148
- backend == "turing" || error("predict is not yet supported for backend $backend")
149
- model = Base.invokelatest(entry, predict_namedtuple(request["data"]))
150
- stage = "load_samples"
151
- rchn = chains_from_wire(JSON.parsefile(request["samples"]), request["predict"]["targets"])
152
- stage = "predict"
153
- pp = Base.invokelatest(predict, rng, model, rchn)
154
- isempty(names(pp)) &&
155
- error("no variables predicted; check that [predict].targets name unconditioned outcomes")
156
- chains_to_wire(pp)
157
- else
158
- data = backend == "juliabugs" ? bugs_namedtuple(request["data"]) : to_namedtuple(request["data"])
159
- sampler, warmup = build_sampler(request["sampler"], backend)
160
- draws = Int(request["sampler"]["draws"])
161
- chains = Int(get(request["sampler"], "chains", 4))
162
- stage = "sample"
163
- chn = if backend == "juliabugs"
164
- model = Base.invokelatest(entry, data)
165
- Base.invokelatest(sample_bugs, model, sampler, warmup, draws, chains, rng)
166
- else
167
- Base.invokelatest(build_and_sample, entry, data, sampler, draws, chains, rng)
168
- end
169
- chains_to_wire(chn)
170
- end
171
-
172
- stage = "write"
173
- tmp = out * ".tmp"
174
- open(tmp, "w") do io
175
- JSON.print(io, wire)
176
- end
177
- mv(tmp, out; force = true)
178
-
179
- println(JSON.json(provenance()))
180
- catch err
181
- println(stderr, JSON.json(Dict("error" => sprint(showerror, err), "stage" => stage)))
9
+ result = handle_request(request)
10
+ if result["ok"]
11
+ println(JSON.json(result["provenance"]))
12
+ else
13
+ println(stderr, JSON.json(Dict("error" => result["error"], "stage" => result["stage"])))
182
14
  exit(1)
183
15
  end
184
16
  end
package/dist/fitlib.jl ADDED
@@ -0,0 +1,289 @@
1
+ # MCMC.js fit library, shared by the one-shot driver (driver.jl) and the
2
+ # persistent worker (worker.jl). handle_request runs one fit or predict request
3
+ # (Turing or JuliaBUGS) and writes the draws as MCMCChains-JSON (the
4
+ # @mcmcjs/core samples artifact). This is the only place that touches the
5
+ # backend chain types, so a chain-type change is contained here.
6
+ using JSON
7
+ using Logging
8
+ using Random
9
+ using StableRNGs
10
+ using Turing
11
+ using Turing: VarName
12
+ using MCMCChains
13
+ using SHA
14
+ using Pkg
15
+ # Imported (not `using`) so they do not pull `@model` etc. into Main and clash
16
+ # with Turing's exports, which would break Turing model files on include.
17
+ import JuliaBUGS
18
+ import AdvancedHMC
19
+ import ForwardDiff
20
+ import FlexiChains
21
+ import DimensionalData
22
+
23
+ # AbstractMCMC reports sampling progress through ProgressLogging (reachable via
24
+ # Turing's dependency, no extra package in the managed env).
25
+ const ProgressLogging = Turing.AbstractMCMC.ProgressLogging
26
+
27
+ # Where progress JSON lines go: the driver leaves this on stderr; the worker
28
+ # points it at the client socket for the duration of a request.
29
+ const PROGRESS_IO = Ref{IO}(stderr)
30
+
31
+ # Translates ProgressLogging records into one JSON line per update
32
+ # ({"mcmcjs":"progress",...}). The TypeScript side filters these out of the
33
+ # failure protocol's stderr buffer. Info-and-above records pass through to a
34
+ # plain console logger; Debug records (AdvancedHMC step-size search) are dropped.
35
+ struct JSONProgressLogger <: Logging.AbstractLogger
36
+ fallback::Logging.AbstractLogger
37
+ last::Dict{Int,Float64}
38
+ end
39
+ JSONProgressLogger() =
40
+ JSONProgressLogger(Logging.ConsoleLogger(stderr, Logging.Info), Dict{Int,Float64}())
41
+
42
+ Logging.min_enabled_level(::JSONProgressLogger) = Logging.BelowMinLevel
43
+ Logging.shouldlog(::JSONProgressLogger, args...) = true
44
+ Logging.catch_exceptions(::JSONProgressLogger) = true
45
+
46
+ function emit_progress(logger::JSONProgressLogger, name, fraction, done)
47
+ m = match(r"Chain (\d+) of (\d+)", String(name))
48
+ chain = m === nothing ? 1 : parse(Int, m.captures[1])
49
+ of = m === nothing ? 1 : parse(Int, m.captures[2])
50
+ f = done ? 1.0 : fraction === nothing ? 0.0 : Float64(fraction)
51
+ # AbstractMCMC already steps by 1%; the dedupe guards chattier emitters.
52
+ if done || f - get(logger.last, chain, -1.0) >= 0.01
53
+ logger.last[chain] = f
54
+ println(
55
+ PROGRESS_IO[],
56
+ JSON.json(
57
+ Dict(
58
+ "mcmcjs" => "progress",
59
+ "chain" => chain,
60
+ "of" => of,
61
+ "fraction" => round(f; digits = 4),
62
+ "done" => done,
63
+ ),
64
+ ),
65
+ )
66
+ flush(PROGRESS_IO[])
67
+ end
68
+ end
69
+
70
+ function Logging.handle_message(
71
+ logger::JSONProgressLogger, level, message, _module, group, id, file, line; kwargs...
72
+ )
73
+ progress = message isa ProgressLogging.ProgressString ? message.progress :
74
+ message isa ProgressLogging.Progress ? message : nothing
75
+ if progress !== nothing
76
+ emit_progress(logger, progress.name, progress.fraction, progress.done)
77
+ return
78
+ end
79
+ if haskey(kwargs, :progress) && (kwargs[:progress] isa Real || kwargs[:progress] == "done")
80
+ value = kwargs[:progress]
81
+ emit_progress(logger, string(message), value == "done" ? nothing : value, value == "done")
82
+ return
83
+ end
84
+ level >= Logging.Info || return
85
+ Logging.handle_message(logger.fallback, level, message, _module, group, id, file, line; kwargs...)
86
+ end
87
+
88
+ function build_sampler(sampler, backend)
89
+ algorithm = get(sampler, "algorithm", "NUTS")
90
+ algorithm == "NUTS" || error("unsupported sampler algorithm: $algorithm")
91
+ warmup = Int(get(sampler, "warmup", 1000))
92
+ adapt_delta = Float64(get(sampler, "adapt_delta", 0.8))
93
+ nuts = backend == "juliabugs" ? AdvancedHMC.NUTS(adapt_delta) : Turing.NUTS(warmup, adapt_delta)
94
+ return nuts, warmup
95
+ end
96
+
97
+ to_namedtuple(data) = (; (Symbol(k) => v for (k, v) in data)...)
98
+
99
+ # JuliaBUGS requires dense arrays with a concrete numeric eltype, but JSON parses
100
+ # them as Vector{Any} (and nested arrays as vectors of vectors). narrow promotes a
101
+ # numeric array to a concrete eltype and stacks equal-sized nested arrays into a
102
+ # dense array; an empty array becomes Float64[].
103
+ function narrow(v::AbstractVector)
104
+ isempty(v) && return Float64[]
105
+ elems = [narrow(x) for x in v]
106
+ if all(e -> e isa AbstractArray, elems) && allequal(size.(elems))
107
+ return stack(elems; dims = 1)
108
+ end
109
+ return eltype(elems) <: Real && !isconcretetype(eltype(elems)) ? float.(elems) : elems
110
+ end
111
+ narrow(x) = x
112
+ bugs_namedtuple(data) = (; (Symbol(k) => narrow(v) for (k, v) in data)...)
113
+
114
+ # JSON null arrives as `nothing`; map it to `missing` so a blanked outcome becomes
115
+ # a sampling (predictive) statement in the model.
116
+ to_missing(x) = x === nothing ? missing : (x isa AbstractVector ? map(to_missing, x) : x)
117
+ predict_namedtuple(data) = (; (Symbol(k) => to_missing(v) for (k, v) in data)...)
118
+
119
+ # Rebuild a posterior Chains (latents only) from our MCMCChains-JSON wire object so
120
+ # it can feed Turing.predict. The varname_to_symbol map is required for VarName
121
+ # indexing; target outcome columns are excluded.
122
+ function chains_from_wire(wire, targets)
123
+ sz = wire["size"]
124
+ nIter, nParams, nChains = Int(sz[1]), Int(sz[2]), Int(sz[3])
125
+ flat = wire["value_flat"]
126
+ allnames = wire["parameters"]
127
+ is_target(n) = any(t -> n == t || startswith(n, t * "["), targets)
128
+ keep = [n for n in wire["name_map"]["parameters"] if !is_target(n)]
129
+ index = Dict(n => i for (i, n) in enumerate(allnames))
130
+ arr = Array{Float64,3}(undef, nIter, length(keep), nChains)
131
+ for (newp, n) in enumerate(keep)
132
+ p = index[n]
133
+ for c in 1:nChains, i in 1:nIter
134
+ v = flat[i + (p - 1) * nIter + (c - 1) * nIter * nParams]
135
+ arr[i, newp, c] = v === nothing ? NaN : Float64(v)
136
+ end
137
+ end
138
+ syms = Symbol.(keep)
139
+ vmap = Dict{VarName,Symbol}(eval(:(Turing.@varname($(Meta.parse(n))))) => Symbol(n) for n in keep)
140
+ return MCMCChains.Chains(arr, syms, Dict(:parameters => syms); info = (; varname_to_symbol = vmap))
141
+ end
142
+
143
+ # Build the model and sample in one call so both run in the latest world age:
144
+ # the model methods are defined by `include` at runtime, so a plain call from
145
+ # this (older) world would fail with "method too new". invokelatest fixes that.
146
+ # Returns Turing's default chain type, a FlexiChain.
147
+ function build_and_sample(entry, data, sampler, draws, chains, rng)
148
+ model = entry(data)
149
+ return Logging.with_logger(JSONProgressLogger()) do
150
+ sample(rng, model, sampler, MCMCSerial(), draws, chains; progress = true)
151
+ end
152
+ end
153
+
154
+ # FlexiChains-native wire writer. DimArray(chn) splits array-valued parameters
155
+ # into scalar leaves (theta -> theta[1], theta[2]) in the (iter, chain, param)
156
+ # orientation; Real-valued extras become the internals section, matching what
157
+ # the MCMCChains bridge produced.
158
+ function vnchain_to_wire(chn)
159
+ da = DimensionalData.DimArray(chn)
160
+ pnames = string.(collect(DimensionalData.lookup(da, :param)))
161
+ arr = parent(da)
162
+ nIter, nChains, nParams = size(arr)
163
+
164
+ extras = [e for e in FlexiChains.extras(chn) if eltype(chn[e]) <: Union{Missing,Real}]
165
+ enames = [string(Symbol(e)) for e in extras]
166
+ total = nParams + length(extras)
167
+
168
+ flat = Vector{Union{Float64,Nothing}}(undef, nIter * total * nChains)
169
+ cell(v) = v === missing ? nothing : Float64(v)
170
+ for c in 1:nChains, p in 1:nParams, i in 1:nIter
171
+ flat[i + (p - 1) * nIter + (c - 1) * nIter * total] = cell(arr[i, c, p])
172
+ end
173
+ for (k, e) in enumerate(extras)
174
+ m = chn[e]
175
+ p = nParams + k
176
+ for c in 1:nChains, i in 1:nIter
177
+ flat[i + (p - 1) * nIter + (c - 1) * nIter * total] = cell(m[i, c])
178
+ end
179
+ end
180
+
181
+ return Dict(
182
+ "size" => [nIter, total, nChains],
183
+ "value_flat" => flat,
184
+ "parameters" => vcat(pnames, enames),
185
+ "name_map" => Dict("parameters" => pnames, "internals" => enames),
186
+ )
187
+ end
188
+
189
+ # JuliaBUGS compiles the model at runtime, so building and sampling must run in
190
+ # separate latest-world frames; merging them reintroduces a world-age error. The
191
+ # result is already an MCMCChains.Chains, so it is not re-wrapped.
192
+ function sample_bugs(model, sampler, warmup, draws, chains, rng)
193
+ return Logging.with_logger(JSONProgressLogger()) do
194
+ JuliaBUGS.AbstractMCMC.sample(
195
+ rng, model, sampler, JuliaBUGS.AbstractMCMC.MCMCSerial(), draws, chains;
196
+ chain_type = MCMCChains.Chains, n_adapts = warmup, discard_initial = warmup, progress = true,
197
+ )
198
+ end
199
+ end
200
+
201
+ # Build the exact wire object parseMCMCChainsJson consumes: value_flat is indexed
202
+ # i + p*nIter + c*nIter*nParams (column-major over [iteration, parameter, chain]).
203
+ function chains_to_wire(chn)
204
+ arr = Array(chn.value)
205
+ nIter, nParams, nChains = size(arr)
206
+ flat = Vector{Union{Float64,Nothing}}(undef, nIter * nParams * nChains)
207
+ for c in 1:nChains, p in 1:nParams, i in 1:nIter
208
+ v = arr[i, p, c]
209
+ flat[i + (p - 1) * nIter + (c - 1) * nIter * nParams] = v === missing ? nothing : Float64(v)
210
+ end
211
+ return Dict(
212
+ "size" => [nIter, nParams, nChains],
213
+ "value_flat" => flat,
214
+ "parameters" => string.(names(chn)),
215
+ "name_map" => Dict(
216
+ "parameters" => string.(get(chn.name_map, :parameters, Symbol[])),
217
+ "internals" => string.(get(chn.name_map, :internals, Symbol[])),
218
+ ),
219
+ )
220
+ end
221
+
222
+ function provenance()
223
+ packages = Dict{String,String}()
224
+ for (_, info) in Pkg.dependencies()
225
+ if info.name in
226
+ ("Turing", "FlexiChains", "MCMCChains", "DynamicPPL", "AbstractMCMC", "JuliaBUGS", "AdvancedHMC", "ForwardDiff", "ADTypes") &&
227
+ info.version !== nothing
228
+ packages[info.name] = string(info.version)
229
+ end
230
+ end
231
+ manifest = joinpath(dirname(Base.active_project()), "Manifest.toml")
232
+ return Dict(
233
+ "julia_version" => string(VERSION),
234
+ "packages" => packages,
235
+ "manifest_sha256" => isfile(manifest) ? bytes2hex(sha256(read(manifest))) : "",
236
+ )
237
+ end
238
+
239
+ # Runs one request end to end. Returns {"ok": true, "provenance": ...} or
240
+ # {"ok": false, "error": ..., "stage": ...}; never throws.
241
+ function handle_request(request)
242
+ out = request["out"]
243
+ mode = get(request, "mode", "fit")
244
+ backend = get(get(request, "backend", Dict()), "id", "turing")
245
+ stage = "compile"
246
+ try
247
+ Random.seed!(Int(request["seed"]))
248
+ rng = StableRNG(Int(request["seed"]))
249
+ Base.include(Main, abspath(request["model"]["file"]))
250
+ entry = getfield(Main, Symbol(request["model"]["entry"]))
251
+
252
+ wire = if mode == "predict"
253
+ backend == "turing" || error("predict is not yet supported for backend $backend")
254
+ model = Base.invokelatest(entry, predict_namedtuple(request["data"]))
255
+ stage = "load_samples"
256
+ rchn = chains_from_wire(JSON.parsefile(request["samples"]), request["predict"]["targets"])
257
+ stage = "predict"
258
+ pp = Base.invokelatest(predict, rng, model, rchn)
259
+ isempty(names(pp)) &&
260
+ error("no variables predicted; check that [predict].targets name unconditioned outcomes")
261
+ chains_to_wire(pp)
262
+ else
263
+ data = backend == "juliabugs" ? bugs_namedtuple(request["data"]) : to_namedtuple(request["data"])
264
+ sampler, warmup = build_sampler(request["sampler"], backend)
265
+ draws = Int(request["sampler"]["draws"])
266
+ chains = Int(get(request["sampler"], "chains", 4))
267
+ stage = "sample"
268
+ if backend == "juliabugs"
269
+ model = Base.invokelatest(entry, data)
270
+ chn = Base.invokelatest(sample_bugs, model, sampler, warmup, draws, chains, rng)
271
+ chains_to_wire(chn)
272
+ else
273
+ chn = Base.invokelatest(build_and_sample, entry, data, sampler, draws, chains, rng)
274
+ vnchain_to_wire(chn)
275
+ end
276
+ end
277
+
278
+ stage = "write"
279
+ tmp = out * ".tmp"
280
+ open(tmp, "w") do io
281
+ JSON.print(io, wire)
282
+ end
283
+ mv(tmp, out; force = true)
284
+
285
+ return Dict("ok" => true, "provenance" => provenance())
286
+ catch err
287
+ return Dict("ok" => false, "error" => sprint(showerror, err), "stage" => stage)
288
+ end
289
+ end