feat(rectification): 少问几道就出卡,交付卡以范围为主(D1–D4)

- D1 引导窗口题每个校正最多 2 道(前端 GUIDED_WINDOW_CASE_LIMIT;引擎
  GUIDED_COLLECT_LIMIT 未动:event_probes.py 属冻结评分身份,改它需重新冻结)
- D2 七条定向线与跳过线重问问完即出卡,没问到的引导窗口不再挡卡,出卡后也不再挂窗口题
- D3 卡头加副标题「最可能 HH:MM」
- D4 前两列相差 ≥5 个百分点才显示相对可能性,否则一句「这几个时刻目前区分不开……」
- 离线回放 scripts/research/fewer_probes_card_replay.py:真值不降、宽度中位 ±1 分钟、提问 11.4→7.8
- Skill 10.0.29 → 10.0.30;DESIGN / VOICE / CHANGELOG / PROGRESS / 真机清单

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
Jesse_Chen
2026-09-26 14:27:12 +08:00
co-authored by Claude Opus 5.5
parent 7ddce2c96a
commit 4e6e8d87ef
48 changed files with 7885 additions and 54 deletions
+14
View File
@@ -3516,6 +3516,20 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.rectification-range-delivery__accept:disabled {
cursor: default;
}
/* D3 (2026-09-26): representative minute as the subtitle under the range title. */
.rectification-candidates-heading .rectification-range-delivery__most-likely {
color: var(--color-ink-secondary);
font-size: var(--type-body-md);
font-variant-numeric: tabular-nums;
line-height: 1.5;
}
/* D4 (2026-09-26): replaces the per-column numbers when the top two are < 5 points apart. */
.rectification-range-delivery__indistinct {
margin: 0;
color: var(--color-ink);
font-size: var(--type-body-md);
line-height: 1.65;
}
.rectification-range-delivery__invite {
margin: 0;
color: var(--color-ink);
@@ -6,6 +6,8 @@ import {
RECTIFICATION_USER_COPY,
REPRESENTATIVE_MINUTE_DISCLAIMER,
rangeDeliveryEventCopy,
rangeDeliveryMostLikely,
rangeDeliveryShowsPercents,
} from "@/lib/rectification-agentic/user-copy";
import type { RangeDeliveryProjection } from "@/lib/rectification-agentic/v9/divergence-panel";
import {
@@ -51,11 +53,21 @@ export function RectificationRangeDelivery({
// it actually does — swap the charting clock — instead of the neutral 「更像这个」.
const recordConflict = delivery?.record_conflict ?? null;
const caption = delivery?.narrow_hint ?? null;
// D3: the range is the title; the representative minute is the subtitle.
const mostLikely = delivery?.representative_time ?? result.representativeTime;
// D4: per-column numbers only when the first column leads by ≥ 5 points.
const showPercents = rangeDeliveryShowsPercents(columns);
const indistinct = columns.length >= 2 && !showPercents;
return (
<section className="rectification-candidates rectification-range-delivery" aria-label="生时校正区间">
<div className="rectification-candidates-heading">
<strong>{title}</strong>
{mostLikely ? (
<span className="rectification-range-delivery__most-likely">
{rangeDeliveryMostLikely(mostLikely)}
</span>
) : null}
</div>
{caption ? (
<p className="rectification-range-delivery__narrow">{caption}</p>
@@ -71,6 +83,11 @@ export function RectificationRangeDelivery({
{delivery?.tie_break_note ? (
<p className="rectification-range-delivery__narrow">{delivery.tie_break_note}</p>
) : null}
{indistinct ? (
<p className="rectification-range-delivery__indistinct">
{RECTIFICATION_USER_COPY.rangeDeliveryIndistinct}
</p>
) : null}
{sharedTraits.length > 0 ? (
<ul className="rectification-range-delivery__shared">
{sharedTraits.map((line) => (
@@ -93,11 +110,13 @@ export function RectificationRangeDelivery({
>
<div className="rectification-range-delivery__time">{candidateDate ? `${candidateDate}${relativeDay} ${column.time}` : column.time}</div>
{candidateDate ? <p className="rectification-range-delivery__muted">采用后,排盘使用这一日期和时间;原填报日期保留。</p> : null}
<p className="rectification-range-delivery__likelihood">
{RECTIFICATION_USER_COPY.rangeDeliveryRelativeLikelihood}
{" "}
{column.probability_percent}%
</p>
{showPercents ? (
<p className="rectification-range-delivery__likelihood">
{RECTIFICATION_USER_COPY.rangeDeliveryRelativeLikelihood}
{" "}
{column.probability_percent}%
</p>
) : null}
{columnHasTraits(column) ? (
<div className="rectification-range-delivery__block">
{column.traits.d9 ? <p>{column.traits.d9}</p> : null}
@@ -239,7 +239,12 @@ export type DecideRectificationInput = Readonly<{
targetedCollectExhausted?: boolean;
/** D1: width ≤ 10, top-two display gap > 3, not an exact first-place tie. */
precisionGateMet?: boolean;
/** Guided window / skip-retry / uncovered-domain pool is empty. */
/**
* The lines that may hold the card are asked out: skip-retry and the
* targeted seven (production also requires the engine refresh). Since D2
* (2026-09-26) unasked guided boundary windows are not part of this — see
* `cardHoldingLinesExhausted`.
*/
guidedCollectExhausted?: boolean;
/** Opening search window from `case.candidateRange`. Omit in helper/unit paths. */
openingCandidateRange?: readonly [string, string] | null;
@@ -660,8 +665,11 @@ function stillNeedNarrowing(input: DecideRectificationInput): boolean {
/**
* D2 + D6. The precision gate only holds the card back **while there is still
* a question to ask**: delivery is `userStopped || everyLineAsked`, where
* `everyLineAsked` is the guided pool (windows + skip retry + uncovered
* domains), the engine refresh, and the targeted seven. Once all of them are
* `everyLineAsked` is the skip retry, the engine refresh, and the targeted
* seven. Product 2026-09-26 D2: guided boundary windows (at most two per
* Case, `GUIDED_WINDOW_CASE_LIMIT`) are asked when they come up, but no longer hold the card — once
* the targeted seven and their one re-ask are asked the card goes out with
* the gate unmet. Once all of them are
* asked out there is nothing left to collect, so the reader gets the card
* under the existing rules whether or not D1 is met — D2 says the card, the
* adopt button, and the three body lines do not change, and carry no
@@ -165,6 +165,7 @@ export const RECTIFICATION_USER_COPY = {
rangeDeliveryTitle: "目前范围",
rangeDeliveryMoreLikeThis: "更像这个",
rangeDeliveryRelativeLikelihood: "相对可能性",
rangeDeliveryIndistinct: "这几个时刻目前区分不开,补一件带年月的经历能帮助分开。",
rangeDeliveryNoWindow: "未来一年没有明显的时段",
rangeDeliveryNotCompared: "这一分钟还没对照",
rangeDeliveryAdopting: "正在采用…",
@@ -187,6 +188,28 @@ export function rangeDeliveryTopTwoTied(
<= RANGE_DELIVERY_TIE_PERCENT;
}
/**
* D4 (product 2026-09-26): per-column relative likelihood is shown only when
* the first column leads the second by at least this many percentage points.
* Below it the numbers carry no discrimination (the in-window constant part of
* every event score is ~5x the part that moves with the minute), so the card
* shows no numbers and one `rangeDeliveryIndistinct` sentence instead.
*/
export const RANGE_DELIVERY_PERCENT_MIN_GAP = 5;
export function rangeDeliveryShowsPercents(
columns: readonly { probability_percent: number }[],
): boolean {
if (columns.length < 2) return false;
const ranked = columns.map((column) => column.probability_percent).sort((left, right) => right - left);
return ranked[0]! - ranked[1]! >= RANGE_DELIVERY_PERCENT_MIN_GAP;
}
/** D3 (product 2026-09-26): subtitle under the range title. */
export function rangeDeliveryMostLikely(time: string): string {
return `最可能 ${time}`;
}
export const RANGE_DELIVERY_DOMAIN_LABEL: Readonly<Record<string, string>> = {
education: "学业",
career: "事业",
@@ -551,6 +574,8 @@ export function listUserVisibleCopy(): string[] {
RECTIFICATION_USER_COPY.rangeDeliveryTitle,
RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis,
RECTIFICATION_USER_COPY.rangeDeliveryRelativeLikelihood,
RECTIFICATION_USER_COPY.rangeDeliveryIndistinct,
rangeDeliveryMostLikely("05:07"),
RECTIFICATION_USER_COPY.rangeDeliveryNoWindow,
RECTIFICATION_USER_COPY.rangeDeliveryNotCompared,
RECTIFICATION_USER_COPY.rangeDeliveryAdopting,
@@ -90,4 +90,5 @@ export const MAX_RESUMABLE_CASES_PER_USER = 1;
export const RECTIFICATION_SKILL_NAME = "jyotish-birth-time-rectification";
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
export const RECTIFICATION_SKILL_VERSION = "10.0.29";
// 原值: "10.0.29" / 新值: "10.0.30" / 原因: D2 出卡句与 D4 卡上百分比规则写进 Skill(2026-09-26)
export const RECTIFICATION_SKILL_VERSION = "10.0.30";
@@ -1033,6 +1033,30 @@ function windowAlreadyAsked(
});
}
/**
* D1 (product 2026-09-26): at most two guided boundary windows per Case.
* Enforced here, not by lowering the engine's `GUIDED_COLLECT_LIMIT` (6, in
* `scripts/rectification/event_probes.py`): that file is part of the frozen
* scoring identity (`scripts/research/sealed_holdout_rerun.py`
* `PRODUCTION_FILES`), and any byte change there needs a research re-freeze
* this change was not authorised for (BUG-1047 anti-recurrence). The receipt
* keeps its ranked windows; the Case asks the first two it has not asked, and
* a new receipt after an answer cannot add a third.
*/
export const GUIDED_WINDOW_CASE_LIMIT = 2;
function askedGuidedWindowCount(topics: readonly CollectionTopic[]): number {
const asked = new Set<string>();
for (const topic of topics) {
const status = topicStatus(topic);
if (status === "active" || !status) continue;
const ref = parseGuidedWindowQuestionId(topicQuestionId(topic));
if (!ref || ref.stage !== "existence") continue;
asked.add(`${ref.year}:${ref.monthLo}:${ref.monthHi}`);
}
return asked.size;
}
export function guidedWindowPool(
windows: readonly GuidedCollectWindow[],
evidence: readonly CollectionEvidence[],
@@ -1043,6 +1067,7 @@ export function guidedWindowPool(
): CollectionPoolItem[] {
if (pendingTargetedYearDomain(declinedTopics, evidence)) return [];
if (pendingGuidedYearWindow(declinedTopics, evidence)) return [];
if (askedGuidedWindowCount(declinedTopics) >= GUIDED_WINDOW_CASE_LIMIT) return [];
const declined = collectDeclinedKinds(declinedTopics);
const covered = coveredCollectKinds(evidence);
const openDomains = COLLECT_KIND_ORDER.filter((kind) => (
@@ -1274,6 +1299,36 @@ export function guidedCollectExhausted(
);
}
/**
* D2 (product 2026-09-26): the lines that may still hold the delivery card
* are the targeted seven and their one re-ask (plus a year follow-up already
* in progress). Guided boundary windows are still asked while they come up
* first (at most `GUIDED_WINDOW_CASE_LIMIT` = 2 per Case), but an unasked
* window no longer keeps the card back: offline replay shows they add
* questions without moving the delivered range
* (`docs/research/fewer_probes_card_replay_2026_09_26.json`).
*/
export function cardHoldingLinesExhausted(
remainingLayers: readonly string[],
evidence: readonly CollectionEvidence[],
declinedTopics: readonly CollectionTopic[] = [],
splitTimes?: readonly [string, string] | null,
candidateCount?: number,
): boolean {
if (pendingTargetedYearDomain(declinedTopics, evidence)) return false;
if (pendingGuidedYearWindow(declinedTopics, evidence)) return false;
if (guidedRetryPool(evidence, declinedTopics, splitTimes, candidateCount).length > 0) {
return false;
}
return targetedCollectExhausted(
remainingLayers,
evidence,
declinedTopics,
splitTimes,
candidateCount,
);
}
export const COLLECT_FLOW_BANNED_PHRASES = [
"任何领域",
"领域不限",
@@ -64,7 +64,7 @@ import {
remainingSplitLayers,
remainingSplitTimes,
targetedCollectExhausted,
guidedCollectExhausted,
cardHoldingLinesExhausted,
type GuidedCollectWindow,
} from "./collection-question-pool.ts";
import { evidenceLedgerFingerprint } from "./tool-service";
@@ -587,14 +587,15 @@ function narrowingExhaustion(
live.remainingSplitTimes,
live.remainingCandidateCount,
),
// F3/BUG-751: an empty guided pool is not "asked out" while the engine
// refresh has never been tried (BUG-654 / BUG-656).
// F3/BUG-751: nothing is "asked out" while the engine refresh has never
// been tried (BUG-654 / BUG-656). D2 (2026-09-26): once the targeted seven
// and their one re-ask are asked, the card is delivered — unasked guided
// windows no longer hold it back for the precision gate.
guidedCollectExhausted: options?.guidedCollectExhausted
?? (refreshDone && guidedCollectExhausted(
?? (refreshDone && cardHoldingLinesExhausted(
remainingLayers,
dossier.evidence,
declined,
live.guidedCollectWindows,
live.remainingSplitTimes,
live.remainingCandidateCount,
)),
@@ -134,6 +134,7 @@ export { GENERIC_COLLECT_QUESTION };
import {
RECTIFICATION_TERMINATION_COPY,
decideRectification,
sessionOutcomeAllowsDelivery,
type HoldoutValidationStatus,
} from "../core/rectification-decision.ts";
import type { ConflictProbe } from "../core/types.ts";
@@ -3140,6 +3141,9 @@ export function buildMethodFollowupPlan(input: {
&& datedPoolEmpty
&& !input.tieBreakRequested
) {
// D2 (2026-09-26): once the card is out, unasked guided windows are not
// asked any more (they no longer hold the card, so they must not ride
// along under it either). Retry and targeted lines are unchanged.
const targeted = targetedCollectFollowup(
input.remainingLayers ?? [],
input.evidence,
@@ -3150,7 +3154,7 @@ export function buildMethodFollowupPlan(input: {
input.remainingSplitTimes,
input.remainingCandidateCount,
input.remainingCredibleRange,
input.guidedWindows ?? [],
sessionOutcomeAllowsDelivery(sessionOutcome) ? [] : input.guidedWindows ?? [],
);
if (targeted) next = targeted;
else if (pendingHoldout) next = makeFollowup(pendingHoldout, false);