From 883c3337b499145aba3171c5717b76ce65242479 Mon Sep 17 00:00:00 2001 From: dhgoal <153369624+dhgoal@users.noreply.github.com> Date: Thu, 9 Jul 2026 08:56:07 +0900 Subject: [PATCH] feat(miner-portfolio): add pure non-convergence detector to gittensory-engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New packages/gittensory-engine/src/portfolio/non-convergence.ts: a pure classifier over one queue item's attempt/outcome counts, returning { status, reasons } where status is converging | stalled | non_convergent. Zero attempts and a reached-done item read converging; a single failure/re-enqueue reads stalled; only a sustained streak past threshold reads non_convergent. No IO, no Date.now, no randomness. DETECTOR only — no enforcement; the fail-closed Governor chokepoint that composes it is separate maintainer-owned work (#2340). Mirrors the pure-classifier discipline of contributor-fit.ts; re-exported from the package entrypoint. Closes #4286 --- packages/gittensory-engine/src/index.ts | 1 + .../src/portfolio/non-convergence.ts | 97 +++++++++++++++++++ .../test/portfolio-non-convergence.test.ts | 70 +++++++++++++ 3 files changed, 168 insertions(+) create mode 100644 packages/gittensory-engine/src/portfolio/non-convergence.ts create mode 100644 packages/gittensory-engine/test/portfolio-non-convergence.test.ts diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index 617d471a1c..d4d57a1d5b 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -164,6 +164,7 @@ export { type PromptPacketTextField, } from "./prompt-packet.js"; export * from "./portfolio/queue.js"; +export * from "./portfolio/non-convergence.js"; export { applyAiPolicyFatigueToRankInput, createAiPolicyFatigueCacheEntry, diff --git a/packages/gittensory-engine/src/portfolio/non-convergence.ts b/packages/gittensory-engine/src/portfolio/non-convergence.ts new file mode 100644 index 0000000000..3c24afc72f --- /dev/null +++ b/packages/gittensory-engine/src/portfolio/non-convergence.ts @@ -0,0 +1,97 @@ +// Non-convergence DETECTOR (#4286): a pure classifier over one portfolio-queue item's attempt/outcome +// history. It answers whether that item is making progress, is merely stalled, or is stuck in a +// non-convergent loop (cycling queued → in_progress → queued without ever reaching `done`, per the +// re-enqueue-in-place behaviour at packages/gittensory-miner/lib/portfolio-queue.js:108-115). +// +// DETECTOR ONLY — no enforcement, no write-blocking, no IO, no Date.now(), no randomness. It takes typed +// counts and returns a typed verdict; it gates nothing on its own. The fail-closed Governor chokepoint that +// COMPOSES this signal with rate-limit + budget caps into one allow/deny decision is separate, +// maintainer-owned work tracked in #2340 (milestone 13) — explicitly not this module. +// +// Mirrors the pure-classifier-over-typed-input discipline of ../contributor-fit.ts (typed input in, +// { status, reasons } out, and "absence of history is not evidence of a problem"). + +export type PortfolioConvergenceStatus = "converging" | "stalled" | "non_convergent"; + +/** One queue item's attempt/outcome history. Plain counts — the caller already tracks or supplies these; + * this module invents no persistence (the queue table carries no attempt-history columns today). */ +export type PortfolioConvergenceInput = { + /** Total attempts made on this item so far. */ + attempts: number; + /** Consecutive failed attempts since the last improvement (reset to 0 on any progress). */ + consecutiveFailures: number; + /** Times the item was re-enqueued (queued → in_progress → queued) without ever reaching `done`. */ + reenqueues: number; + /** Whether the item has ever reached a terminal `done` outcome. */ + reachedDone: boolean; +}; + +/** Streak lengths at (or above) which a still-unfinished item reads non-convergent. */ +export type PortfolioConvergenceThresholds = { + /** consecutiveFailures ≥ this ⇒ non_convergent. */ + maxConsecutiveFailures: number; + /** reenqueues (without reaching done) ≥ this ⇒ non_convergent. */ + maxReenqueues: number; +}; + +/** Conservative defaults — a single failure or re-enqueue never trips these; only a sustained streak does. */ +export const DEFAULT_PORTFOLIO_CONVERGENCE_THRESHOLDS: PortfolioConvergenceThresholds = { + maxConsecutiveFailures: 3, + maxReenqueues: 3, +}; + +export type PortfolioConvergenceVerdict = { + status: PortfolioConvergenceStatus; + reasons: string[]; +}; + +/** + * Classify one queue item's convergence from its attempt/outcome counts. Pure and deterministic. + * + * - Zero attempts (not yet tried) reads `converging` — a first attempt is not evidence of a stuck loop + * (the same non-judgment-on-absence rule ../contributor-fit.ts applies to a first attempt). + * - An item that has reached `done` is `converging` by definition. + * - A sustained streak — `consecutiveFailures` or `reenqueues` at/above its threshold — reads + * `non_convergent`. A single failure or re-enqueue below threshold reads `stalled`, not non-convergent. + * - Attempts in progress with no failure streak read `converging`. + */ +export function classifyPortfolioConvergence( + input: PortfolioConvergenceInput, + thresholds: PortfolioConvergenceThresholds = DEFAULT_PORTFOLIO_CONVERGENCE_THRESHOLDS, +): PortfolioConvergenceVerdict { + if (input.attempts <= 0) { + return { + status: "converging", + reasons: ["No attempts yet; a first attempt is not evidence of a stuck loop."], + }; + } + if (input.reachedDone) { + return { status: "converging", reasons: ["Item reached done."] }; + } + + const reasons: string[] = []; + if (input.consecutiveFailures >= thresholds.maxConsecutiveFailures) { + reasons.push( + `${input.consecutiveFailures} consecutive failures (≥ ${thresholds.maxConsecutiveFailures}).`, + ); + } + if (input.reenqueues >= thresholds.maxReenqueues) { + reasons.push( + `re-enqueued ${input.reenqueues} times without reaching done (≥ ${thresholds.maxReenqueues}).`, + ); + } + if (reasons.length > 0) { + return { status: "non_convergent", reasons }; + } + + if (input.consecutiveFailures > 0 || input.reenqueues > 0) { + return { + status: "stalled", + reasons: [ + `${input.consecutiveFailures} consecutive failure(s), ${input.reenqueues} re-enqueue(s) — below the non-convergence threshold.`, + ], + }; + } + + return { status: "converging", reasons: ["Attempts in progress with no failure streak."] }; +} diff --git a/packages/gittensory-engine/test/portfolio-non-convergence.test.ts b/packages/gittensory-engine/test/portfolio-non-convergence.test.ts new file mode 100644 index 0000000000..3b7908f696 --- /dev/null +++ b/packages/gittensory-engine/test/portfolio-non-convergence.test.ts @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + classifyPortfolioConvergence, + DEFAULT_PORTFOLIO_CONVERGENCE_THRESHOLDS, + type PortfolioConvergenceInput, +} from "../dist/index.js"; + +const base: PortfolioConvergenceInput = { + attempts: 0, + consecutiveFailures: 0, + reenqueues: 0, + reachedDone: false, +}; + +test("zero attempts reads converging — a first attempt is not evidence of a stuck loop", () => { + const v = classifyPortfolioConvergence({ ...base, attempts: 0 }); + assert.equal(v.status, "converging"); + assert.match(v.reasons.join(" "), /first attempt/i); +}); + +test("a single failure is stalled, never non_convergent", () => { + const v = classifyPortfolioConvergence({ ...base, attempts: 1, consecutiveFailures: 1 }); + assert.equal(v.status, "stalled"); + assert.notEqual(v.status, "non_convergent"); +}); + +test("a single re-enqueue below threshold is stalled (the reenqueues arm of the stalled OR)", () => { + const v = classifyPortfolioConvergence({ ...base, attempts: 2, reenqueues: 1 }); + assert.equal(v.status, "stalled"); +}); + +test("attempts in progress with no failure streak reads converging", () => { + const v = classifyPortfolioConvergence({ ...base, attempts: 5, consecutiveFailures: 0, reenqueues: 0 }); + assert.equal(v.status, "converging"); + assert.match(v.reasons.join(" "), /no failure streak/i); +}); + +test("an item that reached done reads converging regardless of prior failures", () => { + const v = classifyPortfolioConvergence({ attempts: 4, consecutiveFailures: 9, reenqueues: 9, reachedDone: true }); + assert.equal(v.status, "converging"); + assert.match(v.reasons.join(" "), /done/i); +}); + +test("a consecutive-failure streak at threshold reads non_convergent", () => { + const v = classifyPortfolioConvergence({ ...base, attempts: 3, consecutiveFailures: 3 }); + assert.equal(v.status, "non_convergent"); + assert.match(v.reasons.join(" "), /consecutive failures/i); +}); + +test("repeated re-enqueue without reaching done reads non_convergent", () => { + const v = classifyPortfolioConvergence({ ...base, attempts: 3, reenqueues: 3 }); + assert.equal(v.status, "non_convergent"); + assert.match(v.reasons.join(" "), /re-enqueued/i); +}); + +test("both streaks past threshold surface both reasons", () => { + const v = classifyPortfolioConvergence({ ...base, attempts: 6, consecutiveFailures: 4, reenqueues: 5 }); + assert.equal(v.status, "non_convergent"); + assert.equal(v.reasons.length, 2); +}); + +test("thresholds are configurable — a stricter cap trips sooner, and the default is exported", () => { + const strict = classifyPortfolioConvergence( + { ...base, attempts: 1, consecutiveFailures: 1 }, + { maxConsecutiveFailures: 1, maxReenqueues: 1 }, + ); + assert.equal(strict.status, "non_convergent"); + assert.equal(DEFAULT_PORTFOLIO_CONVERGENCE_THRESHOLDS.maxConsecutiveFailures, 3); +});