//! The conformance corpus of extensions/opus-dred.md §9: `live-conformance/dred/`.
//!
//! [`generate`] writes it from fixed seeds and the pinned weights: every packet is written here
//! (its DRED indices drawn from the draft's own distributions by decoding a seeded byte stream,
//! [`payload::sample_latent`]), every loss pattern is a seed, every key is SHA-256 of this
//! corpus's label and an index, every time is a constant, and Ed25519 signing is deterministic
//! (RFC 8032), so every file is the same bytes on every machine. The weights are not in the
//! corpus: it names them by digest, and the generator and every verifier read the file the
//! draft names. Each vector states the outcome it was written to have, and the generator asserts
//! that this module reaches it, and that the corpus gives every reason of §8, before it writes.
//!
//! [`verify`] checks a corpus directory against this module. `live-conformance/dred_verify.py`
//! checks the same files from the extension's text, sharing no code with this module.

use std::path::Path;

use ed25519_dalek::SigningKey;
use serde_json::{json, Value};
use sha2::{Digest, Sha256};

use super::float_ref::{self, FloatModel};
use super::lower::{self, LOWERING};
use super::payload::{self, Header, Payload};
use super::scenario::{self, LossModel, Slot};
use super::weights::{self, WEIGHTS_SHA256};
use super::{hex32, packet, row_bytes, Model, Reason, Row, CAPABILITY_INT};
use crate::container::{Conditioning, Manifest, ModelRequirement, Wai};
use crate::synthesis;

/// Silk wideband, 20 ms (RFC 6716 §3.1, configuration 9), mono.
const TOC_SILK_WB_20: u8 = 9 << 3;
/// One frame of 20 ms, in units of 2.5 ms.
const FRAME_TICKS: i64 = 8;
const LABEL: &[u8] = b"wai:dred/corpus\x00";
/// The weights file of the draft's complete blobs ([DRED-Complete-Blobs], draft §7.2): another
/// file, named by the negative control that names the wrong set.
const OTHER_WEIGHTS_SHA256: &str = "6493b12c864f6d23593287fd15a06d1a9a39ce4db17fd1976067c036da0efdb1";

fn sha(parts: &[&[u8]]) -> [u8; 32] {
    let mut h = Sha256::new();
    for p in parts {
        h.update(p);
    }
    h.finalize().into()
}

/// `n` bytes of the seeded stream `label`, item `i`: SHA-256 in counter mode.
fn seeded(label: &str, i: u64, n: usize) -> Vec<u8> {
    let mut out = Vec::with_capacity(n + 32);
    let mut c = 0u32;
    while out.len() < n {
        out.extend_from_slice(&sha(&[LABEL, label.as_bytes(), &[0], &i.to_be_bytes(), &c.to_be_bytes()]));
        c += 1;
    }
    out.truncate(n);
    out
}

fn key(i: u8) -> SigningKey {
    SigningKey::from_bytes(&sha(&[LABEL, b"key", &[i]]))
}

fn hex(b: &[u8]) -> String {
    b.iter().map(|x| format!("{x:02x}")).collect()
}

fn unhex(s: &str) -> Option<Vec<u8>> {
    if !s.len().is_multiple_of(2) {
        return None;
    }
    (0..s.len()).step_by(2).map(|i| u8::from_str_radix(s.get(i..i + 2)?, 16).ok()).collect()
}

/// A stream's encoding parameters.
#[derive(Clone, Copy)]
struct Spec {
    name: &'static str,
    packets: usize,
    q0: u32,
    dq: u32,
    qmax: u32,
    /// Latent vectors a packet carries, at most.
    depth: usize,
    encoded_offset: u32,
    experimental: bool,
    loss: LossModel,
}

