避免四舍五入和缺区间把还可能的时间从目前范围里藏掉。

Co-Authored-By: Grok (agent loop)
This commit is contained in:
jesse-ux
2026-10-17 21:16:32 +08:00
co-authored by Grok (agent loop)
parent 25c51ff0d0
commit 1ba473970f
21 changed files with 1165 additions and 218 deletions
@@ -7,7 +7,7 @@ import { createPortal } from "react-dom";
import type { ChatMessage } from "@/lib/chat-message-view";
import { canShowRectificationSelectionCards } from "@/lib/rectification-candidate-result";
import { RectificationRangeDelivery } from "@/components/rectification-range-delivery";
import { livingDisplayFromCandidates } from "@/lib/rectification-agentic/core/candidate-window";
import { livingDisplayFromReceipt } from "@/lib/rectification-agentic/core/candidate-window";
import {
RECTIFICATION_EMPTY_ACTION_LABEL,
RECTIFICATION_EMPTY_COPY,
@@ -611,7 +611,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
{showReadonlyRange && message.renderKey === latestSettledAssistant?.renderKey && candidateResult?.credibleRange && (
<RectificationReadonlyRange
range={candidateResult.credibleRange}
displayLabel={livingDisplayFromCandidates(candidateResult.candidates, candidateResult.declaredBirthDate)?.label ?? null}
displayLabel={livingDisplayFromReceipt(candidateResult.decisionReceipt, candidateResult.declaredBirthDate)?.label ?? null}
sessionOutcome={candidateResult.sessionOutcome}
discriminatingEventProbes={candidateResult.decisionReceipt?.discriminating_event_probes}
contrastProbes={contrastProbesFromReceipt(candidateResult.decisionReceipt)}
@@ -1,6 +1,6 @@
"use client";
import { livingDisplayFromCandidates, windowIntervalsLabel } from "@/lib/rectification-agentic/core/candidate-window";
import { livingDisplayFromReceipt, windowIntervalsLabel } from "@/lib/rectification-agentic/core/candidate-window";
import type { RectificationCandidateResult } from "@/lib/rectification-candidate-result";
import {
RECTIFICATION_USER_COPY,
@@ -44,7 +44,7 @@ export function RectificationRangeDelivery({
const busy = Boolean(acceptingCandidateId) || readonly || adoptLocked;
const adoptAllowed = sessionOutcomeAllowsAdopt(result.sessionOutcome) && result.canAdopt === true;
const columns = delivery?.columns ?? [];
const living = livingDisplayFromCandidates(result.candidates, result.declaredBirthDate);
const living = livingDisplayFromReceipt(result.decisionReceipt, result.declaredBirthDate);
const rangeLabel = living
? living.label
: result.credibleIntervals?.length ? windowIntervalsLabel(result.credibleIntervals, result.declaredBirthDate)
@@ -3,7 +3,7 @@ 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, unionStillValidIntervals } from "./credible-range.ts";
import { livingDisplayForInference, rangeFromTimes, unionStillValidRange, unionStillValidIntervals } from "./credible-range.ts";
import { candidatePositionFields, type CandidatePosition } from "./candidate-window.ts";
import { candidateSetId } from "./candidate-set-id.ts";
import { entropyFromScores, normalizeScores } from "./entropy.ts";
@@ -265,17 +265,20 @@ export function buildInferenceState(input: {
winnerId: lastRound?.winner_id,
scoresAfter: lastRound?.scores_after,
});
const deliveredCandidates = candidates.map((item) => (
decision.result_status === "converged" && item.id === decision.winner_id
? { ...item, status: "winner" as const }
: item
));
const livingDisplay = livingDisplayForInference(deliveredCandidates);
return {
...draft,
phase: phaseFor(decision.result_status, draft.phase),
result_status: decision.result_status,
representative_time: decision.representative_time,
credible_range: deliveredRange,
candidates: candidates.map((item) => (
decision.result_status === "converged" && item.id === decision.winner_id
? { ...item, status: "winner" }
: item
)),
candidates: deliveredCandidates,
living_display_intervals: livingDisplay?.intervals ?? null,
};
}
@@ -181,14 +181,15 @@ export function mergeLivingDisplayIntervals(
function clusterScore(row: LivingClusterSource): number | null {
if (typeof row.posterior_score === "number" && Number.isFinite(row.posterior_score)) return row.posterior_score;
if (typeof row.relativeSupport === "number" && Number.isFinite(row.relativeSupport)) return row.relativeSupport;
return null;
}
/**
* Living clusters inside `lead` of the peak, merged for display.
* Returns null when no candidate carries a cluster interval, so callers keep
* the historical envelope label.
* Legacy recompute. Delivery reads stored intervals instead.
* Parsed cards only have rounded relativeSupport, which this function ignores.
* Membership for a new result is livingDisplayForInference.
* Returns null when a living candidate has no cluster interval, or when a
* score is missing, so callers keep the envelope title.
*/
export function livingDisplayFromCandidates(
candidates: readonly LivingClusterSource[] | null | undefined,
@@ -196,15 +197,15 @@ export function livingDisplayFromCandidates(
lead = MIN_SEPARATION_LEAD,
): LivingDisplay | null {
if (!candidates?.length) return null;
const scored = candidates
.filter((row) => row.status !== "eliminated")
.map((row) => ({ row, score: clusterScore(row) }))
.filter((item): item is { row: LivingClusterSource; score: number } => item.score !== null);
if (!scored.length) return null;
const active = candidates.filter((row) => row.status !== "eliminated");
if (!active.length || active.some((row) => clusterScore(row) === null)) return null;
const scored = active.map((row) => ({ row, score: clusterScore(row)! }));
const peak = Math.max(...scored.map((item) => item.score));
const living = scored.filter((item) => peak - item.score < lead);
if (!living.length) return null;
if (living.some((item) => !(item.row.cluster_intervals ?? item.row.clusterIntervals)?.length)) return null;
const intervals: CandidateInterval[] = [];
for (const item of scored) {
if (peak - item.score >= lead) continue;
for (const item of living) {
const parts = item.row.cluster_intervals ?? item.row.clusterIntervals ?? [];
for (const part of parts) {
if (part?.start_at && part?.end_at) intervals.push({ start_at: String(part.start_at), end_at: String(part.end_at) });
@@ -221,6 +222,59 @@ export function livingDisplayFromCandidates(
};
}
/** Stored write-time segments. Invalid or empty values fall back to the envelope title. */
export function parseLivingDisplayIntervals(value: unknown): readonly CandidateInterval[] | null {
if (!Array.isArray(value) || value.length === 0) return null;
const rows: CandidateInterval[] = [];
for (const raw of value) {
if (!raw || typeof raw !== "object") return null;
const start = (raw as { start_at?: unknown }).start_at;
const end = (raw as { end_at?: unknown }).end_at;
if (!validLocalMinute(start) || !validLocalMinute(end) || start > end) return null;
rows.push({ start_at: start, end_at: end });
}
return rows;
}
export function livingDisplayFromStoredIntervals(
value: unknown,
birthDate?: string | null,
): LivingDisplay | null {
const intervals = parseLivingDisplayIntervals(value);
if (!intervals) return null;
return {
intervals,
label: windowIntervalsLabel(intervals, birthDate),
widthMinutes: inclusiveIntervalMinutes(intervals),
segmentCount: intervals.length,
};
}
export function livingDisplayNarrationFromStored(
value: unknown,
birthDate?: string | null,
): { displayLabel: string; displayWidthMinutes: number; displaySegmentCount: number } | null {
const display = livingDisplayFromStoredIntervals(value, birthDate);
if (!display) return null;
return {
displayLabel: display.label,
displayWidthMinutes: display.widthMinutes,
displaySegmentCount: display.segmentCount,
};
}
export function livingDisplayFromReceipt(
receipt: Readonly<Record<string, unknown>> | null | undefined,
birthDate?: string | null,
): LivingDisplay | null {
const state = receipt?.inference_state;
if (!state || typeof state !== "object" || Array.isArray(state)) return null;
return livingDisplayFromStoredIntervals(
(state as { living_display_intervals?: unknown }).living_display_intervals,
birthDate,
);
}
/** Fields to spread into range narration. Null when there is nothing to switch. */
export function livingDisplayNarration(
candidates: readonly LivingClusterSource[] | null | undefined,
@@ -1,6 +1,13 @@
import { MIN_SEPARATION_LEAD } from "./candidate-separation.ts";
import type { InferenceCandidate } from "./types.ts";
import { unionWindowIntervals, type WindowInterval } from "./candidate-window.ts";
import {
inclusiveIntervalMinutes,
mergeLivingDisplayIntervals,
unionWindowIntervals,
windowIntervalsLabel,
type LivingDisplay,
type WindowInterval,
} from "./candidate-window.ts";
export function unionStillValidIntervals(candidates: readonly InferenceCandidate[], lead = MIN_SEPARATION_LEAD): readonly WindowInterval[] {
const active = rankActive(candidates);
@@ -66,6 +73,33 @@ export function unionStillValidRange(
return [fromMinutes(minutes[0]!), fromMinutes(minutes[minutes.length - 1]!)];
}
export type LivingDisplayDecision = LivingDisplay & Readonly<{ memberIds: readonly string[] }>;
/**
* Display membership is stillValidCandidates on raw posterior_score.
* A living candidate with no cluster intervals drops the whole segmented
* title so callers keep the envelope. Rounded relativeSupport is not a score.
*/
export function livingDisplayForInference(
candidates: readonly InferenceCandidate[] | null | undefined,
birthDate?: string | null,
lead = MIN_SEPARATION_LEAD,
): LivingDisplayDecision | null {
if (!candidates?.length) return null;
const living = stillValidCandidates(candidates, lead);
if (!living.length) return null;
if (living.some((row) => !row.cluster_intervals?.length)) return null;
const merged = mergeLivingDisplayIntervals(living.flatMap((row) => row.cluster_intervals ?? []));
if (!merged.length) return null;
return {
intervals: merged,
label: windowIntervalsLabel(merged, birthDate),
widthMinutes: inclusiveIntervalMinutes(merged),
segmentCount: merged.length,
memberIds: living.map((row) => row.id),
};
}
export function rangeFromTimes(times: readonly string[]): readonly [string, string] | null {
const minutes = times
.map(toMinutes)
@@ -62,7 +62,7 @@ export const PROBE_WEIGHT = {
yearless: 0.5,
} as const;
import type { CandidatePosition, WindowInterval } from "./candidate-window.ts";
import type { CandidateInterval, CandidatePosition, WindowInterval } from "./candidate-window.ts";
import type { AscendantSignature, SegmentMinute, SegmentSummary, TargetChart } from "./segment-summary.ts";
export type InferenceCandidate = CandidatePosition & Readonly<{
@@ -169,6 +169,12 @@ export type InferenceState = Readonly<{
representative_time: string | null;
credible_range: readonly [string, string] | null;
credible_intervals?: readonly WindowInterval[];
/**
* Segments shown as 「目前范围」. Written from still-valid candidates using
* raw posterior_score, the same lead as credible_range. Null or absent:
* readers keep the envelope title and must not recompute from rounded scores.
*/
living_display_intervals?: readonly CandidateInterval[] | null;
segment_minutes?: readonly SegmentMinute[];
segment_targets?: readonly TargetChart[];
segment_scan_complete?: boolean;
@@ -24,7 +24,7 @@ import {
import { mapRectificationRpcError } from "@/lib/rectification-agentic/v9/case-service";
import { CHOICE_ACTION, STOP_ACTION } from "@/lib/rectification-agentic/v9/choice-action";
import { previousInferenceFromReceipt } from "@/lib/rectification-agentic/v9/inference-adapter";
import { livingDisplayNarration } from "@/lib/rectification-agentic/core/candidate-window.ts";
import { livingDisplayNarrationFromStored } from "@/lib/rectification-agentic/core/candidate-window.ts";
import { parseAgentChoiceCopy } from "@/lib/rectification-agentic/v9/choice-card";
import {
optionIdForAnswerClass,
@@ -263,8 +263,8 @@ export async function handleRectificationTypedMessage(
narrateAdopt,
});
const allowDelivery = deliveryNarrationAllowed(decision, decision.nextAction);
const living = livingDisplayNarration(
previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt)?.candidates,
const living = livingDisplayNarrationFromStored(
previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt)?.living_display_intervals,
birthDate,
) ?? {};
const intermediate = nonConvergingRangeNarration({
@@ -366,8 +366,8 @@ export async function handleRectificationTypedMessage(
caseId,
narrateAdopt,
});
const living = livingDisplayNarration(
previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt)?.candidates,
const living = livingDisplayNarrationFromStored(
previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt)?.living_display_intervals,
birthDate,
) ?? {};
const assistantMessage = idle.hostNarration || nonConvergingRangeNarration({ ...decision, ...living });
@@ -31,7 +31,7 @@ import { attemptTimeoutForRemainingBudget, canStartRetryAttempt } from "../../re
import { stripQuestionSentences, stripVerbalWindowChange } from "./collect-prompt";
import { focusSpokenPrompt } from "./turn-question";
import { previousInferenceFromReceipt } from "../core/compose-receipt.ts";
import { livingDisplayFromCandidates } from "../core/candidate-window.ts";
import { livingDisplayFromStoredIntervals } from "../core/candidate-window.ts";
import {
activityChangedFromTool,
mapStreamChunkToActivity,
@@ -648,7 +648,7 @@ export async function streamV9Attempt(
// P3: the only clocks and percentages a model sentence may carry are this
// turn's server facts (range, representative minute, event fit rate).
const fitRate = refinementFromDecisionReceipt(latestDossier.latestResult?.decisionReceipt ?? null).event_fit_rate;
const livingSpeech = livingDisplayFromCandidates(latestInference?.candidates);
const livingSpeech = livingDisplayFromStoredIntervals(latestInference?.living_display_intervals);
const representativeTime = latestInference?.representative_time ?? latestDossier.latestResult?.representativeTime ?? decision.representativeTime ?? null;
const whitelistSources = (livingSpeech
? [{ credibleIntervals: livingSpeech.intervals, representativeTime }]
@@ -11,7 +11,7 @@ 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";
import { livingDisplayNarration } from "../core/candidate-window.ts";
import { livingDisplayNarrationFromStored } from "../core/candidate-window.ts";
import {
deliveryNarrationAllowed,
engineCapabilityCeilingFromReceipt,
@@ -366,7 +366,7 @@ function adoptHostNarration(input: {
segmentSummary: input.dossier.case.rectificationDomain === null ? null : inference?.segment_summary ?? null,
productDelivery: productDeliveryForDomain(input.dossier.case.rectificationDomain),
openingRange: openingRangeFromDossier(input.dossier),
...(livingDisplayNarration(inference?.candidates, birthDate) ?? {}),
...(livingDisplayNarrationFromStored(inference?.living_display_intervals, birthDate) ?? {}),
});
// BUG-1147: the segment card already says which chart is credible and which cannot be told
// apart; the minute-candidate hint 「这几个候选按现有信息分不开」 contradicts it.
@@ -2084,7 +2084,7 @@ async function persistExhaustionCollect(input: {
representativeTime: decision.representativeTime ?? input.decision.representativeTime,
openingRange: openingRangeFromDossier(dossier),
variant: "intermediate",
...(livingDisplayNarration(inference?.candidates, birthDate) ?? {}),
...(livingDisplayNarrationFromStored(inference?.living_display_intervals, birthDate) ?? {}),
});
const persistedFocus = await persistFocusAfterChoice({
accounting: input.accounting,
@@ -2263,7 +2263,7 @@ async function persistExhaustionCollect(input: {
representativeTime: decision.representativeTime ?? input.decision.representativeTime,
openingRange: openingRangeFromDossier(dossier),
variant: "intermediate",
...(livingDisplayNarration(inference?.candidates, birthDate) ?? {}),
...(livingDisplayNarrationFromStored(inference?.living_display_intervals, birthDate) ?? {}),
});
const hostNarration = exhaustionCarrierNarration(range, hint);
return {
@@ -17,7 +17,7 @@ import type { RectificationDecision } from "../core/rectification-decision.ts";
import { deliveryWidthMinutes } from "../core/precision-gate.ts";
import { rangeWidthMinutes } from "../user-copy.ts";
import { previousInferenceFromReceipt } from "./inference-adapter.ts";
import { livingDisplayFromCandidates } from "../core/candidate-window.ts";
import { livingDisplayFromStoredIntervals } from "../core/candidate-window.ts";
import { decisionBudgetFromInference } from "./decision-from-dossier.ts";
export const RECTIFICATION_TELEMETRY_FIELDS = [
@@ -278,8 +278,8 @@ export function buildRectificationTelemetryRow(input: RectificationTelemetryInpu
questions_open: kindCount("open"),
};
const typedSum = Object.values(typed).reduce((sum, value) => sum + value, 0);
const livingWidth = livingDisplayFromCandidates(
previousInferenceFromReceipt(input.dossier.latestResult?.decisionReceipt ?? null)?.candidates,
const livingWidth = livingDisplayFromStoredIntervals(
previousInferenceFromReceipt(input.dossier.latestResult?.decisionReceipt ?? null)?.living_display_intervals,
)?.widthMinutes ?? null;
const width = livingWidth ?? deliveryWidthMinutes(input.rangeDelivery?.range ?? input.decision.credibleRange);
return {