Skip to main content

sva_core/
answer.rs

1// Concern: writes one reading and the render around it as the `data` object | Non-concern: the error envelope (output.rs), subcommand JSON (sva-cli) | IO: (Answer) -> a JSON string
2
3use std::path::Path;
4
5use sva_engine::{
6    Alias, AliasBand, Answer, Arguments, BandCrest, BandTrack, Bands, Binding, Buffer, CacheStats,
7    Cost, Counters, Crest, Detail, EnvelopeFrame, FormantFrame, Label, LedgerEntry, Loudness,
8    LoudnessFrame, Onsets, Outcome, Output, PayloadKind, Pruned, Source, SpectralSum, Spectrum,
9    StereoFrame, StereoImage, Work,
10};
11
12use crate::json::{NONE, capped, escape, latest, list, num};
13
14/// Past this a caller reading stdout wants a later interval start, not a wall of JSON.
15pub const SAMPLE_LIMIT: usize = 4096;
16
17fn maybe(v: Option<f64>) -> String {
18    v.map(num).unwrap_or_else(|| NONE.to_string())
19}
20
21fn counted(v: Option<usize>) -> String {
22    v.map_or_else(|| NONE.to_string(), |c| c.to_string())
23}
24
25fn ledger_json(entries: &[LedgerEntry], skim: bool) -> String {
26    list(entries, |e| {
27        let clipped = e
28            .clipped
29            .map_or_else(|| NONE.to_string(), |c| c.to_string());
30        let channel = counted(e.channel);
31        if skim {
32            return format!(
33                "\n    {{ \"node\": \"{}\", \"channel\": {channel}, \"rms\": {}, \"peak\": {}, \"clipped\": {clipped} }}",
34                escape(&e.node),
35                num(e.rms),
36                num(e.peak)
37            );
38        }
39        format!(
40            "\n    {{ \"node\": \"{}\", \"channel\": {channel}, \"depth\": {}, \"unit\": \"{}\", \
41             \"control\": {}, \"rms\": {}, \"peak\": {}, \"min\": {}, \"max\": {}, \
42             \"share\": {}, \"clipped\": {clipped} }}",
43            escape(&e.node),
44            e.depth,
45            e.kind.unit().name(),
46            !e.kind.is_audio(),
47            num(e.rms),
48            num(e.peak),
49            num(e.min),
50            num(e.max),
51            maybe(e.share)
52        )
53    })
54}
55
56fn spectrum_json(s: &Spectrum) -> String {
57    format!(
58        "{{ \"frame_size\": {}, \"frames\": {}, \"resolution_hz\": {}, \"rms\": {}, \
59         \"centroid_hz\": {}, \"rolloff85_hz\": {}, \"peaks\": {}, \"bands\": {} }}",
60        s.frame_size,
61        s.frames,
62        num(s.resolution_hz),
63        num(s.rms),
64        num(s.centroid_hz),
65        num(s.rolloff85_hz),
66        list(&s.peaks, |p| format!(
67            "{{ \"hz\": {}, \"db\": {} }}",
68            num(p.hz),
69            num(p.db)
70        )),
71        list(&s.bands, |b| format!(
72            "\n    {{ \"lo_hz\": {}, \"hi_hz\": {}, \"db\": {} }}",
73            num(b.lo_hz),
74            num(b.hi_hz),
75            num(b.db)
76        ))
77    )
78}
79
80fn stereo_json(image: &StereoImage) -> String {
81    let frame = |f: &StereoFrame| {
82        format!(
83            "\n      {{ \"t\": {}, \"correlation\": {}, \"mid_rms\": {}, \"side_rms\": {}, \
84             \"width\": {}, \"balance_db\": {}, \"mono_db\": {} }}",
85            num(f.t_secs),
86            num(f.correlation),
87            num(f.mid_rms),
88            num(f.side_rms),
89            num(f.width),
90            num(f.balance_db),
91            num(f.mono_db)
92        )
93    };
94    format!(
95        "{{ \"channels\": {}, \"overall\": {}, \"frames\": {} }}",
96        image.channels,
97        frame(&image.overall),
98        list(&image.frames, frame)
99    )
100}
101
102fn formants_json(f: &FormantFrame) -> String {
103    format!(
104        "\n    {{ \"t\": {}, \"order\": {}, \"energy\": {}, \"residual\": {}, \"formants\": {} }}",
105        num(f.t_secs),
106        f.order,
107        num(f.energy),
108        num(f.residual),
109        list(&f.formants, |v| format!(
110            "{{ \"hz\": {}, \"bandwidth_hz\": {}, \"db\": {} }}",
111            num(v.hz),
112            num(v.bandwidth_hz),
113            num(v.db)
114        ))
115    )
116}
117
118fn band_json(b: &BandTrack, start_secs: f64, rate_hz: f64, limit: Option<usize>) -> String {
119    let shown = limit.map_or(b.rms.len(), |cap| b.rms.len().min(cap));
120    format!(
121        "\n    {{ \"centre_hz\": {}, \"cam\": {}, \"erb_hz\": {}, \"q\": {}, \"peak\": {}, \
122         \"time_to_peak_secs\": {}, \"rise_10_90_secs\": {}, \
123         \"floor\": {{ \"peak\": {}, \"time_to_peak_secs\": {}, \"rise_10_90_secs\": {} }}, \
124         \"rms\": {} }}",
125        num(b.centre_hz),
126        num(b.cam),
127        num(b.erb_hz),
128        num(b.q),
129        num(b.peak),
130        maybe(b.time_to_peak_secs),
131        maybe(b.rise_10_90_secs),
132        num(b.floor.peak),
133        num(b.floor.time_to_peak_secs),
134        maybe(b.floor.rise_10_90_secs),
135        capped(
136            &b.rms,
137            shown,
138            |n| start_secs + n as f64 / rate_hz,
139            |v| num(*v),
140        )
141    )
142}
143
144fn bands_json(b: &Bands, limit: Option<usize>) -> String {
145    format!(
146        "{{ \"rate_hz\": {}, \"start_secs\": {}, \"bands\": {} }}",
147        num(b.rate_hz),
148        num(b.start_secs),
149        list(&b.bands, |band| band_json(
150            band,
151            b.start_secs,
152            b.rate_hz,
153            limit
154        ))
155    )
156}
157
158fn loudness_json(l: &Loudness, limit: Option<usize>) -> String {
159    let frames = |fs: &[LoudnessFrame]| {
160        let shown = limit.map_or(fs.len(), |cap| fs.len().min(cap));
161        capped(
162            fs,
163            shown,
164            |n| fs[n].t,
165            |f| format!("\n    {{ \"t\": {}, \"lufs\": {} }}", num(f.t), num(f.lufs)),
166        )
167    };
168    format!(
169        "{{ \"integrated_lufs\": {}, \"range_lu\": {}, \"momentary_max_lufs\": {}, \
170         \"short_term_max_lufs\": {}, \"sample_peak\": {}, \"sample_peak_dbfs\": {}, \
171         \"peak_note\": \"{}\", \"momentary\": {}, \"short_term\": {} }}",
172        maybe(l.integrated_lufs),
173        maybe(l.range_lu),
174        maybe(l.momentary_max_lufs),
175        maybe(l.short_term_max_lufs),
176        num(l.sample_peak),
177        maybe(l.sample_peak_dbfs),
178        escape(l.peak_note),
179        frames(&l.momentary),
180        frames(&l.short_term)
181    )
182}
183
184fn crest_json(c: &Crest) -> String {
185    let band = |b: &BandCrest| {
186        format!(
187            "\n    {{ \"lo_hz\": {}, \"hi_hz\": {}, \"centre_hz\": {}, \"peak\": {}, \
188             \"rms\": {}, \"crest_db\": {}, \"counted\": {} }}",
189            num(b.lo_hz),
190            num(b.hi_hz),
191            num(b.centre_hz),
192            num(b.peak),
193            num(b.rms),
194            num(b.crest_db),
195            b.counted
196        )
197    };
198    format!(
199        "{{ \"broadband_crest_db\": {}, \"spread_db\": {}, \"widest_band_hz\": {}, \
200         \"tightest_band_hz\": {}, \"counted_under_db\": {}, \"bands\": {} }}",
201        num(c.broadband_crest_db),
202        maybe(c.spread_db),
203        maybe(c.widest_band_hz),
204        maybe(c.tightest_band_hz),
205        num(c.counted_under_db),
206        list(&c.bands, band)
207    )
208}
209
210fn onsets_json(o: &Onsets) -> String {
211    format!(
212        "{{ \"onsets\": {}, \"resolution_secs\": {}, \"ioi_histogram\": {} }}",
213        list(&o.onsets, |t| format!(
214            "{{ \"t_secs\": {}, \"strength\": {} }}",
215            num(t.t_secs),
216            num(t.strength)
217        )),
218        num(o.resolution_secs),
219        list(&o.ioi_histogram, |b| format!(
220            "{{ \"lo_secs\": {}, \"hi_secs\": {}, \"count\": {} }}",
221            num(b.lo_secs),
222            num(b.hi_secs),
223            b.count
224        )),
225    )
226}
227
228fn alias_json(a: &Alias) -> String {
229    let band = |b: &AliasBand| {
230        format!(
231            "\n    {{ \"lo_hz\": {}, \"hi_hz\": {}, \"signal_db\": {}, \"alias_db\": {}, \
232             \"nmr_db\": {} }}",
233            num(b.lo_hz),
234            num(b.hi_hz),
235            num(b.signal_db),
236            num(b.alias_db),
237            num(b.nmr_db)
238        )
239    };
240    format!(
241        "{{ \"oversample\": {}, \"sample_rate\": {}, \"frame_size\": {}, \"frames\": {}, \
242         \"scored_frames\": {}, \"playback_db_spl\": {}, \"asr_db\": {}, \"nmr_db\": {}, \
243         \"nmr_peak_db\": {}, \"peak_at_secs\": {}, \"audible\": {}, \
244         \"instances\": {}, \"bands\": {} }}",
245        a.oversample,
246        num(a.sample_rate),
247        a.frame_size,
248        a.frames,
249        a.scored_frames,
250        num(a.playback_db_spl),
251        num(a.asr_db),
252        num(a.nmr_db),
253        num(a.nmr_peak_db),
254        num(a.peak_at_secs),
255        a.audible,
256        a.instances,
257        list(&a.bands, band)
258    )
259}
260
261fn binding_json(b: &Binding) -> String {
262    format!(
263        "\n    {{ \"name\": \"{}\", \"source\": \"{}\" }}",
264        escape(&b.name),
265        escape(&b.source)
266    )
267}
268
269fn arguments_json(a: &Arguments) -> String {
270    let at = |s: sva_ast::ByteSpan| format!("{{ \"start\": {}, \"end\": {} }}", s.start, s.end);
271    let calls = list(&a.calls, |c| {
272        let arguments = list(&c.arguments, |x| {
273            format!(
274                "{{ \"name\": \"{}\", \"value\": {}, \"written\": {} }}",
275                escape(&x.name),
276                num(x.value),
277                x.written
278            )
279        });
280        format!(
281            "\n      {{ \"name\": \"{}\", \"at\": {}, \"arguments\": {arguments} }}",
282            escape(&c.name),
283            at(c.at)
284        )
285    });
286    let chosen = list(&a.chosen, |c| {
287        format!(
288            "\n      {{ \"name\": \"{}\", \"at\": {}, \"operands\": {}, \"chosen\": {} }}",
289            escape(&c.name),
290            at(c.at),
291            list(&c.operands, |v| num(*v)),
292            c.chosen
293        )
294    });
295    format!(
296        "\n    {{ \"node\": \"{}\", \"calls\": {calls}, \"chosen\": {chosen} }}",
297        escape(&a.node)
298    )
299}
300
301/// One object per component: a buffer is planar, and the component IS the channel.
302fn samples_json(b: &Buffer, limit: Option<usize>) -> String {
303    let component = |c: usize| {
304        let plane = b.plane(c);
305        let shown = limit.map_or(plane.len(), |cap| plane.len().min(cap));
306        format!(
307            "\n    {{ \"channel\": {c}, \"values\": {} }}",
308            capped(
309                plane,
310                shown,
311                |n| b.origin_secs() + n as f64 / f64::from(b.rate),
312                |v| num(*v)
313            )
314        )
315    };
316    let components: Vec<usize> = (0..b.width).collect();
317    format!(
318        "{{ \"rate\": {}, \"origin_secs\": {}, \"width\": {}, \"components\": {} }}",
319        b.rate,
320        num(b.origin_secs()),
321        b.width,
322        list(&components, |c| component(*c))
323    )
324}
325
326fn symbolic_json(n: &SpectralSum) -> String {
327    let terms: Vec<String> = n.atoms().map(sva_engine::sketch_atom).collect();
328    format!(
329        "{{ \"var\": \"{}\", \"lanes\": {}, \"terms\": {} }}",
330        match n.var {
331            sva_engine::Var::T => "t",
332            sva_engine::Var::F => "f",
333        },
334        n.lanes.len(),
335        crate::json::strings(&terms)
336    )
337}
338
339/// A cost tree, deepest row last, each already folded to the share it is worth printing.
340fn flops_json(tree: &sva_engine::FlopTree) -> String {
341    format!(
342        "{{ \"total\": {}, \"budget\": {}, \"rows\": {} }}",
343        tree.total,
344        tree.budget,
345        list(&tree.rows, |r: &sva_engine::FlopRow| format!(
346            "\n    {{ \"depth\": {}, \"node\": \"{}\", \"own\": {}, \"subtree\": {}, \
347             \"percent\": {}, \"route\": \"{}\", \"shared\": {} }}",
348            r.depth,
349            escape(&r.node),
350            r.own,
351            r.subtree,
352            num(r.percent),
353            escape(r.route),
354            r.shared
355        ))
356    )
357}
358
359/// `limit` caps the arrays a stdout reader scrolls past; `None` writes every value. `skim`
360/// only changes the ledger.
361pub fn value_json(output: &Output, limit: Option<usize>, skim: bool) -> String {
362    match output {
363        Output::Lines(lines) => list(lines, line_json),
364        Output::Atoms(sketches) => crate::json::strings(sketches),
365        Output::Symbolic(sum) => symbolic_json(sum),
366        Output::Samples(buffer) => samples_json(buffer, limit),
367        Output::Ledger(entries) => ledger_json(entries, skim),
368        Output::Spectrum(s) => spectrum_json(s),
369        Output::Stereo(image) => stereo_json(image),
370        Output::Bands(b) => bands_json(b, limit),
371        Output::Loudness(l) => loudness_json(l, limit),
372        Output::Crest(c) => crest_json(c),
373        Output::Onsets(o) => onsets_json(o),
374        Output::Alias(a) => alias_json(a),
375        Output::Bindings(b) => list(b, binding_json),
376        Output::Arguments(a) => list(a, arguments_json),
377        Output::Flops(tree) => flops_json(tree),
378        Output::Envelope(frames) => list(frames, |f: &EnvelopeFrame| {
379            format!(
380                "\n    {{ \"t\": {}, \"rms\": {}, \"peak\": {} }}",
381                num(f.t_secs),
382                num(f.rms),
383                num(f.peak)
384            )
385        }),
386        Output::Pitch(frames) => list(frames, |f| {
387            format!(
388                "\n    {{ \"t\": {}, \"notes\": {} }}",
389                num(f.t_secs),
390                list(&f.notes, |n| format!(
391                    "{{ \"note\": \"{}\", \"hz\": {}, \"cents\": {}, \"db\": {}, \
392                     \"harmonic_of\": {} }}",
393                    escape(&n.name),
394                    num(n.hz),
395                    num(n.cents),
396                    num(n.db),
397                    maybe(n.harmonic_of)
398                ))
399            )
400        }),
401        Output::Formants(frames) => list(frames, formants_json),
402    }
403}
404
405fn line_json(l: &sva_engine::Line) -> String {
406    format!(
407        "\n    {{ \"hz\": {}, \"re\": {}, \"im\": {}, \"db\": {} }}",
408        num(l.hz),
409        num(l.amp.re),
410        num(l.amp.im),
411        num(20.0 * l.amp.abs().log10())
412    )
413}
414
415/// FORMAT 14.3: every answer says which reading ran, under which profile, and at what rate.
416/// A reading that truncated a series names what it left out, so the kept list is never read
417/// as the whole of it.
418pub fn answer_json(answer: &Answer, limit: Option<usize>, skim: bool) -> String {
419    format!(
420        "{{ \"source\": \"{}\", \"profile\": \"{}\", \"rate\": {}, \"value\": {}, \
421         \"dropped\": {}, \"tail_db\": {} }}",
422        match answer.source {
423            Source::Exact => "exact",
424            Source::Measured => "measured",
425        },
426        escape(answer.profile),
427        answer
428            .rate
429            .map_or_else(|| NONE.to_string(), |r| r.to_string()),
430        value_json(&answer.value, limit, skim),
431        list(&answer.dropped, line_json),
432        maybe(answer.tail_db)
433    )
434}
435
436/// What one row of the collapse table states beyond its name, as JSON fields.
437fn detail_json(detail: &Detail) -> String {
438    match detail {
439        Detail::Lines {
440            placed,
441            summed,
442            dropped,
443            dropped_more,
444            terms,
445            tail_db,
446            ..
447        } => format!(
448            ", \"placed\": {placed}, \"summed\": {summed}, \"dropped\": {}, \
449             \"dropped_more\": {dropped_more}, \"terms\": {}, \"tail_db\": {}",
450            list(dropped, |d| format!(
451                "{{ \"hz\": {}, \"db\": {} }}",
452                num(d.hz),
453                num(d.db)
454            )),
455            counted(*terms),
456            maybe(*tail_db)
457        ),
458        Detail::Cropped { tail_db, .. } => format!(", \"tail_db\": {}", maybe(*tail_db)),
459        Detail::Point {
460            alias_db, tail_db, ..
461        } => format!(
462            ", \"alias_db\": {}, \"tail_db\": {}",
463            maybe(*alias_db),
464            maybe(*tail_db)
465        ),
466        Detail::Spectrum { wrap_db, .. } => format!(", \"wrap_db\": {}", num(*wrap_db)),
467        Detail::Roundtrip { edited, .. } => format!(", \"edited\": {edited}"),
468        Detail::Continuous { .. } | Detail::Reading { .. } => String::new(),
469        Detail::Added { parts } => format!(
470            ", \"addends\": {}",
471            list(parts, |part| format!(
472                "{{ \"rule\": \"{}\"{} }}",
473                part.rule().as_str(),
474                detail_json(part)
475            ))
476        ),
477    }
478}
479
480/// The collapse label beside the reading it belongs to, per FORMAT 9.3. `detail` is the one
481/// place a key may be absent: each `rule` is its own shape, and its fields belong to it.
482pub fn label_json(label: &Label) -> String {
483    let detail = detail_json(&label.detail);
484    let cost = match label.cost {
485        Some(Cost { flops, budget }) => format!(", \"flops\": {flops}, \"flop_budget\": {budget}"),
486        None => format!(", \"flops\": {NONE}, \"flop_budget\": {NONE}"),
487    };
488    let pruned = label.pruned.as_ref().map_or(NONE.to_string(), pruned_json);
489    let cost = format!(
490        "{cost}, \"moved_s\": {}, \"pruned\": {pruned}",
491        maybe(label.moved)
492    );
493    format!(
494        "{{ \"source\": \"{}\", \"profile\": \"{}\", \"rate\": {}, \"rule\": \"{}\"{detail}{cost} }}",
495        match label.source {
496            Source::Exact => "exact",
497            Source::Measured => "measured",
498        },
499        escape(label.profile),
500        label.rate,
501        escape(label.rule().as_str())
502    )
503}
504
505/// The level a term under which, for good, was taken as zero, and each node cut at the sample
506/// it is zero from.
507pub fn pruned_json(pruned: &Pruned) -> String {
508    let cuts = list(&pruned.cuts, |(node, from)| {
509        format!("{{ \"node\": \"{}\", \"from\": {from} }}", escape(node))
510    });
511    format!("{{ \"db\": {}, \"cuts\": {cuts} }}", num(pruned.db))
512}
513
514/// Whole counts every one; `waves` is null where a node's go uncounted.
515pub fn work_json(work: &Work) -> String {
516    let waves = work.waves.map_or(NONE.to_string(), |w| w.to_string());
517    format!(
518        "{{ \"samples\": {}, \"priced_flops\": {}, \"waves\": {waves} }}",
519        work.samples, work.priced_flops
520    )
521}
522
523/// `computed` counts every miss, `stored` the misses memory kept and `replaced` the volatile
524/// misses kept in place of their last value; memory's figures are as the render left it, and
525/// `tier` what passed between memory and the disk meanwhile.
526pub fn stats_json(stats: &CacheStats) -> String {
527    stats_with(stats, "")
528}
529
530/// `stats_json` over a stream's latest lookups, `dropped`: the latest nodes its live edits
531/// started silent, and `pruned`: where its table prunes. Each list's `pagination.count` counts
532/// all it made.
533pub fn stream_stats_json(
534    stats: &CacheStats,
535    (dropped, made): (&[&str], usize),
536    pruned: &Pruned,
537) -> String {
538    let dropped = latest(dropped, made, |name| format!("\"{}\"", escape(name)));
539    let pruned = pruned_json(pruned);
540    stats_with(
541        stats,
542        &format!(", \"dropped\": {dropped}, \"pruned\": {pruned}"),
543    )
544}
545
546fn stats_with(stats: &CacheStats, extra: &str) -> String {
547    let made = stats.shed + stats.lookups.len();
548    let lookups = latest(&stats.lookups, made, |l| {
549        let outcome = match l.outcome {
550            Outcome::Hit => "hit",
551            Outcome::ComputedStored => "computed_stored",
552            Outcome::ComputedNotStored => "computed_not_stored",
553            Outcome::ComputedReplaced => "computed_replaced",
554            Outcome::Extended => "extended",
555            Outcome::Prefix => "prefix",
556        };
557        format!(
558            "{{ \"node\": \"{}\", \"key\": \"{}\", \"kind\": \"{}\", \"outcome\": \"{outcome}\" }}",
559            escape(&l.node),
560            l.key,
561            match l.kind {
562                PayloadKind::Segments => "segments",
563                PayloadKind::Frames => "frames",
564                PayloadKind::Run => "run",
565            }
566        )
567    });
568    format!(
569        "{{ \"nodes\": {}, \"hits\": {}, \"computed\": {}, \"stored\": {}, \"replaced\": {}, \"extended\": {}, \
570         \"bytes\": {}, \"max_bytes\": {}, \"entries\": {}, \"evictions\": {}, \
571         \"tier\": {}, \"lookups\": {lookups}{extra} }}",
572        stats.nodes(),
573        stats.hits(),
574        stats.computed(),
575        stats.stored(),
576        stats.replaced(),
577        stats.extended(),
578        stats.bytes,
579        stats.max_bytes,
580        stats.entries,
581        stats.evictions,
582        counters_json(&stats.tier)
583    )
584}
585
586/// What passed between memory and the disk beneath it.
587pub fn counters_json(counters: &Counters) -> String {
588    format!(
589        "{{ \"disk_lookups\": {}, \"disk_reads\": {}, \"disk_read_bytes\": {}, \
590         \"promotions\": {}, \"writebacks\": {}, \"hits\": {}, \"evictions\": {}, \
591         \"probation_evictions\": {}, \"protected_evictions\": {} }}",
592        counters.disk_lookups,
593        counters.disk_reads,
594        counters.disk_read_bytes,
595        counters.promotions,
596        counters.writebacks,
597        counters.hits,
598        counters.evictions(),
599        counters.probation_evictions,
600        counters.protected_evictions
601    )
602}
603
604/// One answer as a report prints it.
605pub struct Printed {
606    pub name: String,
607    pub answer: Answer,
608    /// `ledger(skim=1)`: each row's level alone.
609    pub skim: bool,
610}
611
612/// What a render answered with, and where anything too big for the object went instead.
613pub struct Report<'a> {
614    pub target: &'a str,
615    pub rate: u32,
616    /// The precision every sample was written to; `None` for a file read back.
617    pub bits: Option<i32>,
618    /// The seconds the readings were taken over; `None` where none read samples.
619    pub interval: Option<(f64, f64)>,
620    pub profile: &'a str,
621    pub label: Option<&'a Label>,
622    pub written: &'a [(String, &'a Path)],
623    pub answers: &'a [Printed],
624    pub limit: Option<usize>,
625}
626
627/// `written` names every reading that went to a file rather than into `representations`.
628pub fn query_data(report: &Report) -> String {
629    let written = list(report.written, |(name, path)| {
630        format!(
631            "{{ \"representation\": \"{}\", \"path\": \"{}\" }}",
632            escape(name),
633            escape(&path.display().to_string())
634        )
635    });
636    let label = report
637        .label
638        .map(label_json)
639        .unwrap_or_else(|| NONE.to_string());
640    let reads = report
641        .answers
642        .iter()
643        .map(|printed| {
644            format!(
645                "\"{}\": {}",
646                escape(&printed.name),
647                answer_json(&printed.answer, report.limit, printed.skim)
648            )
649        })
650        .collect::<Vec<_>>()
651        .join(",\n    ");
652    let interval = report.interval.map_or(NONE.to_string(), |(start, end)| {
653        format!(
654            "{{ \"start_secs\": {}, \"end_secs\": {} }}",
655            num(start),
656            num(end)
657        )
658    });
659    format!(
660        "{{\n  \"target\": \"{}\",\n  \"sample_rate\": {},\n  \"bits\": {},\n  \
661         \"profile\": \"{}\",\n  \"interval\": {interval},\n  \"label\": {label},\n  \
662         \"written\": {written},\n  \"representations\": {{\n    {reads}\n  }}\n}}",
663        escape(report.target),
664        report.rate,
665        report.bits.map_or(NONE.to_string(), |b| b.to_string()),
666        escape(report.profile),
667    )
668}