/// The payload packet `n` of a stream carries: its state, and latents for frames `n, n−2, …`,
/// each frame's indices drawn once at `q0` and requantized to the chunk's quantizer. A payload
/// whose last latent the decoder would not read (it took fewer than 8 bits past what the decoder
/// stops at, draft §3) loses it, until the payload decodes to exactly what it holds.
fn stream_payload(s: &Spec, n: usize) -> Payload {
    let header = Header { q0: s.q0, dq: s.dq, extended: (s.encoded_offset >= 32).then_some(s.encoded_offset >> 5), indicator: s.encoded_offset & 31, qmax: s.qmax };
    let state = payload::sample_state(&seeded(&format!("{}/state", s.name), n as u64, 512), s.q0);
    let mut latents = Vec::new();
    for l in 0..s.depth.min(n / 2 + 1) {
        let m = n - 2 * l;
        let x0 = payload::sample_latent(&seeded(&format!("{}/latent", s.name), m as u64, 512), s.q0);
        let q = header.quantizer(l);
        latents.push((payload::requantize(&x0, s.q0, q), q));
    }
    loop {
        let p = Payload { header, state, latents: latents.clone() };
        if payload::parse(&payload::write(&p), None).as_ref() == Ok(&p) {
            return p;
        }
        latents.pop();
    }
}

fn stream_packets(s: &Spec) -> Vec<Vec<u8>> {
    (0..s.packets).map(|n| packet::build(TOC_SILK_WB_20, &[], &packet::dred_padding(&payload::write(&stream_payload(s, n)), s.experimental))).collect()
}

/// A packet as the object a live track carries: a `WAI1` envelope of `wai.audio.opus.packet`.
fn object(p: &[u8]) -> Vec<u8> {
    let m = Manifest::new("audio", "perceptual", ModelRequirement::new(crate::codecs::CAP_AUDIO_OPUS_PACKET), Conditioning::new("none"));
    Wai::new(m, p.to_vec()).to_bytes().expect("a small envelope")
}

/// The payload of a `WAI1` envelope (SPEC §2): what follows the manifest and its length.
pub fn envelope_payload(e: &[u8]) -> Option<&[u8]> {
    if e.get(..4)? != b"WAI1" {
        return None;
    }
    let ml = u32::from_le_bytes(e.get(4..8)?.try_into().ok()?) as usize;
    let at = 8usize.checked_add(ml)?;
    let pl = u32::from_le_bytes(e.get(at..at + 4)?.try_into().ok()?) as usize;
    let body = e.get(at + 4..)?;
    (body.len() == pl).then_some(body)
}

fn slots_json(slots: &[Slot]) -> Value {
    Value::Array(
        slots
            .iter()
            .map(|s| match s.source {
                Some((n, r)) => json!([s.frame, s.half, n, r]),
                None => json!([s.frame, s.half, null, null]),
            })
            .collect(),
    )
}

fn read_slots(v: &Value) -> Option<Vec<Slot>> {
    v.as_array()?
        .iter()
        .map(|s| {
            let a = s.as_array().filter(|a| a.len() == 4)?;
            let frame = a[0].as_u64()? as usize;
            let half = a[1].as_u64().filter(|&h| h < 2)? as u8;
            let source = match (&a[2], &a[3]) {
                (Value::Null, Value::Null) => None,
                (n, r) => Some((n.as_u64()? as usize, r.as_u64()? as usize)),
            };
            Some(Slot { frame, half, source })
        })
        .collect()
}

fn loss_json(l: &LossModel) -> Value {
    json!({ "seed": hex(&l.seed), "enter": l.enter, "stay": l.stay })
}

fn read_loss(v: &Value) -> Option<LossModel> {
    let seed: [u8; 32] = unhex(v["seed"].as_str()?)?.try_into().ok()?;
    let t = |k: &str| v[k].as_u64().filter(|&x| x <= 1 << 16).map(|x| x as u32);
    Some(LossModel { seed, enter: t("enter")?, stay: t("stay")? })
}

/// A stream as a verifier is given it: the received packets' objects, `None` where lost.
pub struct Stream {
    pub objects: Vec<Option<Vec<u8>>>,
    pub loss: LossModel,
}

impl Stream {
    fn packets(&self) -> Vec<Option<Vec<u8>>> {
        self.objects.iter().map(|o| o.as_ref().map(|e| envelope_payload(e).expect("an envelope").to_vec())).collect()
    }
}

/// What a sink's record of a reconstruction states (§5.4).
pub struct Claim {
    pub weights_sha256: String,
    pub lost: Vec<usize>,
    pub slots: Vec<Slot>,
    pub output: [u8; 32],
}

/// Where a case is refused: the record, or entry `i` of the player's log.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum At {
    Record,
    Player(usize),
}

/// A case's outcome (§5.5, §7).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Outcome {
    Accept,
    Refuse(&'static str, At),
}

