Merge remote-tracking branch 'origin/staging' into codex/consult-no-presupposition-backtest-20261001
Independent Staging Quality Gate / validate (push) Successful in 17m11s
Independent Staging Quality Gate / publish (push) Successful in 3m23s

# Conflicts:
#	CHANGELOG.md
#	docs/BUG_HISTORY.md
This commit is contained in:
Jesse_Chen
2026-10-02 07:17:06 +08:00
10 changed files with 216 additions and 3 deletions
@@ -7,6 +7,7 @@
*/
import { createHash } from "node:crypto";
import { postAdoptCheckVerdict } from "./post-adopt-verdict.ts";
import { posteriorMap, scoreDeltas } from "../core/decision-fingerprint";
import { productDeliveryForDomain } from "./product-delivery.ts";
import { nextProbe } from "../core/build-state";
@@ -2415,6 +2416,26 @@ ${nextInterview.hostNarration}`;
hostNarration = adoptionNarration ?? completedRangeNarration ?? hostNarration;
}
// BUG-1170: an answered post-adopt check says what it meant for the adopted time.
const answeredIntent = input.dossier.conversationSummary.activeFocus?.intent;
if (accepted && !skipThisProbe && input.userStopped !== true
&& (answeredIntent === "reverse_verify" || answeredIntent === "out_of_sample_check")) {
const before = inferenceForPersistedAnswer(input.dossier.latestResult?.decisionReceipt ?? null);
const after = input.decisionState ?? before;
const segmentProduct = input.dossier.case.rectificationDomain !== null && input.dossier.case.rectificationDomain !== undefined;
const verdict = postAdoptCheckVerdict({
acceptedTime: input.dossier.case.acceptedTime,
probe: input.probeId
? (after?.probes.find((probe) => probe.id === input.probeId) ?? before?.probes.find((probe) => probe.id === input.probeId) ?? null)
: null,
answerClass: input.answerClass,
candidates: after?.candidates ?? [],
segmentBefore: segmentProduct ? before?.segment_summary ?? null : null,
segmentAfter: segmentProduct ? after?.segment_summary ?? null : null,
});
if (verdict) hostNarration = hostNarration ? `${verdict}\n\n${hostNarration}` : verdict;
}
const choiceStem = nextChoiceReady ? renderableChoiceStem(nextFocus) : null;
if (choiceStem) {
const body = skipThisProbe
@@ -0,0 +1,81 @@
/**
* What a post-adopt check said about the adopted time (BUG-1170, product 10-01 option A).
*
* The closing line used to be only 「前事核对到这里」: the reader answered and learned nothing.
* The verdict reads the answered probe's own expected outcomes for the candidate that holds the
* adopted minute, and — in the segment product — whether any target chart's ascendant or tier
* moved with this answer. Server facts only; no model text.
*/
import type { AnswerClass, ConflictProbe, InferenceCandidate } from "../core/types.ts";
import { chartSummaryCopy, type SegmentSummary } from "../core/segment-summary.ts";
export type PostAdoptVerdictInput = Readonly<{
acceptedTime: string | null | undefined;
probe: ConflictProbe | null | undefined;
answerClass: AnswerClass | null | undefined;
candidates: readonly InferenceCandidate[];
segmentBefore?: SegmentSummary | null;
segmentAfter?: SegmentSummary | null;
}>;
function minutes(clock: string): number | null {
const match = /^(\d{2}):(\d{2})/.exec(clock);
return match ? Number(match[1]) * 60 + Number(match[2]) : null;
}
function inRange(clock: number, range: readonly [string, string]): boolean {
const start = minutes(range[0]);
const end = minutes(range[1]);
if (start === null || end === null) return false;
return start <= end ? clock >= start && clock <= end : clock >= start || clock <= end;
}
/** The candidate that stands for the adopted minute: its cluster holds it, else the nearest. */
export function candidateForAdoptedMinute(
acceptedTime: string,
candidates: readonly InferenceCandidate[],
): InferenceCandidate | null {
const clock = acceptedTime.slice(0, 5);
const at = minutes(clock);
if (at === null || candidates.length === 0) return null;
const holding = candidates.find((candidate) => candidate.time === clock || candidate.cluster_times?.includes(clock))
?? candidates.find((candidate) => inRange(at, candidate.cluster_range));
if (holding) return holding;
const distance = (candidate: InferenceCandidate) => {
const value = minutes(candidate.time);
if (value === null) return Number.POSITIVE_INFINITY;
const raw = Math.abs(value - at);
return Math.min(raw, 1440 - raw);
};
return [...candidates].sort((left, right) => distance(left) - distance(right))[0] ?? null;
}
function chartsMoved(before: SegmentSummary, after: SegmentSummary): boolean {
return after.charts.some((chart) => {
const prior = before.charts.find((row) => row.chart === chart.chart);
return !prior || prior.sign !== chart.sign || prior.tier !== chart.tier;
});
}
export function postAdoptCheckVerdict(input: PostAdoptVerdictInput): string | null {
const clock = input.acceptedTime?.trim().slice(0, 5) ?? "";
if (!clock || !input.answerClass) return null;
if (input.answerClass === "unsure") return "这件事记不清,不算。";
if (!input.probe) return null;
const candidate = candidateForAdoptedMinute(clock, input.candidates);
const outcome = input.probe.expected_outcomes.find((row) => row.answer_class === input.answerClass) ?? null;
const ids = candidate ? new Set([candidate.id, candidate.time]) : new Set<string>();
const supports = Boolean(outcome?.supports.some((id) => ids.has(id)));
const conflicts = Boolean(outcome?.conflicts.some((id) => ids.has(id)));
const fit = supports && !conflicts
? `这件事和 ${clock} 对得上。`
: conflicts && !supports
? `这件事和 ${clock} 对不上;如果别的经历也对不上,可以回到上面的卡片改选。`
: `这件事分不出 ${clock} 和别的时间。`;
const before = input.segmentBefore ?? null;
const after = input.segmentAfter ?? null;
if (!before || !after) return fit;
return chartsMoved(before, after)
? `${fit}盘型结论有变化:${chartSummaryCopy(after)}`
: `${fit}盘型结论不变。`;
}