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, 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 alias_json(a: &Alias) -> String {
211    let band = |b: &AliasBand| {
212        format!(
213            "\n    {{ \"lo_hz\": {}, \"hi_hz\": {}, \"signal_db\": {}, \"alias_db\": {}, \
214             \"nmr_db\": {} }}",
215            num(b.lo_hz),
216            num(b.hi_hz),
217            num(b.signal_db),
218            num(b.alias_db),
219            num(b.nmr_db)
220        )
221    };
222    format!(
223        "{{ \"oversample\": {}, \"sample_rate\": {}, \"frame_size\": {}, \"frames\": {}, \
224         \"scored_frames\": {}, \"playback_db_spl\": {}, \"asr_db\": {}, \"nmr_db\": {}, \
225         \"nmr_peak_db\": {}, \"peak_at_secs\": {}, \"audible\": {}, \
226         \"instances\": {}, \"bands\": {} }}",
227        a.oversample,
228        num(a.sample_rate),
229        a.frame_size,
230        a.frames,
231        a.scored_frames,
232        num(a.playback_db_spl),
233        num(a.asr_db),
234        num(a.nmr_db),
235        num(a.nmr_peak_db),
236        num(a.peak_at_secs),
237        a.audible,
238        a.instances,
239        list(&a.bands, band)
240    )
241}
242
243fn binding_json(b: &Binding) -> String {
244    format!(
245        "\n    {{ \"name\": \"{}\", \"source\": \"{}\" }}",
246        escape(&b.name),
247        escape(&b.source)
248    )
249}
250
251fn arguments_json(a: &Arguments) -> String {
252    let at = |s: sva_ast::ByteSpan| format!("{{ \"start\": {}, \"end\": {} }}", s.start, s.end);
253    let calls = list(&a.calls, |c| {
254        let arguments = list(&c.arguments, |x| {
255            format!(
256                "{{ \"name\": \"{}\", \"value\": {}, \"written\": {} }}",
257                escape(&x.name),
258                num(x.value),
259                x.written
260            )
261        });
262        format!(
263            "\n      {{ \"name\": \"{}\", \"at\": {}, \"arguments\": {arguments} }}",
264            escape(&c.name),
265            at(c.at)
266        )
267    });
268    let chosen = list(&a.chosen, |c| {
269        format!(
270            "\n      {{ \"name\": \"{}\", \"at\": {}, \"operands\": {}, \"chosen\": {} }}",
271            escape(&c.name),
272            at(c.at),
273            list(&c.operands, |v| num(*v)),
274            c.chosen
275        )
276    });
277    format!(
278        "\n    {{ \"node\": \"{}\", \"calls\": {calls}, \"chosen\": {chosen} }}",
279        escape(&a.node)
280    )
281}
282
283/// One object per component: a buffer is planar, and the component IS the channel.
284fn samples_json(b: &Buffer, limit: Option<usize>) -> String {
285    let component = |c: usize| {
286        let plane = b.plane(c);
287        let shown = limit.map_or(plane.len(), |cap| plane.len().min(cap));
288        format!(
289            "\n    {{ \"channel\": {c}, \"values\": {} }}",
290            capped(
291                plane,
292                shown,
293                |n| b.origin_secs() + n as f64 / f64::from(b.rate),
294                |v| num(*v)
295            )
296        )
297    };
298    let components: Vec<usize> = (0..b.width).collect();
299    format!(
300        "{{ \"rate\": {}, \"origin_secs\": {}, \"width\": {}, \"components\": {} }}",
301        b.rate,
302        num(b.origin_secs()),
303        b.width,
304        list(&components, |c| component(*c))
305    )
306}
307
308fn symbolic_json(n: &SpectralSum) -> String {
309    let terms: Vec<String> = n.atoms().map(sva_engine::sketch_atom).collect();
310    format!(
311        "{{ \"var\": \"{}\", \"lanes\": {}, \"terms\": {} }}",
312        match n.var {
313            sva_engine::Var::T => "t",
314            sva_engine::Var::F => "f",
315        },
316        n.lanes.len(),
317        crate::json::strings(&terms)
318    )
319}
320
321/// A cost tree, deepest row last, each already folded to the share it is worth printing.
322fn flops_json(tree: &sva_engine::FlopTree) -> String {
323    format!(
324        "{{ \"total\": {}, \"budget\": {}, \"rows\": {} }}",
325        tree.total,
326        tree.budget,
327        list(&tree.rows, |r: &sva_engine::FlopRow| format!(
328            "\n    {{ \"depth\": {}, \"node\": \"{}\", \"own\": {}, \"subtree\": {}, \
329             \"percent\": {}, \"route\": \"{}\", \"shared\": {} }}",
330            r.depth,
331            escape(&r.node),
332            r.own,
333            r.subtree,
334            num(r.percent),
335            escape(r.route),
336            r.shared
337        ))
338    )
339}
340
341/// `limit` caps the arrays a stdout reader scrolls past; `None` writes every value. `skim`
342/// only changes the ledger.
343pub fn value_json(output: &Output, limit: Option<usize>, skim: bool) -> String {
344    match output {
345        Output::Lines(lines) => list(lines, line_json),
346        Output::Atoms(sketches) => crate::json::strings(sketches),
347        Output::Symbolic(sum) => symbolic_json(sum),
348        Output::Samples(buffer) => samples_json(buffer, limit),
349        Output::Ledger(entries) => ledger_json(entries, skim),
350        Output::Spectrum(s) => spectrum_json(s),
351        Output::Stereo(image) => stereo_json(image),
352        Output::Bands(b) => bands_json(b, limit),
353        Output::Loudness(l) => loudness_json(l, limit),
354        Output::Crest(c) => crest_json(c),
355        Output::Alias(a) => alias_json(a),
356        Output::Bindings(b) => list(b, binding_json),
357        Output::Arguments(a) => list(a, arguments_json),
358        Output::Flops(tree) => flops_json(tree),
359        Output::Envelope(frames) => list(frames, |f: &EnvelopeFrame| {
360            format!(
361                "\n    {{ \"t\": {}, \"rms\": {}, \"peak\": {} }}",
362                num(f.t_secs),
363                num(f.rms),
364                num(f.peak)
365            )
366        }),
367        Output::Pitch(frames) => list(frames, |f| {
368            format!(
369                "\n    {{ \"t\": {}, \"notes\": {} }}",
370                num(f.t_secs),
371                list(&f.notes, |n| format!(
372                    "{{ \"note\": \"{}\", \"hz\": {}, \"cents\": {}, \"db\": {}, \
373                     \"harmonic_of\": {} }}",
374                    escape(&n.name),
375                    num(n.hz),
376                    num(n.cents),
377                    num(n.db),
378                    maybe(n.harmonic_of)
379                ))
380            )
381        }),
382        Output::Formants(frames) => list(frames, formants_json),
383    }
384}
385
386fn line_json(l: &sva_engine::Line) -> String {
387    format!(
388        "\n    {{ \"hz\": {}, \"re\": {}, \"im\": {}, \"db\": {} }}",
389        num(l.hz),
390        num(l.amp.re),
391        num(l.amp.im),
392        num(20.0 * l.amp.abs().log10())
393    )
394}
395
396/// FORMAT 14.3: every answer says which reading ran, under which profile, and at what rate.
397/// A reading that truncated a series names what it left out, so the kept list is never read
398/// as the whole of it.
399pub fn answer_json(answer: &Answer, limit: Option<usize>, skim: bool) -> String {
400    format!(
401        "{{ \"source\": \"{}\", \"profile\": \"{}\", \"rate\": {}, \"value\": {}, \
402         \"dropped\": {}, \"tail_db\": {} }}",
403        match answer.source {
404            Source::Exact => "exact",
405            Source::Measured => "measured",
406        },
407        escape(answer.profile),
408        answer
409            .rate
410            .map_or_else(|| NONE.to_string(), |r| r.to_string()),
411        value_json(&answer.value, limit, skim),
412        list(&answer.dropped, line_json),
413        maybe(answer.tail_db)
414    )
415}
416
417/// What one row of the collapse table states beyond its name, as JSON fields.
418fn detail_json(detail: &Detail) -> String {
419    match detail {
420        Detail::Lines {
421            placed,
422            summed,
423            dropped,
424            dropped_more,
425            terms,
426            tail_db,
427            ..
428        } => format!(
429            ", \"placed\": {placed}, \"summed\": {summed}, \"dropped\": {}, \
430             \"dropped_more\": {dropped_more}, \"terms\": {}, \"tail_db\": {}",
431            list(dropped, |d| format!(
432                "{{ \"hz\": {}, \"db\": {} }}",
433                num(d.hz),
434                num(d.db)
435            )),
436            counted(*terms),
437            maybe(*tail_db)
438        ),
439        Detail::Cropped { tail_db, .. } => format!(", \"tail_db\": {}", maybe(*tail_db)),
440        Detail::Point { alias_db, .. } => format!(", \"alias_db\": {}", maybe(*alias_db)),
441        Detail::Spectrum { wrap_db, .. } => format!(", \"wrap_db\": {}", num(*wrap_db)),
442        Detail::Roundtrip { edited, .. } => format!(", \"edited\": {edited}"),
443        Detail::Continuous { .. } | Detail::Reading { .. } => String::new(),
444        Detail::Added { parts } => format!(
445            ", \"addends\": {}",
446            list(parts, |part| format!(
447                "{{ \"rule\": \"{}\"{} }}",
448                part.rule().as_str(),
449                detail_json(part)
450            ))
451        ),
452    }
453}
454
455/// The collapse label beside the reading it belongs to, per FORMAT 9.3. `detail` is the one
456/// place a key may be absent: each `rule` is its own shape, and its fields belong to it.
457pub fn label_json(label: &Label) -> String {
458    let detail = detail_json(&label.detail);
459    let cost = match label.cost {
460        Some(Cost { flops, budget }) => format!(", \"flops\": {flops}, \"flop_budget\": {budget}"),
461        None => format!(", \"flops\": {NONE}, \"flop_budget\": {NONE}"),
462    };
463    let pruned = label.pruned.as_ref().map_or(NONE.to_string(), pruned_json);
464    let cost = format!(
465        "{cost}, \"moved_s\": {}, \"pruned\": {pruned}",
466        maybe(label.moved)
467    );
468    format!(
469        "{{ \"source\": \"{}\", \"profile\": \"{}\", \"rate\": {}, \"rule\": \"{}\"{detail}{cost} }}",
470        match label.source {
471            Source::Exact => "exact",
472            Source::Measured => "measured",
473        },
474        escape(label.profile),
475        label.rate,
476        escape(label.rule().as_str())
477    )
478}
479
480/// The level a term under which, for good, was taken as zero, and each node cut at the sample
481/// it is zero from.
482pub fn pruned_json(pruned: &Pruned) -> String {
483    let cuts = list(&pruned.cuts, |(node, from)| {
484        format!("{{ \"node\": \"{}\", \"from\": {from} }}", escape(node))
485    });
486    format!("{{ \"db\": {}, \"cuts\": {cuts} }}", num(pruned.db))
487}
488
489/// Whole counts every one; `waves` is null where a node's go uncounted.
490pub fn work_json(work: &Work) -> String {
491    let waves = work.waves.map_or(NONE.to_string(), |w| w.to_string());
492    format!(
493        "{{ \"samples\": {}, \"priced_flops\": {}, \"waves\": {waves} }}",
494        work.samples, work.priced_flops
495    )
496}
497
498/// `computed` counts every miss, `stored` the misses memory kept and `replaced` the volatile
499/// misses kept in place of their last value; memory's figures are as the render left it, and
500/// `tier` what passed between memory and the disk meanwhile.
501pub fn stats_json(stats: &CacheStats) -> String {
502    stats_with(stats, "")
503}
504
505/// `stats_json` over a stream's latest lookups, `dropped`: the latest nodes its live edits
506/// started silent, and `pruned`: where its table prunes. Each list's `pagination.count` counts
507/// all it made.
508pub fn stream_stats_json(
509    stats: &CacheStats,
510    (dropped, made): (&[&str], usize),
511    pruned: &Pruned,
512) -> String {
513    let dropped = latest(dropped, made, |name| format!("\"{}\"", escape(name)));
514    let pruned = pruned_json(pruned);
515    stats_with(
516        stats,
517        &format!(", \"dropped\": {dropped}, \"pruned\": {pruned}"),
518    )
519}
520
521fn stats_with(stats: &CacheStats, extra: &str) -> String {
522    let made = stats.shed + stats.lookups.len();
523    let lookups = latest(&stats.lookups, made, |l| {
524        let outcome = match l.outcome {
525            Outcome::Hit => "hit",
526            Outcome::ComputedStored => "computed_stored",
527            Outcome::ComputedNotStored => "computed_not_stored",
528            Outcome::ComputedReplaced => "computed_replaced",
529            Outcome::Extended => "extended",
530            Outcome::Prefix => "prefix",
531        };
532        format!(
533            "{{ \"node\": \"{}\", \"key\": \"{}\", \"kind\": \"{}\", \"outcome\": \"{outcome}\" }}",
534            escape(&l.node),
535            l.key,
536            match l.kind {
537                PayloadKind::Segments => "segments",
538                PayloadKind::Frames => "frames",
539                PayloadKind::Run => "run",
540            }
541        )
542    });
543    format!(
544        "{{ \"nodes\": {}, \"hits\": {}, \"computed\": {}, \"stored\": {}, \"replaced\": {}, \"extended\": {}, \
545         \"bytes\": {}, \"max_bytes\": {}, \"entries\": {}, \"evictions\": {}, \
546         \"tier\": {}, \"lookups\": {lookups}{extra} }}",
547        stats.nodes(),
548        stats.hits(),
549        stats.computed(),
550        stats.stored(),
551        stats.replaced(),
552        stats.extended(),
553        stats.bytes,
554        stats.max_bytes,
555        stats.entries,
556        stats.evictions,
557        counters_json(&stats.tier)
558    )
559}
560
561/// What passed between memory and the disk beneath it.
562pub fn counters_json(counters: &Counters) -> String {
563    format!(
564        "{{ \"disk_lookups\": {}, \"disk_reads\": {}, \"disk_read_bytes\": {}, \
565         \"promotions\": {}, \"writebacks\": {}, \"hits\": {}, \"evictions\": {}, \
566         \"probation_evictions\": {}, \"protected_evictions\": {} }}",
567        counters.disk_lookups,
568        counters.disk_reads,
569        counters.disk_read_bytes,
570        counters.promotions,
571        counters.writebacks,
572        counters.hits,
573        counters.evictions(),
574        counters.probation_evictions,
575        counters.protected_evictions
576    )
577}
578
579/// One answer as a report prints it.
580pub struct Printed {
581    pub name: String,
582    pub answer: Answer,
583    /// `ledger(skim=1)`: each row's level alone.
584    pub skim: bool,
585}
586
587/// What a render answered with, and where anything too big for the object went instead.
588pub struct Report<'a> {
589    pub target: &'a str,
590    pub rate: u32,
591    /// The precision every sample was written to; `None` for a file read back.
592    pub bits: Option<i32>,
593    /// The seconds the readings were taken over; `None` where none read samples.
594    pub interval: Option<(f64, f64)>,
595    pub profile: &'a str,
596    pub label: Option<&'a Label>,
597    pub written: &'a [(String, &'a Path)],
598    pub answers: &'a [Printed],
599    /// Readings a crate outside this pipeline answered, each already a JSON value: this
600    /// envelope only says which reading ran, under which profile, and at what rate.
601    pub analyses: &'a [(String, String)],
602    pub limit: Option<usize>,
603}
604
605/// `written` names every reading that went to a file rather than into `representations`.
606pub fn query_data(report: &Report) -> String {
607    let written = list(report.written, |(name, path)| {
608        format!(
609            "{{ \"representation\": \"{}\", \"path\": \"{}\" }}",
610            escape(name),
611            escape(&path.display().to_string())
612        )
613    });
614    let label = report
615        .label
616        .map(label_json)
617        .unwrap_or_else(|| NONE.to_string());
618    let reads = report
619        .answers
620        .iter()
621        .map(|printed| {
622            format!(
623                "\"{}\": {}",
624                escape(&printed.name),
625                answer_json(&printed.answer, report.limit, printed.skim)
626            )
627        })
628        .chain(report.analyses.iter().map(|(name, value)| {
629            format!(
630                "\"{}\": {{ \"source\": \"measured\", \"profile\": \"{}\", \"rate\": {}, \
631                 \"value\": {value} }}",
632                escape(name),
633                escape(report.profile),
634                report.rate
635            )
636        }))
637        .collect::<Vec<_>>()
638        .join(",\n    ");
639    let interval = report.interval.map_or(NONE.to_string(), |(start, end)| {
640        format!(
641            "{{ \"start_secs\": {}, \"end_secs\": {} }}",
642            num(start),
643            num(end)
644        )
645    });
646    format!(
647        "{{\n  \"target\": \"{}\",\n  \"sample_rate\": {},\n  \"bits\": {},\n  \
648         \"profile\": \"{}\",\n  \"interval\": {interval},\n  \"label\": {label},\n  \
649         \"written\": {written},\n  \"representations\": {{\n    {reads}\n  }}\n}}",
650        escape(report.target),
651        report.rate,
652        report.bits.map_or(NONE.to_string(), |b| b.to_string()),
653        escape(report.profile),
654    )
655}