/// What the next entry of a player's log is chained to: the previous entry's link hash,
/// session, signer, position and time.
type Chained = ([u8; 32], [u8; 32], [u8; 32], u64, u64);

/// The checks of §5.5 and §7.2 over one stream and its record and log, in their order.
pub fn check(model: &Model, s: &Stream, c: &Claim, player: &[String]) -> Outcome {
    let refuse = |r: Reason| Outcome::Refuse(r.code(), At::Record);
    if c.weights_sha256 != WEIGHTS_SHA256 {
        return refuse(Reason::WeightsDigestMismatch);
    }
    let lost: Vec<usize> = s.loss.lost(s.objects.len()).iter().enumerate().filter(|&(_, &x)| x).map(|(i, _)| i).collect();
    let received_gaps: Vec<usize> = s.objects.iter().enumerate().filter(|(_, o)| o.is_none()).map(|(i, _)| i).collect();
    if c.lost != lost || received_gaps != lost {
        return refuse(Reason::LossPatternMismatch);
    }
    let packets = s.packets();
    let slots = scenario::plan(&packets, FRAME_TICKS);
    if c.slots != slots {
        return refuse(Reason::ReconstructionMismatch);
    }
    let Ok(rows) = scenario::rebuild(model, &packets, &slots) else { return refuse(Reason::ReconstructionMismatch) };
    if scenario::digest(&scenario::buffer(&slots, &rows)) != c.output {
        return refuse(Reason::ReconstructionMismatch);
    }
    // The presentation statements (§7.2): a chained log, each `present` entry naming a
    // recovering object and that run's digest, at the profile's tier, decoder and variant.
    let runs = scenario::recoveries(&slots);
    let mut prev: Option<Chained> = None;
    for (i, text) in player.iter().enumerate() {
        let at = At::Player(i);
        let e = match synthesis::read_player_entry(text) {
            Ok(e) => e,
            Err(r) => return Outcome::Refuse(r.code(), at),
        };
        let chain_ok = match prev {
            None => e.seq == 0 && e.prev.is_none(),
            Some((link, session, signer, seq, t)) => e.seq == seq + 1 && e.prev == Some(link) && e.session == session && e.signer == signer && e.at >= t,
        };
        if !chain_ok {
            return Outcome::Refuse(synthesis::Reason::PlayerChainBroken.code(), at);
        }
        prev = Some((e.link, e.session, e.signer, e.seq, e.at));
        let v: Value = serde_json::from_str(text).expect("read above");
        let synthesis::Entry::Present { subject, tier, output, .. } = &e.entry else {
            continue;
        };
        let reproduces = v["entry"]["decoder"] == CAPABILITY_INT
            && v["entry"]["variant"] == LOWERING
            && tier == "decode-equivalence"
            && runs.iter().any(|(n, r)| {
                s.objects.get(*n).and_then(|o| o.as_ref()).is_some_and(|o| crate::jwp_hash::content_hash(o) == *subject)
                    && *output == Some(scenario::digest(&scenario::buffer(&slots[r.clone()], &rows[r.clone()])))
            });
        if !reproduces {
            return Outcome::Refuse(synthesis::Reason::PresentOutputIrreproducible.code(), at);
        }
    }
    Outcome::Accept
}

fn player_log(stream: &Stream, slots: &[Slot], rows: &[Option<Row>], output_of: impl Fn(usize, &[u8; 32]) -> [u8; 32]) -> Vec<String> {
    let k = key(1);
    let session = sha(&[LABEL, b"session"]);
    let mut out = Vec::new();
    let mut prev: Option<[u8; 32]> = None;
    for (seq, (n, r)) in scenario::recoveries(slots).into_iter().filter(|(n, _)| stream.objects.get(*n).is_some_and(|o| o.is_some())).enumerate() {
        let obj = stream.objects[n].as_ref().unwrap();
        let out_digest = output_of(seq, &scenario::digest(&scenario::buffer(&slots[r.clone()], &rows[r])));
        let body = json!({
            "kind": "wai-player",
            "session": hex(&session),
            "seq": seq,
            "prev": prev.map(|p| hex(&p)),
            "at": n as u64 * 20_000,
            "entry": {
                "type": "present",
                "subject": hex(&crate::jwp_hash::content_hash(obj)),
                "origin": null,
                "class": null,
                "media": "audio",
                "tier": "decode-equivalence",
                "decoder": CAPABILITY_INT,
                "variant": LOWERING,
                "output": hex(&out_digest),
            },
        });
        let signed = synthesis::sign(body, &k);
        prev = Some(synthesis::link_hash(&signed));
        out.push(serde_json::to_string(&signed).unwrap());
    }
    out
}

