避免四舍五入和缺区间把还可能的时间从目前范围里藏掉。
Co-Authored-By: Grok (agent loop)
This commit is contained in:
co-authored by
Grok (agent loop)
parent
25c51ff0d0
commit
1ba473970f
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user