/**
 * rigor/graph-indices.test.js — js-rigor property tests for GraphIndices.
 *
 * GraphIndices maintains five indexes over the relation set:
 *   - relationsBySrcRelDst: Map<key, relationObj>           — direct lookup (src,rel,dst) → rel
 *   - relationsBySrcRel:    Map<key, Set<relationObj>>      — all relations from src under rel
 *   - relationsByDstRel:    Map<key, Set<relationObj>>      — all relations to dst under rel
 *   - relationsByRel:       Map<rel, Set<relationObj>>      — all relations under a name
 *   - outgoingEdges:        Map<srcId, dstId[]>             — adjacency (with duplicates)
 *   - incomingEdges:        Map<dstId, srcId[]>             — reverse adjacency
 *
 * Properties verified against a brute-force oracle (two naive Maps):
 *   - getDirectRelation(src, rel, dst) matches the oracle
 *   - getRelationsFromSrc(src, rel) returns the exact set the oracle records
 *   - getRelationsToDst(dst, rel) returns the exact set the oracle records
 *   - getRelationsByName(rel) returns the exact set the oracle records
 *   - After add+remove cycle, getDirectRelation returns undefined
 *   - Adding the same relation twice is idempotent (Set semantics)
 *   - clear() empties every index
 */
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { rigor } from '@rigor/core';
import { GraphIndices } from '../../src/core/GraphIndices.js';

const RELATIONS = ['owner', 'viewer', 'editor', 'member', 'parent', 'admin'];

/**
 * Build a relation object with optional strength metadata.
 */
function relObj(src, rel, dst, possibility = 1.0, value = undefined) {
  const r = { src, rel, dst, possibility };
  if (value !== undefined) r.value = value;
  r.changed_last_at = 1000;
  r.updated_last_at = 1000;
  r.source = 'persistent';
  return r;
}

/**
 * Brute-force oracle that mirrors GraphIndices' production contract.
 * After RF-22, addRelation enforces (src, rel, dst) uniqueness and replaces
 * any existing entry — both in the composite-key index AND in the Set-backed
 * indexes. The oracle mirrors that exactly:
 *   - direct: Map<"src|rel|dst", relObj>      — last writer wins
 *   - byName: Map<rel, Set<relObj>>            — Set by reference identity
 *   - bySrc:  Map<"src|rel", Set<relObj>>
 *   - byDst:  Map<"dst|rel", Set<relObj>>
 *
 * add(r): if (src,rel,dst) is new, insert into all four. If duplicate, replace
 *         in `direct` AND swap the old ref out of all Sets before adding the new.
 * remove(r, opSrc, opDst, opRel): lookup by the (opSrc, opRel, opDst) args
 *         (production uses these, not the relObj's fields), then delete from all.
 */