fn specs() -> [Spec; 2] {
    [
        Spec {
            name: "speech_q2",
            packets: 40,
            q0: 2,
            dq: 0,
            qmax: 15,
            depth: 4,
            encoded_offset: 8,
            experimental: true,
            loss: LossModel { seed: sha(&[LABEL, b"loss/speech_q2"]), enter: 14_000, stay: 45_000 },
        },
        Spec {
            name: "slope_id32",
            packets: 32,
            q0: 5,
            dq: 3,
            qmax: 11,
            depth: 8,
            encoded_offset: 40,
            experimental: false,
            loss: LossModel { seed: sha(&[LABEL, b"loss/slope_id32"]), enter: 12_000, stay: 45_000 },
        },
    ]
}

/// The payload vectors (§9.2): packets, each with what a decoder states of its DRED.
fn payload_vectors(model: &Model) -> Vec<Value> {
    let mut v = Vec::new();
    let mut accept = |name: &str, note: &str, p: Vec<u8>| {
        let (bytes, frame_offset) = packet::find_dred(&p).unwrap().unwrap();
        let pl = payload::parse(bytes, None).unwrap();
        let rows = model.decode(&pl, usize::MAX).unwrap();
        let h = pl.header;
        v.push(json!({
            "name": name,
            "note": note,
            "packet": hex(&p),
            "outcome": "dred",
            "dred": {
                "frame_offset": frame_offset,
                "q0": h.q0, "dq": h.dq, "extended": h.extended, "indicator": h.indicator, "qmax": h.qmax,
                "dred_offset": h.dred_offset(frame_offset),
                "latents": pl.latents.len(),
                "symbols_sha256": hex(&sha(&[&pl.symbol_bytes()])),
                "rows": rows.len(),
                "features_sha256": hex(&sha(&[&row_bytes(&rows)])),
                "newest": rows[0].to_vec(),
            }
        }));
    };
    let s = specs();
    accept("experimental_q2", "ID 126 with the 'D', 12 prefix; q0 2, no slope", stream_packets(&s[0])[11].clone());
    accept("id32_slope_extended", "ID 32; q0 5, dQ 3 (3/16), qmax 11; an extended offset (encoded offset 40)", stream_packets(&s[1])[15].clone());
    // Two 20 ms frames in one packet, the DRED extension on the second (a frame separator).
    {
        let pl = payload::write(&stream_payload(&s[0], 7));
        let mut pad = vec![0x02];
        pad.extend_from_slice(&packet::dred_padding(&pl, true));
        let mut p = vec![TOC_SILK_WB_20 | 3, 0x40 | 2];
        let mut n = pad.len();
        while n >= 255 {
            p.push(255);
            n -= 254;
        }
        p.push(n as u8);
        p.extend_from_slice(&pad);
        accept("second_frame", "two frames of 20 ms; the DRED extension follows a frame separator, so it belongs to frame 1 (frame offset 8)", p);
    }
    let mut none = |name: &str, note: &str, p: Vec<u8>| {
        assert_eq!(packet::find_dred(&p), Ok(None), "{name}");
        v.push(json!({ "name": name, "note": note, "packet": hex(&p), "outcome": "none" }));
    };
    let pl = payload::write(&stream_payload(&s[0], 9));
    none("experimental_wrong_version", "ID 126 whose prefix names another version: not DRED", packet::build(TOC_SILK_WB_20, &[], &[&[packet::DRED_EXPERIMENTAL_ID << 1, b'D', 11][..], &pl].concat()));
    none("truncated_extension", "ID 32 with a length past the padding: the extension is ignored (draft-ietf-mlcodec-opus-extension-06 §2.7)", packet::build(TOC_SILK_WB_20, &[], &[&[(packet::DRED_ID << 1) | 1, 250][..], &pl[..20]].concat()));
    none("padding_only", "padding of zeros (extension ID 0): no extension", packet::build(TOC_SILK_WB_20, &[], &[0u8; 12]));
    let mut refuse = |name: &str, note: &str, p: Vec<u8>, r: Reason| {
        let got = match packet::find_dred(&p) {
            Err(e) => e,
            Ok(Some((b, _))) => payload::parse(b, None).expect_err("refused"),
            Ok(None) => panic!("{name}: no DRED"),
        };
        assert_eq!(got, r, "{name}");
        v.push(json!({ "name": name, "note": note, "packet": hex(&p), "outcome": { "refuse": r.code() } }));
    };
    refuse("odd_two_frames", "code 1 (two frames of equal size) with an odd body: RFC 6716 §3.4 R3", vec![TOC_SILK_WB_20 | 1, 1, 2, 3], Reason::PacketMalformed);
    refuse("padding_past_end", "code 3 whose padding length passes the packet: RFC 6716 §3.4 R7", vec![TOC_SILK_WB_20 | 3, 0x41, 200, 0], Reason::PacketMalformed);
    // A coefficient whose magnitude passes the cap, the rest of the payload ordinary.
    {
        let mut p = stream_payload(&s[0], 9);
        let t = &crate::dred_exact::tables::STATE_DECAY;
        let k = (0..payload::STATE_DIM).filter(|&k| payload::coded(t[k][2], crate::dred_exact::tables::STATE_P0[k][2])).max_by_key(|&k| t[k][2]).unwrap();
        p.state[k] = payload::MAGNITUDE_CAP + 5;
        let pk = packet::build(TOC_SILK_WB_20, &[], &packet::dred_padding(&payload::write(&p), true));
        refuse("magnitude_past_cap", "a state coefficient of magnitude 2^16 + 5: its escape run passes the cap", pk, Reason::DredLatentRange);
    }
    v
}

