feat(rectification): range delivery needs 4 dated events across 3 domains (R1, BUG-1193)
Product decision 2026-10-02 (TASK-upstream-sync5 R1): a time range is offered
only with at least 4 dated, primary-scoreable events covering 3 domains,
counted on all of them (training + reserved holdout). Was 3 training events /
2 domains in three TS copies and the Python acceptance gate while the policy
file already said 4/3.
- One definition: references/rectification_policy.v1.json
(minConfirmationEvents / minConfirmationDomains). TS core/types MIN_DATED_*,
rectification-decision MIN_STANDALONE_*, evidence-model MIN_ACCEPTANCE_*,
the convergence evaluator and the post-inference trainingGateOpen all read
it; Python decision_policy MIN_ACCEPTANCE_* alias MIN_CONFIRMATION_*.
- Python receipt counts all scoreable events / domains for event_quality and
domain_diversity; decision policy identity v3 -> v4 (candidate UUIDs carry
it). Candidate scores unchanged (77 v5 cases A/B identical), so the
algorithm stays rectification-v5-matrix-scoring-10.
- Memoization golden v3 written by write_golden; v2 frozen by sha256 with a
test that its scores equal v3 and only the receipt policy moved.
- Collect gap copy names the exact gap ("再来两件……其中至少一件不是……")
instead of always "再来一件"; VOICE.md updated. Legacy life-events form copy
4/3 as well.
- 30 frontend test files, 4 Python tests: fixtures extended to the same
scenario at 4/3, or assertions changed with 原值/新值/原因 notes.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7fb165ecf6
commit
758fee954b
@@ -4,6 +4,7 @@ import { useRef, useState } from "react";
|
||||
import { lifeEventSchema } from "@/lib/birth-time-journey";
|
||||
import type { LifeEvent } from "@/lib/birth-time-evidence";
|
||||
import { ErrorToast } from "@/components/error-toast";
|
||||
import { RECTIFICATION_POLICY } from "@/lib/rectification-policy";
|
||||
|
||||
type EventDraft = {
|
||||
readonly rowId: string;
|
||||
@@ -27,7 +28,7 @@ const domainOptions = [
|
||||
{ value: "health_pressure", label: "健康、事故或低谷" },
|
||||
] as const;
|
||||
|
||||
const initialDomains = ["education", "career", "relationship"] as const;
|
||||
const initialDomains = ["education", "career", "relationship", "relocation"] as const;
|
||||
|
||||
function initialDrafts(events: readonly LifeEvent[]): readonly EventDraft[] {
|
||||
if (events.length > 0) {
|
||||
@@ -107,7 +108,7 @@ export function BirthTimeLifeEvents({
|
||||
}
|
||||
|
||||
function removeDraft(rowId: string) {
|
||||
setDrafts((current) => current.length <= 3
|
||||
setDrafts((current) => current.length <= RECTIFICATION_POLICY.minConfirmationEvents
|
||||
? current
|
||||
: current.filter((draft) => draft.rowId !== rowId));
|
||||
}
|
||||
@@ -124,8 +125,8 @@ export function BirthTimeLifeEvents({
|
||||
return;
|
||||
}
|
||||
const events = parsed.flatMap((item) => item.success ? [item.data] : []);
|
||||
if (new Set(events.map((event) => event.domain)).size < 2) {
|
||||
setError("请至少选择两个不同领域的经历,以便区分候选时间。");
|
||||
if (new Set(events.map((event) => event.domain)).size < RECTIFICATION_POLICY.minConfirmationDomains) {
|
||||
setError("请至少选择三个不同领域的经历,以便区分候选时间。");
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
@@ -139,7 +140,7 @@ export function BirthTimeLifeEvents({
|
||||
<span>{drafts.length} / 6</span>
|
||||
</div>
|
||||
<p className="birth-time-evidence-note">
|
||||
请填写至少三条、覆盖两个领域的经历。只使用日期和类型评分,不会从描述中猜测。
|
||||
请填写至少四条、覆盖三个领域的经历。只使用日期和类型评分,不会从描述中猜测。
|
||||
</p>
|
||||
<div className="birth-time-event-list">
|
||||
{drafts.map((draft, index) => (
|
||||
|
||||
@@ -4,7 +4,8 @@ import {
|
||||
CONVERGENCE_LEAD,
|
||||
CONVERGENCE_TOP_SHARE,
|
||||
DEFAULT_MAX_DISCRIMINATION_ROUNDS,
|
||||
MIN_TRAINING_EVENTS,
|
||||
MIN_DATED_DOMAINS,
|
||||
MIN_DATED_EVENTS,
|
||||
STABLE_WINNER_ROUNDS,
|
||||
type InferenceCandidate,
|
||||
type InferenceEvent,
|
||||
@@ -42,7 +43,8 @@ export function evaluateConvergence(state: Pick<
|
||||
const active = rankActive(state.candidates);
|
||||
const top = active[0] ?? null;
|
||||
const runnerUp = active[1] ?? null;
|
||||
const trainingCount = state.events.filter((item) => item.usage === "training").length;
|
||||
const dated = state.events.filter((item) => item.usage === "training" || item.usage === "holdout");
|
||||
const datedDomains = new Set(dated.map((item) => item.domain));
|
||||
const unionRange = unionStillValidRange(state.candidates) ?? state.credible_range;
|
||||
const equivalent = Boolean(
|
||||
unionRange
|
||||
@@ -64,7 +66,7 @@ export function evaluateConvergence(state: Pick<
|
||||
const stable = winnerStable(state.rounds, top?.id ?? null);
|
||||
const noCriticalConflict = (top?.strong_conflict_count ?? 0) === 0;
|
||||
const holdoutPassed = state.holdout_passed;
|
||||
const sufficient = trainingCount >= MIN_TRAINING_EVENTS;
|
||||
const sufficient = dated.length >= MIN_DATED_EVENTS && datedDomains.size >= MIN_DATED_DOMAINS;
|
||||
|
||||
if (equivalent) {
|
||||
return {
|
||||
|
||||
@@ -18,6 +18,8 @@ import { EFFECTIVE_ANSWER_SAFETY_CAP } from "../../birth-time-dynamic-stop-polic
|
||||
import { RECTIFICATION_POLICY } from "../../rectification-policy.ts";
|
||||
import {
|
||||
DEFAULT_MAX_DISCRIMINATION_ROUNDS,
|
||||
MIN_DATED_DOMAINS,
|
||||
MIN_DATED_EVENTS,
|
||||
type RectificationPhase,
|
||||
type ResultStatus,
|
||||
} from "./types.ts";
|
||||
@@ -105,9 +107,9 @@ export function engineCapabilityCeilingFromReceipt(value: unknown): EngineCapabi
|
||||
|
||||
export const RECTIFICATION_TERMINATION_COPY = "当前最优结果是候选时间段,而不是已经确认的唯一出生分钟。临时代表时间仅用于下一轮验证与比较。";
|
||||
|
||||
/** Standalone range-delivery floor; the exact-minute confirmation gate remains 4/3. */
|
||||
export const MIN_STANDALONE_DATED_EVENTS = 3;
|
||||
export const MIN_STANDALONE_DATED_DOMAINS = 2;
|
||||
/** Standalone range-delivery floor (R1: 4 dated events / 3 domains); same source as the confirmation gate. */
|
||||
export const MIN_STANDALONE_DATED_EVENTS = MIN_DATED_EVENTS;
|
||||
export const MIN_STANDALONE_DATED_DOMAINS = MIN_DATED_DOMAINS;
|
||||
|
||||
export type DecisionSessionOutcome =
|
||||
| "collect_evidence"
|
||||
|
||||
@@ -5,13 +5,22 @@
|
||||
* probe, assign scores, eliminate candidates, or declare convergence.
|
||||
*/
|
||||
|
||||
import { RECTIFICATION_POLICY } from "../../rectification-policy.ts";
|
||||
|
||||
export const INFERENCE_ALGORITHM_VERSION = "rectification-inference-v1";
|
||||
export const HIGH_INFORMATION_GAIN = 0.08;
|
||||
export const DEFAULT_MAX_DISCRIMINATION_ROUNDS = 8;
|
||||
export const CONVERGENCE_LEAD = 0.2;
|
||||
export const CONVERGENCE_TOP_SHARE = 0.7;
|
||||
export const STABLE_WINNER_ROUNDS = 2;
|
||||
export const MIN_TRAINING_EVENTS = 3;
|
||||
/**
|
||||
* Range-delivery evidence floor (R1, 2026-10-02, BUG-1193): at least 4 dated,
|
||||
* primary-scoreable events across 3 domains, counted on all of them (training
|
||||
* plus the reserved holdout). The only source is
|
||||
* `references/rectification_policy.v1.json`; Python reads the same file.
|
||||
*/
|
||||
export const MIN_DATED_EVENTS: number = RECTIFICATION_POLICY.minConfirmationEvents;
|
||||
export const MIN_DATED_DOMAINS: number = RECTIFICATION_POLICY.minConfirmationDomains;
|
||||
export const STRONG_CONFLICT_ELIMINATION_COUNT = 3;
|
||||
|
||||
export type RectificationPhase =
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
|
||||
import { rangeDeliveryCollectClosed, USER_COLLECT_QUESTION } from "../user-copy.ts";
|
||||
import { canonicalCollectDomain } from "./domain-alias.ts";
|
||||
import { trainingScoreableGate } from "./evidence-model.ts";
|
||||
import {
|
||||
isPrimaryScoreableEvidence,
|
||||
MIN_ACCEPTANCE_DOMAINS,
|
||||
MIN_ACCEPTANCE_EVENTS,
|
||||
trainingScoreableGate,
|
||||
} from "./evidence-model.ts";
|
||||
import { hasDatedTransition, signFromTransitions, validDatedTransitions, type TransitionSignLookup } from "../core/sign-from-transitions.ts";
|
||||
|
||||
export const COLLECT_KIND_ORDER = [
|
||||
@@ -459,10 +464,39 @@ export function preciseGapNarration(
|
||||
: covered.size > 1
|
||||
? ""
|
||||
: "";
|
||||
const needOther = covered.size === 1
|
||||
? `再来一件不是${KIND_LABEL[[...covered][0]]}的、记得大概年月的事就能开始筛,比如${exampleText}。`
|
||||
: `再来一件记得大概年月的事就能开始筛,比如${exampleText}。`;
|
||||
return `${recorded}${sameKind}${needOther}`;
|
||||
return `${recorded}${sameKind}${evidenceGapAsk(evidence, covered)}比如${exampleText}。`;
|
||||
}
|
||||
|
||||
const COUNT_WORD = ["零", "一", "两", "三", "四", "五", "六"] as const;
|
||||
|
||||
function countWord(value: number): string {
|
||||
return COUNT_WORD[value] ?? String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* R1 (BUG-1193): name the exact gap to the 4-event / 3-domain floor instead
|
||||
* of always asking for "one more". Counts follow trainingScoreableGate.
|
||||
*/
|
||||
function evidenceGapAsk(
|
||||
evidence: readonly CollectionEvidence[],
|
||||
covered: ReadonlySet<CollectKind>,
|
||||
): string {
|
||||
const primary = evidence.filter(isPrimaryScoreableEvidence);
|
||||
const domains = new Set(primary.map((item) => item.domain));
|
||||
const missingEvents = Math.max(0, MIN_ACCEPTANCE_EVENTS - primary.length);
|
||||
const missingDomains = Math.max(0, MIN_ACCEPTANCE_DOMAINS - domains.size);
|
||||
const total = Math.max(1, missingEvents, missingDomains);
|
||||
const labels = [...covered].map((kind) => KIND_LABEL[kind]).join("或");
|
||||
if (missingDomains === 0 || !labels) {
|
||||
const spread = missingDomains > 1 ? `、至少涉及${countWord(missingDomains)}个方面` : "";
|
||||
return `再来${countWord(total)}件记得大概年月的事${spread}${spread ? "," : ""}就能开始筛,`;
|
||||
}
|
||||
if (missingDomains === total) {
|
||||
return total === 1
|
||||
? `再来一件不是${labels}的、记得大概年月的事就能开始筛,`
|
||||
: `再来${countWord(total)}件不是${labels}、彼此也不同类的、记得大概年月的事就能开始筛,`;
|
||||
}
|
||||
return `再来${countWord(total)}件记得大概年月的事,其中至少${countWord(missingDomains)}件不是${labels}的,就能开始筛,`;
|
||||
}
|
||||
|
||||
export function moreCollectHint(
|
||||
|
||||
@@ -813,7 +813,7 @@ function evidenceStopInputs(evidence: DecisionDossier["evidence"]): {
|
||||
const primary = evidence.filter(isPrimaryScoreableEvidence);
|
||||
return {
|
||||
// The standalone delivery floor is evaluated by decideRectification;
|
||||
// trainingScoreableGate is a separate 3/2 engine-readiness check.
|
||||
// trainingScoreableGate applies the same 4/3 floor (R1) for engine readiness.
|
||||
datedEventCount: primary.length,
|
||||
datedDomainCount: new Set(primary.map((item) => item.domain)).size,
|
||||
};
|
||||
@@ -1105,8 +1105,9 @@ export function decideAfterInferenceChange(input: {
|
||||
birthTimeSource: normalizeBirthTimeSource(input.dossier.case.birthTimeSource),
|
||||
};
|
||||
}
|
||||
const training = input.state.events.filter((item) => item.usage === "training");
|
||||
const trainingDomains = new Set(training.map((item) => item.domain));
|
||||
// R1: the floor counts every dated event (training + reserved holdout), like the Python receipt.
|
||||
const dated = input.state.events.filter((item) => item.usage === "training" || item.usage === "holdout");
|
||||
const datedDomains = new Set(dated.map((item) => item.domain));
|
||||
const evidenceStops = evidenceStopInputs(input.dossier.evidence);
|
||||
const userUncertaintyHigh = uncertaintyHighFromAnswers(input.state.answered_probes);
|
||||
const contrastPacket = contrastPacketFromState(input.state);
|
||||
@@ -1147,8 +1148,8 @@ export function decideAfterInferenceChange(input: {
|
||||
return {
|
||||
...decideRectification({
|
||||
methodCoverageAll: blockingMethodsCovered(collecting.methods),
|
||||
trainingGateOpen: training.length >= MIN_ACCEPTANCE_EVENTS
|
||||
&& trainingDomains.size >= MIN_ACCEPTANCE_DOMAINS,
|
||||
trainingGateOpen: dated.length >= MIN_ACCEPTANCE_EVENTS
|
||||
&& datedDomains.size >= MIN_ACCEPTANCE_DOMAINS,
|
||||
candidateScores: input.state.candidates
|
||||
.filter((item) => item.status !== "eliminated")
|
||||
.map((item) => ({ time: item.time, score: item.posterior_score, window_index: item.window_index })),
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import { splitHoldoutEvents, type DatedEventInput } from "../core/split-holdout.ts";
|
||||
import { MIN_DATED_DOMAINS, MIN_DATED_EVENTS } from "../core/types.ts";
|
||||
|
||||
export const EVIDENCE_KINDS = [
|
||||
"education_start",
|
||||
@@ -230,9 +231,12 @@ export const NON_PRIMARY_SCORING_DOMAINS: ReadonlySet<string> = new Set([
|
||||
"other",
|
||||
]);
|
||||
|
||||
/** Same floors as `scripts/rectification/decision_policy.py`. Counted on training events only. */
|
||||
export const MIN_ACCEPTANCE_EVENTS = 3;
|
||||
export const MIN_ACCEPTANCE_DOMAINS = 2;
|
||||
/**
|
||||
* Same floors as `scripts/rectification/decision_policy.py` (R1: 4 / 3).
|
||||
* Counted on all dated primary-scoreable events: training plus the reserved holdout.
|
||||
*/
|
||||
export const MIN_ACCEPTANCE_EVENTS = MIN_DATED_EVENTS;
|
||||
export const MIN_ACCEPTANCE_DOMAINS = MIN_DATED_DOMAINS;
|
||||
|
||||
export function isBackgroundEvidenceKind(kind: EvidenceKind): boolean {
|
||||
return BACKGROUND_ONLY_KINDS.has(kind);
|
||||
@@ -280,9 +284,9 @@ function yearFromIso(value: string | null): number | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Discrimination requires 3 training events / 2 training domains.
|
||||
* Holdout is reserved from the 4th dated event and does not count.
|
||||
* 3 collected events all stay in training.
|
||||
* Discrimination and range delivery require 4 dated events across 3 domains
|
||||
* (R1). The holdout is still reserved from the 4th dated event; it counts
|
||||
* toward the floor but never toward scoring.
|
||||
*/
|
||||
export function trainingScoreableGate(evidence: readonly ScoreableEvidence[]): Readonly<{
|
||||
trainingCount: number;
|
||||
@@ -300,14 +304,15 @@ export function trainingScoreableGate(evidence: readonly ScoreableEvidence[]): R
|
||||
}));
|
||||
const split = splitHoldoutEvents(dated);
|
||||
const training = split.filter((item) => item.usage === "training");
|
||||
const domains = new Set(training.map((item) => item.domain));
|
||||
const trainingDomains = new Set(training.map((item) => item.domain));
|
||||
const holdoutCount = split.filter((item) => item.usage === "holdout").length;
|
||||
const datedDomains = new Set(scoreable.map((item) => item.domain));
|
||||
return {
|
||||
trainingCount: training.length,
|
||||
trainingDomainCount: domains.size,
|
||||
trainingDomainCount: trainingDomains.size,
|
||||
holdoutCount,
|
||||
holdoutStatus: holdoutCount > 0 ? "reserved" : "not_reserved_min_events",
|
||||
open: training.length >= MIN_ACCEPTANCE_EVENTS && domains.size >= MIN_ACCEPTANCE_DOMAINS,
|
||||
open: scoreable.length >= MIN_ACCEPTANCE_EVENTS && datedDomains.size >= MIN_ACCEPTANCE_DOMAINS,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user