function makeOracle() {
  const direct = new Map();
  const byName = new Map();
  const bySrc = new Map();
  const byDst = new Map();
  const outgoingEdges = new Map();
  const incomingEdges = new Map();

  function key(a, b, c) {
    return c !== undefined ? `${a}|${b}|${c}` : `${a}|${b}`;
  }

  function add(r) {
    const directKey = key(r.src, r.rel, r.dst);
    const existing = direct.get(directKey);
    if (existing && existing !== r) {
      // Replace: remove existing from all Sets, then insert r
      const srcRel = key(existing.src, existing.rel);
      const dstRel = key(existing.dst, existing.rel);
      byName.get(existing.rel)?.delete(existing);
      if (byName.get(existing.rel)?.size === 0) byName.delete(existing.rel);
      bySrc.get(srcRel)?.delete(existing);
      if (bySrc.get(srcRel)?.size === 0) bySrc.delete(srcRel);
      byDst.get(dstRel)?.delete(existing);
      if (byDst.get(dstRel)?.size === 0) byDst.delete(dstRel);
    }
    if (!existing) {
      // New: also update outgoingEdges / incomingEdges (production appends on every add)
      if (!outgoingEdges.has(r.src)) outgoingEdges.set(r.src, []);
      outgoingEdges.get(r.src).push(r.dst);
      if (!incomingEdges.has(r.dst)) incomingEdges.set(r.dst, []);
      incomingEdges.get(r.dst).push(r.src);
    }
    direct.set(directKey, r);
    if (!byName.has(r.rel)) byName.set(r.rel, new Set());
    byName.get(r.rel).add(r);
    const srcRel = key(r.src, r.rel);
    if (!bySrc.has(srcRel)) bySrc.set(srcRel, new Set());
    bySrc.get(srcRel).add(r);
    const dstRel = key(r.dst, r.rel);
    if (!byDst.has(dstRel)) byDst.set(dstRel, new Set());
    byDst.get(dstRel).add(r);
  }

  function remove(r, opSrc, opDst, opRel) {
    // Production uses the args (srcId, dstId, relation) for the composite key
    const srcId = opSrc !== undefined ? opSrc : r.src;
    const dstId = opDst !== undefined ? opDst : r.dst;
    const rel = opRel !== undefined ? opRel : r.rel;
    const directKey = key(srcId, rel, dstId);
    const stored = direct.get(directKey);
    if (!stored) return { skipped: true };
    direct.delete(directKey);
    const srcRel = key(stored.src, stored.rel);
    const dstRel = key(stored.dst, stored.rel);
    byName.get(stored.rel)?.delete(stored);
    if (byName.get(stored.rel)?.size === 0) byName.delete(stored.rel);
    bySrc.get(srcRel)?.delete(stored);
    if (bySrc.get(srcRel)?.size === 0) bySrc.delete(srcRel);
    byDst.get(dstRel)?.delete(stored);
    if (byDst.get(dstRel)?.size === 0) byDst.delete(dstRel);
  }

  function clear() {
    direct.clear();
    byName.clear();
    bySrc.clear();
    byDst.clear();
    outgoingEdges.clear();
    incomingEdges.clear();
  }

  function getDirect(src, rel, dst) { return direct.get(key(src, rel, dst)); }
  function getByName(rel) { return Array.from(byName.get(rel) ?? []); }
  function getBySrc(src, rel) { return Array.from(bySrc.get(key(src, rel)) ?? []); }
  function getByDst(dst, rel) { return Array.from(byDst.get(key(dst, rel)) ?? []); }

  return { add, remove, clear, getDirect, getByName, getBySrc, getByDst, direct, byName, bySrc, byDst, outgoingEdges, incomingEdges };
}