/// Everything the corpus holds, built from the pinned weights.
pub fn build(weights_file: &[u8]) -> Result<Vec<(String, Vec<u8>)>, String> {
    let layers = weights::pinned(weights_file).map_err(|r| r.code().to_string())?;
    let model = Model::from_layers(&layers).map_err(|r| r.code().to_string())?;
    let float = FloatModel::new(&layers);
    let tanh = lower::tanh_table();
    let sig = lower::sigmoid_table();
    let tb = |t: &[i16]| sha(&[&t.iter().flat_map(|x| x.to_le_bytes()).collect::<Vec<u8>>()]);

    let mut streams = serde_json::Map::new();
    let mut cases = Vec::new();
    for s in specs() {
        let pk = stream_packets(&s);
        let lost = s.loss.lost(s.packets);
        let objects: Vec<Option<Vec<u8>>> = pk.iter().zip(&lost).map(|(p, &l)| (!l).then(|| object(p))).collect();
        let stream = Stream { objects, loss: s.loss };
        let packets = stream.packets();
        let slots = scenario::plan(&packets, FRAME_TICKS);
        let rows = scenario::rebuild(&model, &packets, &slots).map_err(|r| r.code().to_string())?;
        let output = scenario::digest(&scenario::buffer(&slots, &rows));
        let lost_idx: Vec<usize> = lost.iter().enumerate().filter(|&(_, &x)| x).map(|(i, _)| i).collect();
        assert!(slots.iter().any(|x| x.source.is_some()) && slots.iter().any(|x| x.source.is_none()), "{}: covered and uncovered", s.name);
        streams.insert(
            s.name.into(),
            json!({
                "frame_ticks": FRAME_TICKS,
                "objects": stream.objects.iter().map(|o| o.as_ref().map(|e| hex(e))).collect::<Vec<_>>(),
                "loss": loss_json(&s.loss),
            }),
        );
        let good_log = player_log(&stream, &slots, &rows, |_, d| *d);
        let claim = |w: &str, lost: &[usize], slots: &[Slot], out: &[u8; 32]| json!({ "weights_sha256": w, "lost": lost, "slots": slots_json(slots), "output": hex(out) });
        let mut case = |name: String, note: &str, claim: Value, player: Vec<String>, outcome: Outcome| {
            cases.push((s.name, name, note.to_string(), claim, player, outcome));
        };
        case(format!("{}/recovered", s.name), "the reconstruction as the profile gives it, and a player's log of its recoveries", claim(WEIGHTS_SHA256, &lost_idx, &slots, &output), good_log.clone(), Outcome::Accept);
        if s.name != "speech_q2" {
            continue;
        }
        // The float decode of the same latents, at 2^16, claimed as the exact reconstruction.
        let float_rows: Vec<Option<Row>> = slots
            .iter()
            .map(|sl| {
                sl.source.map(|(n, r)| {
                    let (pl, _) = scenario::dred_of(packets[n].as_deref().unwrap()).unwrap();
                    float_ref::to_q16(&float.decode(&pl, r / 4 + 1))[r]
                })
            })
            .collect();
        assert_ne!(float_rows, rows, "the float decode differs from the profile's");
        let float_out = scenario::digest(&scenario::buffer(&slots, &float_rows));
        case(format!("{}/float_decode_claimed_exact", s.name), "the record states the digest of a floating-point decode of the same latents (rounded to 2^16)", claim(WEIGHTS_SHA256, &lost_idx, &slots, &float_out), vec![], Outcome::Refuse(Reason::ReconstructionMismatch.code(), At::Record));
        let float_log = player_log(&stream, &slots, &float_rows, |_, d| *d);
        // The first entry whose run the float decode rebuilds differently.
        let first = float_log.iter().zip(&good_log).position(|(a, b)| {
            let (a, b): (Value, Value) = (serde_json::from_str(a).unwrap(), serde_json::from_str(b).unwrap());
            a["entry"]["output"] != b["entry"]["output"]
        });
        case(format!("{}/float_decode_presented_exact", s.name), "the record is right; the player's log presents the floating-point decode at decode-equivalence", claim(WEIGHTS_SHA256, &lost_idx, &slots, &output), float_log, Outcome::Refuse(synthesis::Reason::PresentOutputIrreproducible.code(), At::Player(first.expect("a run the float decode changes"))));
        case(format!("{}/other_weights", s.name), "the record names another weights file (the draft's complete blob)", claim(OTHER_WEIGHTS_SHA256, &lost_idx, &slots, &output), vec![], Outcome::Refuse(Reason::WeightsDigestMismatch.code(), At::Record));
        let received = (0..s.packets).find(|i| !lost[*i] && *i > 0).unwrap();
        let mut wrong_lost = lost_idx.clone();
        wrong_lost.push(received);
        wrong_lost.sort_unstable();
        case(format!("{}/loss_not_the_pattern", s.name), "the record lists a received packet as lost", claim(WEIGHTS_SHA256, &wrong_lost, &slots, &output), vec![], Outcome::Refuse(Reason::LossPatternMismatch.code(), At::Record));
        let mut moved = slots.clone();
        let i = moved.iter().position(|x| x.source.is_some()).unwrap();
        moved[i].source = moved[i].source.map(|(n, r)| (n, r + 1));
        case(format!("{}/row_moved", s.name), "the record names the next older row for one lost half", claim(WEIGHTS_SHA256, &lost_idx, &moved, &output), vec![], Outcome::Refuse(Reason::ReconstructionMismatch.code(), At::Record));
        let mut flipped = output;
        flipped[0] ^= 1;
        case(format!("{}/digest_flipped", s.name), "the record's digest with one bit changed", claim(WEIGHTS_SHA256, &lost_idx, &slots, &flipped), vec![], Outcome::Refuse(Reason::ReconstructionMismatch.code(), At::Record));
        assert!(good_log.len() >= 2, "{}: two recoveries", s.name);
        let mut bad = good_log.clone();
        let mut v: Value = serde_json::from_str(&bad[1]).unwrap();
        let sig = v["sig"].as_str().unwrap().to_string();
        let flipped_sig = format!("{}{}", if &sig[..1] == "0" { "1" } else { "0" }, &sig[1..]);
        v["sig"] = json!(flipped_sig);
        bad[1] = serde_json::to_string(&v).unwrap();
        case(format!("{}/log_signature_broken", s.name), "the second entry of the player's log with one digit of its signature changed", claim(WEIGHTS_SHA256, &lost_idx, &slots, &output), bad, Outcome::Refuse(synthesis::Reason::BadSignature.code(), At::Player(1)));
    }

    // Every case reaches its outcome here before anything is written.
    let mut given: Vec<&str> = Vec::new();
    let mut case_json = Vec::new();
    for (stream_name, name, note, claim, player, outcome) in &cases {
        let sv = &streams[*stream_name];
        let stream = read_stream(sv).ok_or("a stream")?;
        let c = read_claim(claim).ok_or("a claim")?;
        let got = check(&model, &stream, &c, player);
        assert_eq!(&got, outcome, "{name}");
        if let Outcome::Refuse(code, _) = outcome {
            given.push(code);
        }
        case_json.push(json!({ "name": name, "stream": stream_name, "note": note, "record": claim, "player": player, "outcome": outcome_json(outcome) }));
    }
    let payloads = payload_vectors(&model);
    for p in &payloads {
        if let Some(c) = p["outcome"]["refuse"].as_str() {
            given.push(Reason::LISTED.iter().find(|r| r.code() == c).unwrap().code());
        }
    }
    for r in Reason::LISTED {
        assert!(given.contains(&r.code()), "the corpus gives {}", r.code());
    }
    let doc = json!({
        "profile": LOWERING,
        "capability": CAPABILITY_INT,
        "weights": { "sha256": WEIGHTS_SHA256, "file": "dred_unquantized_weights.bin" },
        "tables_sha256": hex32(&payload::tables_digest()),
        "lowering": {
            "exp_c0": lower::EXP_C0.to_string(),
            "tanh_table_sha256": hex32(&tb(&tanh)),
            "sigmoid_table_sha256": hex32(&tb(&sig)),
            "init_sha256": hex32(&model.init_sha256),
            "step_sha256": hex32(&model.step_sha256),
        },
        "payloads": payloads,
        "streams": Value::Object(streams),
        "cases": case_json,
    });
    let mut text = serde_json::to_string_pretty(&doc).unwrap();
    text.push('\n');
    Ok(vec![("vectors.json".into(), text.into_bytes())])
}

