fix(rectification): stop speaking unstampable distinguish stems (BUG-674/675)
Independent Staging Quality Gate / validate (push) Failing after 20s
Independent Staging Quality Gate / publish (push) Skipped

Distinguish followups that cannot stamp a probe now go to persistExhaustionCollect instead of repeating the card stem in the assistant body. Choice focuses without asked_turn_id hang on the last assistant turn, and persisted_question with a live choice_card renders the existing card. Representative-time inconsistency is warn-only (BUG-676 investigating).
This commit is contained in:
jesse-ux
2026-09-14 03:42:48 +08:00
parent 9375012f17
commit 69ffa07d6b
16 changed files with 887 additions and 46 deletions
@@ -2,6 +2,7 @@ import { applyProbeOutcome, outcomeByMinuteForAnswer } from "./apply-probe-outco
import type { TransitionSignLookup } from "./sign-from-transitions.ts";
import { clusterRangeFor, clusterEquivalentCandidates } from "./cluster-candidates.ts";
import { evaluateConvergence, holdoutStillRanksFirst, rankActive } from "./convergence-evaluator.ts";
import { warnRepresentativeTimeInconsistency } from "./representative-time-guard.ts";
import { rangeFromTimes, unionStillValidRange } from "./credible-range.ts";
import { entropyFromScores, normalizeScores } from "./entropy.ts";
import { selectHighestGainProbe } from "./select-probe.ts";
@@ -197,6 +198,14 @@ export function buildInferenceState(input: {
...(transitions ? { transitions } : {}),
};
const decision = evaluateConvergence({ ...draft, holdout_passed: holdoutPassed });
const lastRound = rounds.at(-1) ?? null;
warnRepresentativeTimeInconsistency({
representativeTime: decision.representative_time,
eliminatedIds: lastRound?.eliminated_ids,
credibleRange: decision.credible_range ?? draft.credible_range,
winnerId: lastRound?.winner_id,
scoresAfter: lastRound?.scores_after,
});
return {
...draft,
phase: phaseFor(decision.result_status, draft.phase),
@@ -0,0 +1,77 @@
export type RepresentativeTimeGuardInput = Readonly<{
representativeTime: string | null | undefined;
eliminatedIds?: readonly string[] | null;
credibleRange?: readonly string[] | null;
winnerId?: string | null;
scoresAfter?: Readonly<Record<string, number>> | null;
}>;
export type RepresentativeTimeGuardResult = Readonly<{
ok: boolean;
inEliminated: boolean;
outsideRange: boolean;
}>;
function clockMinutes(value: string): number | null {
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(value.trim());
if (!match) return null;
return Number(match[1]) * 60 + Number(match[2]);
}
function outsideCredibleRange(time: string, range: readonly string[]): boolean {
if (range.length === 0) return false;
if (range.includes(time)) return false;
if (range.length < 2) return true;
const start = clockMinutes(range[0] ?? "");
const end = clockMinutes(range[range.length - 1] ?? "");
const point = clockMinutes(time);
if (start === null || end === null || point === null) return true;
if (start <= end) return point < start || point > end;
return point < start && point > end;
}
function scoreExtremes(scores: Readonly<Record<string, number>> | null | undefined): {
max?: readonly [string, number];
min?: readonly [string, number];
} {
let max: readonly [string, number] | undefined;
let min: readonly [string, number] | undefined;
for (const [time, score] of Object.entries(scores ?? {})) {
if (!Number.isFinite(score)) continue;
if (!max || score > max[1]) max = [time, score];
if (!min || score < min[1]) min = [time, score];
}
return { max, min };
}
export function inspectRepresentativeTime(input: RepresentativeTimeGuardInput): RepresentativeTimeGuardResult {
const time = typeof input.representativeTime === "string" ? input.representativeTime.trim() : "";
if (!time) {
return { ok: true, inEliminated: false, outsideRange: false };
}
const inEliminated = (input.eliminatedIds ?? []).includes(time);
const outsideRange = outsideCredibleRange(time, input.credibleRange ?? []);
return {
ok: !inEliminated && !outsideRange,
inEliminated,
outsideRange,
};
}
export function warnRepresentativeTimeInconsistency(input: RepresentativeTimeGuardInput): RepresentativeTimeGuardResult {
const result = inspectRepresentativeTime(input);
if (result.ok) return result;
const extremes = scoreExtremes(input.scoresAfter);
console.warn(JSON.stringify({
event: "rectification_representative_time_inconsistent",
winner_id: input.winnerId ?? null,
representative_time: input.representativeTime ?? null,
credible_range: input.credibleRange ?? null,
eliminated_ids: input.eliminatedIds ?? [],
scores_after_max: extremes.max ?? null,
scores_after_min: extremes.min ?? null,
in_eliminated: result.inEliminated,
outside_range: result.outsideRange,
}));
return result;
}