fix(review): group a triplet's findings title first, so arrival order never changes the rows

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-23 18:30:52 +02:00
commit a5fcb5e8b4
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
6 changed files with 339 additions and 37 deletions

View file

@ -123,7 +123,8 @@ const VERDICT_SCHEMA = {
// first (for a catalogue key it always ties inside one triplet, since the
// rule_key is shared), then a reviewer finding (no `engine`) over an engine
// finding (decision 13, operator 12.10), then the higher asserted severity;
// the workflow has no conformance rule, so otherwise the first-seen one stays.
// the workflow has no conformance rule, so otherwise the first in the group's
// order stays (the order the two passes below place the members in).
// The survivor carries the highest severity any finding in its group asserted,
// so an engine's mark never lowers what a reviewer agent asserted.
// As in coordinator-contract Pass 1, one source never duplicates itself: its
@ -131,25 +132,64 @@ const VERDICT_SCHEMA = {
// (voyage-lekerepo trial 5, finding 7). `sourceOf` names the reviewer or engine.
const sameTitle = (a, b) => a.title === undefined || b.title === undefined ||
String(a.title).trim().toLowerCase() === String(b.title).trim().toLowerCase();
// As there too, a triplet's findings are grouped in two passes, so the order
// they arrive in never changes which row a finding joins: (1) every titled
// finding joins the class of its normalized title, over all of the triplet's
// findings; (2) the classes, in alphabetical order of the normalized title,
// each join the first earlier group whose every member fits every finding of
// the class (another source, or the same title), else they form a group of
// their own; untitled findings come last, in canonical order (source, detail,
// the whole finding), each joining the first group it fits. Members keep the
// order the passes place them in (the classes alphabetically, each class's
// findings in canonical order, the untitled findings last), so a full tie on
// the survivor never follows arrival order either; a triplet's groups keep the
// order of their earliest input index. With `perSource: false` everything on a
// triplet is one group, as before.
const sourceOf = new Map();
const cmpStr = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
function groupTriplet(entries) {
const srcOf = (f) => sourceOf.get(f) ?? null;
const fits = (m, f) => srcOf(m) !== srcOf(f) || sameTitle(m, f);
const canon = (x, y) => cmpStr(String(srcOf(x.f) ?? ''), String(srcOf(y.f) ?? ''))
|| cmpStr(String(x.f.detail ?? ''), String(y.f.detail ?? ''))
|| cmpStr(JSON.stringify(x.f), JSON.stringify(y.f));
const classes = new Map();
const untitled = [];
for (const e of entries) {
if (e.f.title === undefined) {
untitled.push(e);
continue;
}
const t = String(e.f.title).trim().toLowerCase();
if (!classes.has(t)) classes.set(t, []);
classes.get(t).push(e);
}
const groups = [];
const place = (members) => {
let g = groups.find((grp) => grp.every((m) => members.every((e) => fits(m.f, e.f))));
if (!g) {
g = [];
groups.push(g);
}
g.push(...members);
};
for (const t of [...classes.keys()].sort(cmpStr)) place(classes.get(t).sort(canon));
for (const e of untitled.sort(canon)) place([e]);
return groups
.map((g) => ({ first: Math.min(...g.map((e) => e.i)), members: g.map((e) => e.f) }))
.sort((a, b) => a.first - b.first)
.map((g) => g.members);
}
function dedupByTriplet(findings, { perSource = true } = {}) {
const byTriplet = new Map();
for (const f of findings) {
findings.forEach((f, i) => {
const key = tripletOf(f);
if (!byTriplet.has(key)) byTriplet.set(key, []);
const subs = byTriplet.get(key);
const src = sourceOf.get(f) ?? null;
const fits = (g) => g.every((m) => (sourceOf.get(m) ?? null) !== src || sameTitle(m, f));
let sub = !perSource ? subs[0]
: subs.find((g) => fits(g) && g.some((m) => m.title !== undefined && f.title !== undefined && sameTitle(m, f)))
?? subs.find(fits);
if (!sub) {
sub = [];
subs.push(sub);
}
sub.push(f);
}
const groups = [...byTriplet.values()].flat();
byTriplet.get(key).push({ f, i });
});
const groups = [...byTriplet.values()].flatMap((entries) => (perSource
? groupTriplet(entries)
: [entries.map((e) => e.f)]));
const tier = (f) => sevRank(tierOf(f.rule_key) ?? f.severity);
const out = [];
for (const group of groups) {