fn outcome_json(o: &Outcome) -> Value {
    match o {
        Outcome::Accept => json!("accept"),
        Outcome::Refuse(code, At::Record) => json!({ "refuse": code, "at": ["record"] }),
        Outcome::Refuse(code, At::Player(i)) => json!({ "refuse": code, "at": ["player", i] }),
    }
}

fn read_stream(v: &Value) -> Option<Stream> {
    let objects = v["objects"].as_array()?.iter().map(|o| if o.is_null() { Some(None) } else { unhex(o.as_str()?).map(Some) }).collect::<Option<Vec<_>>>()?;
    Some(Stream { objects, loss: read_loss(&v["loss"])? })
}

fn read_claim(v: &Value) -> Option<Claim> {
    Some(Claim {
        weights_sha256: v["weights_sha256"].as_str()?.to_string(),
        lost: v["lost"].as_array()?.iter().map(|x| x.as_u64().map(|x| x as usize)).collect::<Option<Vec<_>>>()?,
        slots: read_slots(&v["slots"])?,
        output: unhex(v["output"].as_str()?)?.try_into().ok()?,
    })
}

/// Write the corpus into `dir`.
pub fn generate(weights_file: &[u8], dir: &Path) -> Result<(), String> {
    std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
    for (name, bytes) in build(weights_file)? {
        std::fs::write(dir.join(name), bytes).map_err(|e| e.to_string())?;
    }
    Ok(())
}

/// Check a corpus directory: it is what [`build`] writes, byte for byte, and no other file is
/// there but its README and its fetch script; and every case and payload vector reaches its
/// outcome (which [`build`] asserts).
pub fn verify(weights_file: &[u8], dir: &Path) -> Result<(), String> {
    let want = build(weights_file)?;
    for (name, bytes) in &want {
        let got = std::fs::read(dir.join(name)).map_err(|e| format!("{name}: {e}"))?;
        if &got != bytes {
            return Err(format!("{name} differs from what the generator writes"));
        }
    }
    let mut names: Vec<String> = std::fs::read_dir(dir).map_err(|e| e.to_string())?.filter_map(|e| e.ok()?.file_name().into_string().ok()).collect();
    names.sort();
    let mut expect: Vec<String> = want.iter().map(|(n, _)| n.clone()).collect();
    expect.push("README.md".into());
    expect.push("fetch.sh".into());
    expect.sort();
    if names != expect {
        return Err(format!("the corpus holds {names:?}, not {expect:?}"));
    }
    Ok(())
}