describe('GraphIndices indexes (rigor)', () => {
  it('getDirectRelation matches the brute-force oracle', async () => {
    async function check(operations) {
      const gi = new GraphIndices();
      const oracle = makeOracle();
      // op codes: 0=add, 1=remove, 2=query
      for (const op of operations) {
        if (op[0] === 0) {
          gi.addRelation(op[1]);
          oracle.add(op[1]);
        } else if (op[0] === 1) {
          gi.removeRelation(op[1], op[2], op[3], op[4]);
          // Mirror production's key construction exactly: it looks up by the
          // (srcId, dstId, relation) ARGS, not the relObj's own fields.
          oracle.remove(op[1], op[2], op[3], op[4]);
        } else if (op[0] === 2) {
          const [, src, rel, dst] = op;
          const actual = gi.getDirectRelation(src, rel, dst);
          const expected = oracle.getDirect(src, rel, dst);
          if (actual !== expected) {
            throw new Error(
              `getDirectRelation(${src},${rel},${dst}): actual=${JSON.stringify(actual)} expected=${JSON.stringify(expected)}`
            );
          }
        }
      }
      return true;
    }

    const relGen = rigor.gen.tuple(
      rigor.gen.int(0, 5),
      rigor.gen.enum(RELATIONS),
      rigor.gen.int(0, 5),
      rigor.gen.float({ min: 0, max: 1 })
    ).map(([src, rel, dst, p]) => relObj(src, rel, dst, p));

    const opGen = rigor.gen.array(
      rigor.gen.oneOf([
        rigor.gen.tuple(rigor.gen.constant(0), relGen),
        rigor.gen.tuple(rigor.gen.constant(1), relGen, rigor.gen.int(0, 5), rigor.gen.int(0, 5), rigor.gen.enum(RELATIONS)),
        rigor.gen.tuple(rigor.gen.constant(2), rigor.gen.int(0, 5), rigor.gen.enum(RELATIONS), rigor.gen.int(0, 5))
      ]),
      1, 12
    );

    const report = await rigor.campaign(
      [rigor.fn('check', check, rigor.args(opGen))],
      rigor.crucible([
        rigor.invariant('getDirectRelation-matches-oracle', ({ actual }) => actual !== undefined)
      ])
    ).run({ effort: 1500, seed: 'graph-indices-direct-a' , artifacts: { dir: '', persist: 'never' }});

    if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
    const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'getDirectRelation-matches-oracle');
    assert.ok(inv);
    assert.equal(inv.passed, true, `getDirectRelation diverged from oracle in ${inv.failureCount} cases`);
  });

  it('getRelationsFromSrc matches the brute-force oracle', async () => {
    async function check(operations) {
      const gi = new GraphIndices();
      const oracle = makeOracle();
      for (const op of operations) {
        if (op[0] === 0) {
          gi.addRelation(op[1]);
          oracle.add(op[1]);
        } else if (op[0] === 1) {
          gi.removeRelation(op[1], op[2], op[3], op[4]);
          oracle.remove(op[1], op[2], op[3], op[4]);
        } else if (op[0] === 2) {
          const [, src, rel] = op;
          const actual = gi.getRelationsFromSrc(src, rel);
          const expected = oracle.getBySrc(src, rel);
          // Both should be Sets — compare content
          if (actual.length !== expected.length) {
            throw new Error(
              `getRelationsFromSrc(${src},${rel}): length actual=${actual.length} expected=${expected.length}`
            );
          }
          const actualKeys = new Set(actual.map(r => `${r.src}|${r.rel}|${r.dst}`));
          const expectedKeys = new Set(expected.map(r => `${r.src}|${r.rel}|${r.dst}`));
          if (actualKeys.size !== expectedKeys.size ||
              ![...actualKeys].every(k => expectedKeys.has(k))) {
            throw new Error(
              `getRelationsFromSrc(${src},${rel}): content mismatch`
            );
          }
        }
      }
      return true;
    }

    const relGen = rigor.gen.tuple(
      rigor.gen.int(0, 5),
      rigor.gen.enum(RELATIONS),
      rigor.gen.int(0, 5),
      rigor.gen.float({ min: 0, max: 1 })
    ).map(([src, rel, dst, p]) => relObj(src, rel, dst, p));

    const opGen = rigor.gen.array(
      rigor.gen.oneOf([
        rigor.gen.tuple(rigor.gen.constant(0), relGen),
        rigor.gen.tuple(rigor.gen.constant(1), relGen, rigor.gen.int(0, 5), rigor.gen.int(0, 5), rigor.gen.enum(RELATIONS)),
        rigor.gen.tuple(rigor.gen.constant(2), rigor.gen.int(0, 5), rigor.gen.enum(RELATIONS))
      ]),
      1, 12
    );

    const report = await rigor.campaign(
      [rigor.fn('check', check, rigor.args(opGen))],
      rigor.crucible([
        rigor.invariant('getRelationsFromSrc-matches-oracle', ({ actual }) => actual !== undefined)
      ])
    ).run({ effort: 1500, seed: 'graph-indices-direct-b' , artifacts: { dir: '', persist: 'never' }});

    if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
    const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'getRelationsFromSrc-matches-oracle');
    assert.ok(inv);
    assert.equal(inv.passed, true, `getRelationsFromSrc diverged from oracle in ${inv.failureCount} cases`);
  });

  it('getRelationsToDst matches the brute-force oracle', async () => {
    async function check(operations) {
      const gi = new GraphIndices();
      const oracle = makeOracle();
      for (const op of operations) {
        if (op[0] === 0) {
          gi.addRelation(op[1]);
          oracle.add(op[1]);
        } else if (op[0] === 1) {
          gi.removeRelation(op[1], op[2], op[3], op[4]);
          oracle.remove(op[1], op[2], op[3], op[4]);
        } else if (op[0] === 2) {
          const [, dst, rel] = op;
          const actual = gi.getRelationsToDst(dst, rel);
          const expected = oracle.getByDst(dst, rel);
          if (actual.length !== expected.length) {
            throw new Error(
              `getRelationsToDst(${dst},${rel}): length actual=${actual.length} expected=${expected.length}`
            );
          }
          const actualKeys = new Set(actual.map(r => `${r.src}|${r.rel}|${r.dst}`));
          const expectedKeys = new Set(expected.map(r => `${r.src}|${r.rel}|${r.dst}`));
          if (actualKeys.size !== expectedKeys.size ||
              ![...actualKeys].every(k => expectedKeys.has(k))) {
            throw new Error(`getRelationsToDst(${dst},${rel}): content mismatch`);
          }
        }
      }
      return true;
    }

    const relGen = rigor.gen.tuple(
      rigor.gen.int(0, 5),
      rigor.gen.enum(RELATIONS),
      rigor.gen.int(0, 5),
      rigor.gen.float({ min: 0, max: 1 })
    ).map(([src, rel, dst, p]) => relObj(src, rel, dst, p));

    const opGen = rigor.gen.array(
      rigor.gen.oneOf([
        rigor.gen.tuple(rigor.gen.constant(0), relGen),
        rigor.gen.tuple(rigor.gen.constant(1), relGen, rigor.gen.int(0, 5), rigor.gen.int(0, 5), rigor.gen.enum(RELATIONS)),
        rigor.gen.tuple(rigor.gen.constant(2), rigor.gen.int(0, 5), rigor.gen.enum(RELATIONS))
      ]),
      1, 12
    );

    const report = await rigor.campaign(
      [rigor.fn('check', check, rigor.args(opGen))],
      rigor.crucible([
        rigor.invariant('getRelationsToDst-matches-oracle', ({ actual }) => actual !== undefined)
      ])
    ).run({ effort: 1500, seed: 'graph-indices-direct-c' , artifacts: { dir: '', persist: 'never' }});

    if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
    const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'getRelationsToDst-matches-oracle');
    assert.ok(inv);
    assert.equal(inv.passed, true, `getRelationsToDst diverged from oracle in ${inv.failureCount} cases`);
  });

  it('getRelationsByName matches the brute-force oracle', async () => {
    async function check(operations) {
      const gi = new GraphIndices();
      const oracle = makeOracle();
      for (const op of operations) {
        if (op[0] === 0) {
          gi.addRelation(op[1]);
          oracle.add(op[1]);
        } else if (op[0] === 1) {
          gi.removeRelation(op[1], op[2], op[3], op[4]);
          oracle.remove(op[1], op[2], op[3], op[4]);
        } else if (op[0] === 2) {
          const [, rel] = op;
          const actual = gi.getRelationsByName(rel);
          const expected = oracle.getByName(rel);
          if (actual.length !== expected.length) {
            throw new Error(
              `getRelationsByName(${rel}): length actual=${actual.length} expected=${expected.length}`
            );
          }
          const actualKeys = new Set(actual.map(r => `${r.src}|${r.rel}|${r.dst}`));
          const expectedKeys = new Set(expected.map(r => `${r.src}|${r.rel}|${r.dst}`));
          if (actualKeys.size !== expectedKeys.size ||
              ![...actualKeys].every(k => expectedKeys.has(k))) {
            throw new Error(`getRelationsByName(${rel}): content mismatch`);
          }
        }
      }
      return true;
    }

    const relGen = rigor.gen.tuple(
      rigor.gen.int(0, 5),
      rigor.gen.enum(RELATIONS),
      rigor.gen.int(0, 5),
      rigor.gen.float({ min: 0, max: 1 })
    ).map(([src, rel, dst, p]) => relObj(src, rel, dst, p));

    const opGen = rigor.gen.array(
      rigor.gen.oneOf([
        rigor.gen.tuple(rigor.gen.constant(0), relGen),
        rigor.gen.tuple(rigor.gen.constant(1), relGen, rigor.gen.int(0, 5), rigor.gen.int(0, 5), rigor.gen.enum(RELATIONS)),
        rigor.gen.tuple(rigor.gen.constant(2), rigor.gen.enum(RELATIONS))
      ]),
      1, 12
    );

    const report = await rigor.campaign(
      [rigor.fn('check', check, rigor.args(opGen))],
      rigor.crucible([
        rigor.invariant('getRelationsByName-matches-oracle', ({ actual }) => actual !== undefined)
      ])
    ).run({ effort: 1500, seed: 'graph-indices-direct-d' , artifacts: { dir: '', persist: 'never' }});

    if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
    const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'getRelationsByName-matches-oracle');
    assert.ok(inv);
    assert.equal(inv.passed, true, `getRelationsByName diverged from oracle in ${inv.failureCount} cases`);
  });

  it('addRelation is idempotent for same (src,rel,dst) regardless of object identity (RF-22)', async () => {
    // RF-22 closure regression test. Two distinct objects sharing the same
    // (src, rel, dst) tuple must not produce two entries in the by-rel/
    // by-src-rel/by-dst-rel indexes — relationsBySrcRelDst's composite-key
    // dedup is the canonical invariant.
    async function check(src, rel, dst, possibility1, possibility2) {
      const gi = new GraphIndices();
      const r1 = relObj(src, rel, dst, possibility1);
      const r2 = relObj(src, rel, dst, possibility2);
      gi.addRelation(r1);
      gi.addRelation(r2);

      // direct lookup returns the FIRST (last-writer-wins on the composite key
      // means the second call overwrites, so r2 should be returned)
      const direct = gi.getDirectRelation(src, rel, dst);
      if (direct !== r2) {
        throw new Error(`getDirectRelation should return r2, got ${JSON.stringify(direct)}`);
      }

      // byName/bySrcRel/byDstRel must contain only r2 (the surviving entry)
      const byName = gi.getRelationsByName(rel);
      if (byName.length !== 1 || byName[0] !== r2) {
        throw new Error(`getRelationsByName should return only r2, got ${byName.length} entries`);
      }
      const bySrc = gi.getRelationsFromSrc(src, rel);
      if (bySrc.length !== 1 || bySrc[0] !== r2) {
        throw new Error(`getRelationsFromSrc should return only r2, got ${bySrc.length} entries`);
      }
      const byDst = gi.getRelationsToDst(dst, rel);
      if (byDst.length !== 1 || byDst[0] !== r2) {
        throw new Error(`getRelationsToDst should return only r2, got ${byDst.length} entries`);
      }
      return true;
    }

    const report = await rigor.campaign(
      [rigor.fn('check', check,
        rigor.args(
          rigor.gen.int(0, 10),
          rigor.gen.enum(RELATIONS),
          rigor.gen.int(0, 10),
          rigor.gen.float({ min: 0, max: 1 }),
          rigor.gen.float({ min: 0, max: 1 })
        )
      )],
      rigor.crucible([
        rigor.invariant('addRelation-tuple-idempotent', ({ actual }) => actual !== undefined)
      ])
    ).run({ effort: 800, seed: 'graph-indices-src' , artifacts: { dir: '', persist: 'never' }});

    if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
    const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'addRelation-tuple-idempotent');
    assert.ok(inv);
    assert.equal(inv.passed, true, `RF-22 idempotence violated in ${inv.failureCount} cases`);
  });

  it('addRelation is idempotent (Set semantics, same obj not added twice)', async () => {
    async function check(r) {
      const gi = new GraphIndices();
      gi.addRelation(r);
      const before = gi.relationsByRel.get(r.rel)?.size ?? 0;
      gi.addRelation(r);  // same object again
      const after = gi.relationsByRel.get(r.rel)?.size ?? 0;
      if (before !== after) {
        throw new Error(`addRelation not idempotent: before=${before} after=${after}`);
      }
      // direct lookup should return the same object
      const a = gi.getDirectRelation(r.src, r.rel, r.dst);
      if (a !== r) {
        throw new Error(`getDirectRelation returned different object identity`);
      }
      return true;
    }

    const relGen = rigor.gen.tuple(
      rigor.gen.int(0, 5),
      rigor.gen.enum(RELATIONS),
      rigor.gen.int(0, 5),
      rigor.gen.float({ min: 0, max: 1 })
    ).map(([src, rel, dst, p]) => relObj(src, rel, dst, p));

    const report = await rigor.campaign(
      [rigor.fn('check', check, rigor.args(relGen))],
      rigor.crucible([
        rigor.invariant('addRelation-idempotent', ({ actual }) => actual !== undefined)
      ])
    ).run({ effort: 800, seed: 'graph-indices-dst' , artifacts: { dir: '', persist: 'never' }});

    if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
    const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'addRelation-idempotent');
    assert.ok(inv);
    assert.equal(inv.passed, true, `addRelation idempotence violated in ${inv.failureCount} cases`);
  });

  it('clear empties every index', async () => {
    async function check(rels) {
      const gi = new GraphIndices();
      for (const r of rels) gi.addRelation(r);
      gi.clear();
      if (gi.relationsBySrcRelDst.size !== 0) throw new Error('relationsBySrcRelDst not empty');
      if (gi.relationsBySrcRel.size !== 0) throw new Error('relationsBySrcRel not empty');
      if (gi.relationsByDstRel.size !== 0) throw new Error('relationsByDstRel not empty');
      if (gi.relationsByRel.size !== 0) throw new Error('relationsByRel not empty');
      if (gi.outgoingEdges.size !== 0) throw new Error('outgoingEdges not empty');
      if (gi.incomingEdges.size !== 0) throw new Error('incomingEdges not empty');
      // keyManager should be cleared too — re-adding same rel returns different id only if cleared
      // Actually re-adding after clear should still work
      gi.addRelation(rels[0]);
      if (!gi.getDirectRelation(rels[0].src, rels[0].rel, rels[0].dst)) {
        throw new Error('cannot re-add after clear');
      }
      return true;
    }

    const relGen = rigor.gen.array(
      rigor.gen.tuple(
        rigor.gen.int(0, 5),
        rigor.gen.enum(RELATIONS),
        rigor.gen.int(0, 5),
        rigor.gen.float({ min: 0, max: 1 })
      ).map(([src, rel, dst, p]) => relObj(src, rel, dst, p)),
      1, 8
    );

    const report = await rigor.campaign(
      [rigor.fn('check', check, rigor.args(relGen))],
      rigor.crucible([
        rigor.invariant('clear-empties-indexes', ({ actual }) => actual !== undefined)
      ])
    ).run({ effort: 800, seed: 'graph-indices-name' , artifacts: { dir: '', persist: 'never' }});

    if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
    const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'clear-empties-indexes');
    assert.ok(inv);
    assert.equal(inv.passed, true, `clear contract violated in ${inv.failureCount} cases`);
  });

  it('add then remove returns undefined from getDirectRelation', async () => {
    async function check(r) {
      const gi = new GraphIndices();
      gi.addRelation(r);
      const found = gi.getDirectRelation(r.src, r.rel, r.dst);
      if (!found) throw new Error(`expected to find ${JSON.stringify(r)}`);
      gi.removeRelation(r, r.src, r.dst, r.rel);
      const afterRemove = gi.getDirectRelation(r.src, r.rel, r.dst);
      if (afterRemove !== undefined) {
        throw new Error(`expected undefined after remove, got ${JSON.stringify(afterRemove)}`);
      }
      // Indexes should be empty for this (src,rel,dst)
      const fromSrc = gi.getRelationsFromSrc(r.src, r.rel);
      if (fromSrc.some(x => x.src === r.src && x.dst === r.dst && x.rel === r.rel)) {
        throw new Error(`getRelationsFromSrc still contains removed relation`);
      }
      const fromDst = gi.getRelationsToDst(r.dst, r.rel);
      if (fromDst.some(x => x.src === r.src && x.dst === r.dst && x.rel === r.rel)) {
        throw new Error(`getRelationsToDst still contains removed relation`);
      }
      const byName = gi.getRelationsByName(r.rel);
      if (byName.some(x => x.src === r.src && x.dst === r.dst && x.rel === r.rel)) {
        throw new Error(`getRelationsByName still contains removed relation`);
      }
      return true;
    }

    const relGen = rigor.gen.tuple(
      rigor.gen.int(0, 5),
      rigor.gen.enum(RELATIONS),
      rigor.gen.int(0, 5),
      rigor.gen.float({ min: 0, max: 1 })
    ).map(([src, rel, dst, p]) => relObj(src, rel, dst, p));

    const report = await rigor.campaign(
      [rigor.fn('check', check, rigor.args(relGen))],
      rigor.crucible([
        rigor.invariant('add-remove-cycle', ({ actual }) => actual !== undefined)
      ])
    ).run({ effort: 800, seed: 'graph-indices-cycle' , artifacts: { dir: '', persist: 'never' }});

    if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
    const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'add-remove-cycle');
    assert.ok(inv);
    assert.equal(inv.passed, true, `add+remove cycle violated in ${inv.failureCount} cases`);
  });
});
