Skip to content

Commit 7f96136

Browse files
RealDiligentclaude
andcommitted
feat(miner): capture bounded candidate context on exclusion signals
Eligibility-exclusion fired events recorded only ruleId/targetKey/outcome, so a later backtest had no way to reconstruct why a candidate was excluded. Adds bounded labels/assignees/owner metadata (50/25 entries, 200 chars each) with a truncated flag when a cap fires. Absent fields are omitted entirely so "not captured" stays distinguishable from "genuinely empty", and a candidate with no context reproduces the pre-change event shape exactly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ec6f6e6 commit 7f96136

2 files changed

Lines changed: 161 additions & 1 deletion

File tree

packages/loopover-miner/lib/discover-cli.ts

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,8 +298,83 @@ function initDefaultSignalTrackingStore(): SignalStore {
298298
// runDiscover's real-run branch (own try/catch, degrade silently). Deliberately NOT called from the dry-run
299299
// branch: a dry run previews what a real run would do (including its own noopQueueStore for the portfolio
300300
// queue) and must not itself contribute real data to a precision report.
301+
// #8544: bounded candidate-context capture, mirroring the ORB precedent (#8129/#8130). Caps exist so a
302+
// pathological repo (hundreds of labels, or a label crafted to be enormous) can't grow the local event
303+
// ledger without limit; `truncated` records THAT clamping happened so a later backtest knows the stored
304+
// context is partial rather than treating it as the full picture.
305+
const MAX_CAPTURED_LABELS = 50;
306+
const MAX_CAPTURED_ASSIGNEES = 25;
307+
const MAX_CAPTURED_STRING_CHARS = 200;
308+
309+
type CapturedCandidateContext = {
310+
labels?: string[];
311+
assignees?: string[];
312+
owner?: string;
313+
truncated?: true;
314+
};
315+
316+
/** #8544: clamp one string list to `max` entries and each entry to {@link MAX_CAPTURED_STRING_CHARS},
317+
* reporting whether either clamp actually fired. */
318+
function boundStringList(values: readonly string[], max: number): { values: string[]; truncated: boolean } {
319+
const listTruncated = values.length > max;
320+
const kept = listTruncated ? values.slice(0, max) : values;
321+
let stringTruncated = false;
322+
const bounded = kept.map((value) => {
323+
if (value.length <= MAX_CAPTURED_STRING_CHARS) return value;
324+
stringTruncated = true;
325+
return value.slice(0, MAX_CAPTURED_STRING_CHARS);
326+
});
327+
return { values: bounded, truncated: listTruncated || stringTruncated };
328+
}
329+
330+
/** #8544: build the bounded `metadata` for one excluded candidate. Absent source fields are OMITTED entirely
331+
* — no nulls and no empty-array placeholders, so "we captured nothing here" stays distinguishable from
332+
* "this candidate genuinely had no labels". Returns undefined when the candidate carries no context at all,
333+
* so pre-#8544 event shapes are reproduced byte-identically rather than gaining an empty object. */
334+
function buildCandidateContextMetadata(candidate: {
335+
labels?: string[] | undefined;
336+
assignees?: string[] | undefined;
337+
owner?: string | undefined;
338+
}): CapturedCandidateContext | undefined {
339+
const metadata: CapturedCandidateContext = {};
340+
let truncated = false;
341+
342+
if (candidate.labels !== undefined) {
343+
const bounded = boundStringList(candidate.labels, MAX_CAPTURED_LABELS);
344+
metadata.labels = bounded.values;
345+
truncated ||= bounded.truncated;
346+
}
347+
if (candidate.assignees !== undefined) {
348+
const bounded = boundStringList(candidate.assignees, MAX_CAPTURED_ASSIGNEES);
349+
metadata.assignees = bounded.values;
350+
truncated ||= bounded.truncated;
351+
}
352+
if (candidate.owner !== undefined) {
353+
// Clamped directly rather than through boundStringList: a single value has no list-length arm, and
354+
// routing it through the array helper left an unreachable `?? ""` fallback.
355+
const clamped = candidate.owner.slice(0, MAX_CAPTURED_STRING_CHARS);
356+
if (clamped.length < candidate.owner.length) truncated = true;
357+
metadata.owner = clamped;
358+
}
359+
360+
if (Object.keys(metadata).length === 0) return undefined;
361+
if (truncated) metadata.truncated = true;
362+
return metadata;
363+
}
364+
301365
async function recordEligibilityExclusionSignals(
302-
excluded: ReadonlyArray<{ candidate: { repoFullName: string; issueNumber: number }; reason: string }>,
366+
excluded: ReadonlyArray<{
367+
candidate: {
368+
repoFullName: string;
369+
issueNumber: number;
370+
// #8544: already present at runtime via EligibilityExclusion<T>'s generic passthrough (see
371+
// FilterCandidate) — read straight through, no new computation at the call site.
372+
labels?: string[] | undefined;
373+
assignees?: string[] | undefined;
374+
owner?: string | undefined;
375+
};
376+
reason: string;
377+
}>,
303378
options: Pick<RunDiscoverOptions, "initSignalTrackingStore" | "nowMs">,
304379
): Promise<void> {
305380
if (excluded.length === 0) return;
@@ -312,12 +387,15 @@ async function recordEligibilityExclusionSignals(
312387
if (!store) return;
313388
const occurredAt = new Date(options.nowMs ?? Date.now()).toISOString();
314389
for (const entry of excluded) {
390+
const metadata = buildCandidateContextMetadata(entry.candidate);
315391
await store
316392
.recordRuleFired({
317393
ruleId: entry.reason,
318394
targetKey: `${entry.candidate.repoFullName}#issue-${entry.candidate.issueNumber}`,
319395
outcome: "exclude",
320396
occurredAt,
397+
// Spread-or-omit: a candidate with no context keeps the exact pre-#8544 event shape.
398+
...(metadata ? { metadata } : {}),
321399
})
322400
.catch(() => undefined);
323401
}

test/unit/miner-discover-cli.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1643,12 +1643,18 @@ describe("runDiscover onResult hook (#6522)", () => {
16431643
});
16441644

16451645
describe("eligibility-exclusion signal tracking (#7982)", () => {
1646+
// #8544: `captured` keeps the full event (metadata included); `fired` stays the pre-#8544 projection so
1647+
// every existing assertion in this block is untouched.
1648+
const captured: Array<Record<string, unknown>> = [];
16461649
function fakeSignalStore() {
16471650
const fired: Array<{ ruleId: string; targetKey: string; outcome: string }> = [];
1651+
captured.length = 0;
16481652
return {
16491653
fired,
1654+
captured,
16501655
store: {
16511656
recordRuleFired: vi.fn(async (event: { ruleId: string; targetKey: string; outcome: string }) => {
1657+
captured.push({ ...event });
16521658
fired.push({ ruleId: event.ruleId, targetKey: event.targetKey, outcome: event.outcome });
16531659
}),
16541660
recordHumanOverride: vi.fn(async () => undefined),
@@ -1674,6 +1680,82 @@ describe("runDiscover onResult hook (#6522)", () => {
16741680
]);
16751681
});
16761682

1683+
// #8544: bounded candidate-context metadata on the same fired events.
1684+
describe("bounded candidate context (#8544)", () => {
1685+
const runExcluded = async (issueOverrides: Record<string, unknown>) => {
1686+
const issues = [fanOutIssue({ issueNumber: 2, ...issueOverrides })];
1687+
const { opts } = discoverWith(issues, new Map([["acme/widgets", trustworthyProfile]]));
1688+
const { captured, store } = fakeSignalStore();
1689+
vi.spyOn(console, "log").mockImplementation(() => undefined);
1690+
await runDiscover(["acme/widgets", "--json"], { ...opts, initSignalTrackingStore: () => store });
1691+
return captured[0] as { metadata?: Record<string, unknown> } | undefined;
1692+
};
1693+
1694+
it("captures labels, assignees and owner when all three are present", async () => {
1695+
const event = await runExcluded({ labels: ["blocked"], assignees: ["octocat"], owner: "acme" });
1696+
expect(event?.metadata).toEqual({ labels: ["blocked"], assignees: ["octocat"], owner: "acme" });
1697+
expect(event?.metadata).not.toHaveProperty("truncated");
1698+
});
1699+
1700+
it("omits each field that is absent from the candidate rather than storing null or []", async () => {
1701+
const event = await runExcluded({ labels: ["blocked"], assignees: undefined, owner: undefined });
1702+
expect(event?.metadata).toEqual({ labels: ["blocked"] });
1703+
expect(event?.metadata).not.toHaveProperty("assignees");
1704+
expect(event?.metadata).not.toHaveProperty("owner");
1705+
});
1706+
1707+
it("keeps an empty array distinguishable from an absent field", async () => {
1708+
const event = await runExcluded({ labels: ["blocked"], assignees: [] });
1709+
expect((event?.metadata as { assignees?: string[] })?.assignees).toEqual([]);
1710+
});
1711+
1712+
it("labels exactly at the 50 cap are kept whole with no truncated flag", async () => {
1713+
const labels = ["blocked", ...Array.from({ length: 49 }, (_, i) => `l${i}`)];
1714+
const event = await runExcluded({ labels });
1715+
expect((event?.metadata as { labels: string[] }).labels).toHaveLength(50);
1716+
expect(event?.metadata).not.toHaveProperty("truncated");
1717+
});
1718+
1719+
it("labels one over the cap are clamped to 50 and flagged truncated", async () => {
1720+
const labels = ["blocked", ...Array.from({ length: 50 }, (_, i) => `l${i}`)];
1721+
const event = await runExcluded({ labels });
1722+
expect((event?.metadata as { labels: string[] }).labels).toHaveLength(50);
1723+
expect(event?.metadata).toHaveProperty("truncated", true);
1724+
});
1725+
1726+
it("assignees exactly at the 25 cap are kept whole; one over is clamped and flagged", async () => {
1727+
const atCap = await runExcluded({ labels: ["blocked"], assignees: Array.from({ length: 25 }, (_, i) => `u${i}`) });
1728+
expect((atCap?.metadata as { assignees: string[] }).assignees).toHaveLength(25);
1729+
expect(atCap?.metadata).not.toHaveProperty("truncated");
1730+
1731+
const overCap = await runExcluded({ labels: ["blocked"], assignees: Array.from({ length: 26 }, (_, i) => `u${i}`) });
1732+
expect((overCap?.metadata as { assignees: string[] }).assignees).toHaveLength(25);
1733+
expect(overCap?.metadata).toHaveProperty("truncated", true);
1734+
});
1735+
1736+
it("a string exactly at the 200-char cap is kept whole; one char over is clamped and flagged", async () => {
1737+
const atCap = await runExcluded({ labels: ["blocked", "x".repeat(200)] });
1738+
expect((atCap?.metadata as { labels: string[] }).labels[1]).toHaveLength(200);
1739+
expect(atCap?.metadata).not.toHaveProperty("truncated");
1740+
1741+
const overCap = await runExcluded({ labels: ["blocked", "x".repeat(201)] });
1742+
expect((overCap?.metadata as { labels: string[] }).labels[1]).toHaveLength(200);
1743+
expect(overCap?.metadata).toHaveProperty("truncated", true);
1744+
});
1745+
1746+
it("omits metadata entirely when the candidate carries no context at all (pre-#8544 event shape)", async () => {
1747+
const event = await runExcluded({ labels: undefined, assignees: undefined, owner: undefined });
1748+
expect(event).toBeDefined();
1749+
expect(event).not.toHaveProperty("metadata");
1750+
});
1751+
1752+
it("clamps an over-long owner string and flags it", async () => {
1753+
const event = await runExcluded({ labels: ["blocked"], owner: "o".repeat(201) });
1754+
expect((event?.metadata as { owner: string }).owner).toHaveLength(200);
1755+
expect(event?.metadata).toHaveProperty("truncated", true);
1756+
});
1757+
});
1758+
16771759
it("records nothing when nothing was excluded", async () => {
16781760
const issues = [fanOutIssue({ issueNumber: 1, labels: ["help wanted"] })];
16791761
const { opts } = discoverWith(issues, new Map([["acme/widgets", trustworthyProfile]]));

0 commit comments

Comments
 (0)